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 4, 2025
1 parent fa9dcf0 commit 484e969
Show file tree
Hide file tree
Showing 15 changed files with 843 additions and 36 deletions.
67 changes: 67 additions & 0 deletions packages/libraries/federation-link-utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# GraphQL Hive - federation-link-utils

[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.

---

This library can be used to make a custom features for GraphQL schemas backed by Federation's
[`@link`](https://www.apollographql.com/docs/graphos/reference/federation/directives#the-link-directive)
directive.

## Features

- Link version support.
- Import `as`/namespacing support that follows the [link spec](https://specs.apollo.dev/link/v1.0/).
- Only `graphql` as a dependency.

## Usage

```graphql
# schema.graphql

directive @example(eg: String!) on FIELD
extend schema @link(url: "https://specs.graphql-hive.com/example/v0.1", import: ["@example"])
type Query {
user: User @example(eg: "query { user { id name } }")
}

type User {
id: ID!
name: String
}
```

```typescript
// specs.ts
import { detectLinkedImplementations, LinkableSpec } from '@graphql-hive/federation-link-utils'

const exampleSpec = new LinkableSpec('https://specs.graphql-hive.com/example', {
'v0.1': resolveImportName => (typeDefs: DocumentNode) => {
const examples: Record<string, string> = {}
const exampleName = resolveImportName('@example')
visit(typeDefs, {
FieldDefinition: node => {
const example = node.directives?.find(d => d.name.value === exampleName)
if (example) {
examples[node.name.value] = (
example.arguments?.find(a => a.name.value === 'eg')?.value as
| StringValueNode
| undefined
)?.value
}
}
})
return examples
}
})
const typeDefs = parse(sdl)
const linkedSpecs = detectLinkedImplementations(typeDefs, [exampleSpec])
const result = linkedSpecs.map(apply => apply(typeDefs))

// result[0] ==> { user: "query { user { id name } }"}
```

The LinkableSpec is unopinionated on how the spec is implemented. However, it's recommended to keep
this consistent between all LinkedSpecs. I.e. always return a yoga plugin.
62 changes: 62 additions & 0 deletions packages/libraries/federation-link-utils/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"name": "@graphql-hive/federation-link-utils",
"version": "0.0.1",
"type": "module",
"repository": {
"type": "git",
"url": "graphql-hive/platform",
"directory": "packages/libraries/federation-link-utils"
},
"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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { DocumentNode, parse, StringValueNode, visit } from 'graphql';
import { detectLinkedImplementations, LinkableSpec } from '../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/federation/v2.3")
@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"}}},
]
`);
});
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 correctly parses the identity, name, and version', (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);
});
});
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/federation-link-utils/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.js';
import type { LinkableSpec } from './linkable-spec.js';

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

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);
}
Loading

0 comments on commit 484e969

Please sign in to comment.