Skip to content

Commit

Permalink
feature: add linkable-spec utility lib
Browse files Browse the repository at this point in the history
  • Loading branch information
jdolle committed Feb 3, 2025
1 parent 25a8c65 commit bf8c6e8
Show file tree
Hide file tree
Showing 13 changed files with 655 additions and 0 deletions.
9 changes: 9 additions & 0 deletions packages/libraries/linkable-specs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# GraphQL Hive - linkable-specs

[Hive](https://the-guild.dev/graphql/hive) is a fully open-source schema registry, analytics,
metrics and gateway for [GraphQL federation](https://the-guild.dev/graphql/hive/federation) and
other GraphQL APIs.

---

Utility classes for parsing federated `@link`s.
62 changes: 62 additions & 0 deletions packages/libraries/linkable-specs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"name": "@graphql-hive/linkable-specs",
"version": "0.0.1",
"type": "module",
"repository": {
"type": "git",
"url": "graphql-hive/platform",
"directory": "packages/libraries/linkable-specs"
},
"homepage": "https://the-guild.dev/graphql/hive",
"author": {
"email": "[email protected]",
"name": "The Guild",
"url": "https://the-guild.dev"
},
"license": "MIT",
"private": true,
"engines": {
"node": ">=16.0.0"
},
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"exports": {
".": {
"require": {
"types": "./dist/typings/index.d.cts",
"default": "./dist/cjs/index.js"
},
"import": {
"types": "./dist/typings/index.d.ts",
"default": "./dist/esm/index.js"
},
"default": {
"types": "./dist/typings/index.d.ts",
"default": "./dist/esm/index.js"
}
},
"./package.json": "./package.json"
},
"typings": "dist/typings/index.d.ts",
"scripts": {
"build": "node ../../../scripts/generate-version.mjs && bob build",
"check:build": "bob check"
},
"peerDependencies": {
"graphql": "^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
},
"devDependencies": {
"graphql": "16.9.0",
"tslib": "2.8.1",
"vitest": "2.0.5"
},
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public",
"directory": "dist"
},
"sideEffects": false,
"typescript": {
"definition": "dist/typings/index.d.ts"
}
}
79 changes: 79 additions & 0 deletions packages/libraries/linkable-specs/src/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { DocumentNode, parse, StringValueNode, visit } from 'graphql';
import { detectLinkedImplementations, LinkableSpec } from '../index';

describe('index', () => {
test('LinkableSpec and detectLinkedImplementations can be used to easily implement linked schema functionality', () => {
const sdl = `
directive @meta(name: String!, content: String!) on SCHEMA | FIELD
directive @metadata_example(eg: String!) on FIELD
extend schema
@link(url: "https://specs.apollo.dev/link/v1.0")
@link(url: "https://specs.graphql-hive.com/metadata/v0.1", import: ["@meta"])
type Query {
ping: String @meta(name: "owner", content: "hive-console-team")
pong: String @metadata__example(eg: "1...2...3... Pong")
}
`;

const metaSpec = new LinkableSpec('https://specs.graphql-hive.com/metadata', {
// The return value could be used to map sdl, collect information, or create a graphql yoga plugin.
// In this test, it's used to collect metadata information from the schema.
'v0.1': resolveImportName => (typeDefs: DocumentNode) => {
const collectedMeta: Record<string, Record<string, string>> = {};
const metaName = resolveImportName('@meta');
const exampleName = resolveImportName('@example');
visit(typeDefs, {
FieldDefinition: node => {
let metaData: Record<string, string> = {};
const fieldName = node.name.value;
const meta = node.directives?.find(d => d.name.value === metaName);
if (meta) {
metaData['name'] =
(
meta.arguments?.find(a => a.name.value === 'name')?.value as
| StringValueNode
| undefined
)?.value ?? '??';
metaData['content'] =
(
meta.arguments?.find(a => a.name.value === 'content')?.value as
| StringValueNode
| undefined
)?.value ?? '??';
}

const example = node.directives?.find(d => d.name.value === exampleName);
if (example) {
metaData['eg'] =
(
example.arguments?.find(a => a.name.value === 'eg')?.value as
| StringValueNode
| undefined
)?.value ?? '??';
}
if (Object.keys(metaData).length) {
collectedMeta[fieldName] ??= {};
collectedMeta[fieldName] = Object.assign(collectedMeta[fieldName], metaData);
}
return;
},
});
// collect metadata
return `running on v0.1.\nFound metadata: ${JSON.stringify(collectedMeta)}}`;
},
'v0.2': _resolveImportName => (_typeDefs: DocumentNode) => {
// collect metadata
return `running on v0.2...`;
},
});
const typeDefs = parse(sdl);
const linked = detectLinkedImplementations(typeDefs, [metaSpec]);
expect(linked.map(link => link(typeDefs))).toMatchInlineSnapshot(`
[
running on v0.1.
Found metadata: {"ping":{"name":"owner","content":"hive-console-team"},"pong":{"eg":"1...2...3... Pong"}}},
]
`);
});
});
60 changes: 60 additions & 0 deletions packages/libraries/linkable-specs/src/__tests__/link-url.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { FederatedLinkUrl } from '../link-url';

