Skip to content

Commit 60803b1

Browse files
authored
Merge branch 'main' into console-1003
2 parents fe3f808 + c4fa4a1 commit 60803b1

26 files changed

+1505
-876
lines changed

integration-tests/tests/schema/contracts.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,6 @@ test('federation schema contains list of tags', async () => {
268268
},
269269
],
270270
});
271-
272271
expect(result.tags).toMatchInlineSnapshot(`
273272
[
274273
toyota,
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# GraphQL Hive - federation-link-utils
2+
3+
[Hive](https://the-guild.dev/graphql/hive) is a fully open-source schema registry, analytics,
4+
metrics and gateway for [GraphQL federation](https://the-guild.dev/graphql/hive/federation) and
5+
other GraphQL APIs.
6+
7+
---
8+
9+
This library can be used to create custom features for GraphQL schemas backed by Federation's
10+
[`@link`](https://www.apollographql.com/docs/graphos/reference/federation/directives#the-link-directive)
11+
directive.
12+
13+
## Features
14+
15+
- Link version support.
16+
- Import `as`/namespacing support that follows the [link spec](https://specs.apollo.dev/link/v1.0/).
17+
- Only `graphql` as a peer dependency.
18+
19+
## Usage
20+
21+
This library is for power users who want to develop their own Federation 2 `@link` feature(s). It
22+
enables you to define and support multiple versions of the feature and to easily reference the named
23+
imports. This includes official federation features if you choose to implement them yourself.
24+
25+
```graphql
26+
# schema.graphql
27+
28+
directive @example(eg: String!) on FIELD
29+
extend schema @link(url: "https://specs.graphql-hive.com/example/v1.0", import: ["@example"])
30+
type Query {
31+
user: User @example(eg: "query { user { id name } }")
32+
}
33+
34+
type User {
35+
id: ID!
36+
name: String
37+
}
38+
```
39+
40+
```typescript
41+
// specs.ts
42+
import { extractLinkImplementations } from '@graphql-hive/federation-link-utils'
43+
44+
const typeDefs = parse(sdl)
45+
const { matchesImplementation, resolveImportName } = extractLinkImplementations(typeDefs);
46+
if (matchesImplementation('https://specs.graphql-hive.com/example', 'v1.0')) {
47+
const examples: Record<string, string> = {}
48+
const exampleName = resolveImportName('https://specs.graphql-hive.com/example', '@example')
49+
visit(typeDefs, {
50+
FieldDefinition: node => {
51+
const example = node.directives?.find(d => d.name.value === exampleName)
52+
if (example) {
53+
examples[node.name.value] = (
54+
example.arguments?.find(a => a.name.value === 'eg')?.value as
55+
| StringValueNode
56+
| undefined
57+
)?.value
58+
}
59+
}
60+
})
61+
return examples
62+
}
63+
64+
// result[0] ==> { user: "query { user { id name } }"}
65+
```
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{
2+
"name": "@graphql-hive/federation-link-utils",
3+
"version": "0.0.1",
4+
"type": "module",
5+
"repository": {
6+
"type": "git",
7+
"url": "graphql-hive/platform",
8+
"directory": "packages/libraries/federation-link-utils"
9+
},
10+
"homepage": "https://the-guild.dev/graphql/hive",
11+
"author": {
12+
"email": "[email protected]",
13+
"name": "The Guild",
14+
"url": "https://the-guild.dev"
15+
},
16+
"license": "MIT",
17+
"private": true,
18+
"engines": {
19+
"node": ">=16.0.0"
20+
},
21+
"main": "dist/cjs/index.js",
22+
"module": "dist/esm/index.js",
23+
"exports": {
24+
".": {
25+
"require": {
26+
"types": "./dist/typings/index.d.cts",
27+
"default": "./dist/cjs/index.js"
28+
},
29+
"import": {
30+
"types": "./dist/typings/index.d.ts",
31+
"default": "./dist/esm/index.js"
32+
},
33+
"default": {
34+
"types": "./dist/typings/index.d.ts",
35+
"default": "./dist/esm/index.js"
36+
}
37+
},
38+
"./package.json": "./package.json"
39+
},
40+
"typings": "dist/typings/index.d.ts",
41+
"scripts": {
42+
"build": "node ../../../scripts/generate-version.mjs && bob build",
43+
"check:build": "bob check"
44+
},
45+
"peerDependencies": {
46+
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0"
47+
},
48+
"devDependencies": {
49+
"graphql": "16.9.0",
50+
"tslib": "2.8.1",
51+
"vitest": "3.0.5"
52+
},
53+
"publishConfig": {
54+
"registry": "https://registry.npmjs.org",
55+
"access": "public",
56+
"directory": "dist"
57+
},
58+
"sideEffects": false,
59+
"typescript": {
60+
"definition": "dist/typings/index.d.ts"
61+
}
62+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Exposes a simple and efficient API for interacting with Federation V2's `@link` directives
3+
* according to spec.
4+
*/
5+
6+
import type { DocumentNode } from 'graphql';
7+
import { FederatedLinkUrl } from './link-url.js';
8+
import { FederatedLink } from './link.js';
9+
10+
export const FEDERATION_V1 = Symbol('Federation_V1');
11+
12+
export type LinkVersion = string | { major: number; minor: number } | null | typeof FEDERATION_V1;
13+
14+
export function extractLinkImplementations(typeDefs: DocumentNode): {
15+
/**
16+
*
17+
* @param identity The link identity. E.g. https://specs.apollo.dev/link/v1.0
18+
* @param name The imported object name, without namespacing. E.g. "@link"
19+
* @returns The imported object's name within the typedefs. E.g.
20+
* For `@link(url: "https://example.com/", import: [{ name: "@example", as: "@eg" }])`,
21+
* `resolveImportName("@example")` returns "eg".
22+
* And for `@link(url: "https://example.com/foo")`, `resolveImportName("@example")`
23+
* returns the namespaced name, "foo__example"
24+
*/
25+
resolveImportName: (identity: string, name: string) => string;
26+
27+
/**
28+
* Check that the linked version is supported by the code implementation.
29+
*
30+
* @param identity The link identity. E.g. https://specs.graphql-hive.com/example
31+
* @param version The version in which the feature was added. E.g. 1.0
32+
* @returns true if the supplied link supports this the version argument.
33+
* E.g. matchesImplementation('https://specs.graphql-hive.com/example', '1.1') returns true if
34+
* is version >= 1.1 < 2.0, but false if the link is version 1.0
35+
*/
36+
matchesImplementation: (identity: string, version: LinkVersion) => boolean;
37+
} {
38+
const linkByIdentity = Object.fromEntries(
39+
FederatedLink.fromTypedefs(typeDefs).map(l => [l.identity, l]),
40+
);
41+
// Any schema with a `@link` directive present is considered federation 2
42+
// although according to federation docs, schemas require linking specifically
43+
// the federation 2.x spec. The reason for not being so picky is that supergraphs also
44+
// use @link, but do not necessarily link to the federation 2.x spec.
45+
const supportsFederationV2 = Object.keys(linkByIdentity).length > 0;
46+
47+
return {
48+
resolveImportName: (identity, name) => {
49+
if (!supportsFederationV2) {
50+
// Identities dont matter for Federation v1. There are no links to reference.
51+
// So return the name without the identity's namespace
52+
return name.startsWith('@') ? name.substring(1) : name;
53+
}
54+
55+
const matchingLink = linkByIdentity[identity];
56+
if (!matchingLink) {
57+
const defaultLink = new FederatedLink(FederatedLinkUrl.fromUrl(identity), null, []);
58+
// The identity was not imported, but return we still will return what is assumed to be the name
59+
// of the import based off the identity. `matchesImplementation` should be used for cases where
60+
// it matters whether or not a specific url was linked.
61+
return defaultLink.resolveImportName(name);
62+
}
63+
return matchingLink.resolveImportName(name);
64+
},
65+
matchesImplementation: (identity, version) => {
66+
if (version === FEDERATION_V1) {
67+
return !supportsFederationV2;
68+
}
69+
const matchingLink = linkByIdentity[identity];
70+
if (!matchingLink) {
71+
return false;
72+
}
73+
if (typeof version === 'string') {
74+
return matchingLink.supports(version);
75+
}
76+
if (version === null) {
77+
return matchingLink.supports(version);
78+
}
79+
return matchingLink.supports(version.major, version.minor);
80+
},
81+
};
82+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { ConstValueNode, Kind } from 'graphql';
2+
3+
export class FederatedLinkImport {
4+
constructor(
5+
public name: string,
6+
public as: string | null,
7+
) {}
8+
9+
public toString(): string {
10+
return this.as ? `{ name: "${this.name}", as: "${this.as}" }` : `"${this.name}"`;
11+
}
12+
13+
static fromTypedefs(node: ConstValueNode): FederatedLinkImport[] {
14+
if (node.kind == Kind.LIST) {
15+
const imports = node.values.map(v => {
16+
if (v.kind === Kind.STRING) {
17+
return new FederatedLinkImport(v.value, null);
18+
}
19+
if (v.kind === Kind.OBJECT) {
20+
let name: string = '';
21+
let as: string | null = null;
22+
23+
v.fields.forEach(f => {
24+
if (f.name.value === 'name') {
25+
if (f.value.kind !== Kind.STRING) {
26+
throw new Error(
27+
`Expected string value for @link "name" field but got "${f.value.kind}"`,
28+
);
29+
}
30+
name = f.value.value;
31+
} else if (f.name.value === 'as') {
32+
if (f.value.kind !== Kind.STRING) {
33+
throw new Error(
34+
`Expected string value for @link "as" field but got "${f.value.kind}"`,
35+
);
36+
}
37+
as = f.value.value;
38+
}
39+
});
40+
return new FederatedLinkImport(name, as);
41+
}
42+
throw new Error(`Unexpected value kind "${v.kind}" in @link import declaration`);
43+
});
44+
return imports;
45+
}
46+
throw new Error(`Expected a list of @link imports but got "${node.kind}"`);
47+
}
48+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
const VERSION_MATCH = /v(\d{1,3})\.(\d{1,4})/i;
2+
3+
function parseVersion(version: string | null): [number, number] {
4+
const versionParts = version?.match(VERSION_MATCH);
5+
if (versionParts?.length) {
6+
const [_full, major, minor] = versionParts;
7+
return [Number(major), Number(minor)];
8+
}
9+
return [-1, -1];
10+
}
11+
12+
/**
13+
* A wrapper around the `@link` url -- this parses all necessary data to identify the link
14+
* and determine which version is most appropriate to use.
15+
*/
16+
export class FederatedLinkUrl {
17+
// -1 if no version is set
18+
private readonly major: number;
19+
private readonly minor: number;
20+
21+
constructor(
22+
public readonly identity: string,
23+
public readonly name: string | null,
24+
public readonly version: string | null,
25+
) {
26+
const [major, minor] = parseVersion(version);
27+
this.major = major;
28+
this.minor = minor;
29+
}
30+
31+
public toString(): string {
32+
return `${this.identity}${this.version ? `/${this.version}` : ''}`;
33+
}
34+
35+
static fromUrl = (urlSource: string): FederatedLinkUrl => {
36+
const url = new URL(urlSource);
37+
const parts = url.pathname.split('/').filter(Boolean);
38+
const versionOrName = parts[parts.length - 1];
39+
if (versionOrName) {
40+
if (VERSION_MATCH.test(versionOrName)) {
41+
const maybeName = parts[parts.length - 2];
42+
return new FederatedLinkUrl(
43+
url.origin + (maybeName ? `/${parts.slice(0, parts.length - 1).join('/')}` : ''),
44+
maybeName ?? null,
45+
versionOrName,
46+
);
47+
}
48+
return new FederatedLinkUrl(`${url.origin}/${parts.join('/')}`, versionOrName, null);
49+
}
50+
return new FederatedLinkUrl(url.origin, null, null);
51+
};
52+
53+
/** Check if this version supports another version */
54+
supports(version: string): boolean;
55+
supports(major: number, minor: number): boolean;
56+
supports(version: FederatedLinkUrl): boolean;
57+
supports(version: null): boolean;
58+
supports(...args: [string] | [number, number] | [FederatedLinkUrl] | [null]): boolean {
59+
const majorOrVersion = args[0];
60+
let major: number, minor: number;
61+
if (typeof majorOrVersion === 'string') {
62+
[major, minor] = parseVersion(majorOrVersion);
63+
} else if (typeof majorOrVersion === 'number') {
64+
[major, minor] = args as [number, number];
65+
} else if (majorOrVersion instanceof FederatedLinkUrl) {
66+
// check that it is the same spec
67+
if (majorOrVersion.identity !== this.identity) {
68+
return false;
69+
}
70+
major = majorOrVersion.major;
71+
minor = majorOrVersion.minor;
72+
} else if (majorOrVersion === null) {
73+
// handles null case
74+
return majorOrVersion === this.version;
75+
} else {
76+
throw new Error(`Unsupported version argument: ${JSON.stringify(args)} [${typeof args}].`);
77+
}
78+
return this.isCompatibleVersion(major, minor);
79+
}
80+
81+
private isCompatibleVersion(major: number, minor: number): boolean {
82+
if (this.major === major) {
83+
if (this.major === 0) {
84+
return this.minor === minor;
85+
}
86+
return this.minor >= minor;
87+
}
88+
return false;
89+
}
90+
}

0 commit comments

Comments
 (0)