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

Merged
merged 7 commits into from
Feb 12, 2025
Merged
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
1 change: 0 additions & 1 deletion integration-tests/tests/schema/contracts.spec.ts
Original file line number Diff line number Diff line change
@@ -268,7 +268,6 @@ test('federation schema contains list of tags', async () => {
},
],
});

expect(result.tags).toMatchInlineSnapshot(`
[
toyota,
65 changes: 65 additions & 0 deletions packages/libraries/federation-link-utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# 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 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/v1.0", import: ["@example"])
type Query {
user: User @example(eg: "query { user { id name } }")
}

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

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

const typeDefs = parse(sdl)
const { matchesImplementation, resolveImportName } = extractLinkImplementations(typeDefs);
if (matchesImplementation('https://specs.graphql-hive.com/example', 'v1.0')) {
const examples: Record<string, string> = {}
const exampleName = resolveImportName('https://specs.graphql-hive.com/example', '@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
}

// result[0] ==> { user: "query { user { id name } }"}
```
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": "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": "^14.0.0 || ^15.0.0 || ^16.0.0"
},
"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"
}
}
82 changes: 82 additions & 0 deletions packages/libraries/federation-link-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Exposes a simple and efficient API for interacting with Federation V2's `@link` directives
* according to spec.
*/

import type { DocumentNode } from 'graphql';
import { FederatedLinkUrl } from './link-url.js';
import { FederatedLink } from './link.js';

export const FEDERATION_V1 = Symbol('Federation_V1');

export type LinkVersion = string | { major: number; minor: number } | null | typeof FEDERATION_V1;

export function extractLinkImplementations(typeDefs: DocumentNode): {
/**
*
* @param identity The link identity. E.g. https://specs.apollo.dev/link/v1.0
* @param name The imported object name, without namespacing. E.g. "@link"
* @returns The imported object's name within the typedefs. E.g.
* For `@link(url: "https://example.com/", import: [{ name: "@example", as: "@eg" }])`,
* `resolveImportName("@example")` returns "eg".
* And for `@link(url: "https://example.com/foo")`, `resolveImportName("@example")`
* returns the namespaced name, "foo__example"
*/
resolveImportName: (identity: string, name: string) => string;

/**
* Check that the linked version is supported by the code implementation.
*
* @param identity The link identity. E.g. https://specs.graphql-hive.com/example
* @param version The version in which the feature was added. E.g. 1.0
* @returns true if the supplied link supports this the version argument.
* E.g. matchesImplementation('https://specs.graphql-hive.com/example', '1.1') returns true if
* is version >= 1.1 < 2.0, but false if the link is version 1.0
*/
matchesImplementation: (identity: string, version: LinkVersion) => boolean;
} {
const linkByIdentity = Object.fromEntries(
FederatedLink.fromTypedefs(typeDefs).map(l => [l.identity, l]),
);
// Any schema with a `@link` directive present is considered federation 2
// although according to federation docs, schemas require linking specifically
// the federation 2.x spec. The reason for not being so picky is that supergraphs also
// use @link, but do not necessarily link to the federation 2.x spec.
const supportsFederationV2 = Object.keys(linkByIdentity).length > 0;

return {
resolveImportName: (identity, name) => {
if (!supportsFederationV2) {
// Identities dont matter for Federation v1. There are no links to reference.
// So return the name without the identity's namespace
return name.startsWith('@') ? name.substring(1) : name;
}

const matchingLink = linkByIdentity[identity];
if (!matchingLink) {
const defaultLink = new FederatedLink(FederatedLinkUrl.fromUrl(identity), null, []);
// The identity was not imported, but return we still will return what is assumed to be the name
// of the import based off the identity. `matchesImplementation` should be used for cases where
// it matters whether or not a specific url was linked.
return defaultLink.resolveImportName(name);
}
return matchingLink.resolveImportName(name);
},
matchesImplementation: (identity, version) => {
if (version === FEDERATION_V1) {
return !supportsFederationV2;
}
const matchingLink = linkByIdentity[identity];
if (!matchingLink) {
return false;
}
if (typeof version === 'string') {
return matchingLink.supports(version);
}
if (version === null) {
return matchingLink.supports(version);
}
return matchingLink.supports(version.major, version.minor);
},
};
}
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}"`);
}
}
90 changes: 90 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,90 @@
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);
}

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

