From 484e9695ccb29303619af3af43c64ef975eb2ee7 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 30 Jan 2025 20:46:08 -0800 Subject: [PATCH 1/2] feature: add linkable-spec utility lib --- .../libraries/federation-link-utils/README.md | 67 +++++++ .../federation-link-utils/package.json | 62 +++++++ .../src/__tests__/index.spec.ts | 77 ++++++++ .../src/__tests__/link-url.spec.ts | 60 +++++++ .../src/__tests__/link.spec.ts | 72 ++++++++ .../src/__tests__/linkable-spec.spec.ts | 20 +++ .../federation-link-utils/src/index.ts | 21 +++ .../federation-link-utils/src/link-import.ts | 48 +++++ .../federation-link-utils/src/link-url.ts | 86 +++++++++ .../federation-link-utils/src/link.ts | 131 ++++++++++++++ .../src/linkable-spec.ts | 53 ++++++ .../federation-link-utils/src/version.ts | 1 + .../federation-link-utils/tsconfig.json | 14 ++ pnpm-lock.yaml | 164 ++++++++++++++---- tsconfig.json | 3 + 15 files changed, 843 insertions(+), 36 deletions(-) create mode 100644 packages/libraries/federation-link-utils/README.md create mode 100644 packages/libraries/federation-link-utils/package.json create mode 100644 packages/libraries/federation-link-utils/src/__tests__/index.spec.ts create mode 100644 packages/libraries/federation-link-utils/src/__tests__/link-url.spec.ts create mode 100644 packages/libraries/federation-link-utils/src/__tests__/link.spec.ts create mode 100644 packages/libraries/federation-link-utils/src/__tests__/linkable-spec.spec.ts create mode 100644 packages/libraries/federation-link-utils/src/index.ts create mode 100644 packages/libraries/federation-link-utils/src/link-import.ts create mode 100644 packages/libraries/federation-link-utils/src/link-url.ts create mode 100644 packages/libraries/federation-link-utils/src/link.ts create mode 100644 packages/libraries/federation-link-utils/src/linkable-spec.ts create mode 100644 packages/libraries/federation-link-utils/src/version.ts create mode 100644 packages/libraries/federation-link-utils/tsconfig.json diff --git a/packages/libraries/federation-link-utils/README.md b/packages/libraries/federation-link-utils/README.md new file mode 100644 index 0000000000..de8743d30f --- /dev/null +++ b/packages/libraries/federation-link-utils/README.md @@ -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 = {} + 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. diff --git a/packages/libraries/federation-link-utils/package.json b/packages/libraries/federation-link-utils/package.json new file mode 100644 index 0000000000..10ebf791e6 --- /dev/null +++ b/packages/libraries/federation-link-utils/package.json @@ -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": "contact@the-guild.dev", + "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" + } +} diff --git a/packages/libraries/federation-link-utils/src/__tests__/index.spec.ts b/packages/libraries/federation-link-utils/src/__tests__/index.spec.ts new file mode 100644 index 0000000000..3603ac597c --- /dev/null +++ b/packages/libraries/federation-link-utils/src/__tests__/index.spec.ts @@ -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> = {}; + const metaName = resolveImportName('@meta'); + const exampleName = resolveImportName('@example'); + visit(typeDefs, { + FieldDefinition: node => { + let metaData: Record = {}; + 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"}}}, + ] + `); +}); diff --git a/packages/libraries/federation-link-utils/src/__tests__/link-url.spec.ts b/packages/libraries/federation-link-utils/src/__tests__/link-url.spec.ts new file mode 100644 index 0000000000..0c6871c954 --- /dev/null +++ b/packages/libraries/federation-link-utils/src/__tests__/link-url.spec.ts @@ -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); + }); +}); diff --git a/packages/libraries/federation-link-utils/src/__tests__/link.spec.ts b/packages/libraries/federation-link-utils/src/__tests__/link.spec.ts new file mode 100644 index 0000000000..446b1256a8 --- /dev/null +++ b/packages/libraries/federation-link-utils/src/__tests__/link.spec.ts @@ -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'); + }); +}); diff --git a/packages/libraries/federation-link-utils/src/__tests__/linkable-spec.spec.ts b/packages/libraries/federation-link-utils/src/__tests__/linkable-spec.spec.ts new file mode 100644 index 0000000000..f5473c706b --- /dev/null +++ b/packages/libraries/federation-link-utils/src/__tests__/linkable-spec.spec.ts @@ -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.'); + }); +}); diff --git a/packages/libraries/federation-link-utils/src/index.ts b/packages/libraries/federation-link-utils/src/index.ts new file mode 100644 index 0000000000..6821fc0ee3 --- /dev/null +++ b/packages/libraries/federation-link-utils/src/index.ts @@ -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( + typeDefs: DocumentNode, + supportedSpecs: LinkableSpec[], +): T[] { + const links = FederatedLink.fromTypedefs(typeDefs); + return supportedSpecs + .map(spec => { + const specImpl = spec.detectImplementation(links); + return specImpl; + }) + .filter(v => v !== undefined); +} diff --git a/packages/libraries/federation-link-utils/src/link-import.ts b/packages/libraries/federation-link-utils/src/link-import.ts new file mode 100644 index 0000000000..809508748c --- /dev/null +++ b/packages/libraries/federation-link-utils/src/link-import.ts @@ -0,0 +1,48 @@ +import { ConstValueNode, Kind } 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}"`); + } +} diff --git a/packages/libraries/federation-link-utils/src/link-url.ts b/packages/libraries/federation-link-utils/src/link-url.ts new file mode 100644 index 0000000000..a18f8f2634 --- /dev/null +++ b/packages/libraries/federation-link-utils/src/link-url.ts @@ -0,0 +1,86 @@ +const VERSION_MATCH = /v(\d{1,2})\.(\d{1,4})/i; + +function parseVersion(version: string | null): [number, number] { + const versionParts = version?.match(VERSION_MATCH); + if (versionParts?.length) { + const [_full, major, minor] = versionParts; + return [Number(major), Number(minor)]; + } + return [-1, -1]; +} + +/** + * A wrapper around the `@link` url -- this parses all necessary data to identify the link + * and determine which version is most appropriate to use. + */ +export class FederatedLinkUrl { + // -1 if no version is set + private readonly major: number; + private readonly minor: number; + + constructor( + public readonly identity: string, + public readonly name: string | null, + public readonly version: string | null, + ) { + const [major, minor] = parseVersion(version); + this.major = major; + this.minor = minor; + } + + public toString(): string { + return `${this.identity}${this.version ? `/${this.version}` : ''}`; + } + + static fromUrl = (urlSource: string): FederatedLinkUrl => { + const url = new URL(urlSource); + const parts = url.pathname.split('/').filter(Boolean); + const versionOrName = parts[parts.length - 1]; + if (versionOrName) { + if (VERSION_MATCH.test(versionOrName)) { + const maybeName = parts[parts.length - 2]; + return new FederatedLinkUrl( + url.origin + (maybeName ? `/${parts.slice(0, parts.length - 1).join('/')}` : ''), + maybeName ?? null, + versionOrName, + ); + } + return new FederatedLinkUrl(`${url.origin}/${parts.join('/')}`, versionOrName, null); + } + return new FederatedLinkUrl(url.origin, null, null); + }; + + /** Check if this version supports another version */ + supports(version: string): boolean; + supports(major: number, minor: number): boolean; + supports(version: FederatedLinkUrl): boolean; + supports(...args: [string] | [number, number] | [FederatedLinkUrl]): boolean { + const majorOrVersion = args[0]; + let major: number, minor: number; + if (typeof majorOrVersion === 'string') { + [major, minor] = parseVersion(majorOrVersion); + } else if (typeof majorOrVersion === 'number') { + [major, minor] = args as [number, number]; + } else if (majorOrVersion instanceof FederatedLinkUrl) { + // check that it is the same spec + if (majorOrVersion.identity !== this.identity) { + return false; + } + major = majorOrVersion.major; + minor = majorOrVersion.minor; + } else { + throw new Error(`Unsupported version argument: ${args}.`); + } + return this.isCompatibleVersion(major, minor); + } + + private isCompatibleVersion(major: number, minor: number): boolean { + if (this.major === major) { + if (this.major === 0) { + return this.minor === minor; + } + return this.minor >= minor; + } + return false; + } +} diff --git a/packages/libraries/federation-link-utils/src/link.ts b/packages/libraries/federation-link-utils/src/link.ts new file mode 100644 index 0000000000..a1664b7032 --- /dev/null +++ b/packages/libraries/federation-link-utils/src/link.ts @@ -0,0 +1,131 @@ +import { ConstArgumentNode, DocumentNode, Kind } from 'graphql'; +import { FederatedLinkImport } from './link-import.js'; +import { FederatedLinkUrl } from './link-url.js'; + +function linkFromArgs(args: readonly ConstArgumentNode[]): FederatedLink | undefined { + let url: FederatedLinkUrl | undefined, + imports: FederatedLinkImport[] = [], + as: string | null = null; + for (const arg of args) { + switch (arg.name.value) { + case 'url': { + if (arg.value.kind === Kind.STRING) { + url = FederatedLinkUrl.fromUrl(arg.value.value); + } else { + console.warn(`Unexpected kind, ${arg.value.kind}, for argument "url" in @link.`); + } + break; + } + case 'import': { + imports = FederatedLinkImport.fromTypedefs(arg.value); + break; + } + case 'as': { + if (arg.value.kind === Kind.STRING) { + as = arg.value.value ?? null; + } else { + console.warn(`Unexpected kind, ${arg.value.kind}, for argument "as" in @link.`); + } + break; + } + default: { + // ignore. Federation should validate links. + } + } + } + if (url !== undefined) { + return new FederatedLink(url, as, imports); + } + return; +} + +function namespaced(namespace: string | null, name: string) { + if (namespace?.length) { + if (name.startsWith('@')) { + return `@${namespace}__${name.substring(1)}`; + } + return `${namespace}__${name}`; + } + return name; +} + +export class FederatedLink { + // @todo does this need to include import names for every feature or just the namespace? + constructor( + private readonly url: FederatedLinkUrl, + private readonly as: string | null, + private readonly imports: FederatedLinkImport[], + ) {} + + /** Collects all `@link`s defined in graphql typedefs */ + static fromTypedefs(typeDefs: DocumentNode): FederatedLink[] { + let links: FederatedLink[] = []; + for (const definition of typeDefs.definitions) { + if (definition.kind === Kind.SCHEMA_EXTENSION || definition.kind === Kind.SCHEMA_DEFINITION) { + const defLinks = definition.directives?.filter( + directive => directive.name.value === 'link', + ); + const parsedLinks = + defLinks?.map(l => linkFromArgs(l.arguments ?? [])).filter(l => l !== undefined) ?? []; + links = links.concat(parsedLinks); + } + } + return links; + } + + /** + * By default, `@link` will assign a prefix based on the name extracted from the URL. + * If no name is present, a prefix will not be assigned. + * See: https://specs.apollo.dev/link/v1.0/#@link.as + */ + private get namespace(): string | null { + return this.as ?? this.url.name; + } + + toString(): string { + return `@link(url: "${this.url}"${this.as ? `, as: "${this.as}"` : ''}${this.imports.length ? `, import: [${this.imports.join(', ')}]` : ''})`; + } + + get defaultImport(): string | null { + return this.namespace && `@${this.namespace}`; + } + + /** The Link's identity. This is the unique identifier of a `@link`. */ + get identity(): string { + return this.url.identity; + } + + supports(version: string): boolean; + supports(major: number, minor: number): boolean; + supports(version: FederatedLinkUrl): boolean; + supports(...args: [string] | [number, number] | [FederatedLinkUrl]): boolean { + /** @ts-expect-error: ignore tuple error. These are tuples and can be spread. tsc is wrong. */ + return this.url.supports(...args); + } + + /** + * Given the name of an element in a linked schema, this returns the name of that element + * as it has been imported. This accounts for aliasing and namespacing unreferenced imports. + * This can be used by LinkSpecs to get the translated names of elements. + * + * The directive `@` prefix is removed from the returned name. This is to make it easier to match node names when visiting a schema definition. + * When visiting nodes, a directive's name doesn't include the `@`. + * However, the `@` is necessary for the input parameter in order to know how to correctly resolve the name. + * Otherwise, setting the import argument as: import: ["foo"] would incorrectly return `foo` for `@foo`, when it should be `{namespace}__foo`. + * + * @name string The element name in the linked schema. If this is the name of the link (e.g. "example" when linking "https://foo.graphql-hive.com/example"), then this returns the default link import. + * @throws if both importName is null and the url has no name. + * @returns The name of the element as it has been imported. + */ + resolveImportName(elementName: string): string { + if (this.url.name && elementName === `@${this.url.name}`) { + // @note: default is a directive... So remove the `@` + return this.defaultImport!.substring(1); + } + const imported = this.imports.find(i => i.name === elementName); + let resolvedName = imported?.as ?? imported?.name ?? namespaced(this.namespace, elementName); + // Strip the `@` prefix for directives because in all implementations of mapping or visiting a schema, + // directive names are not prefixed with `@`. The `@` is only for SDL. + return resolvedName.startsWith('@') ? resolvedName.substring(1) : resolvedName; + } +} diff --git a/packages/libraries/federation-link-utils/src/linkable-spec.ts b/packages/libraries/federation-link-utils/src/linkable-spec.ts new file mode 100644 index 0000000000..753e9115b5 --- /dev/null +++ b/packages/libraries/federation-link-utils/src/linkable-spec.ts @@ -0,0 +1,53 @@ +import { FederatedLink } from './link'; + +export type Versioned = { + [minVersion: string]: (resolveImportName: FederatedLink['resolveImportName']) => T; +}; + +export class LinkableSpec { + private readonly sortedVersionKeys: string[]; + + constructor( + public readonly identity: string, + public readonly versions: Versioned, + ) { + // sort the versions in descending order for quicker lookups + this.sortedVersionKeys = Object.keys(versions).sort((a, b) => { + const [aMajor, aMinor] = a.split('.').map(Number); + const [bMajor, bMinor] = b.split('.').map(Number); + return bMajor !== aMajor ? bMajor - aMajor : bMinor - aMinor; + }); + } + + /** + * + * @param links List of links used in a schema. Can be extracted from SDL using: `FederatedLink.fromTypedefs(parse(sdl))` + * @returns the minimum version that is supported by this LinkableSpec + */ + private detectLinkVersion(link: FederatedLink): string | null { + // for every link, find the highest supported version + const impl = + link.identity === this.identity && + this.sortedVersionKeys.find(minVersion => link.supports(minVersion)); + return impl || null; + } + + private findLinkByIdentity(links: FederatedLink[]): FederatedLink | undefined { + return links.find(link => link.identity === this.identity); + } + + public detectImplementation(links: FederatedLink[]): T | undefined { + const maybeLink = this.findLinkByIdentity(links); + if (maybeLink) { + const version = this.detectLinkVersion(maybeLink); + if (version) { + const activeVersion = this.versions[version]; + return activeVersion?.(maybeLink.resolveImportName.bind(maybeLink)); + } + console.warn( + `Cannot apply @link due to unsupported version found for "${this.identity}". ` + + `Available versions: ${this.sortedVersionKeys.join(', ')} and any version these are compatible compatible with.`, + ); + } + } +} diff --git a/packages/libraries/federation-link-utils/src/version.ts b/packages/libraries/federation-link-utils/src/version.ts new file mode 100644 index 0000000000..266e19f220 --- /dev/null +++ b/packages/libraries/federation-link-utils/src/version.ts @@ -0,0 +1 @@ +export const version = '0.0.1'; diff --git a/packages/libraries/federation-link-utils/tsconfig.json b/packages/libraries/federation-link-utils/tsconfig.json new file mode 100644 index 0000000000..8cc32d09e0 --- /dev/null +++ b/packages/libraries/federation-link-utils/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src"], + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist", + "rootDir": "src", + "target": "es2017", + "module": "esnext", + "skipLibCheck": true, + "declaration": true, + "declarationMap": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c7964f68c..514e784902 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -543,6 +543,19 @@ importers: version: 16.9.0 publishDirectory: dist + packages/libraries/federation-link-utils: + devDependencies: + graphql: + specifier: 16.9.0 + version: 16.9.0 + tslib: + specifier: 2.8.1 + version: 2.8.1 + vitest: + specifier: 2.0.5 + version: 2.0.5(@types/node@22.10.5)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0) + publishDirectory: dist + packages/libraries/router: {} packages/libraries/yoga: @@ -8321,9 +8334,15 @@ packages: '@vitest/pretty-format@3.0.4': resolution: {integrity: sha512-ts0fba+dEhK2aC9PFuZ9LTpULHpY/nd6jhAQ5IMU7Gaj7crPCTdCFfgvXxruRBLFS+MLraicCuFXxISEq8C93g==} + '@vitest/runner@2.0.5': + resolution: {integrity: sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==} + '@vitest/runner@3.0.4': resolution: {integrity: sha512-dKHzTQ7n9sExAcWH/0sh1elVgwc7OJ2lMOBrAm73J7AH6Pf9T12Zh3lNE1TETZaqrWFXtLlx3NVrLRb5hCK+iw==} + '@vitest/snapshot@2.0.5': + resolution: {integrity: sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==} + '@vitest/snapshot@3.0.4': resolution: {integrity: sha512-+p5knMLwIk7lTQkM3NonZ9zBewzVp9EVkVpvNta0/PlFWpiqLaRcF4+33L1it3uRUCh0BGLOaXPPGEjNKfWb4w==} @@ -8948,10 +8967,6 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chai@5.1.1: - resolution: {integrity: sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==} - engines: {node: '>=12'} - chai@5.1.2: resolution: {integrity: sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==} engines: {node: '>=12'} @@ -15152,10 +15167,6 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyspy@3.0.0: - resolution: {integrity: sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==} - engines: {node: '>=14.0.0'} - tinyspy@3.0.2: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} @@ -15749,6 +15760,11 @@ packages: vfile@6.0.1: resolution: {integrity: sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==} + vite-node@2.0.5: + resolution: {integrity: sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-node@3.0.4: resolution: {integrity: sha512-7JZKEzcYV2Nx3u6rlvN8qdo3QV7Fxyt6hx+CCKz9fbWxdX5IvUOmTWEAxMrWxaiSf7CKGLJQ5rFu8prb/jBjOA==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -15833,6 +15849,31 @@ packages: yaml: optional: true + vitest@2.0.5: + resolution: {integrity: sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.0.5 + '@vitest/ui': 2.0.5 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@3.0.4: resolution: {integrity: sha512-6XG8oTKy2gnJIFTHP6LD7ExFeNLxiTkK3CfMvT7IfR8IN+BYICCf0lXUQmX7i7JoxUP8QmeP4mTnWXgflu4yjw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -16196,7 +16237,7 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: - '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 '@antfu/install-pkg@0.4.1': @@ -19976,7 +20017,7 @@ snapshots: '@antfu/install-pkg': 0.4.1 '@antfu/utils': 0.7.10 '@iconify/types': 2.0.0 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.4.0 kolorist: 1.8.0 local-pkg: 0.5.0 mlly: 1.7.3 @@ -20136,7 +20177,6 @@ snapshots: '@jridgewell/set-array': 1.2.1 '@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/trace-mapping': 0.3.25 - optional: true '@jridgewell/resolve-uri@3.1.1': {} @@ -24756,7 +24796,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.7.3) '@typescript-eslint/utils': 7.18.0(eslint@8.57.1(patch_hash=fjbpfrtrjd6idngyeqxnwopfva))(typescript@5.7.3) - debug: 4.3.7(supports-color@8.1.1) + debug: 4.4.0 eslint: 8.57.1(patch_hash=fjbpfrtrjd6idngyeqxnwopfva) ts-api-utils: 1.3.0(typescript@5.7.3) optionalDependencies: @@ -24770,7 +24810,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -24799,7 +24839,7 @@ snapshots: '@typescript/vfs@1.5.0': dependencies: - debug: 4.3.7(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -24844,7 +24884,7 @@ snapshots: dependencies: '@vitest/spy': 2.0.5 '@vitest/utils': 2.0.5 - chai: 5.1.1 + chai: 5.1.2 tinyrainbow: 1.2.0 '@vitest/expect@3.0.4': @@ -24874,11 +24914,22 @@ snapshots: dependencies: tinyrainbow: 2.0.0 + '@vitest/runner@2.0.5': + dependencies: + '@vitest/utils': 2.0.5 + pathe: 1.1.2 + '@vitest/runner@3.0.4': dependencies: '@vitest/utils': 3.0.4 pathe: 2.0.2 + '@vitest/snapshot@2.0.5': + dependencies: + '@vitest/pretty-format': 2.0.5 + magic-string: 0.30.17 + pathe: 1.1.2 + '@vitest/snapshot@3.0.4': dependencies: '@vitest/pretty-format': 3.0.4 @@ -24887,7 +24938,7 @@ snapshots: '@vitest/spy@2.0.5': dependencies: - tinyspy: 3.0.0 + tinyspy: 3.0.2 '@vitest/spy@3.0.4': dependencies: @@ -25002,13 +25053,13 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.3.7(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color agent-base@7.1.0: dependencies: - debug: 4.3.7(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -25613,14 +25664,6 @@ snapshots: ccount@2.0.1: {} - chai@5.1.1: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.1 - deep-eql: 5.0.2 - loupe: 3.1.2 - pathval: 2.0.0 - chai@5.1.2: dependencies: assertion-error: 2.0.1 @@ -28477,7 +28520,7 @@ snapshots: http-proxy-agent@7.0.0: dependencies: agent-base: 7.1.0 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -28500,14 +28543,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color https-proxy-agent@7.0.4: dependencies: agent-base: 7.1.0 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -30081,7 +30124,7 @@ snapshots: micromark@2.11.4: dependencies: - debug: 4.3.7(supports-color@8.1.1) + debug: 4.4.0 parse-entities: 2.0.0 transitivePeerDependencies: - supports-color @@ -30089,7 +30132,7 @@ snapshots: micromark@4.0.0: dependencies: '@types/debug': 4.1.7 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.4.0 decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.0 @@ -32766,7 +32809,7 @@ snapshots: socks-proxy-agent@8.0.2: dependencies: agent-base: 7.1.0 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.4.0 socks: 2.7.1 transitivePeerDependencies: - supports-color @@ -33277,8 +33320,6 @@ snapshots: tinyrainbow@2.0.0: {} - tinyspy@3.0.0: {} - tinyspy@3.0.2: {} title-case@3.0.3: @@ -33450,7 +33491,7 @@ snapshots: tuf-js@2.2.0: dependencies: '@tufjs/models': 2.0.0 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.4.0 make-fetch-happen: 13.0.0 transitivePeerDependencies: - supports-color @@ -33612,7 +33653,7 @@ snapshots: '@types/unist': 3.0.0 '@ungap/structured-clone': 1.2.0 concat-stream: 2.0.0 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.4.0 glob: 10.3.12 ignore: 5.3.2 is-empty: 1.2.0 @@ -33919,6 +33960,24 @@ snapshots: unist-util-stringify-position: 4.0.0 vfile-message: 4.0.2 + vite-node@2.0.5(@types/node@22.10.5)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0): + dependencies: + cac: 6.7.14 + debug: 4.4.0 + pathe: 1.1.2 + tinyrainbow: 1.2.0 + vite: 5.4.12(@types/node@22.10.5)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@3.0.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0): dependencies: cac: 6.7.14 @@ -33978,6 +34037,39 @@ snapshots: tsx: 4.19.2 yaml: 2.5.0 + vitest@2.0.5(@types/node@22.10.5)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0): + dependencies: + '@ampproject/remapping': 2.3.0 + '@vitest/expect': 2.0.5 + '@vitest/pretty-format': 2.1.8 + '@vitest/runner': 2.0.5 + '@vitest/snapshot': 2.0.5 + '@vitest/spy': 2.0.5 + '@vitest/utils': 2.0.5 + chai: 5.1.2 + debug: 4.4.0 + execa: 8.0.1 + magic-string: 0.30.17 + pathe: 1.1.2 + std-env: 3.8.0 + tinybench: 2.9.0 + tinypool: 1.0.2 + tinyrainbow: 1.2.0 + vite: 5.4.12(@types/node@22.10.5)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0) + vite-node: 2.0.5(@types/node@22.10.5)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.10.5 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@3.0.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0): dependencies: '@vitest/expect': 3.0.4 diff --git a/tsconfig.json b/tsconfig.json index 5b8a1f8313..a702f36101 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -72,6 +72,9 @@ "@graphql-hive/core/src/client/collect-schema-coordinates": [ "./packages/libraries/core/src/client/collect-schema-coordinates.ts" ], + "@graphql-hive/federation-link-utils": [ + "./packages/libraries/federation-link-utils/src/index.ts" + ], "@hive/usage-ingestor/src/normalize-operation": [ "./packages/services/usage-ingestor/src/normalize-operation.ts" ], From 1972b22cc1ff74c70d8413d6bec9f6a92b94833a Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 4 Feb 2025 12:07:06 -0800 Subject: [PATCH 2/2] Support federation 1 --- .../libraries/federation-link-utils/README.md | 14 +- .../src/__tests__/index.spec.ts | 145 ++++++++++-------- .../federation-link-utils/src/index.ts | 15 +- .../federation-link-utils/src/link.ts | 2 +- .../src/linkable-spec.ts | 3 + 5 files changed, 113 insertions(+), 66 deletions(-) diff --git a/packages/libraries/federation-link-utils/README.md b/packages/libraries/federation-link-utils/README.md index de8743d30f..fb927da30c 100644 --- a/packages/libraries/federation-link-utils/README.md +++ b/packages/libraries/federation-link-utils/README.md @@ -18,6 +18,10 @@ directive. ## 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 @@ -35,9 +39,17 @@ type User { ```typescript // specs.ts -import { detectLinkedImplementations, LinkableSpec } from '@graphql-hive/federation-link-utils' +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 = {} const exampleName = resolveImportName('@example') diff --git a/packages/libraries/federation-link-utils/src/__tests__/index.spec.ts b/packages/libraries/federation-link-utils/src/__tests__/index.spec.ts index 3603ac597c..990d22827f 100644 --- a/packages/libraries/federation-link-utils/src/__tests__/index.spec.ts +++ b/packages/libraries/federation-link-utils/src/__tests__/index.spec.ts @@ -1,71 +1,75 @@ import { DocumentNode, parse, StringValueNode, visit } from 'graphql'; -import { detectLinkedImplementations, LinkableSpec } from '../index'; +import { detectLinkedImplementations, FEDERATION_V1, 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"]) +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> = {}; + const metaName = resolveImportName('@meta'); + const exampleName = resolveImportName('@example'); + visit(typeDefs, { + FieldDefinition: node => { + let metaData: Record = {}; + 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 ?? '??'; + } - type Query { - ping: String @meta(name: "owner", content: "hive-console-team") - pong: String @metadata__example(eg: "1...2...3... Pong") + 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 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> = {}; - const metaName = resolveImportName('@meta'); - const exampleName = resolveImportName('@example'); - visit(typeDefs, { - FieldDefinition: node => { - let metaData: Record = {}; - 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 ?? '??'; - } +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"]) - 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...`; - }, - }); + 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(` @@ -75,3 +79,20 @@ test('LinkableSpec and detectLinkedImplementations can be used to easily impleme ] `); }); + +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, + ] + `); +}); diff --git a/packages/libraries/federation-link-utils/src/index.ts b/packages/libraries/federation-link-utils/src/index.ts index 6821fc0ee3..223e3d0d01 100644 --- a/packages/libraries/federation-link-utils/src/index.ts +++ b/packages/libraries/federation-link-utils/src/index.ts @@ -1,19 +1,30 @@ -import { DocumentNode } from 'graphql'; +import type { DocumentNode } from 'graphql'; import { FederatedLink } from './link.js'; -import type { LinkableSpec } from './linkable-spec.js'; +import { FEDERATION_V1, 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'; +const FEDERATION_IDENTITY = 'https://specs.apollo.dev/federation'; + export function detectLinkedImplementations( typeDefs: DocumentNode, supportedSpecs: LinkableSpec[], ): T[] { const links = FederatedLink.fromTypedefs(typeDefs); + const supportsFederationV2 = links.some(l => l.identity === FEDERATION_IDENTITY); + return supportedSpecs .map(spec => { + if (!supportsFederationV2) { + const resolveFed1Name = (name: string) => { + return name.startsWith('@') ? name.substring(1) : name; + }; + return spec.versions[FEDERATION_V1]?.(resolveFed1Name); + } + const specImpl = spec.detectImplementation(links); return specImpl; }) diff --git a/packages/libraries/federation-link-utils/src/link.ts b/packages/libraries/federation-link-utils/src/link.ts index a1664b7032..5b465e2629 100644 --- a/packages/libraries/federation-link-utils/src/link.ts +++ b/packages/libraries/federation-link-utils/src/link.ts @@ -86,7 +86,7 @@ export class FederatedLink { return `@link(url: "${this.url}"${this.as ? `, as: "${this.as}"` : ''}${this.imports.length ? `, import: [${this.imports.join(', ')}]` : ''})`; } - get defaultImport(): string | null { + private get defaultImport(): string | null { return this.namespace && `@${this.namespace}`; } diff --git a/packages/libraries/federation-link-utils/src/linkable-spec.ts b/packages/libraries/federation-link-utils/src/linkable-spec.ts index 753e9115b5..99299b68da 100644 --- a/packages/libraries/federation-link-utils/src/linkable-spec.ts +++ b/packages/libraries/federation-link-utils/src/linkable-spec.ts @@ -1,7 +1,10 @@ import { FederatedLink } from './link'; +export const FEDERATION_V1 = Symbol('Federation V1'); + export type Versioned = { [minVersion: string]: (resolveImportName: FederatedLink['resolveImportName']) => T; + [FEDERATION_V1]?: (resolveImportName: FederatedLink['resolveImportName']) => T; }; export class LinkableSpec {