From 8900074b015ef0a8f0c57e90f635656b31f3df0a Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Fri, 1 May 2026 10:48:24 +0100 Subject: [PATCH 1/8] Fix issue with recursive default value input via SDL --- src/utilities/extendSchema.ts | 69 ++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index 748bef7fc7..1ac0c0028f 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -352,15 +352,27 @@ export function extendSchemaImpl( const config = type.toConfig(); const extensions = typeExtensionsMap[config.name] ?? []; + let fields: GraphQLInputFieldConfigMap | undefined; + return new GraphQLInputObjectType({ ...config, - fields: () => ({ - ...mapValue(config.fields, (field) => ({ - ...field, - type: replaceType(field.type), - })), - ...buildInputFieldMap(extensions), - }), + fields: () => { + if (fields === undefined) { + fields = Object.create(null) as GraphQLInputFieldConfigMap; + Object.assign( + fields, + mapValue(config.fields, (field) => ({ + ...field, + type: replaceType(field.type), + })), + ); + extendInputFieldMap(fields, extensions); + // Default values must be applied _after_ all fields are known to + // prevent issues with recursion. + applyInputFieldDefaultValues(fields, extensions); + } + return fields; + }, extensionASTNodes: config.extensionASTNodes.concat(extensions), }); } @@ -599,12 +611,12 @@ export function extendSchemaImpl( return argConfigMap; } - function buildInputFieldMap( + function extendInputFieldMap( + inputFieldMap: GraphQLInputFieldConfigMap, nodes: ReadonlyArray< InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode >, - ): GraphQLInputFieldConfigMap { - const inputFieldMap = Object.create(null); + ): void { for (const node of nodes) { // FIXME: https://github.com/graphql/graphql-js/issues/2203 const fieldsNodes = /* c8 ignore next */ node.fields ?? []; @@ -618,13 +630,34 @@ export function extendSchemaImpl( inputFieldMap[field.name.value] = { type, description: field.description?.value, - defaultValue: valueFromAST(field.defaultValue, type), + // `defaultValue` will be populated later by applyInputFieldDefaultValues + defaultValue: undefined, deprecationReason: getDeprecationReason(field), astNode: field, }; } } - return inputFieldMap; + } + + function applyInputFieldDefaultValues( + inputFieldMap: GraphQLInputFieldConfigMap, + nodes: ReadonlyArray< + InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode + >, + ): void { + for (const node of nodes) { + // FIXME: https://github.com/graphql/graphql-js/issues/2203 + const fieldsNodes = /* c8 ignore next */ node.fields ?? []; + + for (const field of fieldsNodes) { + const type = inputFieldMap[field.name.value].type; + + inputFieldMap[field.name.value].defaultValue = valueFromAST( + field.defaultValue, + type, + ); + } + } } function buildEnumValueMap( @@ -740,10 +773,20 @@ export function extendSchemaImpl( case Kind.INPUT_OBJECT_TYPE_DEFINITION: { const allNodes = [astNode, ...extensionASTNodes]; + let fields: GraphQLInputFieldConfigMap | undefined; return new GraphQLInputObjectType({ name, description: astNode.description?.value, - fields: () => buildInputFieldMap(allNodes), + fields: () => { + if (fields === undefined) { + fields = Object.create(null) as GraphQLInputFieldConfigMap; + extendInputFieldMap(fields, allNodes); + // Default values must be applied _after_ all fields are known to + // prevent issues with recursion. + applyInputFieldDefaultValues(fields, allNodes); + } + return fields; + }, astNode, extensionASTNodes, isOneOf: isOneOf(astNode), From 5f938686e8e007998e0172f5c0c001cd64095376 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Fri, 1 May 2026 10:51:03 +0100 Subject: [PATCH 2/8] Add tests --- .../__tests__/buildASTSchema-test.ts | 20 ++++++++++++ src/utilities/__tests__/extendSchema-test.ts | 32 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/utilities/__tests__/buildASTSchema-test.ts b/src/utilities/__tests__/buildASTSchema-test.ts index 21de5434c2..5e206b113c 100644 --- a/src/utilities/__tests__/buildASTSchema-test.ts +++ b/src/utilities/__tests__/buildASTSchema-test.ts @@ -509,6 +509,26 @@ describe('Schema Builder', () => { expect(cycleSDL(sdl)).to.equal(sdl); }); + it('builds recursive input object field defaults', () => { + const sdl = dedent` + type Query { + depth(person: PersonInput): Int + } + + input PersonInput { + parent: PersonInput = {parent: null} + } + `; + + const schema = buildSchema(sdl); + const personInput = assertInputObjectType(schema.getType('PersonInput')); + + expect(personInput.getFields().parent.defaultValue).to.deep.equal({ + parent: null, + }); + expect(printSchema(schema)).to.equal(sdl); + }); + it('Simple argument field with default', () => { const sdl = dedent` type Query { diff --git a/src/utilities/__tests__/extendSchema-test.ts b/src/utilities/__tests__/extendSchema-test.ts index 7891a47793..cbfb8fdf7b 100644 --- a/src/utilities/__tests__/extendSchema-test.ts +++ b/src/utilities/__tests__/extendSchema-test.ts @@ -271,6 +271,38 @@ describe('extendSchema', () => { `); }); + it('extends inputs with recursive field defaults', () => { + const schema = buildSchema(` + type Query { + someInput(arg: PersonInput): String + } + + input PersonInput { + parent: PersonInput + } + `); + const extensionSDL = dedent` + extend input PersonInput { + ancestor: PersonInput = {parent: null} + } + `; + const extendedSchema = extendSchema(schema, parse(extensionSDL)); + const personInput = assertInputObjectType( + extendedSchema.getType('PersonInput'), + ); + + expect(personInput.getFields().ancestor.defaultValue).to.deep.equal({ + parent: null, + }); + expect(validateSchema(extendedSchema)).to.deep.equal([]); + expectSchemaChanges(schema, extendedSchema).to.equal(dedent` + input PersonInput { + parent: PersonInput + ancestor: PersonInput = {parent: null} + } + `); + }); + it('extends scalars by adding new directives', () => { const schema = buildSchema(` type Query { From 06ebf978fbdf254b3d2c15c04b7b25416c0c8242 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 15 Jan 2026 12:32:33 +0000 Subject: [PATCH 3/8] Opus 4.5's initial attempt at service implementation --- package-lock.json | 14 +- src/execution/execute.ts | 6 + src/language/__tests__/predicates-test.ts | 4 + src/language/__tests__/service-test.ts | 262 ++++++++++++++++++ src/language/ast.ts | 43 ++- src/language/index.ts | 3 + src/language/kinds.ts | 7 + src/language/lexer.ts | 55 ++++ src/language/parser.ts | 111 +++++++- src/language/predicates.ts | 6 +- src/language/printer.ts | 31 +++ src/language/tokenKind.ts | 1 + src/type/__tests__/service-test.ts | 239 ++++++++++++++++ src/type/index.ts | 19 ++ src/type/introspection.ts | 58 ++++ src/type/schema.ts | 9 + src/type/service.ts | 155 +++++++++++ src/utilities/TypeInfo.ts | 7 + src/utilities/buildASTSchema.ts | 1 + src/utilities/extendSchema.ts | 83 +++++- src/utilities/getIntrospectionQuery.ts | 29 ++ src/utilities/index.ts | 3 + src/utilities/printSchema.ts | 55 +++- .../LoneServiceDefinitionRule-test.ts | 84 ++++++ .../UniqueCapabilityIdentifiersRule-test.ts | 122 ++++++++ src/validation/index.ts | 2 + .../rules/ExecutableDefinitionsRule.ts | 15 +- .../rules/LoneServiceDefinitionRule.ts | 41 +++ .../rules/UniqueCapabilityIdentifiersRule.ts | 76 +++++ src/validation/specifiedRules.ts | 4 + 30 files changed, 1528 insertions(+), 17 deletions(-) create mode 100644 src/language/__tests__/service-test.ts create mode 100644 src/type/__tests__/service-test.ts create mode 100644 src/type/service.ts create mode 100644 src/validation/__tests__/LoneServiceDefinitionRule-test.ts create mode 100644 src/validation/__tests__/UniqueCapabilityIdentifiersRule-test.ts create mode 100644 src/validation/rules/LoneServiceDefinitionRule.ts create mode 100644 src/validation/rules/UniqueCapabilityIdentifiersRule.ts diff --git a/package-lock.json b/package-lock.json index f1ffacebfc..a3e64a4912 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,6 +77,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "^7.16.7", @@ -2237,6 +2238,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.19.0.tgz", "integrity": "sha512-yhktJjMCJX8BSBczh1F/uY8wGRYrBeyn84kH6oyqdIJwTGKmzX5Qiq49LRQ0Jh0LXnWijEziSo6BRqny8nqLVQ==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.19.0", "@typescript-eslint/types": "5.19.0", @@ -2409,6 +2411,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3521,6 +3524,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.13.0.tgz", "integrity": "sha512-D+Xei61eInqauAyTJ6C0q6x9mx7kTUC1KZ0m0LSEexR0V+e94K12LmWX076ZIsldwfQ2RONdaJe0re0TRGQbRQ==", "dev": true, + "peer": true, "dependencies": { "@eslint/eslintrc": "^1.2.1", "@humanwhocodes/config-array": "^0.9.2", @@ -6628,6 +6632,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz", "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6981,6 +6986,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", "dev": true, + "peer": true, "requires": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "^7.16.7", @@ -8592,6 +8598,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.19.0.tgz", "integrity": "sha512-yhktJjMCJX8BSBczh1F/uY8wGRYrBeyn84kH6oyqdIJwTGKmzX5Qiq49LRQ0Jh0LXnWijEziSo6BRqny8nqLVQ==", "dev": true, + "peer": true, "requires": { "@typescript-eslint/scope-manager": "5.19.0", "@typescript-eslint/types": "5.19.0", @@ -8686,7 +8693,8 @@ "version": "8.7.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", - "dev": true + "dev": true, + "peer": true }, "acorn-jsx": { "version": "5.3.2", @@ -9514,6 +9522,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.13.0.tgz", "integrity": "sha512-D+Xei61eInqauAyTJ6C0q6x9mx7kTUC1KZ0m0LSEexR0V+e94K12LmWX076ZIsldwfQ2RONdaJe0re0TRGQbRQ==", "dev": true, + "peer": true, "requires": { "@eslint/eslintrc": "^1.2.1", "@humanwhocodes/config-array": "^0.9.2", @@ -11790,7 +11799,8 @@ "version": "4.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz", "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==", - "dev": true + "dev": true, + "peer": true }, "unbox-primitive": { "version": "1.0.1", diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 05e7d442ec..23594deb3e 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -48,6 +48,7 @@ import { } from '../type/definition'; import { SchemaMetaFieldDef, + ServiceMetaFieldDef, TypeMetaFieldDef, TypeNameMetaFieldDef, } from '../type/introspection'; @@ -1324,6 +1325,11 @@ export function getFieldDef( schema.getQueryType() === parentType ) { return TypeMetaFieldDef; + } else if ( + fieldName === ServiceMetaFieldDef.name && + schema.getQueryType() === parentType + ) { + return ServiceMetaFieldDef; } else if (fieldName === TypeNameMetaFieldDef.name) { return TypeNameMetaFieldDef; } diff --git a/src/language/__tests__/predicates-test.ts b/src/language/__tests__/predicates-test.ts index 427b424606..8ef4262155 100644 --- a/src/language/__tests__/predicates-test.ts +++ b/src/language/__tests__/predicates-test.ts @@ -38,6 +38,7 @@ describe('AST node predicates', () => { 'EnumTypeDefinition', 'InputObjectTypeDefinition', 'DirectiveDefinition', + 'ServiceDefinition', 'SchemaExtension', 'DirectiveExtension', 'ScalarTypeExtension', @@ -46,6 +47,7 @@ describe('AST node predicates', () => { 'UnionTypeExtension', 'EnumTypeExtension', 'InputObjectTypeExtension', + 'ServiceExtension', ]); }); @@ -107,6 +109,7 @@ describe('AST node predicates', () => { 'EnumTypeDefinition', 'InputObjectTypeDefinition', 'DirectiveDefinition', + 'ServiceDefinition', ]); }); @@ -131,6 +134,7 @@ describe('AST node predicates', () => { 'UnionTypeExtension', 'EnumTypeExtension', 'InputObjectTypeExtension', + 'ServiceExtension', ]); }); diff --git a/src/language/__tests__/service-test.ts b/src/language/__tests__/service-test.ts new file mode 100644 index 0000000000..dfd5f156f0 --- /dev/null +++ b/src/language/__tests__/service-test.ts @@ -0,0 +1,262 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { Kind } from '../kinds'; +import { parse } from '../parser'; +import { print } from '../printer'; + +describe('Service Definition Parsing and Printing', () => { + describe('parsing service definitions', () => { + it('parses a simple service definition', () => { + const doc = parse(` + service { + capability graphql.spec + } + `); + + expect(doc.definitions).to.have.length(1); + const serviceDef = doc.definitions[0]; + expect(serviceDef.kind).to.equal(Kind.SERVICE_DEFINITION); + + if (serviceDef.kind === Kind.SERVICE_DEFINITION) { + expect(serviceDef.capabilities).to.have.length(1); + expect(serviceDef.capabilities?.[0].identifier.value).to.equal( + 'graphql.spec', + ); + expect(serviceDef.capabilities?.[0].value).to.equal(undefined); + } + }); + + it('parses service with capability value', () => { + const doc = parse(` + service { + capability graphql.spec = "2024" + } + `); + + const serviceDef = doc.definitions[0]; + if (serviceDef.kind === Kind.SERVICE_DEFINITION) { + expect(serviceDef.capabilities?.[0].identifier.value).to.equal( + 'graphql.spec', + ); + expect(serviceDef.capabilities?.[0].value?.value).to.equal('2024'); + } + }); + + it('parses service with description', () => { + const doc = parse(` + "My GraphQL Service" + service { + capability graphql.spec + } + `); + + const serviceDef = doc.definitions[0]; + if (serviceDef.kind === Kind.SERVICE_DEFINITION) { + expect(serviceDef.description?.value).to.equal('My GraphQL Service'); + } + }); + + it('parses service with capability descriptions', () => { + const doc = parse(` + service { + "Supports the GraphQL spec" + capability graphql.spec + } + `); + + const serviceDef = doc.definitions[0]; + if (serviceDef.kind === Kind.SERVICE_DEFINITION) { + expect(serviceDef.capabilities?.[0].description?.value).to.equal( + 'Supports the GraphQL spec', + ); + } + }); + + it('parses service with multiple capabilities', () => { + const doc = parse(` + service { + capability graphql.spec + capability graphql.federatedQueries + capability org.example.customFeature = "v2" + } + `); + + const serviceDef = doc.definitions[0]; + if (serviceDef.kind === Kind.SERVICE_DEFINITION) { + expect(serviceDef.capabilities).to.have.length(3); + expect(serviceDef.capabilities?.[0].identifier.value).to.equal( + 'graphql.spec', + ); + expect(serviceDef.capabilities?.[1].identifier.value).to.equal( + 'graphql.federatedQueries', + ); + expect(serviceDef.capabilities?.[2].identifier.value).to.equal( + 'org.example.customFeature', + ); + expect(serviceDef.capabilities?.[2].value?.value).to.equal('v2'); + } + }); + + it('parses service with directives', () => { + const doc = parse(` + service @deprecated { + capability graphql.spec + } + `); + + const serviceDef = doc.definitions[0]; + if (serviceDef.kind === Kind.SERVICE_DEFINITION) { + expect(serviceDef.directives).to.have.length(1); + expect(serviceDef.directives?.[0].name.value).to.equal('deprecated'); + } + }); + + it('rejects qualified name with whitespace', () => { + expect(() => parse('service { capability graphql. spec }')).to.throw( + 'Expected a qualified name', + ); + }); + + it('rejects single-component name as capability identifier', () => { + expect(() => parse('service { capability graphql }')).to.throw( + 'Expected a qualified name with at least two components', + ); + }); + }); + + describe('parsing service extensions', () => { + it('parses a service extension', () => { + const doc = parse(` + extend service { + capability graphql.additionalFeature + } + `); + + expect(doc.definitions).to.have.length(1); + const serviceExt = doc.definitions[0]; + expect(serviceExt.kind).to.equal(Kind.SERVICE_EXTENSION); + + if (serviceExt.kind === Kind.SERVICE_EXTENSION) { + expect(serviceExt.capabilities).to.have.length(1); + expect(serviceExt.capabilities?.[0].identifier.value).to.equal( + 'graphql.additionalFeature', + ); + } + }); + + it('parses service extension with directives', () => { + const doc = parse(` + extend service @deprecated { + capability graphql.spec + } + `); + + const serviceExt = doc.definitions[0]; + if (serviceExt.kind === Kind.SERVICE_EXTENSION) { + expect(serviceExt.directives).to.have.length(1); + expect(serviceExt.directives?.[0].name.value).to.equal('deprecated'); + } + }); + }); + + describe('printing service definitions', () => { + it('prints a service definition', () => { + const doc = parse(` + service { + capability graphql.spec + } + `); + + expect(print(doc)).to.equal(`service { + capability graphql.spec +}`); + }); + + it('prints service with capability value', () => { + const doc = parse(` + service { + capability graphql.spec = "2024" + } + `); + + expect(print(doc)).to.equal(`service { + capability graphql.spec = "2024" +}`); + }); + + it('prints service with description', () => { + const doc = parse(` + "My Service" + service { + capability graphql.spec + } + `); + + expect(print(doc)).to.equal(`"My Service" +service { + capability graphql.spec +}`); + }); + + it('prints service with capability description', () => { + const doc = parse(` + service { + "A capability" + capability graphql.spec + } + `); + + expect(print(doc)).to.equal(`service { + "A capability" + capability graphql.spec +}`); + }); + + it('prints service with directives', () => { + const doc = parse(` + service @deprecated { + capability graphql.spec + } + `); + + expect(print(doc)).to.equal(`service @deprecated { + capability graphql.spec +}`); + }); + + it('prints service extension', () => { + const doc = parse(` + extend service { + capability graphql.newFeature + } + `); + + expect(print(doc)).to.equal(`extend service { + capability graphql.newFeature +}`); + }); + + it('prints complex service definition', () => { + const sdl = ` + """A GraphQL service with multiple capabilities""" + service @deprecated(reason: "test") { + """The main spec capability""" + capability graphql.spec = "2024" + capability graphql.federatedQueries + capability org.example.custom + } + `; + const doc = parse(sdl); + expect(print(doc)).to.equal( + `"""A GraphQL service with multiple capabilities""" +service @deprecated(reason: "test") { + """The main spec capability""" + capability graphql.spec = "2024" + capability graphql.federatedQueries + capability org.example.custom +}`, + ); + }); + }); +}); diff --git a/src/language/ast.ts b/src/language/ast.ts index 580327be94..0c842c9764 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -226,6 +226,8 @@ export type ASTNode = | EnumValueDefinitionNode | InputObjectTypeDefinitionNode | DirectiveDefinitionNode + | ServiceDefinitionNode + | ServiceCapabilityNode | SchemaExtensionNode | ScalarTypeExtensionNode | ObjectTypeExtensionNode @@ -234,6 +236,7 @@ export type ASTNode = | EnumTypeExtensionNode | InputObjectTypeExtensionNode | DirectiveExtensionNode + | ServiceExtensionNode | TypeCoordinateNode | MemberCoordinateNode | ArgumentCoordinateNode @@ -338,6 +341,9 @@ export const QueryDocumentKeys: { 'locations', ], + ServiceDefinition: ['description', 'directives', 'capabilities'], + ServiceCapability: ['description', 'identifier', 'value'], + SchemaExtension: ['directives', 'operationTypes'], DirectiveExtension: ['name', 'directives'], @@ -349,6 +355,8 @@ export const QueryDocumentKeys: { EnumTypeExtension: ['name', 'directives', 'values'], InputObjectTypeExtension: ['name', 'directives', 'fields'], + ServiceExtension: ['directives', 'capabilities'], + TypeCoordinate: ['name'], MemberCoordinate: ['name', 'memberName'], ArgumentCoordinate: ['name', 'fieldName', 'argumentName'], @@ -784,7 +792,8 @@ export interface NonNullTypeNode { export type TypeSystemDefinitionNode = | SchemaDefinitionNode | TypeDefinitionNode - | DirectiveDefinitionNode; + | DirectiveDefinitionNode + | ServiceDefinitionNode; /** A schema definition in a type-system document. */ export interface SchemaDefinitionNode { @@ -999,7 +1008,28 @@ export interface DirectiveDefinitionNode { export type TypeSystemExtensionNode = | SchemaExtensionNode | TypeExtensionNode - | DirectiveExtensionNode; + | DirectiveExtensionNode + | ServiceExtensionNode; + +/** Service Definitions */ + +export interface ServiceDefinitionNode { + readonly kind: Kind.SERVICE_DEFINITION; + readonly loc?: Location; + readonly description?: StringValueNode; + readonly directives?: ReadonlyArray; + readonly capabilities?: ReadonlyArray; +} + +export interface ServiceCapabilityNode { + readonly kind: Kind.SERVICE_CAPABILITY; + readonly loc?: Location; + readonly description?: StringValueNode; + /** The capability identifier (a qualified name like "graphql.operationDescriptions") */ + readonly identifier: StringValueNode; + /** Optional string value for the capability */ + readonly value?: StringValueNode; +} /** A schema extension in a type-system document. */ export interface SchemaExtensionNode { @@ -1122,6 +1152,15 @@ export interface DirectiveExtensionNode { readonly directives?: ReadonlyArray; } +/** Service Extension */ + +export interface ServiceExtensionNode { + readonly kind: Kind.SERVICE_EXTENSION; + readonly loc?: Location; + readonly directives?: ReadonlyArray; + readonly capabilities?: ReadonlyArray; +} + // Schema Coordinates /** Any AST node representing a GraphQL schema coordinate. */ diff --git a/src/language/index.ts b/src/language/index.ts index 5f2fc908b0..a6b1c9d2c5 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -94,6 +94,8 @@ export type { EnumValueDefinitionNode, InputObjectTypeDefinitionNode, DirectiveDefinitionNode, + ServiceDefinitionNode, + ServiceCapabilityNode, TypeSystemExtensionNode, SchemaExtensionNode, TypeExtensionNode, @@ -104,6 +106,7 @@ export type { EnumTypeExtensionNode, InputObjectTypeExtensionNode, DirectiveExtensionNode, + ServiceExtensionNode, // Schema Coordinates SchemaCoordinateNode, TypeCoordinateNode, diff --git a/src/language/kinds.ts b/src/language/kinds.ts index 349c89be65..02528ce6e9 100644 --- a/src/language/kinds.ts +++ b/src/language/kinds.ts @@ -83,6 +83,10 @@ enum Kind { /** AST kind for directive definition nodes. */ DIRECTIVE_DEFINITION = 'DirectiveDefinition', + /** Service Definitions */ + SERVICE_DEFINITION = 'ServiceDefinition', + SERVICE_CAPABILITY = 'ServiceCapability', + /** AST kind for schema extension nodes. */ SCHEMA_EXTENSION = 'SchemaExtension', /** AST kind for directive extension nodes. */ @@ -101,6 +105,9 @@ enum Kind { /** AST kind for input object type extension nodes. */ INPUT_OBJECT_TYPE_EXTENSION = 'InputObjectTypeExtension', + /** Service Extension */ + SERVICE_EXTENSION = 'ServiceExtension', + /** AST kind for type coordinate nodes. */ TYPE_COORDINATE = 'TypeCoordinate', /** AST kind for member coordinate nodes. */ diff --git a/src/language/lexer.ts b/src/language/lexer.ts index 6a6d54fe39..aced1d2d7a 100644 --- a/src/language/lexer.ts +++ b/src/language/lexer.ts @@ -934,3 +934,58 @@ export function readName(lexer: LexerInterface, start: number): Token { body.slice(start, position), ); } + +/** + * Reads a qualified name from the source, which is two or more names + * separated by periods with no whitespace or ignored characters. + * + * ``` + * QualifiedName :: + * - QualifiedName . Name [lookahead != `.`] + * - Name . Name [lookahead != `.`] + * ``` + * + * @internal + */ +export function readQualifiedName(lexer: LexerInterface, start: number): Token { + const body = lexer.source.body; + const bodyLength = body.length; + let position = start; + let dotCount = 0; + + while (position < bodyLength) { + const code = body.charCodeAt(position); + if (isNameContinue(code)) { + ++position; + } else if (code === 0x002e) { + // . (period) + // Check if next character is a valid name start + const nextCode = body.charCodeAt(position + 1); + if (isNameStart(nextCode)) { + ++dotCount; + ++position; + } else { + break; + } + } else { + break; + } + } + + // Must have at least one dot to be a qualified name + if (dotCount === 0) { + throw syntaxError( + lexer.source, + start, + 'Expected a qualified name with at least two components separated by periods.', + ); + } + + return createToken( + lexer, + TokenKind.QUALIFIED_NAME, + start, + position, + body.slice(start, position), + ); +} diff --git a/src/language/parser.ts b/src/language/parser.ts index fc87612b25..d968700958 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -58,6 +58,9 @@ import type { SchemaExtensionNode, SelectionNode, SelectionSetNode, + ServiceCapabilityNode, + ServiceDefinitionNode, + ServiceExtensionNode, StringValueNode, Token, TypeCoordinateNode, @@ -73,7 +76,7 @@ import { Location, OperationTypeNode } from './ast'; import { DirectiveLocation } from './directiveLocation'; import { Kind } from './kinds'; import type { LexerInterface } from './lexer'; -import { isPunctuatorTokenKind, Lexer } from './lexer'; +import { isPunctuatorTokenKind, Lexer, readQualifiedName } from './lexer'; import { SchemaCoordinateLexer } from './schemaCoordinateLexer'; import { isSource, Source } from './source'; import { TokenKind } from './tokenKind'; @@ -436,6 +439,8 @@ export class Parser { return this.parseInputObjectTypeDefinition(); case 'directive': return this.parseDirectiveDefinition(); + case 'service': + return this.parseServiceDefinition(); } switch (keywordToken.value) { @@ -1394,6 +1399,8 @@ export class Parser { return this.parseDirectiveDefinitionExtension(); } break; + case 'service': + return this.parseServiceExtension(); } } @@ -1688,6 +1695,108 @@ export class Parser { throw this.unexpected(start); } + // Service Definitions + + /** + * ``` + * ServiceDefinition : Description? service Directives? { ServiceCapability* } + * ``` + */ + parseServiceDefinition(): ServiceDefinitionNode { + const start = this._lexer.token; + const description = this.parseDescription(); + this.expectKeyword('service'); + const directives = this.parseConstDirectives(); + const capabilities = this.optionalMany( + TokenKind.BRACE_L, + this.parseServiceCapability, + TokenKind.BRACE_R, + ); + return this.node(start, { + kind: Kind.SERVICE_DEFINITION, + description, + directives, + capabilities, + }); + } + + /** + * ``` + * ServiceCapability : Description? capability QualifiedName ServiceCapabilityValue? + * ServiceCapabilityValue : = StringValue + * ``` + */ + parseServiceCapability(): ServiceCapabilityNode { + const start = this._lexer.token; + const description = this.parseDescription(); + this.expectKeyword('capability'); + + // Parse qualified name - this is a lexer feature that doesn't allow whitespace + // The current token should be a NAME that starts the qualified name + const identifierToken = this._lexer.token; + if (identifierToken.kind !== TokenKind.NAME) { + throw this.unexpected(identifierToken); + } + + // Use the lexer to read a qualified name directly from this position + const qualifiedNameToken = readQualifiedName( + this._lexer, + identifierToken.start, + ); + + // Create a StringValueNode from the qualified name token + const identifier = this.node(identifierToken, { + kind: Kind.STRING, + value: qualifiedNameToken.value, + block: false, + }); + + // Update the lexer to point to the qualified name token and then advance past it + // The qualified name token will be followed by whatever comes next + (this._lexer as { token: Token }).token = qualifiedNameToken; + this.advanceLexer(); + + // Parse optional value: = StringValue + let value: StringValueNode | undefined; + if (this.expectOptionalToken(TokenKind.EQUALS)) { + value = this.parseStringLiteral(); + } + + return this.node(start, { + kind: Kind.SERVICE_CAPABILITY, + description, + identifier, + value, + }); + } + + /** + * ``` + * ServiceExtension : + * - extend service Directives? { ServiceCapability* } + * - extend service Directives [lookahead != `{`] + * ``` + */ + parseServiceExtension(): ServiceExtensionNode { + const start = this._lexer.token; + this.expectKeyword('extend'); + this.expectKeyword('service'); + const directives = this.parseConstDirectives(); + const capabilities = this.optionalMany( + TokenKind.BRACE_L, + this.parseServiceCapability, + TokenKind.BRACE_R, + ); + if (directives.length === 0 && capabilities.length === 0) { + throw this.unexpected(); + } + return this.node(start, { + kind: Kind.SERVICE_EXTENSION, + directives, + capabilities, + }); + } + // Schema Coordinates /** diff --git a/src/language/predicates.ts b/src/language/predicates.ts index af83151502..3b3018c50f 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -184,7 +184,8 @@ export function isTypeSystemDefinitionNode( return ( node.kind === Kind.SCHEMA_DEFINITION || isTypeDefinitionNode(node) || - node.kind === Kind.DIRECTIVE_DEFINITION + node.kind === Kind.DIRECTIVE_DEFINITION || + node.kind === Kind.SERVICE_DEFINITION ); } @@ -237,7 +238,8 @@ export function isTypeSystemExtensionNode( return ( node.kind === Kind.SCHEMA_EXTENSION || node.kind === Kind.DIRECTIVE_EXTENSION || - isTypeExtensionNode(node) + isTypeExtensionNode(node) || + node.kind === Kind.SERVICE_EXTENSION ); } diff --git a/src/language/printer.ts b/src/language/printer.ts index 143de458ec..1075c37ffa 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -268,6 +268,32 @@ const printDocASTReducer: ASTReducer = { join(locations, ' | '), }, + ServiceDefinition: { + leave: ({ description, directives, capabilities }) => + wrap('', description, '\n') + + join(['service', join(directives, ' '), block(capabilities)], ' '), + }, + + ServiceCapability: { + // Identifier needs to be printed without quotes as a QualifiedName, not a StringValue. + // Use enter to capture the raw identifier value before visitor transforms it. + leave: ({ description, identifier, value }) => { + // identifier here is the result of visiting the StringValue, which adds quotes. + // We need to remove the quotes for qualified names. + const identifierValue = + typeof identifier === 'string' && identifier.startsWith('"') + ? identifier.slice(1, -1) + : identifier; + return ( + wrap('', description, '\n') + + join( + ['capability', identifierValue, value ? '= ' + value : undefined], + ' ', + ) + ); + }, + }, + SchemaExtension: { leave: ({ directives, operationTypes }) => join( @@ -337,6 +363,11 @@ const printDocASTReducer: ASTReducer = { join(['extend directive @' + name, join(directives, ' ')], ' '), }, + ServiceExtension: { + leave: ({ directives, capabilities }) => + join(['extend service', join(directives, ' '), block(capabilities)], ' '), + }, + // Schema Coordinates TypeCoordinate: { leave: ({ name }) => name }, diff --git a/src/language/tokenKind.ts b/src/language/tokenKind.ts index ee7e097954..7500d17c41 100644 --- a/src/language/tokenKind.ts +++ b/src/language/tokenKind.ts @@ -41,6 +41,7 @@ enum TokenKind { BRACE_R = '}', /** A GraphQL name token or name AST node. */ NAME = 'Name', + QUALIFIED_NAME = 'QualifiedName', /** An integer value token or AST node. */ INT = 'Int', /** A floating-point value token or AST node. */ diff --git a/src/type/__tests__/service-test.ts b/src/type/__tests__/service-test.ts new file mode 100644 index 0000000000..963a630eaf --- /dev/null +++ b/src/type/__tests__/service-test.ts @@ -0,0 +1,239 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { buildSchema } from '../../utilities/buildASTSchema'; +import { printSchema } from '../../utilities/printSchema'; + +import { graphqlSync } from '../../graphql'; + +import { GraphQLObjectType } from '../definition'; +import { GraphQLString } from '../scalars'; +import { GraphQLSchema } from '../schema'; +import { assertService, GraphQLService, isService } from '../service'; + +describe('Type System: Service', () => { + describe('GraphQLService', () => { + it('creates a service with capabilities', () => { + const service = new GraphQLService({ + description: 'My GraphQL Service', + capabilities: [ + { identifier: 'graphql.spec', value: '2024' }, + { identifier: 'graphql.federatedQueries' }, + ], + }); + + expect(service.description).to.equal('My GraphQL Service'); + expect(service.capabilities).to.have.length(2); + expect(service.capabilities[0].identifier).to.equal('graphql.spec'); + expect(service.capabilities[0].value).to.equal('2024'); + expect(service.capabilities[1].identifier).to.equal( + 'graphql.federatedQueries', + ); + expect(service.capabilities[1].value).to.equal(null); + }); + + it('has getCapability method', () => { + const service = new GraphQLService({ + capabilities: [ + { identifier: 'graphql.spec', value: '2024' }, + { identifier: 'graphql.federatedQueries' }, + ], + }); + + const cap = service.getCapability('graphql.spec'); + expect(cap?.identifier).to.equal('graphql.spec'); + expect(cap?.value).to.equal('2024'); + + expect(service.getCapability('nonexistent')).to.equal(undefined); + }); + + it('has hasCapability method', () => { + const service = new GraphQLService({ + capabilities: [{ identifier: 'graphql.spec' }], + }); + + expect(service.hasCapability('graphql.spec')).to.equal(true); + expect(service.hasCapability('nonexistent')).to.equal(false); + }); + + it('can be converted to config', () => { + const service = new GraphQLService({ + description: 'Test', + capabilities: [{ identifier: 'graphql.spec', value: '2024' }], + }); + + const config = service.toConfig(); + expect(config.description).to.equal('Test'); + expect(config.capabilities).to.have.length(1); + expect(config.capabilities[0].identifier).to.equal('graphql.spec'); + }); + }); + + describe('isService', () => { + it('returns true for GraphQLService', () => { + const service = new GraphQLService({ capabilities: [] }); + expect(isService(service)).to.equal(true); + }); + + it('returns false for non-services', () => { + expect(isService({})).to.equal(false); + expect(isService(null)).to.equal(false); + expect(isService(undefined)).to.equal(false); + }); + }); + + describe('assertService', () => { + it('returns the service for valid input', () => { + const service = new GraphQLService({ capabilities: [] }); + expect(assertService(service)).to.equal(service); + }); + + it('throws for non-services', () => { + expect(() => assertService({})).to.throw( + 'to be a GraphQL service definition.', + ); + }); + }); +}); + +describe('Schema with Service', () => { + it('can add service to schema', () => { + const service = new GraphQLService({ + capabilities: [{ identifier: 'graphql.spec' }], + }); + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { test: { type: GraphQLString } }, + }), + service, + }); + + expect(schema.getService()).to.equal(service); + }); + + it('builds service from SDL', () => { + const schema = buildSchema(` + type Query { + test: String + } + service { + capability graphql.spec = "2024" + capability graphql.federatedQueries + } + `); + + const service = schema.getService(); + expect(service).to.not.equal(undefined); + expect(service?.capabilities).to.have.length(2); + expect(service?.capabilities[0].identifier).to.equal('graphql.spec'); + expect(service?.capabilities[0].value).to.equal('2024'); + }); + + it('prints service when includeService is true', () => { + const schema = buildSchema(` + type Query { + test: String + } + service { + capability graphql.spec = "2024" + } + `); + + const printed = printSchema(schema, { includeService: true }); + expect(printed).to.include('service {'); + expect(printed).to.include('capability graphql.spec = "2024"'); + }); + + it('does not print service by default', () => { + const schema = buildSchema(` + type Query { + test: String + } + service { + capability graphql.spec + } + `); + + const printed = printSchema(schema); + expect(printed).to.not.include('service'); + expect(printed).to.not.include('capability'); + }); +}); + +describe('Service Introspection', () => { + it('can query __service meta-field', () => { + const schema = buildSchema(` + type Query { + test: String + } + service { + "The GraphQL spec version" + capability graphql.spec = "2024" + capability graphql.federatedQueries + } + `); + + const result = graphqlSync({ + schema, + source: ` + { + __service { + capabilities { + identifier + description + value + } + } + } + `, + }); + + expect(result.errors).to.equal(undefined); + expect(result.data).to.deep.equal({ + __service: { + capabilities: [ + { + identifier: 'graphql.spec', + description: 'The GraphQL spec version', + value: '2024', + }, + { + identifier: 'graphql.federatedQueries', + description: null, + value: null, + }, + ], + }, + }); + }); + + it('returns empty capabilities when no service defined', () => { + const schema = buildSchema(` + type Query { + test: String + } + `); + + const result = graphqlSync({ + schema, + source: ` + { + __service { + capabilities { + identifier + } + } + } + `, + }); + + expect(result.errors).to.equal(undefined); + expect(result.data).to.deep.equal({ + __service: { + capabilities: [], + }, + }); + }); +}); diff --git a/src/type/index.ts b/src/type/index.ts index d3cdec26e2..40beb1cdc8 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -150,6 +150,22 @@ export type { GraphQLDirectiveExtensions, } from './directives'; +export { + // Predicate + isService, + // Assertion + assertService, + // GraphQL Service definition + GraphQLService, +} from './service'; + +export type { + GraphQLCapability, + GraphQLCapabilityConfig, + GraphQLServiceConfig, + GraphQLServiceExtensions, +} from './service'; + // Common built-in scalar instances. export { // Predicate @@ -179,12 +195,15 @@ export { __InputValue, __EnumValue, __TypeKind, + __Service, + __Capability, // "Enum" of Type Kinds TypeKind, // Meta-field definitions. SchemaMetaFieldDef, TypeMetaFieldDef, TypeNameMetaFieldDef, + ServiceMetaFieldDef, } from './introspection'; // Validate GraphQL schema. diff --git a/src/type/introspection.ts b/src/type/introspection.ts index 0427b33d8b..7c25763f9a 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -2,6 +2,7 @@ import { inspect } from '../jsutils/inspect'; import { invariant } from '../jsutils/invariant'; +import type { Maybe } from '../jsutils/Maybe'; import { DirectiveLocation } from '../language/directiveLocation'; import { print } from '../language/printer'; @@ -34,6 +35,7 @@ import { import type { GraphQLDirective } from './directives'; import { GraphQLBoolean, GraphQLString } from './scalars'; import type { GraphQLSchema } from './schema'; +import type { GraphQLCapability, GraphQLService } from './service'; /** The introspection type describing a GraphQL schema. */ export const __Schema: GraphQLObjectType = new GraphQLObjectType({ @@ -545,6 +547,48 @@ export const __TypeKind: GraphQLEnumType = new GraphQLEnumType({ }, }); +export const __Capability: GraphQLObjectType = new GraphQLObjectType({ + name: '__Capability', + description: + 'A service capability describes a feature supported by the GraphQL service.', + fields: () => + ({ + identifier: { + type: new GraphQLNonNull(GraphQLString), + description: + 'The capability identifier uniquely identifying this service capability.', + resolve: (capability) => capability.identifier, + }, + description: { + type: GraphQLString, + resolve: (capability) => capability.description, + }, + value: { + type: GraphQLString, + description: + 'The string value of the service capability, or null if there is no associated value.', + resolve: (capability) => capability.value, + }, + } as GraphQLFieldConfigMap), +}); + +export const __Service: GraphQLObjectType = new GraphQLObjectType({ + name: '__Service', + description: + 'The __Service type is returned from the __service meta-field and provides information about the GraphQL service, most notably about its capabilities.', + fields: () => + ({ + capabilities: { + type: new GraphQLNonNull( + new GraphQLList(new GraphQLNonNull(__Capability)), + ), + description: + 'A list of capabilities detailing each service capability supported by the service.', + resolve: (service) => service?.capabilities ?? [], + }, + } as GraphQLFieldConfigMap, unknown>), +}); + /** * Note that these are GraphQLField and not GraphQLFieldConfig, * so the format for args is different. @@ -596,6 +640,18 @@ export const TypeNameMetaFieldDef: GraphQLField = { }; /** All introspection types defined by the GraphQL specification. */ +export const ServiceMetaFieldDef: GraphQLField = { + name: '__service', + type: new GraphQLNonNull(__Service), + description: 'Access service information and capabilities.', + args: [], + // Returns the service or an empty object - the __Service type fields handle this gracefully + resolve: (_source, _args, _context, { schema }) => + schema.getService() ?? { capabilities: [] }, + deprecationReason: undefined, + extensions: Object.create(null), + astNode: undefined, +}; export const introspectionTypes: ReadonlyArray = Object.freeze([ __Schema, @@ -606,6 +662,8 @@ export const introspectionTypes: ReadonlyArray = __InputValue, __EnumValue, __TypeKind, + __Service, + __Capability, ]); /** diff --git a/src/type/schema.ts b/src/type/schema.ts index fbfef5a136..4ba315345f 100644 --- a/src/type/schema.ts +++ b/src/type/schema.ts @@ -33,6 +33,7 @@ import { import type { GraphQLDirective } from './directives'; import { isDirective, specifiedDirectives } from './directives'; import { __Schema } from './introspection'; +import type { GraphQLService } from './service'; /** * Test if the given value is a GraphQL schema. @@ -198,6 +199,7 @@ export class GraphQLSchema { private _mutationType: Maybe; private _subscriptionType: Maybe; private _directives: ReadonlyArray; + private _service: Maybe; private _typeMap: TypeMap; private _subTypeMap: ObjMap>; private _implementationsMap: ObjMap<{ @@ -325,6 +327,7 @@ export class GraphQLSchema { this._subscriptionType = config.subscription; // Provide specified directives (e.g. @include and @skip) by default. this._directives = config.directives ?? specifiedDirectives; + this._service = config.service; // To preserve order of user-provided types, we add first to add them to // the set of "collected" types, so `collectReferencedTypes` ignore them. @@ -803,6 +806,9 @@ export class GraphQLSchema { * schemaCopy.getQueryType()?.name; // => 'Query' * ``` */ + getService(): Maybe { + return this._service; + } toConfig(): GraphQLSchemaNormalizedConfig { return { description: this.description, @@ -811,6 +817,7 @@ export class GraphQLSchema { subscription: this.getSubscriptionType(), types: Object.values(this.getTypeMap()), directives: this.getDirectives(), + service: this.getService(), extensions: this.extensions, astNode: this.astNode, extensionASTNodes: this.extensionASTNodes, @@ -847,6 +854,7 @@ export interface GraphQLSchemaConfig extends GraphQLSchemaValidationOptions { types?: Maybe>; /** Directives available in this schema or applied to this AST node. */ directives?: Maybe>; + service?: Maybe; /** Custom extension fields reserved for users. */ extensions?: Maybe>; /** AST node from which this schema element was built, if available. */ @@ -860,6 +868,7 @@ export interface GraphQLSchemaNormalizedConfig extends GraphQLSchemaConfig { description: Maybe; types: ReadonlyArray; directives: ReadonlyArray; + service: Maybe; extensions: Readonly; extensionASTNodes: ReadonlyArray; assumeValid: boolean; diff --git a/src/type/service.ts b/src/type/service.ts new file mode 100644 index 0000000000..d3cb243436 --- /dev/null +++ b/src/type/service.ts @@ -0,0 +1,155 @@ +import { devAssert } from '../jsutils/devAssert'; +import { inspect } from '../jsutils/inspect'; +import { instanceOf } from '../jsutils/instanceOf'; +import type { Maybe } from '../jsutils/Maybe'; +import { toObjMap } from '../jsutils/toObjMap'; + +import type { + ServiceCapabilityNode, + ServiceDefinitionNode, + ServiceExtensionNode, +} from '../language/ast'; + +/** + * Test if the given value is a GraphQL service. + */ +export function isService(service: unknown): service is GraphQLService { + return instanceOf(service, GraphQLService); +} + +export function assertService(service: unknown): GraphQLService { + if (!isService(service)) { + throw new Error( + `Expected ${inspect(service)} to be a GraphQL service definition.`, + ); + } + return service; +} + +/** + * Custom extensions + * + * @remarks + * Use a unique identifier name for your extension, for example the name of + * your library or project. Do not use a shortened identifier as this increases + * the risk of conflicts. We recommend you add at most one extension field, + * an object which can contain all the values you need. + */ +export interface GraphQLServiceExtensions { + [attributeName: string]: unknown; +} + +/** + * A service capability describes a feature supported by the GraphQL service + * but not directly expressible via the type system. + */ +export interface GraphQLCapability { + /** The capability identifier (a qualified name like "graphql.operationDescriptions") */ + identifier: string; + /** Optional description of the capability */ + description: Maybe; + /** Optional string value for the capability */ + value: Maybe; + /** The original AST node */ + astNode: Maybe; +} + +export interface GraphQLCapabilityConfig { + /** The capability identifier (a qualified name like "graphql.operationDescriptions") */ + identifier: string; + /** Optional description of the capability */ + description?: Maybe; + /** Optional string value for the capability */ + value?: Maybe; + /** The original AST node */ + astNode?: Maybe; +} + +/** + * GraphQL Service Definition + * + * A GraphQL service is defined in terms of the capabilities that it offers + * which are external to the schema. + */ +export class GraphQLService { + description: Maybe; + capabilities: ReadonlyArray; + extensions: Readonly; + astNode: Maybe; + extensionASTNodes: ReadonlyArray; + + constructor(config: Readonly) { + this.description = config.description; + this.extensions = toObjMap(config.extensions); + this.astNode = config.astNode; + this.extensionASTNodes = config.extensionASTNodes ?? []; + + const capabilities = config.capabilities ?? []; + devAssert( + Array.isArray(capabilities), + 'Service capabilities must be an Array.', + ); + + this.capabilities = capabilities.map((cap) => ({ + identifier: cap.identifier, + description: cap.description ?? null, + value: cap.value ?? null, + astNode: cap.astNode ?? null, + })); + } + + get [Symbol.toStringTag]() { + return 'GraphQLService'; + } + + /** + * Get a capability by its identifier. + */ + getCapability(identifier: string): GraphQLCapability | undefined { + return this.capabilities.find((cap) => cap.identifier === identifier); + } + + /** + * Check if the service has a capability with the given identifier. + */ + hasCapability(identifier: string): boolean { + return this.capabilities.some((cap) => cap.identifier === identifier); + } + + toConfig(): GraphQLServiceNormalizedConfig { + return { + description: this.description, + capabilities: this.capabilities.map((cap) => ({ + identifier: cap.identifier, + description: cap.description, + value: cap.value, + astNode: cap.astNode, + })), + extensions: this.extensions, + astNode: this.astNode, + extensionASTNodes: this.extensionASTNodes, + }; + } + + toString(): string { + return 'service'; + } + + toJSON(): string { + return this.toString(); + } +} + +export interface GraphQLServiceConfig { + description?: Maybe; + capabilities?: Maybe>; + extensions?: Maybe>; + astNode?: Maybe; + extensionASTNodes?: Maybe>; +} + +interface GraphQLServiceNormalizedConfig extends GraphQLServiceConfig { + capabilities: ReadonlyArray; + extensions: Readonly; + extensionASTNodes: ReadonlyArray; +} diff --git a/src/utilities/TypeInfo.ts b/src/utilities/TypeInfo.ts index 5e4a3d1b22..f9b3ef61c6 100644 --- a/src/utilities/TypeInfo.ts +++ b/src/utilities/TypeInfo.ts @@ -33,6 +33,7 @@ import { import type { GraphQLDirective } from '../type/directives'; import { SchemaMetaFieldDef, + ServiceMetaFieldDef, TypeMetaFieldDef, TypeNameMetaFieldDef, } from '../type/introspection'; @@ -729,6 +730,12 @@ function getFieldDef( if (name === TypeMetaFieldDef.name && schema.getQueryType() === parentType) { return TypeMetaFieldDef; } + if ( + name === ServiceMetaFieldDef.name && + schema.getQueryType() === parentType + ) { + return ServiceMetaFieldDef; + } if (name === TypeNameMetaFieldDef.name && isCompositeType(parentType)) { return TypeNameMetaFieldDef; } diff --git a/src/utilities/buildASTSchema.ts b/src/utilities/buildASTSchema.ts index 824db4cdea..34537e158e 100644 --- a/src/utilities/buildASTSchema.ts +++ b/src/utilities/buildASTSchema.ts @@ -80,6 +80,7 @@ export function buildASTSchema( description: undefined, types: [], directives: [], + service: undefined, extensions: Object.create(null), extensionASTNodes: [], assumeValid: false, diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index 1ac0c0028f..46749aa160 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -27,6 +27,8 @@ import type { ScalarTypeExtensionNode, SchemaDefinitionNode, SchemaExtensionNode, + ServiceDefinitionNode, + ServiceExtensionNode, TypeDefinitionNode, TypeNode, UnionTypeDefinitionNode, @@ -79,6 +81,8 @@ import type { GraphQLSchemaValidationOptions, } from '../type/schema'; import { assertSchema, GraphQLSchema } from '../type/schema'; +import type { GraphQLCapabilityConfig } from '../type/service'; +import { GraphQLService } from '../type/service'; import { assertValidSDLExtension } from '../validation/validate'; @@ -205,6 +209,10 @@ export function extendSchemaImpl( // Schema extensions are collected which may add additional operation types. const schemaExtensions: Array = []; + let serviceDef: Maybe; + // Service extensions are collected which may add additional capabilities. + const serviceExtensions: Array = []; + for (const def of documentAST.definitions) { if (def.kind === Kind.SCHEMA_DEFINITION) { schemaDef = def; @@ -228,10 +236,14 @@ export function extendSchemaImpl( existingDirectiveExtensions ? existingDirectiveExtensions.concat([def]) : [def]; + } else if (def.kind === Kind.SERVICE_DEFINITION) { + serviceDef = def; + } else if (def.kind === Kind.SERVICE_EXTENSION) { + serviceExtensions.push(def); } } - // If this document contains no new types, extensions, or directives then + // If this document contains no new types, extensions, directives, or service then // return the same unmodified GraphQLSchema instance. if ( Object.keys(typeExtensionsMap).length === 0 && @@ -239,7 +251,9 @@ export function extendSchemaImpl( Object.keys(directiveExtensionsMap).length === 0 && directiveDefs.length === 0 && schemaExtensions.length === 0 && - schemaDef == null + schemaDef == null && + serviceExtensions.length === 0 && + serviceDef == null ) { return schemaConfig; } @@ -281,12 +295,77 @@ export function extendSchemaImpl( ...directives.map(replaceDirective), ...directiveDefs.map(buildDirective), ], + service: buildService(serviceDef, serviceExtensions), extensions: Object.create(null), astNode: schemaDef ?? schemaConfig.astNode, extensionASTNodes: schemaConfig.extensionASTNodes.concat(schemaExtensions), assumeValid: options?.assumeValid ?? false, }; + function buildService( + astNode: Maybe, + extensionNodes: ReadonlyArray, + ): GraphQLService | undefined { + // Get existing service from config, if any + const existingService = schemaConfig.service; + + // If no new service definition or extensions, return existing service + if (astNode == null && extensionNodes.length === 0) { + return existingService ?? undefined; + } + + // Collect all capabilities from existing service, new definition, and extensions + const allCapabilities: Array = []; + + // Add capabilities from existing service + if (existingService) { + for (const cap of existingService.capabilities) { + allCapabilities.push({ + identifier: cap.identifier, + description: cap.description, + value: cap.value, + astNode: cap.astNode, + }); + } + } + + // Add capabilities from new service definition + if (astNode?.capabilities) { + for (const cap of astNode.capabilities) { + allCapabilities.push({ + identifier: cap.identifier.value, + description: cap.description?.value, + value: cap.value?.value, + astNode: cap, + }); + } + } + + // Add capabilities from extensions + for (const ext of extensionNodes) { + if (ext.capabilities) { + for (const cap of ext.capabilities) { + allCapabilities.push({ + identifier: cap.identifier.value, + description: cap.description?.value, + value: cap.value?.value, + astNode: cap, + }); + } + } + } + + return new GraphQLService({ + description: astNode?.description?.value ?? existingService?.description, + capabilities: allCapabilities, + astNode: astNode ?? existingService?.astNode, + extensionASTNodes: [ + ...(existingService?.extensionASTNodes ?? []), + ...extensionNodes, + ], + }); + } + // Below are functions used for producing this schema that have closed over // this scope and have access to the schema, cache, and newly defined types. diff --git a/src/utilities/getIntrospectionQuery.ts b/src/utilities/getIntrospectionQuery.ts index cec950c733..ed1d2ee2e6 100644 --- a/src/utilities/getIntrospectionQuery.ts +++ b/src/utilities/getIntrospectionQuery.ts @@ -57,6 +57,11 @@ export interface IntrospectionOptions { * Default: 9 */ typeDepth?: number; + /** + * Whether to include service capabilities in the introspection result. + * Default: false + */ + includeService?: boolean; } /** @@ -109,6 +114,7 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { experimentalDirectiveDeprecation: false, oneOf: false, typeDepth: 9, + includeService: false, ...options, }; @@ -146,6 +152,18 @@ ${indent} kind${ofType(level - 1, indent + ' ')} ${indent}}`; } + const serviceQuery = optionsWithDefault.includeService + ? ` + __service { + capabilities { + identifier + ${descriptions} + value + } + } + ` + : ''; + return ` query IntrospectionQuery { __schema { @@ -170,6 +188,7 @@ ${indent}}`; } } } + ${serviceQuery} } fragment FullType on __Type { @@ -486,3 +505,13 @@ export interface IntrospectionDirective { /** Arguments accepted by this field or directive. */ readonly args: ReadonlyArray; } + +export interface IntrospectionCapability { + readonly identifier: string; + readonly description?: Maybe; + readonly value?: Maybe; +} + +export interface IntrospectionService { + readonly capabilities: ReadonlyArray; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0b72d9947e..0b1dfdfc8a 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -32,6 +32,8 @@ export type { IntrospectionInputValue, IntrospectionEnumValue, IntrospectionDirective, + IntrospectionCapability, + IntrospectionService, } from './getIntrospectionQuery'; // Gets the target Operation from a Document. @@ -61,6 +63,7 @@ export { printSchema, printType, printIntrospectionSchema, + type PrintSchemaOptions, } from './printSchema'; // Create a GraphQLType from a GraphQL language AST. diff --git a/src/utilities/printSchema.ts b/src/utilities/printSchema.ts index 92cdc6d12d..223552aa5f 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -35,6 +35,7 @@ import { import { isIntrospectionType } from '../type/introspection'; import { isSpecifiedScalarType } from '../type/scalars'; import type { GraphQLSchema } from '../type/schema'; +import type { GraphQLCapability, GraphQLService } from '../type/service'; import { astFromValue } from './astFromValue'; @@ -57,11 +58,23 @@ import { astFromValue } from './astFromValue'; * printSchema(schema); // => ['directive @upper on FIELD_DEFINITION', '', 'type Query {', ' greeting: String', '}'].join('\n') * ``` */ -export function printSchema(schema: GraphQLSchema): string { +export interface PrintSchemaOptions { + /** + * Include the service definition in the printed schema. + * Defaults to false. + */ + includeService?: boolean; +} + +export function printSchema( + schema: GraphQLSchema, + options?: PrintSchemaOptions, +): string { return printFilteredSchema( schema, (n) => !isSpecifiedDirective(n), isDefinedType, + options, ); } @@ -98,17 +111,25 @@ function printFilteredSchema( schema: GraphQLSchema, directiveFilter: (type: GraphQLDirective) => boolean, typeFilter: (type: GraphQLNamedType) => boolean, + options?: PrintSchemaOptions, ): string { const directives = schema.getDirectives().filter(directiveFilter); const types = Object.values(schema.getTypeMap()).filter(typeFilter); - return [ + const parts = [ printSchemaDefinition(schema), ...directives.map((directive) => printDirective(directive)), ...types.map((type) => printType(type)), - ] - .filter(Boolean) - .join('\n\n'); + ]; + + if (options?.includeService) { + const service = schema.getService(); + if (service) { + parts.push(printService(service)); + } + } + + return parts.filter(Boolean).join('\n\n'); } function printSchemaDefinition(schema: GraphQLSchema): Maybe { @@ -396,3 +417,27 @@ function printDescription( return prefix + blockString.replace(/\n/g, '\n' + indentation) + '\n'; } + +function printService(service: GraphQLService): string { + const capabilities = service.capabilities.map((cap, i) => + printCapability(cap, !i), + ); + return printDescription(service) + 'service' + printBlock(capabilities); +} + +function printCapability( + capability: GraphQLCapability, + firstInBlock: boolean, +): string { + let result = + printDescription(capability, ' ', firstInBlock) + + ' capability ' + + capability.identifier; + + if (capability.value != null) { + const astValue = print({ kind: Kind.STRING, value: capability.value }); + result += ' = ' + astValue; + } + + return result; +} diff --git a/src/validation/__tests__/LoneServiceDefinitionRule-test.ts b/src/validation/__tests__/LoneServiceDefinitionRule-test.ts new file mode 100644 index 0000000000..224272b3e5 --- /dev/null +++ b/src/validation/__tests__/LoneServiceDefinitionRule-test.ts @@ -0,0 +1,84 @@ +import { describe, it } from 'mocha'; + +import { buildSchema } from '../../utilities/buildASTSchema'; + +import { LoneServiceDefinitionRule } from '../rules/LoneServiceDefinitionRule'; + +import { expectSDLValidationErrors } from './harness'; + +function expectSDLErrors(sdlStr: string) { + return expectSDLValidationErrors( + undefined, + LoneServiceDefinitionRule, + sdlStr, + ); +} + +function expectValidSDL(sdlStr: string) { + expectSDLErrors(sdlStr).toDeepEqual([]); +} + +describe('Validate: Lone service definition', () => { + it('no service definition', () => { + expectValidSDL(` + type Query { + test: String + } + `); + }); + + it('one service definition', () => { + expectValidSDL(` + type Query { + test: String + } + service { + capability graphql.spec + } + `); + }); + + it('multiple service definitions', () => { + expectSDLErrors(` + type Query { + test: String + } + service { + capability graphql.spec + } + service { + capability graphql.federatedQueries + } + `).toDeepEqual([ + { + message: 'Must provide only one service definition.', + locations: [{ line: 8, column: 7 }], + }, + ]); + }); + + it('service definition in extension', () => { + const schema = buildSchema(` + type Query { + test: String + } + service { + capability graphql.spec + } + `); + expectSDLValidationErrors( + schema, + LoneServiceDefinitionRule, + ` + service { + capability graphql.federatedQueries + } + `, + ).toDeepEqual([ + { + message: 'Cannot define a new service within a schema extension.', + locations: [{ line: 2, column: 7 }], + }, + ]); + }); +}); diff --git a/src/validation/__tests__/UniqueCapabilityIdentifiersRule-test.ts b/src/validation/__tests__/UniqueCapabilityIdentifiersRule-test.ts new file mode 100644 index 0000000000..e25be08c2d --- /dev/null +++ b/src/validation/__tests__/UniqueCapabilityIdentifiersRule-test.ts @@ -0,0 +1,122 @@ +import { describe, it } from 'mocha'; + +import { buildSchema } from '../../utilities/buildASTSchema'; + +import { UniqueCapabilityIdentifiersRule } from '../rules/UniqueCapabilityIdentifiersRule'; + +import { expectSDLValidationErrors } from './harness'; + +function expectSDLErrors(sdlStr: string) { + return expectSDLValidationErrors( + undefined, + UniqueCapabilityIdentifiersRule, + sdlStr, + ); +} + +function expectValidSDL(sdlStr: string) { + expectSDLErrors(sdlStr).toDeepEqual([]); +} + +describe('Validate: Unique capability identifiers', () => { + it('no service definition', () => { + expectValidSDL(` + type Query { + test: String + } + `); + }); + + it('unique capabilities', () => { + expectValidSDL(` + type Query { + test: String + } + service { + capability graphql.spec + capability graphql.federatedQueries + capability org.example.custom + } + `); + }); + + it('duplicate capabilities in service definition', () => { + expectSDLErrors(` + type Query { + test: String + } + service { + capability graphql.spec + capability graphql.spec + } + `).toDeepEqual([ + { + message: 'There can be only one capability named "graphql.spec".', + locations: [ + { line: 6, column: 20 }, + { line: 7, column: 20 }, + ], + }, + ]); + }); + + it('duplicate capabilities in service extension', () => { + expectSDLErrors(` + type Query { + test: String + } + extend service { + capability graphql.spec + capability graphql.spec + } + `).toDeepEqual([ + { + message: 'There can be only one capability named "graphql.spec".', + locations: [ + { line: 6, column: 20 }, + { line: 7, column: 20 }, + ], + }, + ]); + }); + + it('capability already exists in schema', () => { + const schema = buildSchema(` + type Query { + test: String + } + service { + capability graphql.spec + } + `); + expectSDLValidationErrors( + schema, + UniqueCapabilityIdentifiersRule, + ` + extend service { + capability graphql.spec + } + `, + ).toDeepEqual([ + { + message: + 'Capability "graphql.spec" already exists in the schema. It cannot be redefined.', + locations: [{ line: 3, column: 20 }], + }, + ]); + }); + + it('unique capabilities across definition and extension', () => { + expectValidSDL(` + type Query { + test: String + } + service { + capability graphql.spec + } + extend service { + capability graphql.federatedQueries + } + `); + }); +}); diff --git a/src/validation/index.ts b/src/validation/index.ts index 7a2980e101..4aa187233c 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -95,12 +95,14 @@ export { MaxIntrospectionDepthRule } from './rules/MaxIntrospectionDepthRule'; // SDL-specific validation rules export { LoneSchemaDefinitionRule } from './rules/LoneSchemaDefinitionRule'; +export { LoneServiceDefinitionRule } from './rules/LoneServiceDefinitionRule'; export { UniqueOperationTypesRule } from './rules/UniqueOperationTypesRule'; export { UniqueTypeNamesRule } from './rules/UniqueTypeNamesRule'; export { UniqueEnumValueNamesRule } from './rules/UniqueEnumValueNamesRule'; export { UniqueFieldDefinitionNamesRule } from './rules/UniqueFieldDefinitionNamesRule'; export { UniqueArgumentDefinitionNamesRule } from './rules/UniqueArgumentDefinitionNamesRule'; export { UniqueDirectiveNamesRule } from './rules/UniqueDirectiveNamesRule'; +export { UniqueCapabilityIdentifiersRule } from './rules/UniqueCapabilityIdentifiersRule'; export { PossibleTypeExtensionsRule } from './rules/PossibleTypeExtensionsRule'; // Optional rules not defined by the GraphQL Specification diff --git a/src/validation/rules/ExecutableDefinitionsRule.ts b/src/validation/rules/ExecutableDefinitionsRule.ts index 80920aee06..64f4a3756a 100644 --- a/src/validation/rules/ExecutableDefinitionsRule.ts +++ b/src/validation/rules/ExecutableDefinitionsRule.ts @@ -50,11 +50,20 @@ export function ExecutableDefinitionsRule( Document(node) { for (const definition of node.definitions) { if (!isExecutableDefinitionNode(definition)) { - const defName = + let defName: string; + if ( definition.kind === Kind.SCHEMA_DEFINITION || definition.kind === Kind.SCHEMA_EXTENSION - ? 'schema' - : '"' + definition.name.value + '"'; + ) { + defName = 'schema'; + } else if ( + definition.kind === Kind.SERVICE_DEFINITION || + definition.kind === Kind.SERVICE_EXTENSION + ) { + defName = 'service'; + } else { + defName = '"' + definition.name.value + '"'; + } context.reportError( new GraphQLError(`The ${defName} definition is not executable.`, { nodes: definition, diff --git a/src/validation/rules/LoneServiceDefinitionRule.ts b/src/validation/rules/LoneServiceDefinitionRule.ts new file mode 100644 index 0000000000..90c5047e52 --- /dev/null +++ b/src/validation/rules/LoneServiceDefinitionRule.ts @@ -0,0 +1,41 @@ +import { GraphQLError } from '../../error/GraphQLError'; + +import type { ASTVisitor } from '../../language/visitor'; + +import type { SDLValidationContext } from '../ValidationContext'; + +/** + * Lone Service definition + * + * A GraphQL document is only valid if it contains at most one service definition. + */ +export function LoneServiceDefinitionRule( + context: SDLValidationContext, +): ASTVisitor { + const oldSchema = context.getSchema(); + const alreadyDefined = oldSchema?.getService()?.astNode; + + let serviceDefinitionsCount = 0; + return { + ServiceDefinition(node) { + if (alreadyDefined) { + context.reportError( + new GraphQLError( + 'Cannot define a new service within a schema extension.', + { nodes: node }, + ), + ); + return; + } + + if (serviceDefinitionsCount > 0) { + context.reportError( + new GraphQLError('Must provide only one service definition.', { + nodes: node, + }), + ); + } + ++serviceDefinitionsCount; + }, + }; +} diff --git a/src/validation/rules/UniqueCapabilityIdentifiersRule.ts b/src/validation/rules/UniqueCapabilityIdentifiersRule.ts new file mode 100644 index 0000000000..297c692b4e --- /dev/null +++ b/src/validation/rules/UniqueCapabilityIdentifiersRule.ts @@ -0,0 +1,76 @@ +import { GraphQLError } from '../../error/GraphQLError'; + +import type { + ServiceCapabilityNode, + StringValueNode, +} from '../../language/ast'; +import type { ASTVisitor } from '../../language/visitor'; + +import type { SDLValidationContext } from '../ValidationContext'; + +/** + * Unique capability identifiers + * + * A GraphQL document is only valid if all service capabilities have unique identifiers. + */ +export function UniqueCapabilityIdentifiersRule( + context: SDLValidationContext, +): ASTVisitor { + const schema = context.getSchema(); + const existingCapabilities = new Set(); + + // Collect existing capabilities from the schema + const service = schema?.getService(); + if (service) { + for (const capability of service.capabilities) { + existingCapabilities.add(capability.identifier); + } + } + + const knownCapabilityIdentifiers = new Map>(); + + function checkCapabilityUniqueness( + capabilities: ReadonlyArray | undefined, + ): void { + if (!capabilities) { + return; + } + + for (const capability of capabilities) { + const identifier = capability.identifier.value; + + if (existingCapabilities.has(identifier)) { + context.reportError( + new GraphQLError( + `Capability "${identifier}" already exists in the schema. It cannot be redefined.`, + { nodes: capability.identifier }, + ), + ); + continue; + } + + const knownNodes = knownCapabilityIdentifiers.get(identifier); + if (knownNodes) { + context.reportError( + new GraphQLError( + `There can be only one capability named "${identifier}".`, + { nodes: [...knownNodes, capability.identifier] }, + ), + ); + } else { + knownCapabilityIdentifiers.set(identifier, [capability.identifier]); + } + } + } + + return { + ServiceDefinition(node) { + checkCapabilityUniqueness(node.capabilities); + return false; + }, + ServiceExtension(node) { + checkCapabilityUniqueness(node.capabilities); + return false; + }, + }; +} diff --git a/src/validation/specifiedRules.ts b/src/validation/specifiedRules.ts index 15c742bb42..88b1da2b78 100644 --- a/src/validation/specifiedRules.ts +++ b/src/validation/specifiedRules.ts @@ -21,6 +21,7 @@ import { KnownTypeNamesRule } from './rules/KnownTypeNamesRule'; import { LoneAnonymousOperationRule } from './rules/LoneAnonymousOperationRule'; // SDL-specific validation rules import { LoneSchemaDefinitionRule } from './rules/LoneSchemaDefinitionRule'; +import { LoneServiceDefinitionRule } from './rules/LoneServiceDefinitionRule'; // TODO: Spec Section import { MaxIntrospectionDepthRule } from './rules/MaxIntrospectionDepthRule'; // Spec Section: "Fragments must not form cycles" @@ -48,6 +49,7 @@ import { SingleFieldSubscriptionsRule } from './rules/SingleFieldSubscriptionsRu import { UniqueArgumentDefinitionNamesRule } from './rules/UniqueArgumentDefinitionNamesRule'; // Spec Section: "Argument Uniqueness" import { UniqueArgumentNamesRule } from './rules/UniqueArgumentNamesRule'; +import { UniqueCapabilityIdentifiersRule } from './rules/UniqueCapabilityIdentifiersRule'; import { UniqueDirectiveNamesRule } from './rules/UniqueDirectiveNamesRule'; // Spec Section: "Directives Are Unique Per Location" import { UniqueDirectivesPerLocationRule } from './rules/UniqueDirectivesPerLocationRule'; @@ -117,12 +119,14 @@ export const specifiedRules: ReadonlyArray = Object.freeze([ export const specifiedSDLRules: ReadonlyArray = Object.freeze([ LoneSchemaDefinitionRule, + LoneServiceDefinitionRule, UniqueOperationTypesRule, UniqueTypeNamesRule, UniqueEnumValueNamesRule, UniqueFieldDefinitionNamesRule, UniqueArgumentDefinitionNamesRule, UniqueDirectiveNamesRule, + UniqueCapabilityIdentifiersRule, KnownTypeNamesRule, KnownDirectivesRule, UniqueDirectivesPerLocationRule, From 3314fa81bbe6fb4927b27e9ee2675f4b956e8213 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 15 Jan 2026 14:16:41 +0000 Subject: [PATCH 4/8] Use built in service --- src/type/__tests__/service-test.ts | 102 +++++++++++++++++++---- src/type/index.ts | 3 + src/type/introspection.ts | 10 +-- src/type/schema.ts | 10 ++- src/type/service.ts | 29 +++++++ src/utilities/buildASTSchema.ts | 3 +- src/utilities/extendSchema.ts | 21 +++-- src/utilities/index.ts | 5 +- src/utilities/printSchema.ts | 27 ++---- src/utilities/withServiceCapabilities.ts | 48 +++++++++++ 10 files changed, 202 insertions(+), 56 deletions(-) create mode 100644 src/utilities/withServiceCapabilities.ts diff --git a/src/type/__tests__/service-test.ts b/src/type/__tests__/service-test.ts index 963a630eaf..d3a1ee63d1 100644 --- a/src/type/__tests__/service-test.ts +++ b/src/type/__tests__/service-test.ts @@ -3,13 +3,20 @@ import { describe, it } from 'mocha'; import { buildSchema } from '../../utilities/buildASTSchema'; import { printSchema } from '../../utilities/printSchema'; +import { withServiceCapabilities } from '../../utilities/withServiceCapabilities'; import { graphqlSync } from '../../graphql'; import { GraphQLObjectType } from '../definition'; import { GraphQLString } from '../scalars'; import { GraphQLSchema } from '../schema'; -import { assertService, GraphQLService, isService } from '../service'; +import { + assertService, + builtInService, + GraphQLService, + isBuiltInService, + isService, +} from '../service'; describe('Type System: Service', () => { describe('GraphQLService', () => { @@ -113,6 +120,18 @@ describe('Schema with Service', () => { expect(schema.getService()).to.equal(service); }); + it('uses built-in service by default', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { test: { type: GraphQLString } }, + }), + }); + + expect(schema.getService()).to.equal(builtInService); + expect(isBuiltInService(schema.getService())).to.equal(true); + }); + it('builds service from SDL', () => { const schema = buildSchema(` type Query { @@ -125,13 +144,13 @@ describe('Schema with Service', () => { `); const service = schema.getService(); - expect(service).to.not.equal(undefined); - expect(service?.capabilities).to.have.length(2); - expect(service?.capabilities[0].identifier).to.equal('graphql.spec'); - expect(service?.capabilities[0].value).to.equal('2024'); + expect(isBuiltInService(service)).to.equal(false); + expect(service.capabilities).to.have.length(2); + expect(service.capabilities[0].identifier).to.equal('graphql.spec'); + expect(service.capabilities[0].value).to.equal('2024'); }); - it('prints service when includeService is true', () => { + it('prints service when schema has custom service', () => { const schema = buildSchema(` type Query { test: String @@ -141,20 +160,18 @@ describe('Schema with Service', () => { } `); - const printed = printSchema(schema, { includeService: true }); + const printed = printSchema(schema); expect(printed).to.include('service {'); expect(printed).to.include('capability graphql.spec = "2024"'); }); - it('does not print service by default', () => { - const schema = buildSchema(` - type Query { - test: String - } - service { - capability graphql.spec - } - `); + it('does not print service when schema has built-in service', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { test: { type: GraphQLString } }, + }), + }); const printed = printSchema(schema); expect(printed).to.not.include('service'); @@ -162,6 +179,48 @@ describe('Schema with Service', () => { }); }); +describe('withServiceCapabilities', () => { + it('creates a new schema with custom service', () => { + const originalSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { test: { type: GraphQLString } }, + }), + }); + + expect(isBuiltInService(originalSchema.getService())).to.equal(true); + + const newSchema = withServiceCapabilities(originalSchema, { + description: 'Remote service', + capabilities: [ + { identifier: 'graphql.spec', value: '2024' }, + { identifier: 'custom.feature' }, + ], + }); + + expect(isBuiltInService(newSchema.getService())).to.equal(false); + expect(newSchema.getService().description).to.equal('Remote service'); + expect(newSchema.getService().capabilities).to.have.length(2); + }); + + it('results in printSchema including the service', () => { + const originalSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { test: { type: GraphQLString } }, + }), + }); + + const newSchema = withServiceCapabilities(originalSchema, { + capabilities: [{ identifier: 'graphql.spec', value: '2024' }], + }); + + const printed = printSchema(newSchema); + expect(printed).to.include('service {'); + expect(printed).to.include('capability graphql.spec = "2024"'); + }); +}); + describe('Service Introspection', () => { it('can query __service meta-field', () => { const schema = buildSchema(` @@ -209,7 +268,7 @@ describe('Service Introspection', () => { }); }); - it('returns empty capabilities when no service defined', () => { + it('returns built-in capabilities when no custom service defined', () => { const schema = buildSchema(` type Query { test: String @@ -223,6 +282,7 @@ describe('Service Introspection', () => { __service { capabilities { identifier + value } } } @@ -230,9 +290,15 @@ describe('Service Introspection', () => { }); expect(result.errors).to.equal(undefined); + // The built-in service has the graphql.spec capability expect(result.data).to.deep.equal({ __service: { - capabilities: [], + capabilities: [ + { + identifier: 'graphql.spec', + value: '2024', + }, + ], }, }); }); diff --git a/src/type/index.ts b/src/type/index.ts index 40beb1cdc8..313b1ba7ab 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -157,6 +157,9 @@ export { assertService, // GraphQL Service definition GraphQLService, + // Built-in service + builtInService, + isBuiltInService, } from './service'; export type { diff --git a/src/type/introspection.ts b/src/type/introspection.ts index 7c25763f9a..745354c656 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -2,7 +2,6 @@ import { inspect } from '../jsutils/inspect'; import { invariant } from '../jsutils/invariant'; -import type { Maybe } from '../jsutils/Maybe'; import { DirectiveLocation } from '../language/directiveLocation'; import { print } from '../language/printer'; @@ -584,9 +583,9 @@ export const __Service: GraphQLObjectType = new GraphQLObjectType({ ), description: 'A list of capabilities detailing each service capability supported by the service.', - resolve: (service) => service?.capabilities ?? [], + resolve: (service) => service.capabilities, }, - } as GraphQLFieldConfigMap, unknown>), + } as GraphQLFieldConfigMap), }); /** @@ -645,9 +644,8 @@ export const ServiceMetaFieldDef: GraphQLField = { type: new GraphQLNonNull(__Service), description: 'Access service information and capabilities.', args: [], - // Returns the service or an empty object - the __Service type fields handle this gracefully - resolve: (_source, _args, _context, { schema }) => - schema.getService() ?? { capabilities: [] }, + // Returns the service - all schemas have a service (built-in or custom) + resolve: (_source, _args, _context, { schema }) => schema.getService(), deprecationReason: undefined, extensions: Object.create(null), astNode: undefined, diff --git a/src/type/schema.ts b/src/type/schema.ts index 4ba315345f..61e515efa6 100644 --- a/src/type/schema.ts +++ b/src/type/schema.ts @@ -34,6 +34,7 @@ import type { GraphQLDirective } from './directives'; import { isDirective, specifiedDirectives } from './directives'; import { __Schema } from './introspection'; import type { GraphQLService } from './service'; +import { builtInService } from './service'; /** * Test if the given value is a GraphQL schema. @@ -199,7 +200,7 @@ export class GraphQLSchema { private _mutationType: Maybe; private _subscriptionType: Maybe; private _directives: ReadonlyArray; - private _service: Maybe; + private _service: GraphQLService; private _typeMap: TypeMap; private _subTypeMap: ObjMap>; private _implementationsMap: ObjMap<{ @@ -327,7 +328,8 @@ export class GraphQLSchema { this._subscriptionType = config.subscription; // Provide specified directives (e.g. @include and @skip) by default. this._directives = config.directives ?? specifiedDirectives; - this._service = config.service; + // Provide built-in service by default (similar to specifiedDirectives). + this._service = config.service ?? builtInService; // To preserve order of user-provided types, we add first to add them to // the set of "collected" types, so `collectReferencedTypes` ignore them. @@ -806,7 +808,7 @@ export class GraphQLSchema { * schemaCopy.getQueryType()?.name; // => 'Query' * ``` */ - getService(): Maybe { + getService(): GraphQLService { return this._service; } toConfig(): GraphQLSchemaNormalizedConfig { @@ -868,7 +870,7 @@ export interface GraphQLSchemaNormalizedConfig extends GraphQLSchemaConfig { description: Maybe; types: ReadonlyArray; directives: ReadonlyArray; - service: Maybe; + service: GraphQLService; extensions: Readonly; extensionASTNodes: ReadonlyArray; assumeValid: boolean; diff --git a/src/type/service.ts b/src/type/service.ts index d3cb243436..5643a8c430 100644 --- a/src/type/service.ts +++ b/src/type/service.ts @@ -153,3 +153,32 @@ interface GraphQLServiceNormalizedConfig extends GraphQLServiceConfig { extensions: Readonly; extensionASTNodes: ReadonlyArray; } + +/** + * The built-in service definition that represents the default capabilities + * of the GraphQL.js implementation. This is used when no explicit service + * is provided to a schema. + * + * When printing a schema, if the schema's service is the built-in service, + * it will not be included in the output. This is important because when + * representing a remote schema locally, we should not advertise capabilities + * that the remote server may not support. + */ +export const builtInService: GraphQLService = new GraphQLService({ + description: 'Built-in GraphQL.js service capabilities.', + capabilities: [ + // Core GraphQL spec compliance + { + identifier: 'graphql.spec', + description: 'Indicates compliance with the GraphQL specification.', + value: '2024', + }, + ], +}); + +/** + * Check if a service is the built-in service. + */ +export function isBuiltInService(service: GraphQLService): boolean { + return service === builtInService; +} diff --git a/src/utilities/buildASTSchema.ts b/src/utilities/buildASTSchema.ts index 34537e158e..700c515388 100644 --- a/src/utilities/buildASTSchema.ts +++ b/src/utilities/buildASTSchema.ts @@ -11,6 +11,7 @@ import type { Source } from '../language/source'; import { specifiedDirectives } from '../type/directives'; import type { GraphQLSchemaValidationOptions } from '../type/schema'; import { GraphQLSchema } from '../type/schema'; +import { builtInService } from '../type/service'; import { assertValidSDL } from '../validation/validate'; @@ -80,7 +81,7 @@ export function buildASTSchema( description: undefined, types: [], directives: [], - service: undefined, + service: builtInService, extensions: Object.create(null), extensionASTNodes: [], assumeValid: false, diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index 46749aa160..271b879f49 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -82,7 +82,7 @@ import type { } from '../type/schema'; import { assertSchema, GraphQLSchema } from '../type/schema'; import type { GraphQLCapabilityConfig } from '../type/service'; -import { GraphQLService } from '../type/service'; +import { GraphQLService, isBuiltInService } from '../type/service'; import { assertValidSDLExtension } from '../validation/validate'; @@ -305,20 +305,27 @@ export function extendSchemaImpl( function buildService( astNode: Maybe, extensionNodes: ReadonlyArray, - ): GraphQLService | undefined { - // Get existing service from config, if any + ): GraphQLService { + // Get existing service from config (always defined, at least builtInService) const existingService = schemaConfig.service; // If no new service definition or extensions, return existing service if (astNode == null && extensionNodes.length === 0) { - return existingService ?? undefined; + return existingService; } // Collect all capabilities from existing service, new definition, and extensions const allCapabilities: Array = []; - // Add capabilities from existing service - if (existingService) { + // When a new service definition exists, it replaces the built-in service. + // Only merge capabilities from the existing service if: + // 1. There's no new service definition (only extensions), OR + // 2. The existing service is not the built-in service (it's a custom service) + const shouldMergeExisting = + astNode == null || !isBuiltInService(existingService); + + // Add capabilities from existing service (if we should merge) + if (shouldMergeExisting) { for (const cap of existingService.capabilities) { allCapabilities.push({ identifier: cap.identifier, @@ -360,7 +367,7 @@ export function extendSchemaImpl( capabilities: allCapabilities, astNode: astNode ?? existingService?.astNode, extensionASTNodes: [ - ...(existingService?.extensionASTNodes ?? []), + ...(shouldMergeExisting ? existingService.extensionASTNodes : []), ...extensionNodes, ], }); diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0b1dfdfc8a..a9ee6d5622 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -63,7 +63,6 @@ export { printSchema, printType, printIntrospectionSchema, - type PrintSchemaOptions, } from './printSchema'; // Create a GraphQLType from a GraphQL language AST. @@ -121,3 +120,7 @@ export { resolveASTSchemaCoordinate, } from './resolveSchemaCoordinate'; export type { ResolvedSchemaElement } from './resolveSchemaCoordinate'; + +// Create a new schema with service capabilities. +export { withServiceCapabilities } from './withServiceCapabilities'; +export type { WithServiceCapabilitiesOptions } from './withServiceCapabilities'; diff --git a/src/utilities/printSchema.ts b/src/utilities/printSchema.ts index 223552aa5f..c978141fc4 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -36,6 +36,7 @@ import { isIntrospectionType } from '../type/introspection'; import { isSpecifiedScalarType } from '../type/scalars'; import type { GraphQLSchema } from '../type/schema'; import type { GraphQLCapability, GraphQLService } from '../type/service'; +import { isBuiltInService } from '../type/service'; import { astFromValue } from './astFromValue'; @@ -58,23 +59,11 @@ import { astFromValue } from './astFromValue'; * printSchema(schema); // => ['directive @upper on FIELD_DEFINITION', '', 'type Query {', ' greeting: String', '}'].join('\n') * ``` */ -export interface PrintSchemaOptions { - /** - * Include the service definition in the printed schema. - * Defaults to false. - */ - includeService?: boolean; -} - -export function printSchema( - schema: GraphQLSchema, - options?: PrintSchemaOptions, -): string { +export function printSchema(schema: GraphQLSchema): string { return printFilteredSchema( schema, (n) => !isSpecifiedDirective(n), isDefinedType, - options, ); } @@ -111,7 +100,6 @@ function printFilteredSchema( schema: GraphQLSchema, directiveFilter: (type: GraphQLDirective) => boolean, typeFilter: (type: GraphQLNamedType) => boolean, - options?: PrintSchemaOptions, ): string { const directives = schema.getDirectives().filter(directiveFilter); const types = Object.values(schema.getTypeMap()).filter(typeFilter); @@ -122,11 +110,12 @@ function printFilteredSchema( ...types.map((type) => printType(type)), ]; - if (options?.includeService) { - const service = schema.getService(); - if (service) { - parts.push(printService(service)); - } + // Only print service if it's not the built-in service. + // This is important because when representing a remote schema locally, + // we should not advertise capabilities that the remote server may not support. + const service = schema.getService(); + if (!isBuiltInService(service)) { + parts.push(printService(service)); } return parts.filter(Boolean).join('\n\n'); diff --git a/src/utilities/withServiceCapabilities.ts b/src/utilities/withServiceCapabilities.ts new file mode 100644 index 0000000000..11ef5427ed --- /dev/null +++ b/src/utilities/withServiceCapabilities.ts @@ -0,0 +1,48 @@ +import type { Maybe } from '../jsutils/Maybe'; + +import { GraphQLSchema } from '../type/schema'; +import type { GraphQLCapabilityConfig } from '../type/service'; +import { GraphQLService } from '../type/service'; + +export interface WithServiceCapabilitiesOptions { + /** Optional description for the service */ + description?: Maybe; + /** Capabilities to include in the service */ + capabilities: ReadonlyArray; +} + +/** + * Creates a new schema with a custom service containing the provided capabilities. + * + * This is useful when introspecting a remote GraphQL service's `__service` field + * and wanting to represent those capabilities in the local schema representation. + * + * Example: + * + * ```ts + * const remoteCapabilities = await fetchRemoteServiceCapabilities(); + * const schemaWithCapabilities = withServiceCapabilities(schema, { + * description: 'Remote service capabilities', + * capabilities: remoteCapabilities, + * }); + * ``` + * + * When `printSchema()` is called on the resulting schema, it will include + * the service block with the provided capabilities. + */ +export function withServiceCapabilities( + schema: GraphQLSchema, + options: WithServiceCapabilitiesOptions, +): GraphQLSchema { + const service = new GraphQLService({ + description: options.description, + capabilities: options.capabilities, + }); + + const config = schema.toConfig(); + + return new GraphQLSchema({ + ...config, + service, + }); +} From 3e94b0b329ab9ac132ab39dbebf0ff3dd522de9b Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 15 Jan 2026 14:18:01 +0000 Subject: [PATCH 5/8] Revert package-lock change --- package-lock.json | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index a3e64a4912..f1ffacebfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,7 +77,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "^7.16.7", @@ -2238,7 +2237,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.19.0.tgz", "integrity": "sha512-yhktJjMCJX8BSBczh1F/uY8wGRYrBeyn84kH6oyqdIJwTGKmzX5Qiq49LRQ0Jh0LXnWijEziSo6BRqny8nqLVQ==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.19.0", "@typescript-eslint/types": "5.19.0", @@ -2411,7 +2409,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3524,7 +3521,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.13.0.tgz", "integrity": "sha512-D+Xei61eInqauAyTJ6C0q6x9mx7kTUC1KZ0m0LSEexR0V+e94K12LmWX076ZIsldwfQ2RONdaJe0re0TRGQbRQ==", "dev": true, - "peer": true, "dependencies": { "@eslint/eslintrc": "^1.2.1", "@humanwhocodes/config-array": "^0.9.2", @@ -6632,7 +6628,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz", "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6986,7 +6981,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", "dev": true, - "peer": true, "requires": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "^7.16.7", @@ -8598,7 +8592,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.19.0.tgz", "integrity": "sha512-yhktJjMCJX8BSBczh1F/uY8wGRYrBeyn84kH6oyqdIJwTGKmzX5Qiq49LRQ0Jh0LXnWijEziSo6BRqny8nqLVQ==", "dev": true, - "peer": true, "requires": { "@typescript-eslint/scope-manager": "5.19.0", "@typescript-eslint/types": "5.19.0", @@ -8693,8 +8686,7 @@ "version": "8.7.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", - "dev": true, - "peer": true + "dev": true }, "acorn-jsx": { "version": "5.3.2", @@ -9522,7 +9514,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.13.0.tgz", "integrity": "sha512-D+Xei61eInqauAyTJ6C0q6x9mx7kTUC1KZ0m0LSEexR0V+e94K12LmWX076ZIsldwfQ2RONdaJe0re0TRGQbRQ==", "dev": true, - "peer": true, "requires": { "@eslint/eslintrc": "^1.2.1", "@humanwhocodes/config-array": "^0.9.2", @@ -11799,8 +11790,7 @@ "version": "4.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz", "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==", - "dev": true, - "peer": true + "dev": true }, "unbox-primitive": { "version": "1.0.1", From fb9a8313777f3b57b7b437a5bd487ea13aabfb87 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 15 Jan 2026 14:28:36 +0000 Subject: [PATCH 6/8] More reasonable test values --- src/language/__tests__/service-test.ts | 62 ++++++++++++++------------ 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/language/__tests__/service-test.ts b/src/language/__tests__/service-test.ts index dfd5f156f0..475964816e 100644 --- a/src/language/__tests__/service-test.ts +++ b/src/language/__tests__/service-test.ts @@ -10,7 +10,7 @@ describe('Service Definition Parsing and Printing', () => { it('parses a simple service definition', () => { const doc = parse(` service { - capability graphql.spec + capability example.capability } `); @@ -21,7 +21,7 @@ describe('Service Definition Parsing and Printing', () => { if (serviceDef.kind === Kind.SERVICE_DEFINITION) { expect(serviceDef.capabilities).to.have.length(1); expect(serviceDef.capabilities?.[0].identifier.value).to.equal( - 'graphql.spec', + 'example.capability', ); expect(serviceDef.capabilities?.[0].value).to.equal(undefined); } @@ -30,16 +30,18 @@ describe('Service Definition Parsing and Printing', () => { it('parses service with capability value', () => { const doc = parse(` service { - capability graphql.spec = "2024" + capability example.capability = "Example value" } `); const serviceDef = doc.definitions[0]; if (serviceDef.kind === Kind.SERVICE_DEFINITION) { expect(serviceDef.capabilities?.[0].identifier.value).to.equal( - 'graphql.spec', + 'example.capability', + ); + expect(serviceDef.capabilities?.[0].value?.value).to.equal( + 'Example value', ); - expect(serviceDef.capabilities?.[0].value?.value).to.equal('2024'); } }); @@ -47,7 +49,7 @@ describe('Service Definition Parsing and Printing', () => { const doc = parse(` "My GraphQL Service" service { - capability graphql.spec + capability example.capability } `); @@ -60,15 +62,15 @@ describe('Service Definition Parsing and Printing', () => { it('parses service with capability descriptions', () => { const doc = parse(` service { - "Supports the GraphQL spec" - capability graphql.spec + "Example capability description" + capability example.capability } `); const serviceDef = doc.definitions[0]; if (serviceDef.kind === Kind.SERVICE_DEFINITION) { expect(serviceDef.capabilities?.[0].description?.value).to.equal( - 'Supports the GraphQL spec', + 'Example capability description', ); } }); @@ -76,8 +78,8 @@ describe('Service Definition Parsing and Printing', () => { it('parses service with multiple capabilities', () => { const doc = parse(` service { - capability graphql.spec - capability graphql.federatedQueries + capability example.capability + capability graphql.someFutureCapability capability org.example.customFeature = "v2" } `); @@ -86,11 +88,13 @@ describe('Service Definition Parsing and Printing', () => { if (serviceDef.kind === Kind.SERVICE_DEFINITION) { expect(serviceDef.capabilities).to.have.length(3); expect(serviceDef.capabilities?.[0].identifier.value).to.equal( - 'graphql.spec', + 'example.capability', ); + expect(serviceDef.capabilities?.[0].value).to.equal(null); expect(serviceDef.capabilities?.[1].identifier.value).to.equal( - 'graphql.federatedQueries', + 'graphql.someFutureCapability', ); + expect(serviceDef.capabilities?.[1].value).to.equal(null); expect(serviceDef.capabilities?.[2].identifier.value).to.equal( 'org.example.customFeature', ); @@ -101,7 +105,7 @@ describe('Service Definition Parsing and Printing', () => { it('parses service with directives', () => { const doc = parse(` service @deprecated { - capability graphql.spec + capability example.capability } `); @@ -148,7 +152,7 @@ describe('Service Definition Parsing and Printing', () => { it('parses service extension with directives', () => { const doc = parse(` extend service @deprecated { - capability graphql.spec + capability example.capability } `); @@ -164,24 +168,24 @@ describe('Service Definition Parsing and Printing', () => { it('prints a service definition', () => { const doc = parse(` service { - capability graphql.spec + capability example.capability } `); expect(print(doc)).to.equal(`service { - capability graphql.spec + capability example.capability }`); }); it('prints service with capability value', () => { const doc = parse(` service { - capability graphql.spec = "2024" + capability example.capability = "Example value" } `); expect(print(doc)).to.equal(`service { - capability graphql.spec = "2024" + capability example.capability = "Example value" }`); }); @@ -189,13 +193,13 @@ describe('Service Definition Parsing and Printing', () => { const doc = parse(` "My Service" service { - capability graphql.spec + capability example.capability } `); expect(print(doc)).to.equal(`"My Service" service { - capability graphql.spec + capability example.capability }`); }); @@ -203,25 +207,25 @@ service { const doc = parse(` service { "A capability" - capability graphql.spec + capability example.capability } `); expect(print(doc)).to.equal(`service { "A capability" - capability graphql.spec + capability example.capability }`); }); it('prints service with directives', () => { const doc = parse(` service @deprecated { - capability graphql.spec + capability example.capability } `); expect(print(doc)).to.equal(`service @deprecated { - capability graphql.spec + capability example.capability }`); }); @@ -242,8 +246,8 @@ service { """A GraphQL service with multiple capabilities""" service @deprecated(reason: "test") { """The main spec capability""" - capability graphql.spec = "2024" - capability graphql.federatedQueries + capability example.capability = "Example value" + capability graphql.someFutureCapability capability org.example.custom } `; @@ -252,8 +256,8 @@ service { `"""A GraphQL service with multiple capabilities""" service @deprecated(reason: "test") { """The main spec capability""" - capability graphql.spec = "2024" - capability graphql.federatedQueries + capability example.capability = "Example value" + capability graphql.someFutureCapability capability org.example.custom }`, ); From cb8c1c1d1adcac151f1f8bd4bac3ddb78f31deb2 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 15 Jan 2026 15:10:24 +0000 Subject: [PATCH 7/8] Fix syntax --- src/language/__tests__/service-test.ts | 16 ++++++++-------- src/language/parser.ts | 7 ++++--- src/language/printer.ts | 8 ++++---- src/type/__tests__/service-test.ts | 10 +++++----- src/utilities/printSchema.ts | 2 +- 5 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/language/__tests__/service-test.ts b/src/language/__tests__/service-test.ts index 475964816e..713727af5a 100644 --- a/src/language/__tests__/service-test.ts +++ b/src/language/__tests__/service-test.ts @@ -30,7 +30,7 @@ describe('Service Definition Parsing and Printing', () => { it('parses service with capability value', () => { const doc = parse(` service { - capability example.capability = "Example value" + capability example.capability("Example value") } `); @@ -80,7 +80,7 @@ describe('Service Definition Parsing and Printing', () => { service { capability example.capability capability graphql.someFutureCapability - capability org.example.customFeature = "v2" + capability org.example.customFeature("v2") } `); @@ -90,11 +90,11 @@ describe('Service Definition Parsing and Printing', () => { expect(serviceDef.capabilities?.[0].identifier.value).to.equal( 'example.capability', ); - expect(serviceDef.capabilities?.[0].value).to.equal(null); + expect(serviceDef.capabilities?.[0].value).to.equal(undefined); expect(serviceDef.capabilities?.[1].identifier.value).to.equal( 'graphql.someFutureCapability', ); - expect(serviceDef.capabilities?.[1].value).to.equal(null); + expect(serviceDef.capabilities?.[1].value).to.equal(undefined); expect(serviceDef.capabilities?.[2].identifier.value).to.equal( 'org.example.customFeature', ); @@ -180,12 +180,12 @@ describe('Service Definition Parsing and Printing', () => { it('prints service with capability value', () => { const doc = parse(` service { - capability example.capability = "Example value" + capability example.capability("Example value") } `); expect(print(doc)).to.equal(`service { - capability example.capability = "Example value" + capability example.capability("Example value") }`); }); @@ -246,7 +246,7 @@ service { """A GraphQL service with multiple capabilities""" service @deprecated(reason: "test") { """The main spec capability""" - capability example.capability = "Example value" + capability example.capability("Example value") capability graphql.someFutureCapability capability org.example.custom } @@ -256,7 +256,7 @@ service { `"""A GraphQL service with multiple capabilities""" service @deprecated(reason: "test") { """The main spec capability""" - capability example.capability = "Example value" + capability example.capability("Example value") capability graphql.someFutureCapability capability org.example.custom }`, diff --git a/src/language/parser.ts b/src/language/parser.ts index d968700958..7624c1e26a 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -1723,7 +1723,7 @@ export class Parser { /** * ``` * ServiceCapability : Description? capability QualifiedName ServiceCapabilityValue? - * ServiceCapabilityValue : = StringValue + * ServiceCapabilityValue : ( StringValue ) * ``` */ parseServiceCapability(): ServiceCapabilityNode { @@ -1756,10 +1756,11 @@ export class Parser { (this._lexer as { token: Token }).token = qualifiedNameToken; this.advanceLexer(); - // Parse optional value: = StringValue + // Parse optional value: ( StringValue ) let value: StringValueNode | undefined; - if (this.expectOptionalToken(TokenKind.EQUALS)) { + if (this.expectOptionalToken(TokenKind.PAREN_L)) { value = this.parseStringLiteral(); + this.expectToken(TokenKind.PAREN_R); } return this.node(start, { diff --git a/src/language/printer.ts b/src/language/printer.ts index 1075c37ffa..b071c0be32 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -284,12 +284,12 @@ const printDocASTReducer: ASTReducer = { typeof identifier === 'string' && identifier.startsWith('"') ? identifier.slice(1, -1) : identifier; + const valueSuffix = value ? '(' + value + ')' : ''; return ( wrap('', description, '\n') + - join( - ['capability', identifierValue, value ? '= ' + value : undefined], - ' ', - ) + 'capability ' + + identifierValue + + valueSuffix ); }, }, diff --git a/src/type/__tests__/service-test.ts b/src/type/__tests__/service-test.ts index d3a1ee63d1..ef152d8920 100644 --- a/src/type/__tests__/service-test.ts +++ b/src/type/__tests__/service-test.ts @@ -138,7 +138,7 @@ describe('Schema with Service', () => { test: String } service { - capability graphql.spec = "2024" + capability graphql.spec("2024") capability graphql.federatedQueries } `); @@ -156,13 +156,13 @@ describe('Schema with Service', () => { test: String } service { - capability graphql.spec = "2024" + capability graphql.spec("2024") } `); const printed = printSchema(schema); expect(printed).to.include('service {'); - expect(printed).to.include('capability graphql.spec = "2024"'); + expect(printed).to.include('capability graphql.spec("2024")'); }); it('does not print service when schema has built-in service', () => { @@ -217,7 +217,7 @@ describe('withServiceCapabilities', () => { const printed = printSchema(newSchema); expect(printed).to.include('service {'); - expect(printed).to.include('capability graphql.spec = "2024"'); + expect(printed).to.include('capability graphql.spec("2024")'); }); }); @@ -229,7 +229,7 @@ describe('Service Introspection', () => { } service { "The GraphQL spec version" - capability graphql.spec = "2024" + capability graphql.spec("2024") capability graphql.federatedQueries } `); diff --git a/src/utilities/printSchema.ts b/src/utilities/printSchema.ts index c978141fc4..3edb0a1f51 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -425,7 +425,7 @@ function printCapability( if (capability.value != null) { const astValue = print({ kind: Kind.STRING, value: capability.value }); - result += ' = ' + astValue; + result += '(' + astValue + ')'; } return result; From 370c4d6c695785cedae488a4314420feeb8ef4ac Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 15 Jan 2026 15:31:24 +0000 Subject: [PATCH 8/8] Future proof - entries not capabilities --- src/language/__tests__/service-test.ts | 30 +++++++++++++------------- src/language/ast.ts | 10 +++++---- src/language/index.ts | 1 + src/language/parser.ts | 10 ++++----- src/language/printer.ts | 8 +++---- src/utilities/extendSchema.ts | 8 +++---- 6 files changed, 35 insertions(+), 32 deletions(-) diff --git a/src/language/__tests__/service-test.ts b/src/language/__tests__/service-test.ts index 713727af5a..7d2156edb1 100644 --- a/src/language/__tests__/service-test.ts +++ b/src/language/__tests__/service-test.ts @@ -19,11 +19,11 @@ describe('Service Definition Parsing and Printing', () => { expect(serviceDef.kind).to.equal(Kind.SERVICE_DEFINITION); if (serviceDef.kind === Kind.SERVICE_DEFINITION) { - expect(serviceDef.capabilities).to.have.length(1); - expect(serviceDef.capabilities?.[0].identifier.value).to.equal( + expect(serviceDef.entries).to.have.length(1); + expect(serviceDef.entries?.[0].identifier.value).to.equal( 'example.capability', ); - expect(serviceDef.capabilities?.[0].value).to.equal(undefined); + expect(serviceDef.entries?.[0].value).to.equal(undefined); } }); @@ -36,10 +36,10 @@ describe('Service Definition Parsing and Printing', () => { const serviceDef = doc.definitions[0]; if (serviceDef.kind === Kind.SERVICE_DEFINITION) { - expect(serviceDef.capabilities?.[0].identifier.value).to.equal( + expect(serviceDef.entries?.[0].identifier.value).to.equal( 'example.capability', ); - expect(serviceDef.capabilities?.[0].value?.value).to.equal( + expect(serviceDef.entries?.[0].value?.value).to.equal( 'Example value', ); } @@ -69,7 +69,7 @@ describe('Service Definition Parsing and Printing', () => { const serviceDef = doc.definitions[0]; if (serviceDef.kind === Kind.SERVICE_DEFINITION) { - expect(serviceDef.capabilities?.[0].description?.value).to.equal( + expect(serviceDef.entries?.[0].description?.value).to.equal( 'Example capability description', ); } @@ -86,19 +86,19 @@ describe('Service Definition Parsing and Printing', () => { const serviceDef = doc.definitions[0]; if (serviceDef.kind === Kind.SERVICE_DEFINITION) { - expect(serviceDef.capabilities).to.have.length(3); - expect(serviceDef.capabilities?.[0].identifier.value).to.equal( + expect(serviceDef.entries).to.have.length(3); + expect(serviceDef.entries?.[0].identifier.value).to.equal( 'example.capability', ); - expect(serviceDef.capabilities?.[0].value).to.equal(undefined); - expect(serviceDef.capabilities?.[1].identifier.value).to.equal( + expect(serviceDef.entries?.[0].value).to.equal(undefined); + expect(serviceDef.entries?.[1].identifier.value).to.equal( 'graphql.someFutureCapability', ); - expect(serviceDef.capabilities?.[1].value).to.equal(undefined); - expect(serviceDef.capabilities?.[2].identifier.value).to.equal( + expect(serviceDef.entries?.[1].value).to.equal(undefined); + expect(serviceDef.entries?.[2].identifier.value).to.equal( 'org.example.customFeature', ); - expect(serviceDef.capabilities?.[2].value?.value).to.equal('v2'); + expect(serviceDef.entries?.[2].value?.value).to.equal('v2'); } }); @@ -142,8 +142,8 @@ describe('Service Definition Parsing and Printing', () => { expect(serviceExt.kind).to.equal(Kind.SERVICE_EXTENSION); if (serviceExt.kind === Kind.SERVICE_EXTENSION) { - expect(serviceExt.capabilities).to.have.length(1); - expect(serviceExt.capabilities?.[0].identifier.value).to.equal( + expect(serviceExt.entries).to.have.length(1); + expect(serviceExt.entries?.[0].identifier.value).to.equal( 'graphql.additionalFeature', ); } diff --git a/src/language/ast.ts b/src/language/ast.ts index 0c842c9764..d53bf8679f 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -341,7 +341,7 @@ export const QueryDocumentKeys: { 'locations', ], - ServiceDefinition: ['description', 'directives', 'capabilities'], + ServiceDefinition: ['description', 'directives', 'entries'], ServiceCapability: ['description', 'identifier', 'value'], SchemaExtension: ['directives', 'operationTypes'], @@ -355,7 +355,7 @@ export const QueryDocumentKeys: { EnumTypeExtension: ['name', 'directives', 'values'], InputObjectTypeExtension: ['name', 'directives', 'fields'], - ServiceExtension: ['directives', 'capabilities'], + ServiceExtension: ['directives', 'entries'], TypeCoordinate: ['name'], MemberCoordinate: ['name', 'memberName'], @@ -1018,9 +1018,11 @@ export interface ServiceDefinitionNode { readonly loc?: Location; readonly description?: StringValueNode; readonly directives?: ReadonlyArray; - readonly capabilities?: ReadonlyArray; + readonly entries?: ReadonlyArray; } +export type ServiceEntryNode = ServiceCapabilityNode; + export interface ServiceCapabilityNode { readonly kind: Kind.SERVICE_CAPABILITY; readonly loc?: Location; @@ -1158,7 +1160,7 @@ export interface ServiceExtensionNode { readonly kind: Kind.SERVICE_EXTENSION; readonly loc?: Location; readonly directives?: ReadonlyArray; - readonly capabilities?: ReadonlyArray; + readonly entries?: ReadonlyArray; } // Schema Coordinates diff --git a/src/language/index.ts b/src/language/index.ts index a6b1c9d2c5..7a6a1e2133 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -95,6 +95,7 @@ export type { InputObjectTypeDefinitionNode, DirectiveDefinitionNode, ServiceDefinitionNode, + ServiceEntryNode, ServiceCapabilityNode, TypeSystemExtensionNode, SchemaExtensionNode, diff --git a/src/language/parser.ts b/src/language/parser.ts index 7624c1e26a..f1b8927303 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -1707,7 +1707,7 @@ export class Parser { const description = this.parseDescription(); this.expectKeyword('service'); const directives = this.parseConstDirectives(); - const capabilities = this.optionalMany( + const entries = this.optionalMany( TokenKind.BRACE_L, this.parseServiceCapability, TokenKind.BRACE_R, @@ -1716,7 +1716,7 @@ export class Parser { kind: Kind.SERVICE_DEFINITION, description, directives, - capabilities, + entries, }); } @@ -1783,18 +1783,18 @@ export class Parser { this.expectKeyword('extend'); this.expectKeyword('service'); const directives = this.parseConstDirectives(); - const capabilities = this.optionalMany( + const entries = this.optionalMany( TokenKind.BRACE_L, this.parseServiceCapability, TokenKind.BRACE_R, ); - if (directives.length === 0 && capabilities.length === 0) { + if (directives.length === 0 && entries.length === 0) { throw this.unexpected(); } return this.node(start, { kind: Kind.SERVICE_EXTENSION, directives, - capabilities, + entries, }); } diff --git a/src/language/printer.ts b/src/language/printer.ts index b071c0be32..b3e8d64904 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -269,9 +269,9 @@ const printDocASTReducer: ASTReducer = { }, ServiceDefinition: { - leave: ({ description, directives, capabilities }) => + leave: ({ description, directives, entries }) => wrap('', description, '\n') + - join(['service', join(directives, ' '), block(capabilities)], ' '), + join(['service', join(directives, ' '), block(entries)], ' '), }, ServiceCapability: { @@ -364,8 +364,8 @@ const printDocASTReducer: ASTReducer = { }, ServiceExtension: { - leave: ({ directives, capabilities }) => - join(['extend service', join(directives, ' '), block(capabilities)], ' '), + leave: ({ directives, entries }) => + join(['extend service', join(directives, ' '), block(entries)], ' '), }, // Schema Coordinates diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index 271b879f49..960eaba1a6 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -337,8 +337,8 @@ export function extendSchemaImpl( } // Add capabilities from new service definition - if (astNode?.capabilities) { - for (const cap of astNode.capabilities) { + if (astNode?.entries) { + for (const cap of astNode.entries) { allCapabilities.push({ identifier: cap.identifier.value, description: cap.description?.value, @@ -350,8 +350,8 @@ export function extendSchemaImpl( // Add capabilities from extensions for (const ext of extensionNodes) { - if (ext.capabilities) { - for (const cap of ext.capabilities) { + if (ext.entries) { + for (const cap of ext.entries) { allCapabilities.push({ identifier: cap.identifier.value, description: cap.description?.value,