From 2d72e039929022d3eefedacdc6e5a7a1d85f7650 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Wed, 13 Dec 2023 17:17:38 +0100 Subject: [PATCH] Add sortSDL function (#23) --- .changeset/metal-rules-return.md | 5 + .github/workflows/main.yaml | 2 +- .github/workflows/pr.yaml | 2 +- .node-version | 2 +- __tests__/composition.spec.ts | 169 +++++++++++- __tests__/shared/setup.ts | 10 +- __tests__/shared/testkit.ts | 4 +- __tests__/shared/utils.ts | 220 +-------------- .../errors/FIELD_TYPE_MISMATCH.spec.ts | 70 +++++ package.json | 5 +- pnpm-lock.yaml | 41 ++- src/graphql/sort-sdl.ts | 254 ++++++++++++++++++ src/index.ts | 1 + 13 files changed, 525 insertions(+), 260 deletions(-) create mode 100644 .changeset/metal-rules-return.md create mode 100644 src/graphql/sort-sdl.ts diff --git a/.changeset/metal-rules-return.md b/.changeset/metal-rules-return.md new file mode 100644 index 0000000..fd4f36f --- /dev/null +++ b/.changeset/metal-rules-return.md @@ -0,0 +1,5 @@ +--- +'@theguild/federation-composition': minor +--- + +Add sortSDL function to sort DocumentNode (type system definitions and extensions) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 1b37875..6140d9a 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -8,7 +8,7 @@ jobs: uses: the-guild-org/shared-config/.github/workflows/release-stable.yml@main with: releaseScript: release - nodeVersion: 20 + nodeVersion: 21 packageManager: 'pnpm' secrets: githubToken: ${{ secrets.GUILD_BOT_TOKEN }} diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 7ba449a..bc508f4 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -12,7 +12,7 @@ jobs: - name: setup node uses: actions/setup-node@v3 with: - node-version: 20 + node-version: 21 cache: 'pnpm' - name: install dependencies run: pnpm install --frozen-lockfile diff --git a/.node-version b/.node-version index 2edeafb..b5045cc 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20 \ No newline at end of file +21 \ No newline at end of file diff --git a/__tests__/composition.spec.ts b/__tests__/composition.spec.ts index 77b10ad..6a2be98 100644 --- a/__tests__/composition.spec.ts +++ b/__tests__/composition.spec.ts @@ -1,5 +1,6 @@ import { parse, print } from 'graphql'; import { describe, expect, test } from 'vitest'; +import { sortSDL } from '../src/graphql/sort-sdl.js'; import { sdl as joinSDL } from '../src/specifications/join.js'; import { sdl as linkSDL } from '../src/specifications/link.js'; import { directive as tagDirective } from '../src/specifications/tag.js'; @@ -8,10 +9,9 @@ import { assertCompositionSuccess, testImplementations, } from './shared/testkit.js'; -import { normalizeAst } from './shared/utils.js'; expect.addSnapshotSerializer({ - serialize: value => print(normalizeAst(parse(value as string))), + serialize: value => print(sortSDL(parse(value as string))), test: value => typeof value === 'string' && value.includes('specs.apollo.dev'), }); @@ -24,6 +24,43 @@ testImplementations(api => { console.log(result.supergraphSdl); } + test('duplicated Query fields', () => { + const result = composeServices([ + { + name: 'a', + typeDefs: parse(/* GraphQL */ ` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + + type User @key(fields: "id") { + id: ID! + name: String + } + + type Query { + userById(id: ID!): User + } + `), + }, + { + name: 'b', + typeDefs: parse(/* GraphQL */ ` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + + type User @key(fields: "id") { + id: ID! + name: String + } + + type Query { + userById(id: ID!): User + } + `), + }, + ]); + + assertCompositionFailure(result); + }); + describe.each(['v2.0', 'v2.1', 'v2.2', 'v2.3'] as const)('%s', version => { describe('shareable', () => { test('merge two exact same types', () => { @@ -323,6 +360,41 @@ testImplementations(api => { `); }); + test('merge an argument (non-nullable vs missing)', () => { + const result = composeServices([ + { + name: 'a', + typeDefs: parse(/* GraphQL */ ` + extend schema + @link(url: "https://specs.apollo.dev/federation/${version}", import: ["@shareable"]) + + type Building @shareable { + # Argument is required + height(units: String!): Int! + } + + type Query { + building: Building + } + `), + }, + { + name: 'b', + typeDefs: parse(/* GraphQL */ ` + extend schema + @link(url: "https://specs.apollo.dev/federation/${version}", import: ["@shareable"]) + + type Building @shareable { + # Argument is missing + height: Int! + } + `), + }, + ]); + + assertCompositionFailure(result); + }); + test('merge an argument (nullable vs non-nullable)', () => { const result = composeServices([ { @@ -539,7 +611,7 @@ testImplementations(api => { `); }); - test('merge union types with different fields', () => { + test('merge union types with different members (some are overlapping)', () => { const result = composeServices([ { name: 'a', @@ -602,6 +674,64 @@ testImplementations(api => { `); }); + test('merge union types with different members', () => { + const result = composeServices([ + { + name: 'a', + typeDefs: parse(/* GraphQL */ ` + extend schema + @link(url: "https://specs.apollo.dev/federation/${version}", import: ["@key", "@shareable"]) + + type User @key(fields: "id") { + id: ID! + name: String! + email: String! + } + + union Media = Book + + type Book @shareable { + title: String! + } + `), + }, + { + name: 'b', + typeDefs: parse(/* GraphQL */ ` + extend schema + @link(url: "https://specs.apollo.dev/federation/${version}", import: ["@key", "@shareable"]) + + type User @key(fields: "id") { + id: ID! + age: Int! + } + + union Media = Movie + + type Movie { + title: String! + } + + type Query { + user: User + } + `), + }, + ]); + + assertCompositionSuccess(result); + + expect(result.supergraphSdl).toContainGraphQL(/* GraphQL */ ` + union Media + @join__type(graph: A) + @join__type(graph: B) + @join__unionMember(graph: A, member: "Book") + @join__unionMember(graph: B, member: "Movie") = + Book + | Movie + `); + }); + test('merge input types and field arguments', () => { const result = composeServices([ { @@ -2155,7 +2285,7 @@ testImplementations(api => { `); }); - test.skipIf(api.library === 'guild')('@interfaceObject', () => { + test('@interfaceObject', () => { const result = composeServices([ { name: 'a', @@ -2208,6 +2338,37 @@ testImplementations(api => { if (version !== 'v2.3') { assertCompositionFailure(result); + if (api.library === 'apollo') { + console.log(JSON.stringify(result.errors)); + } + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: '[a] Cannot import unknown element "@interfaceObject".', + extensions: expect.objectContaining({ + code: 'INVALID_LINK_DIRECTIVE_USAGE', + }), + }), + ); + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: '[b] Cannot import unknown element "@interfaceObject".', + extensions: expect.objectContaining({ + code: 'INVALID_LINK_DIRECTIVE_USAGE', + }), + }), + ); + + return; + } + + if (api.library === 'guild') { + // TODO: we don't support @interfaceObject yet + assertCompositionFailure(result); + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining('@interfaceObject is not yet supported'), + }), + ); return; } diff --git a/__tests__/shared/setup.ts b/__tests__/shared/setup.ts index 9a6d58b..44c2643 100644 --- a/__tests__/shared/setup.ts +++ b/__tests__/shared/setup.ts @@ -1,7 +1,7 @@ import type { DocumentNode, TypeSystemDefinitionNode } from 'graphql'; import { Kind, parse, print } from 'graphql'; import { expect } from 'vitest'; -import { normalizeAst } from './utils.js'; +import { sortSDL } from '../../src/graphql/sort-sdl.js'; function isStringOrNode(value: unknown): value is string | DocumentNode | TypeSystemDefinitionNode { return typeof value === 'string' || (!!value && typeof value === 'object' && 'kind' in value); @@ -39,8 +39,8 @@ expect.extend({ } const printed = { - received: print(normalizeAst(ensureDocumentNode(received))), - expected: print(normalizeAst(ensureDocumentNode(expected))), + received: print(sortSDL(ensureDocumentNode(received))), + expected: print(sortSDL(ensureDocumentNode(expected))), }; if (printed.received !== printed.expected) { @@ -75,8 +75,8 @@ expect.extend({ } const printed = { - received: print(normalizeAst(ensureDocumentNode(received))), - expected: print(normalizeAst(ensureDocumentNode(expected))), + received: print(sortSDL(ensureDocumentNode(received))), + expected: print(sortSDL(ensureDocumentNode(expected))), }; if (!printed.received.includes(printed.expected)) { diff --git a/__tests__/shared/testkit.ts b/__tests__/shared/testkit.ts index ee615f5..bb37dd9 100644 --- a/__tests__/shared/testkit.ts +++ b/__tests__/shared/testkit.ts @@ -8,7 +8,7 @@ import { CompositionResult, composeServices as guildComposeServices, } from '../../src/compose.js'; -import { graphql, inspect } from './utils.js'; +import { graphql } from './utils.js'; const missingErrorCodes = [ 'DISALLOWED_INACCESSIBLE', @@ -61,7 +61,7 @@ function composeServicesFactory( const todoCodes = Array.from(uniqueCodes).filter(c => missingErrorCodes.includes(c as any)); if (todoCodes.length) { - throw new Error(['Detected', todoCodes.join(', '), 'in a test'].join(' ')); + console.warn(['Detected', todoCodes.join(', '), 'in a test'].join(' ')); } } } diff --git a/__tests__/shared/utils.ts b/__tests__/shared/utils.ts index a406d2f..a201c60 100644 --- a/__tests__/shared/utils.ts +++ b/__tests__/shared/utils.ts @@ -1,222 +1,4 @@ -import { - ArgumentNode, - DefinitionNode, - DirectiveNode, - DocumentNode, - EnumValueDefinitionNode, - FieldDefinitionNode, - Kind, - NamedTypeNode, - NameNode, - OperationDefinitionNode, - parse, - print, - SelectionNode, - stripIgnoredCharacters, - ValueNode, - VariableDefinitionNode, - visit, -} from 'graphql'; -import sortBy from 'lodash.sortby'; - -// Used to normalize the AST of Supergraph SDLs so that they can be compared without worrying about ordering -export function normalizeAst(doc: DocumentNode) { - try { - return visit(doc, { - Document(node) { - return { - ...node, - definitions: sortNodes(node.definitions), - }; - }, - SchemaDefinition(node) { - return { - ...node, - directives: sortNodes(node.directives), - }; - }, - ScalarTypeDefinition(node) { - return { - ...node, - directives: sortNodes(node.directives), - }; - }, - ObjectTypeDefinition(node) { - return { - ...node, - directives: sortNodes(node.directives), - fields: sortNodes(node.fields), - // TODO: interfaces: sortNodes(node.interfaces), - }; - }, - InterfaceTypeDefinition(node) { - return { - ...node, - directives: sortNodes(node.directives), - fields: sortNodes(node.fields), - }; - }, - EnumTypeDefinition(node) { - return { - ...node, - directives: sortNodes(node.directives), - values: sortNodes(node.values), - }; - }, - EnumValueDefinition(node) { - return { - ...node, - directives: sortNodes(node.directives), - }; - }, - UnionTypeDefinition(node) { - return { - ...node, - types: sortNodes(node.types), - directives: sortNodes(node.directives), - }; - }, - FieldDefinition(node) { - return { - ...node, - directives: sortNodes(node.directives), - // arguments: sortNodes(node.arguments), - }; - }, - DirectiveDefinition(node) { - return { - ...node, - locations: sortNodes(node.locations), - }; - }, - Directive(node) { - for (const arg of node.arguments ?? []) { - if (['requires', 'provides'].includes(arg.name.value) && arg.value.kind === Kind.STRING) { - const parsedFields = parseFields(arg.value.value); - - if (parsedFields) { - const printed = stripIgnoredCharacters(print(parsedFields)); - - (arg.value as any).value = printed.replace(/^\{/, '').replace(/\}$/, ''); - } - } - } - - return { - ...node, - arguments: sortNodes(node.arguments), - }; - }, - StringValue(node) { - return { - ...node, - value: node.value.trim(), - }; - }, - }); - } catch (error) { - console.log('Failed to parse', doc.loc?.source.name); - throw error; - } -} - -function parseFields(fields: string) { - const parsed = parse( - fields.trim().startsWith(`{`) ? `query ${fields}` : `query { ${fields} }`, - ).definitions.find(d => d.kind === Kind.OPERATION_DEFINITION) as - | OperationDefinitionNode - | undefined; - - return parsed?.selectionSet; -} - -function valueNodeToString(node: ValueNode): string { - if ('name' in node) { - return node.name.value; - } - - if ('value' in node) { - return node.value.toString(); - } - - if (node.kind === Kind.LIST) { - return node.values.map(valueNodeToString).join(','); - } - - if (node.kind === Kind.OBJECT) { - return 'OBJECT'; - } - - return 'NULL'; -} - -function sortNodes(nodes: readonly DefinitionNode[]): readonly DefinitionNode[]; -function sortNodes( - nodes: readonly NamedTypeNode[] | undefined, -): readonly NamedTypeNode[] | undefined; -function sortNodes(nodes: readonly ArgumentNode[] | undefined): readonly ArgumentNode[] | undefined; -function sortNodes( - nodes: readonly EnumValueDefinitionNode[] | undefined, -): readonly EnumValueDefinitionNode[] | undefined; -function sortNodes( - nodes: readonly DirectiveNode[] | undefined, -): readonly DirectiveNode[] | undefined; -function sortNodes(nodes: readonly NameNode[] | undefined): readonly NameNode[] | undefined; -function sortNodes( - nodes: readonly FieldDefinitionNode[] | undefined, -): readonly FieldDefinitionNode[] | undefined; -function sortNodes(nodes: readonly any[] | undefined): readonly any[] | undefined { - if (nodes) { - if (nodes.length === 0) { - return []; - } - - if (isOfKindList(nodes, Kind.NAMED_TYPE)) { - return sortBy(nodes, 'name.value'); - } - - if (isOfKindList(nodes, Kind.DIRECTIVE)) { - return sortBy(nodes, n => { - const args = - n.arguments - ?.map(a => a.name.value + valueNodeToString(a.value)) - .sort() - .join(';') ?? ''; - return n.name.value + args; - }); - } - - if (isOfKindList(nodes, Kind.VARIABLE_DEFINITION)) { - return sortBy(nodes, 'variable.name.value'); - } - - if (isOfKindList(nodes, Kind.ARGUMENT)) { - return sortBy(nodes, 'name.value'); - } - - if (isOfKindList(nodes, Kind.ENUM_VALUE_DEFINITION)) { - return sortBy(nodes, 'name.value'); - } - - if ( - isOfKindList(nodes, [Kind.FIELD, Kind.FRAGMENT_SPREAD, Kind.INLINE_FRAGMENT]) - ) { - return sortBy(nodes, 'kind', 'name.value'); - } - - if (isOfKindList(nodes, Kind.NAME)) { - return sortBy(nodes, 'value'); - } - - return sortBy(nodes, 'kind', 'name.value'); - } - - return; -} - -function isOfKindList(nodes: readonly any[], kind: string | string[]): nodes is T[] { - return typeof kind === 'string' ? nodes[0].kind === kind : kind.includes(nodes[0].kind); -} +import { parse } from 'graphql'; export function graphql(literals: string | readonly string[], ...args: any[]) { if (typeof literals === 'string') { diff --git a/__tests__/supergraph/errors/FIELD_TYPE_MISMATCH.spec.ts b/__tests__/supergraph/errors/FIELD_TYPE_MISMATCH.spec.ts index e88482c..4c206a3 100644 --- a/__tests__/supergraph/errors/FIELD_TYPE_MISMATCH.spec.ts +++ b/__tests__/supergraph/errors/FIELD_TYPE_MISMATCH.spec.ts @@ -67,5 +67,75 @@ testVersions((api, version) => { ]), }), ); + + expect( + api.composeServices([ + { + name: 'auth', + typeDefs: graphql` + type User @key(fields: "id") { + id: ID! + name: String + } + + type Query { + me: User + } + `, + }, + { + name: 'images', + typeDefs: graphql` + type Image @key(fields: "url") { + url: Url + type: MimeType + } + + type Query { + images: [Image] + } + + extend type User { + favorite: Image + } + + scalar Url + scalar MimeType + `, + }, + { + name: 'albums', + typeDefs: graphql` + type Album @key(fields: "id") { + id: ID! + user: User + photos: [Image!] + } + + extend type Image { + albums: [Album!] + } + + extend type User { + albums: [Album!] + favorite: Album + } + `, + }, + ]), + ).toEqual( + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + `Type of field "User.favorite" is incompatible across subgraphs: it has type "Album" in subgraph "albums" but type "Image" in subgraph "images"`, + ), + extensions: expect.objectContaining({ + code: 'FIELD_TYPE_MISMATCH', + }), + }), + ]), + }), + ); }); }); diff --git a/package.json b/package.json index 2ab279e..c60f10a 100644 --- a/package.json +++ b/package.json @@ -59,10 +59,11 @@ }, "dependencies": { "constant-case": "^3.0.0", - "json5": "^2.2.0" + "json5": "^2.2.0", + "lodash.sortby": "^4.7.0" }, "devDependencies": { - "@apollo/composition": "2.5.4", + "@apollo/composition": "2.6.2", "@changesets/changelog-github": "0.4.8", "@changesets/cli": "2.26.2", "@theguild/prettier-config": "2.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d4ecb6..7143376 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,11 +11,14 @@ dependencies: json5: specifier: ^2.2.0 version: 2.2.3 + lodash.sortby: + specifier: ^4.7.0 + version: 4.7.0 devDependencies: '@apollo/composition': - specifier: 2.5.4 - version: 2.5.4(graphql@16.8.0) + specifier: 2.6.2 + version: 2.6.2(graphql@16.8.0) '@changesets/changelog-github': specifier: 0.4.8 version: 0.4.8 @@ -40,9 +43,6 @@ devDependencies: graphql: specifier: 16.8.0 version: 16.8.0 - lodash.sortby: - specifier: 4.7.0 - version: 4.7.0 mitata: specifier: 0.1.6 version: 0.1.6 @@ -72,19 +72,19 @@ packages: '@jridgewell/trace-mapping': 0.3.19 dev: true - /@apollo/composition@2.5.4(graphql@16.8.0): - resolution: {integrity: sha512-5fH6hlK1KnH/e5bnnnc81bp1sVIra/oorZ/yydcM5spaFpSGVKtIJRykKZmKtDnpkp3Z5MhTx8nD2j0GOjKT0A==} + /@apollo/composition@2.6.2(graphql@16.8.0): + resolution: {integrity: sha512-rbMRcnvEDtOT+e6OiKUY9virbhpIK9E8bQ6eGoakwCYz8bVXdBBPQdKuvhnvzNAe0LeOPMcsx3Pp9mPT9wz80g==} engines: {node: '>=14.15.0'} peerDependencies: graphql: ^16.5.0 dependencies: - '@apollo/federation-internals': 2.5.4(graphql@16.8.0) - '@apollo/query-graphs': 2.5.4(graphql@16.8.0) + '@apollo/federation-internals': 2.6.2(graphql@16.8.0) + '@apollo/query-graphs': 2.6.2(graphql@16.8.0) graphql: 16.8.0 dev: true - /@apollo/federation-internals@2.5.4(graphql@16.8.0): - resolution: {integrity: sha512-SJCbP90KNlk6QJqUfvBrL/PdYzXKz3aZx2vP2kqJE9Ccc6UrUVrI70XuJ09o6n080sgCArDTC9+ngjdh6P5vTg==} + /@apollo/federation-internals@2.6.2(graphql@16.8.0): + resolution: {integrity: sha512-L5Ppl+FQ2+ETpJ8NCa7T8ifAjAX8K/4NW8N08d6TRUJu0M/8rvIL0CgX033Jno/+FVIFhNBbVN2kGoSKDl1YPQ==} engines: {node: '>=14.15.0'} peerDependencies: graphql: ^16.5.0 @@ -96,13 +96,13 @@ packages: uuid: 9.0.0 dev: true - /@apollo/query-graphs@2.5.4(graphql@16.8.0): - resolution: {integrity: sha512-Bl9SZUNGZLpAGFXuhDMeLYvZ/dYaKRYD2NfMnjS9h6tUa2aLAufvTxK84AhYTuXkqoGibYGk6IzXmRAUtK9uFQ==} + /@apollo/query-graphs@2.6.2(graphql@16.8.0): + resolution: {integrity: sha512-1pQABsPS38Sqz1u3pW1WDmq/xJDWkdZGUsHNSAaSbdRAQMT5Lf9M9uzBUcNR5g+byvzKOc6nnxkh2W/tls5kBw==} engines: {node: '>=14.15.0'} peerDependencies: graphql: ^16.5.0 dependencies: - '@apollo/federation-internals': 2.5.4(graphql@16.8.0) + '@apollo/federation-internals': 2.6.2(graphql@16.8.0) deep-equal: 2.2.2 graphql: 16.8.0 ts-graphviz: 1.8.1 @@ -1370,7 +1370,7 @@ packages: object-is: 1.1.5 object-keys: 1.1.1 object.assign: 4.1.4 - regexp.prototype.flags: 1.5.0 + regexp.prototype.flags: 1.5.1 side-channel: 1.0.4 which-boxed-primitive: 1.0.2 which-collection: 1.0.1 @@ -2232,7 +2232,7 @@ packages: /lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} - dev: true + dev: false /lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} @@ -2725,15 +2725,6 @@ packages: resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==} dev: true - /regexp.prototype.flags@1.5.0: - resolution: {integrity: sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==} - engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - functions-have-names: 1.2.3 - dev: true - /regexp.prototype.flags@1.5.1: resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==} engines: {node: '>= 0.4'} diff --git a/src/graphql/sort-sdl.ts b/src/graphql/sort-sdl.ts new file mode 100644 index 0000000..adab240 --- /dev/null +++ b/src/graphql/sort-sdl.ts @@ -0,0 +1,254 @@ +import { + ArgumentNode, + DefinitionNode, + DirectiveNode, + DocumentNode, + EnumValueDefinitionNode, + FieldDefinitionNode, + Kind, + NamedTypeNode, + NameNode, + OperationDefinitionNode, + parse, + SelectionNode, + stripIgnoredCharacters, + ValueNode, + VariableDefinitionNode, + visit, +} from 'graphql'; +import sortBy from 'lodash.sortby'; +import { print } from './printer.js'; + +export function stripFederationFromSupergraph(supergraph: DocumentNode) { + function remove() { + return null; + } + + return visit(supergraph, { + DirectiveDefinition: remove, + Directive: remove, + SchemaDefinition: remove, + SchemaExtension: remove, + 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; + }, + }); +} + +// Used to normalize the AST of Supergraph SDLs so that they can be compared without worrying about ordering +export function sortSDL(doc: DocumentNode) { + try { + return visit(doc, { + Document(node) { + return { + ...node, + definitions: sortNodes(node.definitions), + }; + }, + SchemaDefinition(node) { + return { + ...node, + directives: sortNodes(node.directives), + }; + }, + ScalarTypeDefinition(node) { + return { + ...node, + directives: sortNodes(node.directives), + }; + }, + ObjectTypeDefinition(node) { + return { + ...node, + directives: sortNodes(node.directives), + fields: sortNodes(node.fields), + // TODO: interfaces: sortNodes(node.interfaces), + }; + }, + InterfaceTypeDefinition(node) { + return { + ...node, + directives: sortNodes(node.directives), + fields: sortNodes(node.fields), + }; + }, + EnumTypeDefinition(node) { + return { + ...node, + directives: sortNodes(node.directives), + values: sortNodes(node.values), + }; + }, + EnumValueDefinition(node) { + return { + ...node, + directives: sortNodes(node.directives), + }; + }, + UnionTypeDefinition(node) { + return { + ...node, + types: sortNodes(node.types), + directives: sortNodes(node.directives), + }; + }, + FieldDefinition(node) { + return { + ...node, + directives: sortNodes(node.directives), + // arguments: sortNodes(node.arguments), + }; + }, + DirectiveDefinition(node) { + return { + ...node, + locations: sortNodes(node.locations), + }; + }, + Directive(node) { + for (const arg of node.arguments ?? []) { + if (['requires', 'provides'].includes(arg.name.value) && arg.value.kind === Kind.STRING) { + const parsedFields = parseFields(arg.value.value); + + if (parsedFields) { + const printed = stripIgnoredCharacters(print(parsedFields)); + + (arg.value as any).value = printed.replace(/^\{/, '').replace(/\}$/, ''); + } + } + } + + return { + ...node, + arguments: sortNodes(node.arguments), + }; + }, + StringValue(node) { + return { + ...node, + value: node.value.trim(), + }; + }, + }); + } catch (error) { + console.log('Failed to parse', doc.loc?.source.name); + throw error; + } +} + +function parseFields(fields: string) { + const parsed = parse( + fields.trim().startsWith(`{`) ? `query ${fields}` : `query { ${fields} }`, + ).definitions.find(d => d.kind === Kind.OPERATION_DEFINITION) as + | OperationDefinitionNode + | undefined; + + return parsed?.selectionSet; +} + +function valueNodeToString(node: ValueNode): string { + if ('name' in node) { + return node.name.value; + } + + if ('value' in node) { + return node.value.toString(); + } + + if (node.kind === Kind.LIST) { + return node.values.map(valueNodeToString).join(','); + } + + if (node.kind === Kind.OBJECT) { + return 'OBJECT'; + } + + return 'NULL'; +} + +function sortNodes(nodes: readonly DefinitionNode[]): readonly DefinitionNode[]; +function sortNodes( + nodes: readonly NamedTypeNode[] | undefined, +): readonly NamedTypeNode[] | undefined; +function sortNodes(nodes: readonly ArgumentNode[] | undefined): readonly ArgumentNode[] | undefined; +function sortNodes( + nodes: readonly EnumValueDefinitionNode[] | undefined, +): readonly EnumValueDefinitionNode[] | undefined; +function sortNodes( + nodes: readonly DirectiveNode[] | undefined, +): readonly DirectiveNode[] | undefined; +function sortNodes(nodes: readonly NameNode[] | undefined): readonly NameNode[] | undefined; +function sortNodes( + nodes: readonly FieldDefinitionNode[] | undefined, +): readonly FieldDefinitionNode[] | undefined; +function sortNodes(nodes: readonly any[] | undefined): readonly any[] | undefined { + if (nodes) { + if (nodes.length === 0) { + return []; + } + + if (isOfKindList(nodes, Kind.NAMED_TYPE)) { + return sortBy(nodes, 'name.value'); + } + + if (isOfKindList(nodes, Kind.DIRECTIVE)) { + return sortBy(nodes, n => { + const args = + n.arguments + ?.map(a => a.name.value + valueNodeToString(a.value)) + .sort() + .join(';') ?? ''; + return n.name.value + args; + }); + } + + if (isOfKindList(nodes, Kind.VARIABLE_DEFINITION)) { + return sortBy(nodes, 'variable.name.value'); + } + + if (isOfKindList(nodes, Kind.ARGUMENT)) { + return sortBy(nodes, 'name.value'); + } + + if (isOfKindList(nodes, Kind.ENUM_VALUE_DEFINITION)) { + return sortBy(nodes, 'name.value'); + } + + if ( + isOfKindList(nodes, [Kind.FIELD, Kind.FRAGMENT_SPREAD, Kind.INLINE_FRAGMENT]) + ) { + return sortBy(nodes, 'kind', 'name.value'); + } + + if (isOfKindList(nodes, Kind.NAME)) { + return sortBy(nodes, 'value'); + } + + return sortBy(nodes, 'kind', 'name.value'); + } + + return; +} + +function isOfKindList(nodes: readonly any[], kind: string | string[]): nodes is T[] { + return typeof kind === 'string' ? nodes[0].kind === kind : kind.includes(nodes[0].kind); +} diff --git a/src/index.ts b/src/index.ts index 6d43af2..784064b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,4 @@ export * from './compose.js'; export * from './types.js'; export * from './validate.js'; export { transformSupergraphToPublicSchema } from './graphql/transform-supergraph-to-public-schema.js'; +export { sortSDL } from './graphql/sort-sdl.js';