Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: add federation-link-util library #6452

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/libraries/federation-link-utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 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.
Comment on lines +9 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Fix Grammar in Library Description

There is a grammatical issue with the phrase “a custom features.” It would be clearer to remove the singular article and use a verb that better suits the plural noun. Consider the following adjustment:

-This library can be used to make a custom features for GraphQL schemas backed by Federation's
+This library can be used to create custom features for GraphQL schemas backed by Federation's
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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.
This library can be used to create custom features for GraphQL schemas backed by Federation's
[`@link`](https://www.apollographql.com/docs/graphos/reference/federation/directives#the-link-directive)
directive.
🧰 Tools
🪛 LanguageTool

[grammar] ~9-~9: Do not use the singular ‘a’ before the plural noun ‘features’.
Context: ... --- This library can be used to make a custom features for GraphQL schemas backed by Federatio...

(VB_A_JJ_NNS)


## 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

This library is for power users who want to develop their own Federation 2 `@link` feature(s). It
enables you to define and support multiple versions of the feature and to easily reference the named
imports. This includes official federation features if you choose to implement them yourself.

```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
jdolle marked this conversation as resolved.
Show resolved Hide resolved
import {
detectLinkedImplementations,
FEDERATION_V1,
LinkableSpec
} from '@graphql-hive/federation-link-utils'

const exampleSpec = new LinkableSpec('https://specs.graphql-hive.com/example', {
[FEDERATION_V1]: resolveImportName => (typeDefs: DocumentNode) => {
// option to support federation 1 schemas. Be extremely cautious here because versioning
// cannot be done safely.
},
'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,98 @@
import { DocumentNode, parse, StringValueNode, visit } from 'graphql';
import { detectLinkedImplementations, FEDERATION_V1, LinkableSpec } from '../index';

const metaSpec = new LinkableSpec('https://specs.graphql-hive.com/metadata', {
[FEDERATION_V1]: _resolveImportName => (_typeDefs: DocumentNode) => {
return 'Missing federation 2 support';
},

// 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...`;
},
});

test('LinkableSpec and detectLinkedImplementations can be used to apply linked schema in Federation 2.x', () => {
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 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"}}},
]
`);
});

test('LinkableSpec and detectLinkedImplementations can be used to apply linked schema in schemas that are missing the link directive', () => {
const sdl = `
directive @meta(name: String!, content: String!) on SCHEMA | FIELD

type Query {
ping: String @meta(name: "owner", content: "hive-console-team")
}
`;
const typeDefs = parse(sdl);
const linked = detectLinkedImplementations(typeDefs, [metaSpec]);
expect(linked.map(link => link(typeDefs))).toMatchInlineSnapshot(`
[
Missing federation 2 support,
]
`);
});
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.');
});
});
Loading
Loading