describe('FederatedLinkUrl', () => {
test.each([
[
'https://spec.example.com/a/b/mySchema/v1.0/',
'https://spec.example.com/a/b/mySchema',
'mySchema',
'v1.0',
],
['https://spec.example.com', 'https://spec.example.com', null, null],
[
'https://spec.example.com/mySchema/v0.1?q=v#frag',
'https://spec.example.com/mySchema',
'mySchema',
'v0.1',
],
['https://spec.example.com/v1.0', 'https://spec.example.com', null, 'v1.0'],
['https://spec.example.com/vX', 'https://spec.example.com/vX', 'vX', null],
])('fromUrl', (url, identity, name, version) => {
const spec = FederatedLinkUrl.fromUrl(url);
expect(spec.identity).toBe(identity);
expect(spec.name).toBe(name);
expect(spec.version).toBe(version);
});

test.each([
['https://spec.example.com/a/b/mySchema/v1.2/', 'https://spec.example.com/a/b/mySchema/v1.0/'],
['https://spec.example.com', 'https://spec.example.com'],
['https://spec.example.com/mySchema/v0.1?q=v#frag', 'https://spec.example.com/mySchema/v0.1'],
['https://spec.example.com/v1.100', 'https://spec.example.com/v1.0'],
['https://spec.example.com/vX', 'https://spec.example.com/vX'],
])(
'supports returns true for specs with the same identity and compatible versions',
(url0, url1) => {
expect(FederatedLinkUrl.fromUrl(url0).supports(FederatedLinkUrl.fromUrl(url1))).toBe(true);
},
);

test.each([
['https://spec.example.com/a/b/mySchema/v1.0/', 'https://spec.example.com/a/b/mySchema/v1.2/'],
['https://spec.example.com/mySchema/v0.1?q=v#frag', 'https://spec.example.com/mySchema/v0.2'],
['https://spec.example.com/v1.0', 'https://spec.example.com/v1.100'],
])(
'supports returns false for specs with the same identity and incompatible versions',
(url0, url1) => {
expect(FederatedLinkUrl.fromUrl(url0).supports(FederatedLinkUrl.fromUrl(url1))).toBe(false);
},
);

test.each([
['https://spec.example.com/a/b/mySchema/v1.0/', 'https://spec.example.com/a/b/mySchema/v1.0'],
['https://spec.example.com', 'https://spec.example.com'],
['https://spec.example.com/mySchema/v0.1?q=v#frag', 'https://spec.example.com/mySchema/v0.1'],
['https://spec.example.com/v1.0', 'https://spec.example.com/v1.0'],
['https://spec.example.com/vX', 'https://spec.example.com/vX'],
])('toString returns the normalized url', (url, str) => {
expect(FederatedLinkUrl.fromUrl(url).toString()).toBe(str);
});
});
72 changes: 72 additions & 0 deletions packages/libraries/linkable-specs/src/__tests__/link.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { parse } from 'graphql';
import { FederatedLink } from '../link';

function trimMultiline(str: string): string {
return str
.split('\n')
.map(s => s.trim())
.filter(l => !!l)
.join('\n');
}

