From 23902026cd45602fa3c232180449cddeb24bbb34 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 15 Jan 2026 12:32:33 +0000 Subject: [PATCH 1/6] 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 | 45 ++- src/language/index.ts | 3 + src/language/kinds.ts | 7 + src/language/lexer.ts | 55 ++++ src/language/parser.ts | 111 +++++++- src/language/predicates.ts | 9 +- 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 | 59 ++++ src/type/schema.ts | 10 + src/type/service.ts | 155 +++++++++++ src/utilities/TypeInfo.ts | 7 + src/utilities/buildASTSchema.ts | 1 + src/utilities/extendSchema.ts | 83 +++++- src/utilities/getIntrospectionQuery.ts | 30 ++ 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, 1536 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 49dbb66741..91b0bbf645 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,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", @@ -2221,6 +2222,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", @@ -2393,6 +2395,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" }, @@ -3495,6 +3498,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", @@ -6508,6 +6512,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" @@ -6861,6 +6866,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", @@ -8461,6 +8467,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", @@ -8555,7 +8562,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", @@ -9377,6 +9385,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", @@ -11589,7 +11598,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 3021354e28..c254996284 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -46,6 +46,7 @@ import { } from '../type/definition'; import { SchemaMetaFieldDef, + ServiceMetaFieldDef, TypeMetaFieldDef, TypeNameMetaFieldDef, } from '../type/introspection'; @@ -1072,6 +1073,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 4cf0057abe..dca534f31b 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', 'ScalarTypeExtension', 'ObjectTypeExtension', @@ -45,6 +46,7 @@ describe('AST node predicates', () => { 'UnionTypeExtension', 'EnumTypeExtension', 'InputObjectTypeExtension', + 'ServiceExtension', ]); }); @@ -106,6 +108,7 @@ describe('AST node predicates', () => { 'EnumTypeDefinition', 'InputObjectTypeDefinition', 'DirectiveDefinition', + 'ServiceDefinition', ]); }); @@ -129,6 +132,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 9b80a86206..3e4a709844 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -173,6 +173,8 @@ export type ASTNode = | EnumValueDefinitionNode | InputObjectTypeDefinitionNode | DirectiveDefinitionNode + | ServiceDefinitionNode + | ServiceCapabilityNode | SchemaExtensionNode | ScalarTypeExtensionNode | ObjectTypeExtensionNode @@ -180,6 +182,7 @@ export type ASTNode = | UnionTypeExtensionNode | EnumTypeExtensionNode | InputObjectTypeExtensionNode + | ServiceExtensionNode | TypeCoordinateNode | MemberCoordinateNode | ArgumentCoordinateNode @@ -282,6 +285,9 @@ export const QueryDocumentKeys: { DirectiveDefinition: ['description', 'name', 'arguments', 'locations'], + ServiceDefinition: ['description', 'directives', 'capabilities'], + ServiceCapability: ['description', 'identifier', 'value'], + SchemaExtension: ['directives', 'operationTypes'], ScalarTypeExtension: ['name', 'directives'], @@ -291,6 +297,8 @@ export const QueryDocumentKeys: { EnumTypeExtension: ['name', 'directives', 'values'], InputObjectTypeExtension: ['name', 'directives', 'fields'], + ServiceExtension: ['directives', 'capabilities'], + TypeCoordinate: ['name'], MemberCoordinate: ['name', 'memberName'], ArgumentCoordinate: ['name', 'fieldName', 'argumentName'], @@ -568,7 +576,8 @@ export interface NonNullTypeNode { export type TypeSystemDefinitionNode = | SchemaDefinitionNode | TypeDefinitionNode - | DirectiveDefinitionNode; + | DirectiveDefinitionNode + | ServiceDefinitionNode; export interface SchemaDefinitionNode { readonly kind: Kind.SCHEMA_DEFINITION; @@ -690,9 +699,32 @@ export interface DirectiveDefinitionNode { readonly locations: ReadonlyArray; } +/** 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; +} + /** Type System Extensions */ -export type TypeSystemExtensionNode = SchemaExtensionNode | TypeExtensionNode; +export type TypeSystemExtensionNode = + | SchemaExtensionNode + | TypeExtensionNode + | ServiceExtensionNode; export interface SchemaExtensionNode { readonly kind: Kind.SCHEMA_EXTENSION; @@ -760,6 +792,15 @@ export interface InputObjectTypeExtensionNode { readonly fields?: ReadonlyArray; } +/** Service Extension */ + +export interface ServiceExtensionNode { + readonly kind: Kind.SERVICE_EXTENSION; + readonly loc?: Location; + readonly directives?: ReadonlyArray; + readonly capabilities?: ReadonlyArray; +} + /** Schema Coordinates */ export type SchemaCoordinateNode = diff --git a/src/language/index.ts b/src/language/index.ts index 28d6400bc4..c404afc4f7 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -87,6 +87,8 @@ export type { EnumValueDefinitionNode, InputObjectTypeDefinitionNode, DirectiveDefinitionNode, + ServiceDefinitionNode, + ServiceCapabilityNode, TypeSystemExtensionNode, SchemaExtensionNode, TypeExtensionNode, @@ -96,6 +98,7 @@ export type { UnionTypeExtensionNode, EnumTypeExtensionNode, InputObjectTypeExtensionNode, + ServiceExtensionNode, // Schema Coordinates SchemaCoordinateNode, TypeCoordinateNode, diff --git a/src/language/kinds.ts b/src/language/kinds.ts index 9c10348a32..405ea44b1a 100644 --- a/src/language/kinds.ts +++ b/src/language/kinds.ts @@ -56,6 +56,10 @@ enum Kind { /** Directive Definitions */ DIRECTIVE_DEFINITION = 'DirectiveDefinition', + /** Service Definitions */ + SERVICE_DEFINITION = 'ServiceDefinition', + SERVICE_CAPABILITY = 'ServiceCapability', + /** Type System Extensions */ SCHEMA_EXTENSION = 'SchemaExtension', @@ -67,6 +71,9 @@ enum Kind { ENUM_TYPE_EXTENSION = 'EnumTypeExtension', INPUT_OBJECT_TYPE_EXTENSION = 'InputObjectTypeExtension', + /** Service Extension */ + SERVICE_EXTENSION = 'ServiceExtension', + /** Schema Coordinates */ TYPE_COORDINATE = 'TypeCoordinate', MEMBER_COORDINATE = 'MemberCoordinate', diff --git a/src/language/lexer.ts b/src/language/lexer.ts index e62ffd70d7..cc3154de69 100644 --- a/src/language/lexer.ts +++ b/src/language/lexer.ts @@ -878,3 +878,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 f489027b6b..452d333330 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -55,6 +55,9 @@ import type { SchemaExtensionNode, SelectionNode, SelectionSetNode, + ServiceCapabilityNode, + ServiceDefinitionNode, + ServiceExtensionNode, StringValueNode, Token, TypeCoordinateNode, @@ -70,7 +73,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'; @@ -334,6 +337,8 @@ export class Parser { return this.parseInputObjectTypeDefinition(); case 'directive': return this.parseDirectiveDefinition(); + case 'service': + return this.parseServiceDefinition(); } switch (keywordToken.value) { @@ -1204,6 +1209,8 @@ export class Parser { return this.parseEnumTypeExtension(); case 'input': return this.parseInputObjectTypeExtension(); + case 'service': + return this.parseServiceExtension(); } } @@ -1457,6 +1464,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 dd53709c61..bc46d03362 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -78,7 +78,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 ); } @@ -98,7 +99,11 @@ export function isTypeDefinitionNode( export function isTypeSystemExtensionNode( node: ASTNode, ): node is TypeSystemExtensionNode { - return node.kind === Kind.SCHEMA_EXTENSION || isTypeExtensionNode(node); + return ( + node.kind === Kind.SCHEMA_EXTENSION || + isTypeExtensionNode(node) || + node.kind === Kind.SERVICE_EXTENSION + ); } export function isTypeExtensionNode(node: ASTNode): node is TypeExtensionNode { diff --git a/src/language/printer.ts b/src/language/printer.ts index fde1cbcf2f..748c921014 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -247,6 +247,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( @@ -311,6 +337,11 @@ const printDocASTReducer: ASTReducer = { join(['extend input', name, join(directives, ' '), block(fields)], ' '), }, + 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 0d7c60355a..4e24ccbc97 100644 --- a/src/language/tokenKind.ts +++ b/src/language/tokenKind.ts @@ -21,6 +21,7 @@ enum TokenKind { PIPE = '|', BRACE_R = '}', NAME = 'Name', + QUALIFIED_NAME = 'QualifiedName', INT = 'Int', FLOAT = 'Float', STRING = 'String', 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 cf276d1e02..3a626f9a37 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -143,6 +143,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 @@ -172,12 +188,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 2c66ca5098..901fe931a2 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -1,5 +1,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'; @@ -32,6 +33,7 @@ import { import type { GraphQLDirective } from './directives'; import { GraphQLBoolean, GraphQLString } from './scalars'; import type { GraphQLSchema } from './schema'; +import type { GraphQLCapability, GraphQLService } from './service'; export const __Schema: GraphQLObjectType = new GraphQLObjectType({ name: '__Schema', @@ -500,6 +502,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. @@ -548,6 +592,19 @@ export const TypeNameMetaFieldDef: GraphQLField = { astNode: undefined, }; +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, @@ -558,6 +615,8 @@ export const introspectionTypes: ReadonlyArray = __InputValue, __EnumValue, __TypeKind, + __Service, + __Capability, ]); export function isIntrospectionType(type: GraphQLNamedType): boolean { diff --git a/src/type/schema.ts b/src/type/schema.ts index 97c2782145..68256eecb8 100644 --- a/src/type/schema.ts +++ b/src/type/schema.ts @@ -31,6 +31,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. @@ -140,6 +141,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<{ @@ -174,6 +176,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. @@ -350,6 +353,10 @@ export class GraphQLSchema { return this.getDirectives().find((directive) => directive.name === name); } + getService(): Maybe { + return this._service; + } + toConfig(): GraphQLSchemaNormalizedConfig { return { description: this.description, @@ -358,6 +365,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, @@ -386,6 +394,7 @@ export interface GraphQLSchemaConfig extends GraphQLSchemaValidationOptions { subscription?: Maybe; types?: Maybe>; directives?: Maybe>; + service?: Maybe; extensions?: Maybe>; astNode?: Maybe; extensionASTNodes?: Maybe>; @@ -398,6 +407,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 e72dfb01fb..8bb839a938 100644 --- a/src/utilities/TypeInfo.ts +++ b/src/utilities/TypeInfo.ts @@ -31,6 +31,7 @@ import { import type { GraphQLDirective } from '../type/directives'; import { SchemaMetaFieldDef, + ServiceMetaFieldDef, TypeMetaFieldDef, TypeNameMetaFieldDef, } from '../type/introspection'; @@ -317,6 +318,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 eeff08e6ed..65646f1d82 100644 --- a/src/utilities/buildASTSchema.ts +++ b/src/utilities/buildASTSchema.ts @@ -50,6 +50,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 d53752d919..cf7321b4c2 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -24,6 +24,8 @@ import type { ScalarTypeExtensionNode, SchemaDefinitionNode, SchemaExtensionNode, + ServiceDefinitionNode, + ServiceExtensionNode, TypeDefinitionNode, TypeNode, UnionTypeDefinitionNode, @@ -76,6 +78,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'; @@ -147,6 +151,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; @@ -162,17 +170,23 @@ export function extendSchemaImpl( : [def]; } else if (def.kind === Kind.DIRECTIVE_DEFINITION) { directiveDefs.push(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 && typeDefs.length === 0 && directiveDefs.length === 0 && schemaExtensions.length === 0 && - schemaDef == null + schemaDef == null && + serviceExtensions.length === 0 && + serviceDef == null ) { return schemaConfig; } @@ -207,12 +221,77 @@ export function extendSchemaImpl( ...schemaConfig.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 373b474ed5..fe6ca4ad51 100644 --- a/src/utilities/getIntrospectionQuery.ts +++ b/src/utilities/getIntrospectionQuery.ts @@ -38,6 +38,12 @@ export interface IntrospectionOptions { * Default: false */ oneOf?: boolean; + + /** + * Whether to include service capabilities in the introspection result. + * Default: false + */ + includeService?: boolean; } /** @@ -52,6 +58,7 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { schemaDescription: false, inputValueDeprecation: false, oneOf: false, + includeService: false, ...options, }; @@ -71,6 +78,18 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { } const oneOf = optionsWithDefault.oneOf ? 'isOneOf' : ''; + const serviceQuery = optionsWithDefault.includeService + ? ` + __service { + capabilities { + identifier + ${descriptions} + value + } + } + ` + : ''; + return ` query IntrospectionQuery { __schema { @@ -91,6 +110,7 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { } } } + ${serviceQuery} } fragment FullType on __Type { @@ -347,3 +367,13 @@ export interface IntrospectionDirective { readonly locations: ReadonlyArray; 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 90f08fc225..e12dab7820 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -24,6 +24,8 @@ export type { IntrospectionInputValue, IntrospectionEnumValue, IntrospectionDirective, + IntrospectionCapability, + IntrospectionService, } from './getIntrospectionQuery'; // Gets the target Operation from a Document. @@ -53,6 +55,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 edac6262c5..cbc67b1aaf 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -33,14 +33,27 @@ 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'; -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, ); } @@ -56,17 +69,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 { @@ -329,3 +350,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 587479e351..9de7a67a33 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -88,12 +88,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 8f82a6797b..f105ae7748 100644 --- a/src/validation/rules/ExecutableDefinitionsRule.ts +++ b/src/validation/rules/ExecutableDefinitionsRule.ts @@ -21,11 +21,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 c312c9839c..c72beebbe9 100644 --- a/src/validation/specifiedRules.ts +++ b/src/validation/specifiedRules.ts @@ -19,6 +19,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" @@ -46,6 +47,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 1dc1efd1bb5ade22df9e28ef4adff496153ad7cf Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 15 Jan 2026 14:16:41 +0000 Subject: [PATCH 2/6] 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 3a626f9a37..4c288f021c 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -150,6 +150,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 901fe931a2..adc309c46d 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -1,6 +1,5 @@ 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'; @@ -539,9 +538,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), }); /** @@ -597,9 +596,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 68256eecb8..5455adb009 100644 --- a/src/type/schema.ts +++ b/src/type/schema.ts @@ -32,6 +32,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. @@ -141,7 +142,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<{ @@ -176,7 +177,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. @@ -353,7 +355,7 @@ export class GraphQLSchema { return this.getDirectives().find((directive) => directive.name === name); } - getService(): Maybe { + getService(): GraphQLService { return this._service; } @@ -407,7 +409,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 65646f1d82..42a1d51606 100644 --- a/src/utilities/buildASTSchema.ts +++ b/src/utilities/buildASTSchema.ts @@ -9,6 +9,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'; @@ -50,7 +51,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 cf7321b4c2..a1b0a157d8 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -79,7 +79,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'; @@ -231,20 +231,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, @@ -286,7 +293,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 e12dab7820..33c0dad069 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -55,7 +55,6 @@ export { printSchema, printType, printIntrospectionSchema, - type PrintSchemaOptions, } from './printSchema'; // Create a GraphQLType from a GraphQL language AST. @@ -113,3 +112,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 cbc67b1aaf..56c494acff 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -34,26 +34,15 @@ 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'; -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, ); } @@ -69,7 +58,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); @@ -80,11 +68,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 5a4aa892e4ddb8c6f3ead582311c5040b2992999 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 15 Jan 2026 14:18:01 +0000 Subject: [PATCH 3/6] 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 91b0bbf645..49dbb66741 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,7 +76,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", @@ -2222,7 +2221,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", @@ -2395,7 +2393,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" }, @@ -3498,7 +3495,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", @@ -6512,7 +6508,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" @@ -6866,7 +6861,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", @@ -8467,7 +8461,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", @@ -8562,8 +8555,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", @@ -9385,7 +9377,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", @@ -11598,8 +11589,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 84abad6f95c6688edf3de52943a211e3ca92a49f Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 15 Jan 2026 14:28:36 +0000 Subject: [PATCH 4/6] 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 ccfa16802b91f3e41b28909fc4fdba8b37c48ef4 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 15 Jan 2026 15:10:24 +0000 Subject: [PATCH 5/6] 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 452d333330..bbce2ad576 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -1492,7 +1492,7 @@ export class Parser { /** * ``` * ServiceCapability : Description? capability QualifiedName ServiceCapabilityValue? - * ServiceCapabilityValue : = StringValue + * ServiceCapabilityValue : ( StringValue ) * ``` */ parseServiceCapability(): ServiceCapabilityNode { @@ -1525,10 +1525,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 748c921014..9c8f78bf8c 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -263,12 +263,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 56c494acff..46f89db706 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -358,7 +358,7 @@ function printCapability( if (capability.value != null) { const astValue = print({ kind: Kind.STRING, value: capability.value }); - result += ' = ' + astValue; + result += '(' + astValue + ')'; } return result; From b9eaf314e94c1083f37f20aea6e75b7816a1c9a1 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 15 Jan 2026 15:31:24 +0000 Subject: [PATCH 6/6] 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 3e4a709844..95b3bd798c 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -285,7 +285,7 @@ export const QueryDocumentKeys: { DirectiveDefinition: ['description', 'name', 'arguments', 'locations'], - ServiceDefinition: ['description', 'directives', 'capabilities'], + ServiceDefinition: ['description', 'directives', 'entries'], ServiceCapability: ['description', 'identifier', 'value'], SchemaExtension: ['directives', 'operationTypes'], @@ -297,7 +297,7 @@ export const QueryDocumentKeys: { EnumTypeExtension: ['name', 'directives', 'values'], InputObjectTypeExtension: ['name', 'directives', 'fields'], - ServiceExtension: ['directives', 'capabilities'], + ServiceExtension: ['directives', 'entries'], TypeCoordinate: ['name'], MemberCoordinate: ['name', 'memberName'], @@ -706,9 +706,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; @@ -798,7 +800,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 c404afc4f7..9660e9b622 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -88,6 +88,7 @@ export type { InputObjectTypeDefinitionNode, DirectiveDefinitionNode, ServiceDefinitionNode, + ServiceEntryNode, ServiceCapabilityNode, TypeSystemExtensionNode, SchemaExtensionNode, diff --git a/src/language/parser.ts b/src/language/parser.ts index bbce2ad576..7c5be3f9b7 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -1476,7 +1476,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, @@ -1485,7 +1485,7 @@ export class Parser { kind: Kind.SERVICE_DEFINITION, description, directives, - capabilities, + entries, }); } @@ -1552,18 +1552,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 9c8f78bf8c..d2d7b73e6b 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -248,9 +248,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: { @@ -338,8 +338,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 a1b0a157d8..44d61552fb 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -263,8 +263,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, @@ -276,8 +276,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,