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..7d2156edb1 --- /dev/null +++ b/src/language/__tests__/service-test.ts @@ -0,0 +1,266 @@ +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 example.capability + } + `); + + 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.entries).to.have.length(1); + expect(serviceDef.entries?.[0].identifier.value).to.equal( + 'example.capability', + ); + expect(serviceDef.entries?.[0].value).to.equal(undefined); + } + }); + + it('parses service with capability value', () => { + const doc = parse(` + service { + capability example.capability("Example value") + } + `); + + const serviceDef = doc.definitions[0]; + if (serviceDef.kind === Kind.SERVICE_DEFINITION) { + expect(serviceDef.entries?.[0].identifier.value).to.equal( + 'example.capability', + ); + expect(serviceDef.entries?.[0].value?.value).to.equal( + 'Example value', + ); + } + }); + + it('parses service with description', () => { + const doc = parse(` + "My GraphQL Service" + service { + capability example.capability + } + `); + + 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 { + "Example capability description" + capability example.capability + } + `); + + const serviceDef = doc.definitions[0]; + if (serviceDef.kind === Kind.SERVICE_DEFINITION) { + expect(serviceDef.entries?.[0].description?.value).to.equal( + 'Example capability description', + ); + } + }); + + it('parses service with multiple capabilities', () => { + const doc = parse(` + service { + capability example.capability + capability graphql.someFutureCapability + capability org.example.customFeature("v2") + } + `); + + const serviceDef = doc.definitions[0]; + if (serviceDef.kind === Kind.SERVICE_DEFINITION) { + expect(serviceDef.entries).to.have.length(3); + expect(serviceDef.entries?.[0].identifier.value).to.equal( + 'example.capability', + ); + expect(serviceDef.entries?.[0].value).to.equal(undefined); + expect(serviceDef.entries?.[1].identifier.value).to.equal( + 'graphql.someFutureCapability', + ); + expect(serviceDef.entries?.[1].value).to.equal(undefined); + expect(serviceDef.entries?.[2].identifier.value).to.equal( + 'org.example.customFeature', + ); + expect(serviceDef.entries?.[2].value?.value).to.equal('v2'); + } + }); + + it('parses service with directives', () => { + const doc = parse(` + service @deprecated { + capability example.capability + } + `); + + 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.entries).to.have.length(1); + expect(serviceExt.entries?.[0].identifier.value).to.equal( + 'graphql.additionalFeature', + ); + } + }); + + it('parses service extension with directives', () => { + const doc = parse(` + extend service @deprecated { + capability example.capability + } + `); + + 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 example.capability + } + `); + + expect(print(doc)).to.equal(`service { + capability example.capability +}`); + }); + + it('prints service with capability value', () => { + const doc = parse(` + service { + capability example.capability("Example value") + } + `); + + expect(print(doc)).to.equal(`service { + capability example.capability("Example value") +}`); + }); + + it('prints service with description', () => { + const doc = parse(` + "My Service" + service { + capability example.capability + } + `); + + expect(print(doc)).to.equal(`"My Service" +service { + capability example.capability +}`); + }); + + it('prints service with capability description', () => { + const doc = parse(` + service { + "A capability" + capability example.capability + } + `); + + expect(print(doc)).to.equal(`service { + "A capability" + capability example.capability +}`); + }); + + it('prints service with directives', () => { + const doc = parse(` + service @deprecated { + capability example.capability + } + `); + + expect(print(doc)).to.equal(`service @deprecated { + capability example.capability +}`); + }); + + 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 example.capability("Example value") + capability graphql.someFutureCapability + 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 example.capability("Example value") + capability graphql.someFutureCapability + capability org.example.custom +}`, + ); + }); + }); +}); diff --git a/src/language/ast.ts b/src/language/ast.ts index 9b80a86206..95b3bd798c 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', 'entries'], + 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', 'entries'], + 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,34 @@ 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 entries?: ReadonlyArray; +} + +export type ServiceEntryNode = ServiceCapabilityNode; + +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 +794,15 @@ export interface InputObjectTypeExtensionNode { readonly fields?: ReadonlyArray; } +/** Service Extension */ + +export interface ServiceExtensionNode { + readonly kind: Kind.SERVICE_EXTENSION; + readonly loc?: Location; + readonly directives?: ReadonlyArray; + readonly entries?: ReadonlyArray; +} + /** Schema Coordinates */ export type SchemaCoordinateNode = diff --git a/src/language/index.ts b/src/language/index.ts index 28d6400bc4..9660e9b622 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -87,6 +87,9 @@ export type { EnumValueDefinitionNode, InputObjectTypeDefinitionNode, DirectiveDefinitionNode, + ServiceDefinitionNode, + ServiceEntryNode, + ServiceCapabilityNode, TypeSystemExtensionNode, SchemaExtensionNode, TypeExtensionNode, @@ -96,6 +99,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..7c5be3f9b7 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,109 @@ 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 entries = this.optionalMany( + TokenKind.BRACE_L, + this.parseServiceCapability, + TokenKind.BRACE_R, + ); + return this.node(start, { + kind: Kind.SERVICE_DEFINITION, + description, + directives, + entries, + }); + } + + /** + * ``` + * 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.PAREN_L)) { + value = this.parseStringLiteral(); + this.expectToken(TokenKind.PAREN_R); + } + + 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 entries = this.optionalMany( + TokenKind.BRACE_L, + this.parseServiceCapability, + TokenKind.BRACE_R, + ); + if (directives.length === 0 && entries.length === 0) { + throw this.unexpected(); + } + return this.node(start, { + kind: Kind.SERVICE_EXTENSION, + directives, + entries, + }); + } + // 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..d2d7b73e6b 100644 --- a/src/language/printer.ts +++ b/src/language/printer.ts @@ -247,6 +247,32 @@ const printDocASTReducer: ASTReducer = { join(locations, ' | '), }, + ServiceDefinition: { + leave: ({ description, directives, entries }) => + wrap('', description, '\n') + + join(['service', join(directives, ' '), block(entries)], ' '), + }, + + 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; + const valueSuffix = value ? '(' + value + ')' : ''; + return ( + wrap('', description, '\n') + + 'capability ' + + identifierValue + + valueSuffix + ); + }, + }, + SchemaExtension: { leave: ({ directives, operationTypes }) => join( @@ -311,6 +337,11 @@ const printDocASTReducer: ASTReducer = { join(['extend input', name, join(directives, ' '), block(fields)], ' '), }, + ServiceExtension: { + leave: ({ directives, entries }) => + join(['extend service', join(directives, ' '), block(entries)], ' '), + }, + // 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..ef152d8920 --- /dev/null +++ b/src/type/__tests__/service-test.ts @@ -0,0 +1,305 @@ +import { expect } from 'chai'; +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, + builtInService, + GraphQLService, + isBuiltInService, + 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('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 { + test: String + } + service { + capability graphql.spec("2024") + capability graphql.federatedQueries + } + `); + + const service = schema.getService(); + 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 schema has custom service', () => { + const schema = buildSchema(` + type Query { + test: String + } + service { + capability graphql.spec("2024") + } + `); + + const printed = printSchema(schema); + expect(printed).to.include('service {'); + expect(printed).to.include('capability graphql.spec("2024")'); + }); + + 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'); + expect(printed).to.not.include('capability'); + }); +}); + +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(` + 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 built-in capabilities when no custom service defined', () => { + const schema = buildSchema(` + type Query { + test: String + } + `); + + const result = graphqlSync({ + schema, + source: ` + { + __service { + capabilities { + identifier + value + } + } + } + `, + }); + + expect(result.errors).to.equal(undefined); + // The built-in service has the graphql.spec capability + expect(result.data).to.deep.equal({ + __service: { + capabilities: [ + { + identifier: 'graphql.spec', + value: '2024', + }, + ], + }, + }); + }); +}); diff --git a/src/type/index.ts b/src/type/index.ts index cf276d1e02..4c288f021c 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -143,6 +143,25 @@ export type { GraphQLDirectiveExtensions, } from './directives'; +export { + // Predicate + isService, + // Assertion + assertService, + // GraphQL Service definition + GraphQLService, + // Built-in service + builtInService, + isBuiltInService, +} from './service'; + +export type { + GraphQLCapability, + GraphQLCapabilityConfig, + GraphQLServiceConfig, + GraphQLServiceExtensions, +} from './service'; + // Common built-in scalar instances. export { // Predicate @@ -172,12 +191,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..adc309c46d 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -32,6 +32,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 +501,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), +}); + /** * Note that these are GraphQLField and not GraphQLFieldConfig, * so the format for args is different. @@ -548,6 +591,18 @@ 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 - all schemas have a service (built-in or custom) + resolve: (_source, _args, _context, { schema }) => schema.getService(), + deprecationReason: undefined, + extensions: Object.create(null), + astNode: undefined, +}; + export const introspectionTypes: ReadonlyArray = Object.freeze([ __Schema, @@ -558,6 +613,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..5455adb009 100644 --- a/src/type/schema.ts +++ b/src/type/schema.ts @@ -31,6 +31,8 @@ import { 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. @@ -140,6 +142,7 @@ export class GraphQLSchema { private _mutationType: Maybe; private _subscriptionType: Maybe; private _directives: ReadonlyArray; + private _service: GraphQLService; private _typeMap: TypeMap; private _subTypeMap: ObjMap>; private _implementationsMap: ObjMap<{ @@ -174,6 +177,8 @@ export class GraphQLSchema { this._subscriptionType = config.subscription; // Provide specified directives (e.g. @include and @skip) by default. this._directives = config.directives ?? specifiedDirectives; + // 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. @@ -350,6 +355,10 @@ export class GraphQLSchema { return this.getDirectives().find((directive) => directive.name === name); } + getService(): GraphQLService { + return this._service; + } + toConfig(): GraphQLSchemaNormalizedConfig { return { description: this.description, @@ -358,6 +367,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 +396,7 @@ export interface GraphQLSchemaConfig extends GraphQLSchemaValidationOptions { subscription?: Maybe; types?: Maybe>; directives?: Maybe>; + service?: Maybe; extensions?: Maybe>; astNode?: Maybe; extensionASTNodes?: Maybe>; @@ -398,6 +409,7 @@ export interface GraphQLSchemaNormalizedConfig extends GraphQLSchemaConfig { description: Maybe; types: ReadonlyArray; directives: ReadonlyArray; + service: GraphQLService; extensions: Readonly; extensionASTNodes: ReadonlyArray; assumeValid: boolean; diff --git a/src/type/service.ts b/src/type/service.ts new file mode 100644 index 0000000000..5643a8c430 --- /dev/null +++ b/src/type/service.ts @@ -0,0 +1,184 @@ +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; +} + +/** + * 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/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..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,6 +51,7 @@ export function buildASTSchema( description: undefined, types: [], directives: [], + service: builtInService, extensions: Object.create(null), extensionASTNodes: [], assumeValid: false, diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index d53752d919..44d61552fb 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, isBuiltInService } 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,84 @@ 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 { + // 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; + } + + // Collect all capabilities from existing service, new definition, and extensions + const allCapabilities: Array = []; + + // 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, + description: cap.description, + value: cap.value, + astNode: cap.astNode, + }); + } + } + + // Add capabilities from new service definition + if (astNode?.entries) { + for (const cap of astNode.entries) { + 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.entries) { + for (const cap of ext.entries) { + 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: [ + ...(shouldMergeExisting ? 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..33c0dad069 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. @@ -110,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 edac6262c5..46f89db706 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -33,6 +33,8 @@ 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 { isBuiltInService } from '../type/service'; import { astFromValue } from './astFromValue'; @@ -60,13 +62,21 @@ function printFilteredSchema( 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'); + ]; + + // 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'); } function printSchemaDefinition(schema: GraphQLSchema): Maybe { @@ -329,3 +339,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/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, + }); +} 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,