describe('FederatedLink', () => {
test.each([
`
extend schema
@link(url: "https://specs.apollo.dev/link/v1.0")
@link(url: "https://specs.apollo.dev/federation/v2.0")
`,
`
extend schema
@link(url: "https://specs.apollo.dev/link/v1.0", as: "@linkz")
@link(url: "https://specs.apollo.dev/federation/v2.0", as: "fed", import: ["@key"])
`,
`
extend schema
@link(url: "https://specs.apollo.dev/link/v1.0")
@link(url: "https://specs.apollo.dev/federation/v2.0", import: [{ name: "@key", as: "@lookup" }])
`,
])('fromTypedefs', (sdl: string) => {
// string manipulate to extract just the link trings
const firstLinkPos = sdl.indexOf('@link(');
const linksOnly = trimMultiline(sdl.substring(firstLinkPos));
// compare to parsed result
const typeDefs = parse(sdl);
const links = FederatedLink.fromTypedefs(typeDefs);
expect(links.join('\n')).toBe(linksOnly);
});

test('resolveImportName', () => {
const sdl = `
extend schema
@link(url: "https://specs.apollo.dev/link/v1.0")
@link(url: "https://specs.apollo.dev/federation/v2.0", import: [{ name: "@key", as: "@lookup" }, "@provides"])
@link(url: "https://unnamed.graphql-hive.com/v0.1", import: ["@meta"])
@link(url: "https://specs.graphql-hive.com/hive/v0.1", import: ["@group"], as: "hivelink")
`;
const links = FederatedLink.fromTypedefs(parse(sdl));
const federationLink = links.find(l => l.identity === 'https://specs.apollo.dev/federation');
expect(federationLink).toBeDefined();
// aliased
expect(federationLink?.resolveImportName('@key')).toBe('lookup');
// unimported
expect(federationLink?.resolveImportName('@external')).toBe('federation__external');
// imported by name only
expect(federationLink?.resolveImportName('@provides')).toBe('provides');

// default import
const linkLink = links.find(l => l.identity === 'https://specs.apollo.dev/link');
expect(linkLink?.resolveImportName('@link')).toBe('link');

// unnamespaced
const unnamedLink = links.find(l => l.identity === 'https://unnamed.graphql-hive.com');
expect(unnamedLink).toBeDefined();
expect(unnamedLink?.resolveImportName('@meta')).toBe('meta');
expect(unnamedLink?.resolveImportName('@unmentioned')).toBe('unmentioned');

// imported as
const hiveLink = links.find(l => l.identity === 'https://specs.graphql-hive.com/hive');
expect(hiveLink?.resolveImportName('@group')).toBe('group');
expect(hiveLink?.resolveImportName('@eg')).toBe('hivelink__eg');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { parse } from 'graphql';
import { FederatedLink } from '../link';
import { LinkableSpec } from '../linkable-spec';

describe('LinkableSpec', () => {
test('getSupportingVersion returned the most compatible version.', () => {
const spec = new LinkableSpec('https://specs.graphql-hive.com/example', {
'v2.0': _resolveImportName => 'Version 2.0 used.',
'v1.0': _resolveImportName => 'Version 1.0 used.',
});
const sdl = `
extend schema
@link(url: "https://specs.graphql-hive.com/example/v1.1")
`;

const links = FederatedLink.fromTypedefs(parse(sdl));
const specImpl = spec.detectImplementation(links);
expect(specImpl).toBe('Version 1.0 used.');
});
});
21 changes: 21 additions & 0 deletions packages/libraries/linkable-specs/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { DocumentNode } from 'graphql';
import { FederatedLink } from './link';
import type { LinkableSpec } from './linkable-spec';

export * from './link-import';
export * from './link-url';
export * from './link';
export * from './linkable-spec';

export function detectLinkedImplementations<T>(
typeDefs: DocumentNode,
supportedSpecs: LinkableSpec<T>[],
): T[] {
const links = FederatedLink.fromTypedefs(typeDefs);
return supportedSpecs
.map(spec => {
const specImpl = spec.detectImplementation(links);
return specImpl;
})
.filter(v => v !== undefined);
}
48 changes: 48 additions & 0 deletions packages/libraries/linkable-specs/src/link-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ConstValueNode, Kind, StringValueNode } from 'graphql';

export class FederatedLinkImport {
constructor(
public name: string,
public as: string | null,
) {}

public toString(): string {
return this.as ? `{ name: "${this.name}", as: "${this.as}" }` : `"${this.name}"`;
}

static fromTypedefs(node: ConstValueNode): FederatedLinkImport[] {
if (node.kind == Kind.LIST) {
const imports = node.values.map(v => {
if (v.kind === Kind.STRING) {
return new FederatedLinkImport(v.value, null);
}
if (v.kind === Kind.OBJECT) {
let name: string = '';
let as: string | null = null;

v.fields.forEach(f => {
if (f.name.value === 'name') {
if (f.value.kind !== Kind.STRING) {
throw new Error(
`Expected string value for @link "name" field but got "${f.value.kind}"`,
);
}
name = f.value.value;
} else if (f.name.value === 'as') {
if (f.value.kind !== Kind.STRING) {
throw new Error(
`Expected string value for @link "as" field but got "${f.value.kind}"`,
);
}
as = f.value.value;
}
});
return new FederatedLinkImport(name, as);
}
throw new Error(`Unexpected value kind "${v.kind}" in @link import declaration`);
});
return imports;
}
throw new Error(`Expected a list of @link imports but got "${node.kind}"`);
}
}
Loading

0 comments on commit bf8c6e8

Please sign in to comment.