Unchanged files with check annotations Beta

COPY --from=shared . /
LABEL org.opencontainers.image.licenses=MIT
LABEL org.opencontainers.image.title=$IMAGE_TITLE

Check warning on line 12 in docker/services.dockerfile

GitHub Actions / build / dockerize (linux/amd64)

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$IMAGE_TITLE' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/

Check warning on line 12 in docker/services.dockerfile

GitHub Actions / build / dockerize (linux/arm64)

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$IMAGE_TITLE' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/
LABEL org.opencontainers.image.version=$RELEASE

Check warning on line 13 in docker/services.dockerfile

GitHub Actions / build / dockerize (linux/amd64)

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$RELEASE' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/
LABEL org.opencontainers.image.description=$IMAGE_DESCRIPTION

Check warning on line 14 in docker/services.dockerfile

GitHub Actions / build / dockerize (linux/amd64)

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$IMAGE_DESCRIPTION' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/

Check warning on line 14 in docker/services.dockerfile

GitHub Actions / build / dockerize (linux/arm64)

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$IMAGE_DESCRIPTION' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/
LABEL org.opencontainers.image.authors="The Guild"
LABEL org.opencontainers.image.vendor="Kamil Kisiela"
LABEL org.opencontainers.image.url="https://github.com/graphql-hive/platform"
LABEL org.opencontainers.image.source="https://github.com/graphql-hive/platform"
ENV ENVIRONMENT production

Check warning on line 20 in docker/services.dockerfile

GitHub Actions / build / dockerize (linux/amd64)

Legacy key/value format with whitespace separator should not be used

LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format More info: https://docs.docker.com/go/dockerfile/rule/legacy-key-value-format/
ENV RELEASE $RELEASE

Check warning on line 21 in docker/services.dockerfile

GitHub Actions / build / dockerize (linux/amd64)

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$RELEASE' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/

Check warning on line 21 in docker/services.dockerfile

GitHub Actions / build / dockerize (linux/amd64)

Legacy key/value format with whitespace separator should not be used

LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format More info: https://docs.docker.com/go/dockerfile/rule/legacy-key-value-format/

Check warning on line 21 in docker/services.dockerfile

GitHub Actions / build / dockerize (linux/arm64)

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$RELEASE' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/

Check warning on line 21 in docker/services.dockerfile

GitHub Actions / build / dockerize (linux/arm64)

Legacy key/value format with whitespace separator should not be used

LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format More info: https://docs.docker.com/go/dockerfile/rule/legacy-key-value-format/
ENV PORT $PORT

Check warning on line 22 in docker/services.dockerfile

GitHub Actions / build / dockerize (linux/amd64)

Legacy key/value format with whitespace separator should not be used

LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format More info: https://docs.docker.com/go/dockerfile/rule/legacy-key-value-format/

Check warning on line 22 in docker/services.dockerfile

GitHub Actions / build / dockerize (linux/arm64)

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$PORT' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/
HEALTHCHECK --interval=5s \
--timeout=5s \
COPY --from=dist . /usr/src/app/
COPY --from=shared . /
ENV ENVIRONMENT production

Check warning on line 10 in docker/migrations.dockerfile

GitHub Actions / build / dockerize (linux/amd64)

Legacy key/value format with whitespace separator should not be used

LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format More info: https://docs.docker.com/go/dockerfile/rule/legacy-key-value-format/

Check warning on line 10 in docker/migrations.dockerfile

GitHub Actions / build / dockerize (linux/arm64)

Legacy key/value format with whitespace separator should not be used

LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format More info: https://docs.docker.com/go/dockerfile/rule/legacy-key-value-format/
ENV NODE_ENV production

Check warning on line 11 in docker/migrations.dockerfile

GitHub Actions / build / dockerize (linux/arm64)

Legacy key/value format with whitespace separator should not be used

LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format More info: https://docs.docker.com/go/dockerfile/rule/legacy-key-value-format/
ENV RELEASE $RELEASE

Check warning on line 12 in docker/migrations.dockerfile

GitHub Actions / build / dockerize (linux/arm64)

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$RELEASE' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/

Check warning on line 12 in docker/migrations.dockerfile

GitHub Actions / build / dockerize (linux/arm64)

Legacy key/value format with whitespace separator should not be used

LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format More info: https://docs.docker.com/go/dockerfile/rule/legacy-key-value-format/
LABEL org.opencontainers.image.licenses=MIT
LABEL org.opencontainers.image.title=$IMAGE_TITLE

Check warning on line 15 in docker/migrations.dockerfile

GitHub Actions / build / dockerize (linux/amd64)

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$IMAGE_TITLE' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/

Check warning on line 15 in docker/migrations.dockerfile

GitHub Actions / build / dockerize (linux/arm64)

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$IMAGE_TITLE' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/
LABEL org.opencontainers.image.version=$RELEASE
LABEL org.opencontainers.image.description=$IMAGE_DESCRIPTION

Check warning on line 17 in docker/migrations.dockerfile

GitHub Actions / build / dockerize (linux/amd64)

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$IMAGE_DESCRIPTION' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/
LABEL org.opencontainers.image.authors="The Guild"
LABEL org.opencontainers.image.vendor="Kamil Kisiela"
LABEL org.opencontainers.image.url="https://github.com/graphql-hive/platform"
RUN npm install --global @graphql-hive/cli@${CLI_VERSION}
LABEL org.opencontainers.image.licenses=MIT
LABEL org.opencontainers.image.title=$IMAGE_TITLE

Check warning on line 12 in docker/cli.dockerfile

GitHub Actions / alpha / publish_docker / Build CLI Docker Image

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$IMAGE_TITLE' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/
LABEL org.opencontainers.image.version=$RELEASE

Check warning on line 13 in docker/cli.dockerfile

GitHub Actions / alpha / publish_docker / Build CLI Docker Image

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$RELEASE' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/
LABEL org.opencontainers.image.description=$IMAGE_DESCRIPTION

Check warning on line 14 in docker/cli.dockerfile

GitHub Actions / alpha / publish_docker / Build CLI Docker Image

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$IMAGE_DESCRIPTION' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/
LABEL org.opencontainers.image.authors="The Guild"
LABEL org.opencontainers.image.vendor="Kamil Kisiela"
LABEL org.opencontainers.image.url="https://github.com/graphql-hive/platform"
LABEL org.opencontainers.image.source="https://github.com/graphql-hive/platform"
ENV ENVIRONMENT production

Check warning on line 20 in docker/cli.dockerfile

GitHub Actions / alpha / publish_docker / Build CLI Docker Image

Legacy key/value format with whitespace separator should not be used

LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format More info: https://docs.docker.com/go/dockerfile/rule/legacy-key-value-format/
ENV RELEASE $RELEASE

Check warning on line 21 in docker/cli.dockerfile

GitHub Actions / alpha / publish_docker / Build CLI Docker Image

Legacy key/value format with whitespace separator should not be used

LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format More info: https://docs.docker.com/go/dockerfile/rule/legacy-key-value-format/

Check warning on line 21 in docker/cli.dockerfile

GitHub Actions / alpha / publish_docker / Build CLI Docker Image

Variables should be defined before their use

UndefinedVar: Usage of undefined variable '$RELEASE' More info: https://docs.docker.com/go/dockerfile/rule/undefined-var/
RUN hive --version
ENTRYPOINT ["hive"]