From 443283e22e89934a268b7a6318c02ffc3bfbf464 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 24 Nov 2023 16:29:40 +0100 Subject: [PATCH] feat: public schema from supergraph (#21) --- .changeset/curly-ants-march.md | 8 + ...nsform-supergraph-to-public-schema.spec.ts | 282 ++++++++++++++++++ src/graphql/helpers.ts | 69 +---- .../transform-supergraph-to-public-schema.ts | 128 ++++++++ src/index.ts | 2 +- 5 files changed, 420 insertions(+), 69 deletions(-) create mode 100644 .changeset/curly-ants-march.md create mode 100644 __tests__/graphql/transform-supergraph-to-public-schema.spec.ts create mode 100644 src/graphql/transform-supergraph-to-public-schema.ts diff --git a/.changeset/curly-ants-march.md b/.changeset/curly-ants-march.md new file mode 100644 index 0000000..1431ed8 --- /dev/null +++ b/.changeset/curly-ants-march.md @@ -0,0 +1,8 @@ +--- +'@theguild/federation-composition': minor +--- + +Remove `stripFederationFromSupergraph` in favor of `transformSupergraphToPublicSchema`. + +Instead of stripping only federation specific types, `transformSupergraphToPublicSchema` yields the +public api schema as served by the gateway. diff --git a/__tests__/graphql/transform-supergraph-to-public-schema.spec.ts b/__tests__/graphql/transform-supergraph-to-public-schema.spec.ts new file mode 100644 index 0000000..d72d086 --- /dev/null +++ b/__tests__/graphql/transform-supergraph-to-public-schema.spec.ts @@ -0,0 +1,282 @@ +import { parse, print } from 'graphql'; +import { describe, expect, test } from 'vitest'; +import { transformSupergraphToPublicSchema } from '../../src/graphql/transform-supergraph-to-public-schema'; + +describe('transformSupergraphToPublicSchema', () => { + describe('@inaccessible', () => { + test('scalar removal', () => { + const sdl = parse(/* GraphQL */ ` + scalar Scalar1 @inaccessible + scalar Scalar2 + `); + const resultSdl = transformSupergraphToPublicSchema(sdl); + expect(print(resultSdl)).toMatchInlineSnapshot('"scalar Scalar2"'); + }); + test('enum removal', () => { + const sdl = parse(/* GraphQL */ ` + enum Enum1 @inaccessible { + VALUE1 + VALUE2 + } + enum Enum2 { + VALUE1 + VALUE2 + } + `); + const resultSdl = transformSupergraphToPublicSchema(sdl); + expect(print(resultSdl)).toMatchInlineSnapshot(` + "enum Enum2 { + VALUE1 + VALUE2 + }" + `); + }); + test('object type removal', () => { + const sdl = parse(/* GraphQL */ ` + type Object1 @inaccessible { + field1: String + } + type Object2 { + field1: String + } + `); + const resultSdl = transformSupergraphToPublicSchema(sdl); + expect(print(resultSdl)).toMatchInlineSnapshot(` + "type Object2 { + field1: String + }" + `); + }); + test('object field removal', () => { + const sdl = parse(/* GraphQL */ ` + type Object { + field1: String @inaccessible + field2: String + } + `); + const resultSdl = transformSupergraphToPublicSchema(sdl); + expect(print(resultSdl)).toMatchInlineSnapshot(` + "type Object { + field2: String + }" + `); + }); + test('interface type removal', () => { + const sdl = parse(/* GraphQL */ ` + interface Interface1 @inaccessible { + field1: String + } + interface Interface2 { + field1: String + } + `); + const resultSdl = transformSupergraphToPublicSchema(sdl); + expect(print(resultSdl)).toMatchInlineSnapshot(` + "interface Interface2 { + field1: String + }" + `); + }); + test('interface field removal', () => { + const sdl = parse(/* GraphQL */ ` + interface Interface { + field1: String @inaccessible + field2: String + } + `); + const resultSdl = transformSupergraphToPublicSchema(sdl); + expect(print(resultSdl)).toMatchInlineSnapshot(` + "interface Interface { + field2: String + }" + `); + }); + test('union type removal', () => { + const sdl = parse(/* GraphQL */ ` + union Union1 @inaccessible = Type1 | Type2 + union Union2 = Type1 | Type2 + `); + const resultSdl = transformSupergraphToPublicSchema(sdl); + expect(print(resultSdl)).toMatchInlineSnapshot('"union Union2 = Type1 | Type2"'); + }); + test('object field argument removal', () => { + const sdl = parse(/* GraphQL */ ` + type Object { + field1(arg1: String @inaccessible): String + field2(arg1: String): String + } + `); + const resultSdl = transformSupergraphToPublicSchema(sdl); + expect(print(resultSdl)).toMatchInlineSnapshot(` + "type Object { + field1: String + field2(arg1: String): String + }" + `); + }); + test('interface field argument removal', () => { + const sdl = parse(/* GraphQL */ ` + interface Object { + field1(arg1: String @inaccessible): String + field2(arg1: String): String + } + `); + const resultSdl = transformSupergraphToPublicSchema(sdl); + expect(print(resultSdl)).toMatchInlineSnapshot(` + "interface Object { + field1: String + field2(arg1: String): String + }" + `); + }); + test('input object type removal', () => { + const sdl = parse(/* GraphQL */ ` + input Input1 @inaccessible { + field1: String + } + input Input2 { + field1: String + } + `); + const resultSdl = transformSupergraphToPublicSchema(sdl); + expect(print(resultSdl)).toMatchInlineSnapshot(` + "input Input2 { + field1: String + }" + `); + }); + test('input object field removal', () => { + const sdl = parse(/* GraphQL */ ` + input Input { + field1: String @inaccessible + field2: String + } + `); + const resultSdl = transformSupergraphToPublicSchema(sdl); + expect(print(resultSdl)).toMatchInlineSnapshot(` + "input Input { + field2: String + }" + `); + }); + }); + test('strips out all federation directives and types', () => { + const sdl = parse(/* GraphQL */ ` + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + @link( + url: "https://specs.apollo.dev/inaccessible/v0.2" + as: "federation__inaccessible" + for: SECURITY + ) { + query: Query + } + + directive @federation__inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + + directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + + directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean + ) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @join__implements( + graph: join__Graph! + interface: String! + ) repeatable on OBJECT | INTERFACE + + directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false + ) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] + ) repeatable on SCHEMA + + scalar join__FieldSet + + enum join__Graph { + BRRT @join__graph(name: "brrt", url: "http://localhost/graphql") + BUBUBU @join__graph(name: "bububu", url: "http://localhost:1/graphql") + } + + scalar link__Import + + enum link__Purpose { + SECURITY + + EXECUTION + } + + type Query @join__type(graph: BRRT) @join__type(graph: BUBUBU) { + foo: Int! @join__field(graph: BRRT) + ok1: Int! @join__field(graph: BRRT) + a: String! @join__field(graph: BUBUBU) + oi: Type2 @federation__inaccessible @join__field(graph: BUBUBU) + } + + type Type2 + @join__type(graph: BRRT, key: "id", extension: true) + @join__type(graph: BUBUBU, key: "id") { + id: ID! @federation__inaccessible + inStock: Boolean! @join__field(graph: BRRT) + field1: String! @federation__inaccessible @join__field(graph: BUBUBU) + } + `); + + const resultSdl = transformSupergraphToPublicSchema(sdl); + expect(print(resultSdl)).toMatchInlineSnapshot(` + "type Query { + foo: Int! + ok1: Int! + a: String! + } + + type Type2 { + inStock: Boolean! + }" + `); + }); + test('graphql specification directives are omitted from the SDL', () => { + const sdl = parse(/* GraphQL */ ` + directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + directive @deprecated(reason: String = "No longer supported") on FIELD_DEFINITION | ENUM_VALUE + `); + const resultSdl = transformSupergraphToPublicSchema(sdl); + expect(print(resultSdl)).toMatchInlineSnapshot('""'); + }); + test('does not omit @deprecated directive', () => { + const sdl = parse(/* GraphQL */ ` + type Query { + foo: String @deprecated(reason: "jooo") + } + `); + const resultSdl = transformSupergraphToPublicSchema(sdl); + expect(print(resultSdl)).toMatchInlineSnapshot(` + "type Query { + foo: String @deprecated(reason: \\"jooo\\") + }" + `); + }); +}); diff --git a/src/graphql/helpers.ts b/src/graphql/helpers.ts index 550044f..94d5f94 100644 --- a/src/graphql/helpers.ts +++ b/src/graphql/helpers.ts @@ -1,72 +1,5 @@ -import { - DefinitionNode, - DirectiveDefinitionNode, - DocumentNode, - Kind, - specifiedDirectives, - visit, -} from 'graphql'; +import { Kind, type DefinitionNode, type DirectiveDefinitionNode } from 'graphql'; export function isDirectiveDefinition(node: DefinitionNode): node is DirectiveDefinitionNode { return node.kind === Kind.DIRECTIVE_DEFINITION; } - -export function stripFederationFromSupergraph(supergraph: DocumentNode) { - function removeDirective(node: { - name: { - value: string; - }; - }) { - const directiveName = node.name.value; - const isSpecifiedDirective = specifiedDirectives.some(d => d.name === directiveName); - if (!isSpecifiedDirective) { - const isFederationDirective = - directiveName === 'link' || - directiveName === 'inaccessible' || - directiveName === 'tag' || - directiveName === 'join__graph' || - directiveName === 'join__type' || - directiveName === 'join__implements' || - directiveName === 'join__unionMember' || - directiveName === 'join__enumValue' || - directiveName === 'join__field'; - - if (isFederationDirective) { - return null; - } - } - } - - return visit(supergraph, { - DirectiveDefinition: removeDirective, - Directive: removeDirective, - SchemaDefinition() { - return null; - }, - SchemaExtension() { - return null; - }, - EnumTypeDefinition: node => { - if ( - node.name.value === 'core__Purpose' || - node.name.value === 'join__Graph' || - node.name.value === 'link__Purpose' - ) { - return null; - } - - return node; - }, - ScalarTypeDefinition: node => { - if ( - node.name.value === '_FieldSet' || - node.name.value === 'link__Import' || - node.name.value === 'join__FieldSet' - ) { - return null; - } - - return node; - }, - }); -} diff --git a/src/graphql/transform-supergraph-to-public-schema.ts b/src/graphql/transform-supergraph-to-public-schema.ts new file mode 100644 index 0000000..71f6b97 --- /dev/null +++ b/src/graphql/transform-supergraph-to-public-schema.ts @@ -0,0 +1,128 @@ +import { + Kind, + specifiedDirectives as specifiedDirectivesArray, + visit, + type ConstDirectiveNode, + type DirectiveDefinitionNode, + type DirectiveNode, + type DocumentNode, + type SchemaDefinitionNode, +} from 'graphql'; + +const federationScalars = new Set(['_FieldSet', 'link__Import', 'join__FieldSet']); +const federationEnums = new Set(['core__Purpose', 'join__Graph', 'link__Purpose']); +const federationDirectives = new Set([ + 'link', + 'tag', + 'join__graph', + 'join__type', + 'join__implements', + 'join__unionMember', + 'join__enumValue', + 'join__field', + 'inaccessible', +]); +const specifiedDirectives = new Set(specifiedDirectivesArray.map(d => d.name)); + +function getAdditionalDirectivesToStrip(documentNode: DocumentNode) { + const schemaDefinitionNode = documentNode.definitions.find( + (node): node is SchemaDefinitionNode => node.kind === Kind.SCHEMA_DEFINITION, + ); + if (!schemaDefinitionNode?.directives?.length) { + return null; + } + + const additionalDirectivesToStrip = new Set(); + for (const directive of schemaDefinitionNode.directives) { + if (directive.name.value !== 'link') { + continue; + } + const asArg = directive.arguments?.find(arg => arg.name.value === 'as'); + + if (asArg?.value.kind === Kind.STRING) { + additionalDirectivesToStrip.add(asArg.value.value); + } + } + + return additionalDirectivesToStrip; +} + +const federationInaccessibleDirectiveUrlPrefix = 'https://specs.apollo.dev/inaccessible'; + +function getInaccessibleDirectiveName(documentNode: DocumentNode) { + const schemaDefinitionNode = documentNode.definitions.find( + (node): node is SchemaDefinitionNode => node.kind === Kind.SCHEMA_DEFINITION, + ); + if (schemaDefinitionNode?.directives?.length) { + for (const directive of schemaDefinitionNode.directives) { + if (directive.name.value !== 'link') { + continue; + } + const urlArg = directive.arguments?.find(arg => arg.name.value === 'url'); + const asArg = directive.arguments?.find(arg => arg.name.value === 'as'); + + if ( + urlArg?.value.kind === Kind.STRING && + urlArg.value.value.startsWith(federationInaccessibleDirectiveUrlPrefix) + ) { + if (asArg?.value.kind === Kind.STRING) { + return asArg.value.value; + } + break; + } + } + } + + return 'inaccessible'; +} + +/** Transform a supergraph document node to the public API schema, as served by a gateway. */ +export function transformSupergraphToPublicSchema(documentNode: DocumentNode): DocumentNode { + const additionalFederationDirectives = getAdditionalDirectivesToStrip(documentNode); + const inaccessibleDirectiveName = getInaccessibleDirectiveName(documentNode); + + function removeFederationOrSpecifiedDirectives( + node: DirectiveDefinitionNode | DirectiveNode, + ): null | undefined { + if ( + federationDirectives.has(node.name.value) || + additionalFederationDirectives?.has(node.name.value) || + (node.kind === Kind.DIRECTIVE_DEFINITION && specifiedDirectives.has(node.name.value)) + ) { + return null; + } + } + + function hasInaccessibleDirective(node: { directives?: readonly ConstDirectiveNode[] }) { + return node.directives?.some(d => d.name.value === inaccessibleDirectiveName); + } + + function removeInaccessibleNode(node: { directives?: readonly ConstDirectiveNode[] }) { + if (hasInaccessibleDirective(node)) { + return null; + } + } + + return visit(documentNode, { + [Kind.DIRECTIVE_DEFINITION]: removeFederationOrSpecifiedDirectives, + [Kind.DIRECTIVE]: removeFederationOrSpecifiedDirectives, + [Kind.SCHEMA_EXTENSION]: () => null, + [Kind.SCHEMA_DEFINITION]: () => null, + [Kind.SCALAR_TYPE_DEFINITION](node) { + if (federationScalars.has(node.name.value) || hasInaccessibleDirective(node)) { + return null; + } + }, + [Kind.ENUM_TYPE_DEFINITION](node) { + if (federationEnums.has(node.name.value) || hasInaccessibleDirective(node)) { + return null; + } + }, + [Kind.OBJECT_TYPE_DEFINITION]: removeInaccessibleNode, + [Kind.FIELD_DEFINITION]: removeInaccessibleNode, + [Kind.INTERFACE_TYPE_DEFINITION]: removeInaccessibleNode, + [Kind.UNION_TYPE_DEFINITION]: removeInaccessibleNode, + [Kind.INPUT_OBJECT_TYPE_DEFINITION]: removeInaccessibleNode, + [Kind.INPUT_VALUE_DEFINITION]: removeInaccessibleNode, + }); +} diff --git a/src/index.ts b/src/index.ts index 7cbf1f7..6d43af2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ export * from './compose.js'; export * from './types.js'; export * from './validate.js'; -export { stripFederationFromSupergraph } from './graphql/helpers.js'; +export { transformSupergraphToPublicSchema } from './graphql/transform-supergraph-to-public-schema.js';