Skip to content

Commit bf8c6e8

Browse files
committed
feature: add linkable-spec utility lib
1 parent 25a8c65 commit bf8c6e8

File tree

13 files changed

+655
-0
lines changed

13 files changed

+655
-0
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# GraphQL Hive - linkable-specs
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+
Utility classes for parsing federated `@link`s.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{
2+
"name": "@graphql-hive/linkable-specs",
3+
"version": "0.0.1",
4+
"type": "module",
5+
"repository": {
6+
"type": "git",
7+
"url": "graphql-hive/platform",
8+
"directory": "packages/libraries/linkable-specs"
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": "^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
47+
},
48+
"devDependencies": {
49+
"graphql": "16.9.0",
50+
"tslib": "2.8.1",
51+
"vitest": "2.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: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { DocumentNode, parse, StringValueNode, visit } from 'graphql';
2+
import { detectLinkedImplementations, LinkableSpec } from '../index';
3+
4+
describe('index', () => {
5+
test('LinkableSpec and detectLinkedImplementations can be used to easily implement linked schema functionality', () => {
6+
const sdl = `
7+
directive @meta(name: String!, content: String!) on SCHEMA | FIELD
8+
directive @metadata_example(eg: String!) on FIELD
9+
extend schema
10+
@link(url: "https://specs.apollo.dev/link/v1.0")
11+
@link(url: "https://specs.graphql-hive.com/metadata/v0.1", import: ["@meta"])
12+
13+
type Query {
14+
ping: String @meta(name: "owner", content: "hive-console-team")
15+
pong: String @metadata__example(eg: "1...2...3... Pong")
16+
}
17+
`;
18+
19+
const metaSpec = new LinkableSpec('https://specs.graphql-hive.com/metadata', {
20+
// The return value could be used to map sdl, collect information, or create a graphql yoga plugin.
21+
// In this test, it's used to collect metadata information from the schema.
22+
'v0.1': resolveImportName => (typeDefs: DocumentNode) => {
23+
const collectedMeta: Record<string, Record<string, string>> = {};
24+
const metaName = resolveImportName('@meta');
25+
const exampleName = resolveImportName('@example');
26+
visit(typeDefs, {
27+
FieldDefinition: node => {
28+
let metaData: Record<string, string> = {};
29+
const fieldName = node.name.value;
30+
const meta = node.directives?.find(d => d.name.value === metaName);
31+
if (meta) {
32+
metaData['name'] =
33+
(
34+
meta.arguments?.find(a => a.name.value === 'name')?.value as
35+
| StringValueNode
36+
| undefined
37+
)?.value ?? '??';
38+
metaData['content'] =
39+
(
40+
meta.arguments?.find(a => a.name.value === 'content')?.value as
41+
| StringValueNode
42+
| undefined
43+
)?.value ?? '??';
44+
}
45+
46+
const example = node.directives?.find(d => d.name.value === exampleName);
47+
if (example) {
48+
metaData['eg'] =
49+
(
50+
example.arguments?.find(a => a.name.value === 'eg')?.value as
51+
| StringValueNode
52+
| undefined
53+
)?.value ?? '??';
54+
}
55+
if (Object.keys(metaData).length) {
56+
collectedMeta[fieldName] ??= {};
57+
collectedMeta[fieldName] = Object.assign(collectedMeta[fieldName], metaData);
58+
}
59+
return;
60+
},
61+
});
62+
// collect metadata
63+
return `running on v0.1.\nFound metadata: ${JSON.stringify(collectedMeta)}}`;
64+
},
65+
'v0.2': _resolveImportName => (_typeDefs: DocumentNode) => {
66+
// collect metadata
67+
return `running on v0.2...`;
68+
},
69+
});
70+
const typeDefs = parse(sdl);
71+
const linked = detectLinkedImplementations(typeDefs, [metaSpec]);
72+
expect(linked.map(link => link(typeDefs))).toMatchInlineSnapshot(`
73+
[
74+
running on v0.1.
75+
Found metadata: {"ping":{"name":"owner","content":"hive-console-team"},"pong":{"eg":"1...2...3... Pong"}}},
76+
]
77+
`);
78+
});
79+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { FederatedLinkUrl } from '../link-url';
2+
3+
describe('FederatedLinkUrl', () => {
4+
test.each([
5+
[
6+
'https://spec.example.com/a/b/mySchema/v1.0/',
7+
'https://spec.example.com/a/b/mySchema',
8+
'mySchema',
9+
'v1.0',
10+
],
11+
['https://spec.example.com', 'https://spec.example.com', null, null],
12+
[
13+
'https://spec.example.com/mySchema/v0.1?q=v#frag',
14+
'https://spec.example.com/mySchema',
15+
'mySchema',
16+
'v0.1',
17+
],
18+
['https://spec.example.com/v1.0', 'https://spec.example.com', null, 'v1.0'],
19+
['https://spec.example.com/vX', 'https://spec.example.com/vX', 'vX', null],
20+
])('fromUrl', (url, identity, name, version) => {
21+
const spec = FederatedLinkUrl.fromUrl(url);
22+
expect(spec.identity).toBe(identity);
23+
expect(spec.name).toBe(name);
24+
expect(spec.version).toBe(version);
25+
});
26+
27+
test.each([
28+
['https://spec.example.com/a/b/mySchema/v1.2/', 'https://spec.example.com/a/b/mySchema/v1.0/'],
29+
['https://spec.example.com', 'https://spec.example.com'],
30+
['https://spec.example.com/mySchema/v0.1?q=v#frag', 'https://spec.example.com/mySchema/v0.1'],
31+
['https://spec.example.com/v1.100', 'https://spec.example.com/v1.0'],
32+
['https://spec.example.com/vX', 'https://spec.example.com/vX'],
33+
])(
34+
'supports returns true for specs with the same identity and compatible versions',
35+
(url0, url1) => {
36+
expect(FederatedLinkUrl.fromUrl(url0).supports(FederatedLinkUrl.fromUrl(url1))).toBe(true);
37+
},
38+
);
39+
40+
test.each([
41+
['https://spec.example.com/a/b/mySchema/v1.0/', 'https://spec.example.com/a/b/mySchema/v1.2/'],
42+
['https://spec.example.com/mySchema/v0.1?q=v#frag', 'https://spec.example.com/mySchema/v0.2'],
43+
['https://spec.example.com/v1.0', 'https://spec.example.com/v1.100'],
44+
])(
45+
'supports returns false for specs with the same identity and incompatible versions',
46+
(url0, url1) => {
47+
expect(FederatedLinkUrl.fromUrl(url0).supports(FederatedLinkUrl.fromUrl(url1))).toBe(false);
48+
},
49+
);
50+
51+
test.each([
52+
['https://spec.example.com/a/b/mySchema/v1.0/', 'https://spec.example.com/a/b/mySchema/v1.0'],
53+
['https://spec.example.com', 'https://spec.example.com'],
54+
['https://spec.example.com/mySchema/v0.1?q=v#frag', 'https://spec.example.com/mySchema/v0.1'],
55+
['https://spec.example.com/v1.0', 'https://spec.example.com/v1.0'],
56+
['https://spec.example.com/vX', 'https://spec.example.com/vX'],
57+
])('toString returns the normalized url', (url, str) => {
58+
expect(FederatedLinkUrl.fromUrl(url).toString()).toBe(str);
59+
});
60+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { parse } from 'graphql';
2+
import { FederatedLink } from '../link';
3+
4+
function trimMultiline(str: string): string {
5+
return str
6+
.split('\n')
7+
.map(s => s.trim())
8+
.filter(l => !!l)
9+
.join('\n');
10+
}
11+
12+
describe('FederatedLink', () => {
13+
test.each([
14+
`
15+
extend schema
16+
@link(url: "https://specs.apollo.dev/link/v1.0")
17+
@link(url: "https://specs.apollo.dev/federation/v2.0")
18+
`,
19+
`
20+
extend schema
21+
@link(url: "https://specs.apollo.dev/link/v1.0", as: "@linkz")
22+
@link(url: "https://specs.apollo.dev/federation/v2.0", as: "fed", import: ["@key"])
23+
`,
24+
`
25+
extend schema
26+
@link(url: "https://specs.apollo.dev/link/v1.0")
27+
@link(url: "https://specs.apollo.dev/federation/v2.0", import: [{ name: "@key", as: "@lookup" }])
28+
`,
29+
])('fromTypedefs', (sdl: string) => {
30+
// string manipulate to extract just the link trings
31+
const firstLinkPos = sdl.indexOf('@link(');
32+
const linksOnly = trimMultiline(sdl.substring(firstLinkPos));
33+
// compare to parsed result
34+
const typeDefs = parse(sdl);
35+
const links = FederatedLink.fromTypedefs(typeDefs);
36+
expect(links.join('\n')).toBe(linksOnly);
37+
});
38+
39+
test('resolveImportName', () => {
40+
const sdl = `
41+
extend schema
42+
@link(url: "https://specs.apollo.dev/link/v1.0")
43+
@link(url: "https://specs.apollo.dev/federation/v2.0", import: [{ name: "@key", as: "@lookup" }, "@provides"])
44+
@link(url: "https://unnamed.graphql-hive.com/v0.1", import: ["@meta"])
45+
@link(url: "https://specs.graphql-hive.com/hive/v0.1", import: ["@group"], as: "hivelink")
46+
`;
47+
const links = FederatedLink.fromTypedefs(parse(sdl));
48+
const federationLink = links.find(l => l.identity === 'https://specs.apollo.dev/federation');
49+
expect(federationLink).toBeDefined();
50+
// aliased
51+
expect(federationLink?.resolveImportName('@key')).toBe('lookup');
52+
// unimported
53+
expect(federationLink?.resolveImportName('@external')).toBe('federation__external');
54+
// imported by name only
55+
expect(federationLink?.resolveImportName('@provides')).toBe('provides');
56+
57+
// default import
58+
const linkLink = links.find(l => l.identity === 'https://specs.apollo.dev/link');
59+
expect(linkLink?.resolveImportName('@link')).toBe('link');
60+
61+
// unnamespaced
62+
const unnamedLink = links.find(l => l.identity === 'https://unnamed.graphql-hive.com');
63+
expect(unnamedLink).toBeDefined();
64+
expect(unnamedLink?.resolveImportName('@meta')).toBe('meta');
65+
expect(unnamedLink?.resolveImportName('@unmentioned')).toBe('unmentioned');
66+
67+
// imported as
68+
const hiveLink = links.find(l => l.identity === 'https://specs.graphql-hive.com/hive');
69+
expect(hiveLink?.resolveImportName('@group')).toBe('group');
70+
expect(hiveLink?.resolveImportName('@eg')).toBe('hivelink__eg');
71+
});
72+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { parse } from 'graphql';
2+
import { FederatedLink } from '../link';
3+
import { LinkableSpec } from '../linkable-spec';
4+
5+
describe('LinkableSpec', () => {
6+
test('getSupportingVersion returned the most compatible version.', () => {
7+
const spec = new LinkableSpec('https://specs.graphql-hive.com/example', {
8+
'v2.0': _resolveImportName => 'Version 2.0 used.',
9+
'v1.0': _resolveImportName => 'Version 1.0 used.',
10+
});
11+
const sdl = `
12+
extend schema
13+
@link(url: "https://specs.graphql-hive.com/example/v1.1")
14+
`;
15+
16+
const links = FederatedLink.fromTypedefs(parse(sdl));
17+
const specImpl = spec.detectImplementation(links);
18+
expect(specImpl).toBe('Version 1.0 used.');
19+
});
20+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { DocumentNode } from 'graphql';
2+
import { FederatedLink } from './link';
3+
import type { LinkableSpec } from './linkable-spec';
4+
5+
export * from './link-import';
6+
export * from './link-url';
7+
export * from './link';
8+
export * from './linkable-spec';
9+
10+
export function detectLinkedImplementations<T>(
11+
typeDefs: DocumentNode,
12+
supportedSpecs: LinkableSpec<T>[],
13+
): T[] {
14+
const links = FederatedLink.fromTypedefs(typeDefs);
15+
return supportedSpecs
16+
.map(spec => {
17+
const specImpl = spec.detectImplementation(links);
18+
return specImpl;
19+
})
20+
.filter(v => v !== undefined);
21+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { ConstValueNode, Kind, StringValueNode } 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+
}

0 commit comments

Comments
 (0)