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 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
jasonkuhrt marked this conversation as resolved.
Show resolved Hide resolved
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 create 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.
Copy link
Member

Choose a reason for hiding this comment

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

nit: peer 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"
Copy link
Member

Choose a reason for hiding this comment

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

do we really need to go back all the way to 0.x?

},
"devDependencies": {
"graphql": "16.9.0",
"tslib": "2.8.1",
"vitest": "3.0.5"
},
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public",
"directory": "dist"
},
"sideEffects": false,
"typescript": {
"definition": "dist/typings/index.d.ts"
}
}
32 changes: 32 additions & 0 deletions packages/libraries/federation-link-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { DocumentNode } from 'graphql';
import { FederatedLink } from './link.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<T>(
Copy link
Member

Choose a reason for hiding this comment

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

nit: can be extractLinkedImplementations

typeDefs: DocumentNode,
supportedSpecs: LinkableSpec<T>[],
): 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;
})
.filter(v => v !== undefined);
}
48 changes: 48 additions & 0 deletions packages/libraries/federation-link-utils/src/link-import.ts
Original file line number Diff line number Diff line change
@@ -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[] {
Copy link
Member

Choose a reason for hiding this comment

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

If see that this code (and some other in the lib implementation) is throwing errors, maybe we should use better kind of errors instead of just Error with a message? 🤔

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}"`);
}
}
127 changes: 127 additions & 0 deletions packages/libraries/federation-link-utils/src/link-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
const VERSION_MATCH = /v(\d{1,3})\.(\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(version: null): boolean;
supports(...args: [string] | [number, number] | [FederatedLinkUrl] | [null]): 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 if (majorOrVersion === null) {
// handles null case
return majorOrVersion === this.version;
} else {
throw new Error(`Unsupported version argument: ${JSON.stringify(args)} [${typeof args}].`);
}
return this.isCompatibleVersion(major, minor);
}

isSupportedBy(version: string): boolean;
isSupportedBy(major: number, minor: number): boolean;
isSupportedBy(version: FederatedLinkUrl): boolean;
isSupportedBy(version: null): boolean;
isSupportedBy(...args: [string] | [number, number] | [FederatedLinkUrl] | [null]): 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 if (majorOrVersion === null) {
// handles null case
return majorOrVersion === this.version;
} else {
throw new Error(`Unsupported version argument: ${JSON.stringify(args)} [${typeof args}].`);
}
return this.isSupportedByVersion(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;
}

private isSupportedByVersion(major: number, minor: number): boolean {
if (this.major === major) {
if (this.major === 0) {
return this.minor === minor;
}
return this.minor <= minor;
}
return false;
}
}
Loading
Loading