diff --git a/docs/APIReference-TypeSystem.md b/docs/APIReference-TypeSystem.md index b777db1ad1..8efd840eb6 100644 --- a/docs/APIReference-TypeSystem.md +++ b/docs/APIReference-TypeSystem.md @@ -206,6 +206,7 @@ class GraphQLScalarType { type GraphQLScalarTypeConfig = { name: string; description?: ?string; + specifiedByUrl?: string; serialize: (value: mixed) => ?InternalType; parseValue?: (value: mixed) => ?InternalType; parseLiteral?: (valueAST: Value) => ?InternalType; diff --git a/src/type/__tests__/definition-test.js b/src/type/__tests__/definition-test.js index 25803830a2..bc26f58cb2 100644 --- a/src/type/__tests__/definition-test.js +++ b/src/type/__tests__/definition-test.js @@ -49,6 +49,16 @@ describe('Type System: Scalars', () => { expect(() => new GraphQLScalarType({ name: 'SomeScalar' })).to.not.throw(); }); + it('accepts a Scalar type defining specifiedByUrl', () => { + expect( + () => + new GraphQLScalarType({ + name: 'SomeScalar', + specifiedByUrl: 'https://example.com/foo_spec', + }), + ).not.to.throw(); + }); + it('accepts a Scalar type defining parseValue and parseLiteral', () => { expect( () => @@ -128,6 +138,19 @@ describe('Type System: Scalars', () => { 'SomeScalar must provide both "parseValue" and "parseLiteral" functions.', ); }); + + it('rejects a Scalar type defining specifiedByUrl with an incorrect type', () => { + expect( + () => + new GraphQLScalarType({ + name: 'SomeScalar', + // $DisableFlowOnNegativeTest + specifiedByUrl: {}, + }), + ).to.throw( + 'SomeScalar must provide "specifiedByUrl" as a string, but got: {}.', + ); + }); }); describe('Type System: Objects', () => { diff --git a/src/type/__tests__/introspection-test.js b/src/type/__tests__/introspection-test.js index 5c60e8a071..1da64836c2 100644 --- a/src/type/__tests__/introspection-test.js +++ b/src/type/__tests__/introspection-test.js @@ -30,6 +30,7 @@ describe('Introspection', () => { }); const source = getIntrospectionQuery({ descriptions: false, + specifiedByUrl: true, directiveIsRepeatable: true, }); @@ -46,6 +47,7 @@ describe('Introspection', () => { { kind: 'OBJECT', name: 'QueryRoot', + specifiedByUrl: null, fields: [ { name: 'onlyField', @@ -67,6 +69,7 @@ describe('Introspection', () => { { kind: 'SCALAR', name: 'String', + specifiedByUrl: null, fields: null, inputFields: null, interfaces: null, @@ -76,6 +79,7 @@ describe('Introspection', () => { { kind: 'SCALAR', name: 'Boolean', + specifiedByUrl: null, fields: null, inputFields: null, interfaces: null, @@ -85,6 +89,7 @@ describe('Introspection', () => { { kind: 'OBJECT', name: '__Schema', + specifiedByUrl: null, fields: [ { name: 'description', @@ -189,6 +194,7 @@ describe('Introspection', () => { { kind: 'OBJECT', name: '__Type', + specifiedByUrl: null, fields: [ { name: 'kind', @@ -227,6 +233,17 @@ describe('Introspection', () => { isDeprecated: false, deprecationReason: null, }, + { + name: 'specifiedByUrl', + args: [], + type: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + isDeprecated: false, + deprecationReason: null, + }, { name: 'fields', args: [ @@ -362,6 +379,7 @@ describe('Introspection', () => { { kind: 'ENUM', name: '__TypeKind', + specifiedByUrl: null, fields: null, inputFields: null, interfaces: null, @@ -412,6 +430,7 @@ describe('Introspection', () => { { kind: 'OBJECT', name: '__Field', + specifiedByUrl: null, fields: [ { name: 'name', @@ -512,6 +531,7 @@ describe('Introspection', () => { { kind: 'OBJECT', name: '__InputValue', + specifiedByUrl: null, fields: [ { name: 'name', @@ -574,6 +594,7 @@ describe('Introspection', () => { { kind: 'OBJECT', name: '__EnumValue', + specifiedByUrl: null, fields: [ { name: 'name', @@ -636,6 +657,7 @@ describe('Introspection', () => { { kind: 'OBJECT', name: '__Directive', + specifiedByUrl: null, fields: [ { name: 'name', @@ -733,6 +755,7 @@ describe('Introspection', () => { { kind: 'ENUM', name: '__DirectiveLocation', + specifiedByUrl: null, fields: null, inputFields: null, interfaces: null, @@ -893,6 +916,26 @@ describe('Introspection', () => { }, ], }, + { + name: 'specifiedBy', + isRepeatable: false, + locations: ['SCALAR'], + args: [ + { + defaultValue: null, + name: 'url', + type: { + kind: 'NON_NULL', + name: null, + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + }, + ], + }, ], }, }, diff --git a/src/type/definition.d.ts b/src/type/definition.d.ts index 530a01a7e7..437fd24791 100644 --- a/src/type/definition.d.ts +++ b/src/type/definition.d.ts @@ -291,6 +291,7 @@ export type Thunk = (() => T) | T; export class GraphQLScalarType { name: string; description: Maybe; + specifiedByUrl: Maybe; serialize: GraphQLScalarSerializer; parseValue: GraphQLScalarValueParser; parseLiteral: GraphQLScalarLiteralParser; @@ -301,6 +302,7 @@ export class GraphQLScalarType { constructor(config: Readonly>); toConfig(): GraphQLScalarTypeConfig & { + specifiedByUrl: Maybe; serialize: GraphQLScalarSerializer; parseValue: GraphQLScalarValueParser; parseLiteral: GraphQLScalarLiteralParser; @@ -327,6 +329,7 @@ export type GraphQLScalarLiteralParser = ( export interface GraphQLScalarTypeConfig { name: string; description?: Maybe; + specifiedByUrl?: Maybe; // Serializes an internal value to include in a response. serialize: GraphQLScalarSerializer; // Parses an externally provided value to use as an input. diff --git a/src/type/definition.js b/src/type/definition.js index 16cd2de08e..4cec43717c 100644 --- a/src/type/definition.js +++ b/src/type/definition.js @@ -568,6 +568,7 @@ function undefineIfEmpty(arr: ?$ReadOnlyArray): ?$ReadOnlyArray { export class GraphQLScalarType { name: string; description: ?string; + specifiedByUrl: ?string; serialize: GraphQLScalarSerializer; parseValue: GraphQLScalarValueParser; parseLiteral: GraphQLScalarLiteralParser; @@ -579,6 +580,7 @@ export class GraphQLScalarType { const parseValue = config.parseValue ?? identityFunc; this.name = config.name; this.description = config.description; + this.specifiedByUrl = config.specifiedByUrl; this.serialize = config.serialize ?? identityFunc; this.parseValue = parseValue; this.parseLiteral = @@ -588,6 +590,14 @@ export class GraphQLScalarType { this.extensionASTNodes = undefineIfEmpty(config.extensionASTNodes); devAssert(typeof config.name === 'string', 'Must provide name.'); + + devAssert( + config.specifiedByUrl == null || + typeof config.specifiedByUrl === 'string', + `${this.name} must provide "specifiedByUrl" as a string, ` + + `but got: ${inspect(config.specifiedByUrl)}.`, + ); + devAssert( config.serialize == null || typeof config.serialize === 'function', `${this.name} must provide "serialize" function. If this custom Scalar is also used as an input type, ensure "parseValue" and "parseLiteral" functions are also provided.`, @@ -613,6 +623,7 @@ export class GraphQLScalarType { return { name: this.name, description: this.description, + specifiedByUrl: this.specifiedByUrl, serialize: this.serialize, parseValue: this.parseValue, parseLiteral: this.parseLiteral, @@ -650,6 +661,7 @@ export type GraphQLScalarLiteralParser = ( export type GraphQLScalarTypeConfig = {| name: string, description?: ?string, + specifiedByUrl?: ?string, // Serializes an internal value to include in a response. serialize?: GraphQLScalarSerializer, // Parses an externally provided value to use as an input. diff --git a/src/type/directives.d.ts b/src/type/directives.d.ts index 8865cdcc6b..4c6801485d 100644 --- a/src/type/directives.d.ts +++ b/src/type/directives.d.ts @@ -59,6 +59,11 @@ export const GraphQLIncludeDirective: GraphQLDirective; */ export const GraphQLSkipDirective: GraphQLDirective; +/** + * Used to provide a URL for specifying the behavior of custom scalar definitions. + */ +export const GraphQLSpecifiedByDirective: GraphQLDirective; + /** * Constant string used for default reason for a deprecation. */ diff --git a/src/type/directives.js b/src/type/directives.js index d3ed6fdae9..70b9a3ca6b 100644 --- a/src/type/directives.js +++ b/src/type/directives.js @@ -192,6 +192,21 @@ export const GraphQLDeprecatedDirective = new GraphQLDirective({ }, }); +/** + * Used to provide a URL for specifying the behaviour of custom scalar definitions. + */ +export const GraphQLSpecifiedByDirective = new GraphQLDirective({ + name: 'specifiedBy', + description: 'Exposes a URL that specifies the behaviour of this scalar.', + locations: [DirectiveLocation.SCALAR], + args: { + url: { + type: GraphQLNonNull(GraphQLString), + description: 'The URL that specifies the behaviour of this scalar.', + }, + }, +}); + /** * The full list of specified directives. */ @@ -199,6 +214,7 @@ export const specifiedDirectives = Object.freeze([ GraphQLIncludeDirective, GraphQLSkipDirective, GraphQLDeprecatedDirective, + GraphQLSpecifiedByDirective, ]); export function isSpecifiedDirective( diff --git a/src/type/introspection.js b/src/type/introspection.js index 62f53325c5..6ef4b0137c 100644 --- a/src/type/introspection.js +++ b/src/type/introspection.js @@ -195,7 +195,7 @@ export const __DirectiveLocation = new GraphQLEnumType({ export const __Type = new GraphQLObjectType({ name: '__Type', description: - 'The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum.\n\nDepending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.', + 'The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum.\n\nDepending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name, description and optional `specifiedByUrl`, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.', fields: () => ({ kind: { @@ -239,6 +239,11 @@ export const __Type = new GraphQLObjectType({ resolve: (type) => type.description !== undefined ? type.description : undefined, }, + specifiedByUrl: { + type: GraphQLString, + resolve: (obj) => + obj.specifiedByUrl !== undefined ? obj.specifiedByUrl : undefined, + }, fields: { type: GraphQLList(GraphQLNonNull(__Field)), args: { diff --git a/src/utilities/__tests__/buildASTSchema-test.js b/src/utilities/__tests__/buildASTSchema-test.js index 2238294e14..753ff73ead 100644 --- a/src/utilities/__tests__/buildASTSchema-test.js +++ b/src/utilities/__tests__/buildASTSchema-test.js @@ -17,6 +17,7 @@ import { GraphQLSkipDirective, GraphQLIncludeDirective, GraphQLDeprecatedDirective, + GraphQLSpecifiedByDirective, } from '../../type/directives'; import { GraphQLID, @@ -215,15 +216,18 @@ describe('Schema Builder', () => { expect(cycleSDL(sdl, { commentDescriptions: true })).to.equal(sdl); }); - it('Maintains @skip & @include', () => { + it('Maintains @include, @skip & @specifiedBy', () => { const schema = buildSchema('type Query'); - expect(schema.getDirectives()).to.have.lengthOf(3); + expect(schema.getDirectives()).to.have.lengthOf(4); expect(schema.getDirective('skip')).to.equal(GraphQLSkipDirective); expect(schema.getDirective('include')).to.equal(GraphQLIncludeDirective); expect(schema.getDirective('deprecated')).to.equal( GraphQLDeprecatedDirective, ); + expect(schema.getDirective('specifiedBy')).to.equal( + GraphQLSpecifiedByDirective, + ); }); it('Overriding directives excludes specified', () => { @@ -231,9 +235,10 @@ describe('Schema Builder', () => { directive @skip on FIELD directive @include on FIELD directive @deprecated on FIELD_DEFINITION + directive @specifiedBy on FIELD_DEFINITION `); - expect(schema.getDirectives()).to.have.lengthOf(3); + expect(schema.getDirectives()).to.have.lengthOf(4); expect(schema.getDirective('skip')).to.not.equal(GraphQLSkipDirective); expect(schema.getDirective('include')).to.not.equal( GraphQLIncludeDirective, @@ -241,17 +246,21 @@ describe('Schema Builder', () => { expect(schema.getDirective('deprecated')).to.not.equal( GraphQLDeprecatedDirective, ); + expect(schema.getDirective('specifiedBy')).to.not.equal( + GraphQLSpecifiedByDirective, + ); }); - it('Adding directives maintains @skip & @include', () => { + it('Adding directives maintains @include, @skip & @specifiedBy', () => { const schema = buildSchema(` directive @foo(arg: Int) on FIELD `); - expect(schema.getDirectives()).to.have.lengthOf(4); + expect(schema.getDirectives()).to.have.lengthOf(5); expect(schema.getDirective('skip')).to.not.equal(undefined); expect(schema.getDirective('include')).to.not.equal(undefined); expect(schema.getDirective('deprecated')).to.not.equal(undefined); + expect(schema.getDirective('specifiedBy')).to.not.equal(undefined); }); it('Type modifiers', () => { @@ -770,6 +779,23 @@ describe('Schema Builder', () => { }); }); + it('Supports @specifiedBy', () => { + const sdl = dedent` + scalar Foo @specifiedBy(url: "https://example.com/foo_spec") + + type Query { + foo: Foo @deprecated + } + `; + expect(cycleSDL(sdl)).to.equal(sdl); + + const schema = buildSchema(sdl); + + expect(schema.getType('Foo')).to.include({ + specifiedByUrl: 'https://example.com/foo_spec', + }); + }); + it('Correctly extend scalar type', () => { const scalarSDL = dedent` scalar SomeScalar diff --git a/src/utilities/__tests__/buildClientSchema-test.js b/src/utilities/__tests__/buildClientSchema-test.js index 3cc63ebfe9..85a7b1ca66 100644 --- a/src/utilities/__tests__/buildClientSchema-test.js +++ b/src/utilities/__tests__/buildClientSchema-test.js @@ -33,7 +33,10 @@ import { introspectionFromSchema } from '../introspectionFromSchema'; * returns that schema printed as SDL. */ function cycleIntrospection(sdlString: string): string { - const options = { directiveIsRepeatable: true }; + const options = { + specifiedByUrl: true, + directiveIsRepeatable: true, + }; const serverSchema = buildSchema(sdlString); const initialIntrospection = introspectionFromSchema(serverSchema, options); @@ -533,6 +536,18 @@ describe('Type System: build schema from introspection', () => { expect(cycleIntrospection(sdl)).to.equal(sdl); }); + it('builds a schema with specifiedBy url', () => { + const sdl = dedent` + scalar Foo @specifiedBy(url: "https://example.com/foo_spec") + + type Query { + foo: Foo + } + `; + + expect(cycleIntrospection(sdl)).to.equal(sdl); + }); + it('can use client schema for limited execution', () => { const schema = buildSchema(` scalar CustomScalar diff --git a/src/utilities/__tests__/extendSchema-test.js b/src/utilities/__tests__/extendSchema-test.js index 5f52ec5bda..527c2a67e2 100644 --- a/src/utilities/__tests__/extendSchema-test.js +++ b/src/utilities/__tests__/extendSchema-test.js @@ -359,6 +359,31 @@ describe('extendSchema', () => { expect(printExtensionNodes(someScalar)).to.deep.equal(extensionSDL); }); + it('extends scalars by adding specifiedBy directive', () => { + const schema = buildSchema(` + type Query { + foo: Foo + } + + scalar Foo + + directive @foo on SCALAR + `); + const extensionSDL = dedent` + extend scalar Foo @foo + + extend scalar Foo @specifiedBy(url: "https://example.com/foo_spec") + `; + + const extendedSchema = extendSchema(schema, parse(extensionSDL)); + const foo = assertScalarType(extendedSchema.getType('Foo')); + + expect(foo.specifiedByUrl).to.equal('https://example.com/foo_spec'); + + expect(validateSchema(extendedSchema)).to.deep.equal([]); + expect(printExtensionNodes(foo)).to.deep.equal(extensionSDL); + }); + it('correctly assign AST nodes to new and extended types', () => { const schema = buildSchema(` type Query diff --git a/src/utilities/__tests__/findBreakingChanges-test.js b/src/utilities/__tests__/findBreakingChanges-test.js index 7246f8b413..1c5aad92a2 100644 --- a/src/utilities/__tests__/findBreakingChanges-test.js +++ b/src/utilities/__tests__/findBreakingChanges-test.js @@ -7,6 +7,7 @@ import { GraphQLSchema } from '../../type/schema'; import { GraphQLSkipDirective, GraphQLIncludeDirective, + GraphQLSpecifiedByDirective, GraphQLDeprecatedDirective, } from '../../type/directives'; @@ -799,7 +800,11 @@ describe('findBreakingChanges', () => { const oldSchema = new GraphQLSchema({}); const newSchema = new GraphQLSchema({ - directives: [GraphQLSkipDirective, GraphQLIncludeDirective], + directives: [ + GraphQLSkipDirective, + GraphQLIncludeDirective, + GraphQLSpecifiedByDirective, + ], }); expect(findBreakingChanges(oldSchema, newSchema)).to.deep.equal([ diff --git a/src/utilities/__tests__/getIntrospectionQuery-test.js b/src/utilities/__tests__/getIntrospectionQuery-test.js index e25a906e19..462d683acf 100644 --- a/src/utilities/__tests__/getIntrospectionQuery-test.js +++ b/src/utilities/__tests__/getIntrospectionQuery-test.js @@ -51,4 +51,16 @@ describe('getIntrospectionQuery', () => { getIntrospectionQuery({ descriptions: false, schemaDescription: true }), ).to.not.match(/\bdescription\b/); }); + + it('include "specifiedByUrl" field', () => { + expect(getIntrospectionQuery()).to.not.match(/\bspecifiedByUrl\b/); + + expect(getIntrospectionQuery({ specifiedByUrl: true })).to.match( + /\bspecifiedByUrl\b/, + ); + + expect(getIntrospectionQuery({ specifiedByUrl: false })).to.not.match( + /\bspecifiedByUrl\b/, + ); + }); }); diff --git a/src/utilities/__tests__/schemaPrinter-test.js b/src/utilities/__tests__/schemaPrinter-test.js index ebfe7f7ff9..db1efddff5 100644 --- a/src/utilities/__tests__/schemaPrinter-test.js +++ b/src/utilities/__tests__/schemaPrinter-test.js @@ -505,6 +505,19 @@ describe('Type System Printer', () => { `); }); + it('Custom Scalar with specifiedByUrl', () => { + const FooType = new GraphQLScalarType({ + name: 'Foo', + specifiedByUrl: 'https://example.com/foo_spec', + }); + + const Schema = new GraphQLSchema({ types: [FooType] }); + const output = printForTest(Schema); + expect(output).to.equal(dedent` + scalar Foo @specifiedBy(url: "https://example.com/foo_spec") + `); + }); + it('Enum', () => { const RGBType = new GraphQLEnumType({ name: 'RGB', @@ -639,6 +652,12 @@ describe('Type System Printer', () => { reason: String = "No longer supported" ) on FIELD_DEFINITION | ENUM_VALUE + """Exposes a URL that specifies the behaviour of this scalar.""" + directive @specifiedBy( + """The URL that specifies the behaviour of this scalar.""" + url: String! + ) on SCALAR + """ A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations. """ @@ -668,12 +687,13 @@ describe('Type System Printer', () => { """ The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the \`__TypeKind\` enum. - Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types. + Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name, description and optional \`specifiedByUrl\`, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types. """ type __Type { kind: __TypeKind! name: String description: String + specifiedByUrl: String fields(includeDeprecated: Boolean = false): [__Field!] interfaces: [__Type!] possibleTypes: [__Type!] @@ -853,6 +873,12 @@ describe('Type System Printer', () => { reason: String = "No longer supported" ) on FIELD_DEFINITION | ENUM_VALUE + # Exposes a URL that specifies the behaviour of this scalar. + directive @specifiedBy( + # The URL that specifies the behaviour of this scalar. + url: String! + ) on SCALAR + # A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations. type __Schema { description: String @@ -875,11 +901,12 @@ describe('Type System Printer', () => { # The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the \`__TypeKind\` enum. # - # Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types. + # Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name, description and optional \`specifiedByUrl\`, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types. type __Type { kind: __TypeKind! name: String description: String + specifiedByUrl: String fields(includeDeprecated: Boolean = false): [__Field!] interfaces: [__Type!] possibleTypes: [__Type!] diff --git a/src/utilities/buildASTSchema.js b/src/utilities/buildASTSchema.js index 7893071779..5299fc53a3 100644 --- a/src/utilities/buildASTSchema.js +++ b/src/utilities/buildASTSchema.js @@ -17,6 +17,7 @@ import { GraphQLSkipDirective, GraphQLIncludeDirective, GraphQLDeprecatedDirective, + GraphQLSpecifiedByDirective, } from '../type/directives'; import { extendSchemaImpl } from './extendSchema'; @@ -106,6 +107,10 @@ export function buildASTSchema( directives.push(GraphQLDeprecatedDirective); } + if (!directives.some((directive) => directive.name === 'specifiedBy')) { + directives.push(GraphQLSpecifiedByDirective); + } + return new GraphQLSchema(config); } diff --git a/src/utilities/buildClientSchema.js b/src/utilities/buildClientSchema.js index f1b52c80f0..0424567e6e 100644 --- a/src/utilities/buildClientSchema.js +++ b/src/utilities/buildClientSchema.js @@ -200,6 +200,7 @@ export function buildClientSchema( return new GraphQLScalarType({ name: scalarIntrospection.name, description: scalarIntrospection.description, + specifiedByUrl: scalarIntrospection.specifiedByUrl, }); } diff --git a/src/utilities/extendSchema.js b/src/utilities/extendSchema.js index f10195854b..8265973387 100644 --- a/src/utilities/extendSchema.js +++ b/src/utilities/extendSchema.js @@ -39,6 +39,8 @@ import { type EnumTypeExtensionNode, type EnumValueDefinitionNode, type DirectiveDefinitionNode, + type ScalarTypeDefinitionNode, + type ScalarTypeExtensionNode, } from '../language/ast'; import { assertValidSDLExtension } from '../validation/validate'; @@ -50,6 +52,7 @@ import { introspectionTypes, isIntrospectionType } from '../type/introspection'; import { GraphQLDirective, GraphQLDeprecatedDirective, + GraphQLSpecifiedByDirective, } from '../type/directives'; import { type GraphQLSchemaValidationOptions, @@ -324,8 +327,14 @@ export function extendSchemaImpl( const config = type.toConfig(); const extensions = typeExtensionsMap[config.name] ?? []; + let specifiedByUrl = config.specifiedByUrl; + for (const extensionNode of extensions) { + specifiedByUrl = getSpecifiedByUrl(extensionNode) ?? specifiedByUrl; + } + return new GraphQLScalarType({ ...config, + specifiedByUrl, extensionASTNodes: config.extensionASTNodes.concat(extensions), }); } @@ -658,6 +667,7 @@ export function extendSchemaImpl( return new GraphQLScalarType({ name, description, + specifiedByUrl: getSpecifiedByUrl(astNode), astNode, extensionASTNodes, }); @@ -700,6 +710,16 @@ function getDeprecationReason( return (deprecated?.reason: any); } +/** + * Given a scalar node, returns the string value for the specifiedByUrl. + */ +function getSpecifiedByUrl( + node: ScalarTypeDefinitionNode | ScalarTypeExtensionNode, +): ?string { + const specifiedBy = getDirectiveValues(GraphQLSpecifiedByDirective, node); + return (specifiedBy?.url: any); +} + /** * Given an ast node, returns its string description. * @deprecated: provided to ease adoption and will be removed in v16. diff --git a/src/utilities/getIntrospectionQuery.d.ts b/src/utilities/getIntrospectionQuery.d.ts index ece6b71db4..b1d5ecc174 100644 --- a/src/utilities/getIntrospectionQuery.d.ts +++ b/src/utilities/getIntrospectionQuery.d.ts @@ -6,6 +6,10 @@ export interface IntrospectionOptions { // Default: true descriptions: boolean; + // Whether to include `specifiedByUrl` in the introspection result. + // Default: false + specifiedByUrl?: boolean; + // Whether to include `isRepeatable` flag on directives. // Default: false directiveIsRepeatable?: boolean; @@ -53,6 +57,7 @@ export interface IntrospectionScalarType { readonly kind: 'SCALAR'; readonly name: string; readonly description?: Maybe; + readonly specifiedByUrl?: Maybe; } export interface IntrospectionObjectType { diff --git a/src/utilities/getIntrospectionQuery.js b/src/utilities/getIntrospectionQuery.js index 8f79aace3b..51e94cd9a1 100644 --- a/src/utilities/getIntrospectionQuery.js +++ b/src/utilities/getIntrospectionQuery.js @@ -7,6 +7,10 @@ export type IntrospectionOptions = {| // Default: true descriptions?: boolean, + // Whether to include `specifiedByUrl` in the introspection result. + // Default: false + specifiedByUrl?: boolean, + // Whether to include `isRepeatable` field on directives. // Default: false directiveIsRepeatable?: boolean, @@ -19,12 +23,16 @@ export type IntrospectionOptions = {| export function getIntrospectionQuery(options?: IntrospectionOptions): string { const optionsWithDefault = { descriptions: true, + specifiedByUrl: false, directiveIsRepeatable: false, schemaDescription: false, ...options, }; const descriptions = optionsWithDefault.descriptions ? 'description' : ''; + const specifiedByUrl = optionsWithDefault.specifiedByUrl + ? 'specifiedByUrl' + : ''; const directiveIsRepeatable = optionsWithDefault.directiveIsRepeatable ? 'isRepeatable' : ''; @@ -58,6 +66,7 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { kind name ${descriptions} + ${specifiedByUrl} fields(includeDeprecated: true) { name ${descriptions} @@ -166,6 +175,7 @@ export type IntrospectionScalarType = {| +kind: 'SCALAR', +name: string, +description?: ?string, + +specifiedByUrl: ?string, |}; export type IntrospectionObjectType = {| diff --git a/src/utilities/printSchema.js b/src/utilities/printSchema.js index fba396fb8d..e856abfea5 100644 --- a/src/utilities/printSchema.js +++ b/src/utilities/printSchema.js @@ -181,7 +181,11 @@ export function printType(type: GraphQLNamedType, options?: Options): string { } function printScalar(type: GraphQLScalarType, options): string { - return printDescription(options, type) + `scalar ${type.name}`; + return ( + printDescription(options, type) + + `scalar ${type.name}` + + printSpecifiedByUrl(type) + ); } function printImplementedInterfaces( @@ -321,6 +325,19 @@ function printDeprecated(fieldOrEnumVal) { return ' @deprecated'; } +function printSpecifiedByUrl(scalar: GraphQLScalarType) { + if (scalar.specifiedByUrl == null) { + return ''; + } + const url = scalar.specifiedByUrl; + const urlAST = astFromValue(url, GraphQLString); + invariant( + urlAST, + 'Unexpected null value returned from `astFromValue` for specifiedByUrl', + ); + return ' @specifiedBy(url: ' + print(urlAST) + ')'; +} + function printDescription( options, def,