diff --git a/.changeset/few-hats-move.md b/.changeset/few-hats-move.md new file mode 100644 index 00000000000..d0052f82a28 --- /dev/null +++ b/.changeset/few-hats-move.md @@ -0,0 +1,5 @@ +--- +'graphql-language-service': patch +--- + +Fix Variables JSON Schema for null values diff --git a/.changeset/tame-lions-teach.md b/.changeset/tame-lions-teach.md new file mode 100644 index 00000000000..60976f7cff0 --- /dev/null +++ b/.changeset/tame-lions-teach.md @@ -0,0 +1,6 @@ +--- +'graphql-language-service': minor +'monaco-graphql': minor +--- + +Add support for custom scalars diff --git a/packages/graphql-language-service/src/utils/__tests__/__schema__/StarWarsSchema.graphql b/packages/graphql-language-service/src/utils/__tests__/__schema__/StarWarsSchema.graphql index d3ca2578e3b..b7f5eb6af96 100644 --- a/packages/graphql-language-service/src/utils/__tests__/__schema__/StarWarsSchema.graphql +++ b/packages/graphql-language-service/src/utils/__tests__/__schema__/StarWarsSchema.graphql @@ -4,6 +4,28 @@ schema { directive @test(testArg: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +scalar SomeCustomScalar +scalar EmailAddress +scalar Even +scalar SpecialScalar +scalar SpecialDate +scalar FooBar +scalar Foo + +""" +An input type with custom scalars +""" +input CustomScalarsInput { + """ + example email + """ + email: EmailAddress + """ + example even + """ + even: Even +} + enum Episode { NEWHOPE EMPIRE diff --git a/packages/graphql-language-service/src/utils/__tests__/getVariablesJSONSchema.spec.ts b/packages/graphql-language-service/src/utils/__tests__/getVariablesJSONSchema.spec.ts index 397540f5d44..7679e37ad90 100644 --- a/packages/graphql-language-service/src/utils/__tests__/getVariablesJSONSchema.spec.ts +++ b/packages/graphql-language-service/src/utils/__tests__/getVariablesJSONSchema.spec.ts @@ -25,7 +25,15 @@ describe('getVariablesJSONSchema', () => { it('should handle scalar types', () => { const variableToType = collectVariables( schema, - parse(`query($id: ID, $string: String!, $boolean: Boolean, $number: Int!, $price: Float) { + parse(`query( + $id: ID, + $string: String!, + $boolean: Boolean, + $number: Int!, + $price: Float, + $custom: SomeCustomScalar, + $anotherCustom: SomeCustomScalar! + ) { characters{ name } @@ -34,11 +42,11 @@ describe('getVariablesJSONSchema', () => { const jsonSchema = getVariablesJSONSchema(variableToType); - expect(jsonSchema.required).toEqual(['string', 'number']); + expect(jsonSchema.required).toEqual(['string', 'number', 'anotherCustom']); expect(jsonSchema.properties).toEqual({ boolean: { - type: 'boolean', + type: ['boolean', 'null'], description: 'Boolean', }, string: { @@ -51,7 +59,201 @@ describe('getVariablesJSONSchema', () => { }, price: { description: 'Float', - type: 'number', + type: ['number', 'null'], + }, + custom: { + description: 'SomeCustomScalar', + type: ['string', 'number', 'boolean', 'integer', 'null'], + }, + anotherCustom: { + description: 'SomeCustomScalar!', + type: ['string', 'number', 'boolean', 'integer'], + }, + }); + }); + + it('should handle custom scalar schemas', () => { + const variableToType = collectVariables( + schema, + parse(`query( + $email: EmailAddress!, + $optionalEmail: EmailAddress, + $evenNumber: Even!, + $optionalEvenNumber: Even, + $special: SpecialScalar!, + $optionalSpecial: SpecialScalar, + $specialDate: SpecialDate!, + $optionalSpecialDate: SpecialDate, + $foobar: FooBar!, + $optionalFoobar: FooBar, + $foo: Foo!, + $optionalFoo: Foo, + $customInput: CustomScalarsInput!, + $optionalCustomInput: CustomScalarsInput + ) { + characters{ + name + } + }`), + ); + + const jsonSchema = getVariablesJSONSchema(variableToType, { + scalarSchemas: { + EmailAddress: { + type: 'string', + format: 'email', + }, + Even: { + type: 'integer', + multipleOf: 2, + description: 'An even number.', + }, + SpecialScalar: { + type: ['string'], + minLength: 5, + }, + FooBar: { + enum: ['foo', 'bar'], + }, + Foo: { + const: 'foo', + }, + SpecialDate: { + description: 'A date or date time.', + oneOf: [ + { + type: 'string', + format: 'date-time', + }, + { + type: 'string', + format: 'date', + }, + ], + }, + }, + }); + + expect(jsonSchema.required).toEqual([ + 'email', + 'evenNumber', + 'special', + 'specialDate', + 'foobar', + 'foo', + 'customInput', + ]); + + expect(jsonSchema.definitions).toEqual({ + CustomScalarsInput: { + description: 'CustomScalarsInput\nAn input type with custom scalars', + properties: { + email: { + description: 'example email\n\nEmailAddress', + format: 'email', + type: ['string', 'null'], + }, + even: { + description: 'example even\n\nEven\nAn even number.', + multipleOf: 2, + type: ['integer', 'null'], + }, + }, + required: [], + type: 'object', + }, + }); + + expect(jsonSchema.properties).toEqual({ + email: { + type: 'string', + format: 'email', + description: 'EmailAddress!', + }, + optionalEmail: { + type: ['string', 'null'], + format: 'email', + description: 'EmailAddress', + }, + evenNumber: { + type: 'integer', + multipleOf: 2, + description: 'Even!\nAn even number.', + }, + optionalEvenNumber: { + type: ['integer', 'null'], + multipleOf: 2, + description: 'Even\nAn even number.', + }, + special: { + type: ['string'], + minLength: 5, + description: 'SpecialScalar!', + }, + optionalSpecial: { + type: ['string', 'null'], + minLength: 5, + description: 'SpecialScalar', + }, + foobar: { + enum: ['foo', 'bar'], + description: 'FooBar!', + }, + optionalFoobar: { + enum: ['foo', 'bar', null], + description: 'FooBar', + }, + foo: { + const: 'foo', + description: 'Foo!', + }, + optionalFoo: { + oneOf: [{ const: 'foo' }, { type: 'null' }], + description: 'Foo', + }, + specialDate: { + description: 'SpecialDate!\nA date or date time.', + oneOf: [ + { + type: 'string', + format: 'date-time', + }, + { + type: 'string', + format: 'date', + }, + ], + }, + optionalSpecialDate: { + description: 'SpecialDate\nA date or date time.', + oneOf: [ + { + type: 'string', + format: 'date-time', + }, + { + type: 'string', + format: 'date', + }, + { + type: 'null', + }, + ], + }, + customInput: { + $ref: '#/definitions/CustomScalarsInput', + description: 'CustomScalarsInput!\nAn input type with custom scalars', + }, + optionalCustomInput: { + description: 'CustomScalarsInput\nAn input type with custom scalars', + oneOf: [ + { + $ref: '#/definitions/CustomScalarsInput', + }, + { + type: 'null', + }, + ], }, }); }); @@ -73,37 +275,41 @@ describe('getVariablesJSONSchema', () => { expect(jsonSchema.properties).toEqual({ input: { $ref: '#/definitions/InputType', - description: 'InputType!', + description: 'InputType!\nexample input type', }, anotherInput: { - $ref: '#/definitions/InputType', - description: 'example input type\nInputType', + oneOf: [{ $ref: '#/definitions/InputType' }, { type: 'null' }], + description: 'InputType\nexample input type', }, }); expect(jsonSchema.definitions).toEqual({ InputType: { type: 'object', - description: 'example input type\nInputType', + description: 'InputType\nexample input type', properties: { key: { - description: 'example key\nString!', + description: 'example key\n\nString!', type: 'string', }, value: { - description: 'example value\nInt', - type: 'integer', + description: 'example value\n\nInt', + type: ['integer', 'null'], default: 42, }, exampleObject: { $ref: '#/definitions/ChildInputType', - description: 'nesting a whole object!\nChildInputType!', + description: 'nesting a whole object!\n\nChildInputType!', }, exampleList: { - type: 'array', + type: ['array', 'null'], items: { - $ref: '#/definitions/ChildInputType', + description: 'ChildInputType', + oneOf: [ + { $ref: '#/definitions/ChildInputType' }, + { type: 'null' }, + ], }, - description: 'list type with default\n[ChildInputType]', + description: 'list type with default\n\n[ChildInputType]', default: [ { isChild: false, @@ -115,7 +321,7 @@ describe('getVariablesJSONSchema', () => { type: 'array', description: '[String]!', items: { - type: 'string', + type: ['string', 'null'], description: 'String', }, default: ['something'], @@ -133,8 +339,8 @@ describe('getVariablesJSONSchema', () => { default: true, }, favoriteBook: { - type: 'string', - description: 'favorite book\nString', + type: ['string', 'null'], + description: 'favorite book\n\nString', default: 'Where the wild things are', }, }, @@ -148,7 +354,7 @@ describe('getVariablesJSONSchema', () => { it('should handle input object types with markdown', () => { const variableToType = collectVariables( schema, - parse(`query($input: InputType!, $anotherInput: InputType, $episode: Episode) { + parse(`query($input: InputType!, $anotherInput: InputType, $episode: Episode, $anotherEpisode: Episode!) { characters { name } @@ -159,59 +365,66 @@ describe('getVariablesJSONSchema', () => { useMarkdownDescription: true, }); - expect(jsonSchema.required).toEqual(['input']); + expect(jsonSchema.required).toEqual(['input', 'anotherEpisode']); expect(jsonSchema.properties).toEqual({ input: { $ref: '#/definitions/InputType', - description: 'InputType!', - markdownDescription: mdTicks('InputType!'), + description: 'InputType!\nexample input type', + markdownDescription: '```graphql\nInputType!\n```\nexample input type', }, anotherInput: { - $ref: '#/definitions/InputType', - // description: 'example input type', - // TODO: fix this for non-nulls? - description: 'example input type\nInputType', - markdownDescription: 'example input type\n```graphql\nInputType\n```', + oneOf: [{ $ref: '#/definitions/InputType' }, { type: 'null' }], + description: 'InputType\nexample input type', + markdownDescription: '```graphql\nInputType\n```\nexample input type', }, episode: { - enum: ['NEWHOPE', 'EMPIRE', 'JEDI'], + enum: ['NEWHOPE', 'EMPIRE', 'JEDI', null], description: 'Episode', - type: 'string', markdownDescription: mdTicks('Episode'), }, + anotherEpisode: { + enum: ['NEWHOPE', 'EMPIRE', 'JEDI'], + description: 'Episode!', + markdownDescription: mdTicks('Episode!'), + }, }); expect(jsonSchema.definitions).toEqual({ InputType: { type: 'object', - description: 'example input type\nInputType', - markdownDescription: `example input type\n${mdTicks('InputType')}`, + description: 'InputType\nexample input type', + markdownDescription: `${mdTicks('InputType')}\nexample input type`, properties: { key: { - description: 'example key\nString!', - markdownDescription: `example key\n${mdTicks('String!')}`, + description: 'example key\n\nString!', + markdownDescription: `example key\n\n${mdTicks('String!')}`, type: 'string', }, value: { - description: 'example value\nInt', - markdownDescription: `example value\n${mdTicks('Int')}`, - type: 'integer', + description: 'example value\n\nInt', + markdownDescription: `example value\n\n${mdTicks('Int')}`, + type: ['integer', 'null'], default: 42, }, exampleObject: { - description: 'nesting a whole object!\nChildInputType!', - markdownDescription: `nesting a whole object!\n${mdTicks( + description: 'nesting a whole object!\n\nChildInputType!', + markdownDescription: `nesting a whole object!\n\n${mdTicks( 'ChildInputType!', )}`, $ref: '#/definitions/ChildInputType', }, exampleList: { - type: 'array', + type: ['array', 'null'], items: { - $ref: '#/definitions/ChildInputType', + description: 'ChildInputType', + markdownDescription: '```graphql\nChildInputType\n```', + oneOf: [ + { $ref: '#/definitions/ChildInputType' }, + { type: 'null' }, + ], }, - description: 'list type with default\n[ChildInputType]', - markdownDescription: `list type with default\n${mdTicks( + description: 'list type with default\n\n[ChildInputType]', + markdownDescription: `list type with default\n\n${mdTicks( '[ChildInputType]', )}`, default: [ @@ -226,7 +439,7 @@ describe('getVariablesJSONSchema', () => { description: '[String]!', markdownDescription: mdTicks('[String]!'), items: { - type: 'string', + type: ['string', 'null'], description: 'String', markdownDescription: mdTicks('String'), }, @@ -241,9 +454,9 @@ describe('getVariablesJSONSchema', () => { properties: { favoriteBook: { default: 'Where the wild things are', - description: 'favorite book\nString', - markdownDescription: 'favorite book\n```graphql\nString\n```', - type: 'string', + description: 'favorite book\n\nString', + markdownDescription: 'favorite book\n\n```graphql\nString\n```', + type: ['string', 'null'], }, isChild: { default: true, diff --git a/packages/graphql-language-service/src/utils/getVariablesJSONSchema.ts b/packages/graphql-language-service/src/utils/getVariablesJSONSchema.ts index fb85b2c325a..9f350cbfc82 100644 --- a/packages/graphql-language-service/src/utils/getVariablesJSONSchema.ts +++ b/packages/graphql-language-service/src/utils/getVariablesJSONSchema.ts @@ -43,6 +43,10 @@ export type JSONSchemaOptions = { * whether to append a non-json schema valid 'markdownDescription` for `monaco-json` */ useMarkdownDescription?: boolean; + /** + * Scalar schema mappings. + */ + scalarSchemas?: Record; }; type JSONSchemaRunningOptions = JSONSchemaOptions & { definitionMarker: Marker; @@ -84,6 +88,44 @@ function renderType(into: string[], t: GraphQLInputType | GraphQLInputField) { } } +function renderDefinitionDescription( + t: GraphQLInputType | GraphQLInputField, + useMarkdown?: boolean, + description?: string | undefined | null, +) { + const into: string[] = []; + + const type = 'type' in t ? t.type : t; + + // input field description + if ('type' in t && t.description) { + text(into, t.description); + text(into, '\n\n'); + } + + // type + text(into, renderTypeToString(type, useMarkdown)); + + // type description + if (description) { + text(into, '\n'); + text(into, description); + } else if (!isScalarType(type) && 'description' in type && type.description) { + text(into, '\n'); + text(into, type.description); + } else if ( + 'ofType' in type && + !isScalarType(type.ofType) && + 'description' in type.ofType && + type.ofType.description + ) { + text(into, '\n'); + text(into, type.ofType.description); + } + + return into.join(''); +} + function renderTypeToString( t: GraphQLInputType | GraphQLInputField, useMarkdown?: boolean, @@ -99,14 +141,14 @@ function renderTypeToString( return into.join(''); } -const scalarTypesMap: { [key: string]: JSONSchema6TypeName } = { - Int: 'integer', - String: 'string', - Float: 'number', - ID: 'string', - Boolean: 'boolean', +const defaultScalarTypesMap: { [key: string]: JSONSchema6 } = { + Int: { type: 'integer' }, + String: { type: 'string' }, + Float: { type: 'number' }, + ID: { type: 'string' }, + Boolean: { type: 'boolean' }, // { "type": "string", "format": "date" } is not compatible with proposed DateTime GraphQL-Scalars.com spec - DateTime: 'string', + DateTime: { type: 'string' }, }; class Marker { @@ -128,112 +170,106 @@ class Marker { * @returns {DefinitionResult} */ function getJSONSchemaFromGraphQLType( - type: GraphQLInputType | GraphQLInputField, + fieldOrType: GraphQLInputType | GraphQLInputField, options?: JSONSchemaRunningOptions, ): DefinitionResult { - let required = false; let definition: CombinedSchema = Object.create(null); const definitions: Definitions = Object.create(null); - // TODO: test that this works? - if ('defaultValue' in type && type.defaultValue !== undefined) { - definition.default = type.defaultValue as JSONSchema4Type | undefined; - } - if (isEnumType(type)) { - definition.type = 'string'; - definition.enum = type.getValues().map(val => val.name); - } + // field or type + const isField = 'type' in fieldOrType; + // type + const type = isField ? fieldOrType.type : fieldOrType; + // base type + const baseType = isNonNullType(type) ? type.ofType : type; + const required = isNonNullType(type); - if (isScalarType(type) && scalarTypesMap[type.name]) { - definition.type = scalarTypesMap[type.name]; - } - if (isListType(type)) { - definition.type = 'array'; - const { definition: def, definitions: defs } = getJSONSchemaFromGraphQLType( - type.ofType, - options, - ); - if (def.$ref) { - definition.items = { $ref: def.$ref }; + if (isScalarType(baseType)) { + // scalars + if (options?.scalarSchemas?.[baseType.name]) { + // deep clone + definition = JSON.parse( + JSON.stringify(options.scalarSchemas[baseType.name]), + ); } else { - definition.items = def; + // any + definition.type = ['string', 'number', 'boolean', 'integer']; } - if (defs) { - for (const defName of Object.keys(defs)) { - definitions[defName] = defs[defName]; + if (!required) { + if (Array.isArray(definition.type)) { + definition.type.push('null'); + } else if (definition.type) { + definition.type = [definition.type, 'null']; + } else if (definition.enum) { + definition.enum.push(null); + } else if (definition.oneOf) { + definition.oneOf.push({ type: 'null' }); + } else { + definition = { + oneOf: [definition, { type: 'null' }], + }; } } - } - if (isNonNullType(type)) { - required = true; + } else if (isEnumType(baseType)) { + definition.enum = baseType.getValues().map(val => val.name); + if (!required) { + definition.enum.push(null); + } + } else if (isListType(baseType)) { + if (required) { + definition.type = 'array'; + } else { + definition.type = ['array', 'null']; + } + const { definition: def, definitions: defs } = getJSONSchemaFromGraphQLType( - type.ofType, + baseType.ofType, options, ); - definition = def; + + definition.items = def; + if (defs) { for (const defName of Object.keys(defs)) { definitions[defName] = defs[defName]; } } - } - if (isInputObjectType(type)) { - definition.$ref = `#/definitions/${type.name}`; - if (options?.definitionMarker.mark(type.name)) { - const fields = type.getFields(); + } else if (isInputObjectType(baseType)) { + if (required) { + definition.$ref = `#/definitions/${baseType.name}`; + } else { + definition.oneOf = [ + { $ref: `#/definitions/${baseType.name}` }, + { type: 'null' }, + ]; + } + if (options?.definitionMarker?.mark(baseType.name)) { + const fields = baseType.getFields(); const fieldDef: PropertiedJSON6 = { type: 'object', properties: {}, required: [], }; - if (type.description) { - fieldDef.description = - type.description + '\n' + renderTypeToString(type); - if (options?.useMarkdownDescription) { - // @ts-expect-error - fieldDef.markdownDescription = - type.description + '\n' + renderTypeToString(type, true); - } - } else { - fieldDef.description = renderTypeToString(type); - if (options?.useMarkdownDescription) { - // @ts-expect-error - fieldDef.markdownDescription = renderTypeToString(type, true); - } + + fieldDef.description = renderDefinitionDescription(baseType); + if (options?.useMarkdownDescription) { + // @ts-expect-error + fieldDef.markdownDescription = renderDefinitionDescription( + baseType, + true, + ); } for (const fieldName of Object.keys(fields)) { const field = fields[fieldName]; const { required: fieldRequired, - definition: typeDefinition, - definitions: typeDefinitions, - } = getJSONSchemaFromGraphQLType(field.type, options); - - const { definition: fieldDefinition, - // definitions: fieldDefinitions, + definitions: typeDefinitions, } = getJSONSchemaFromGraphQLType(field, options); - fieldDef.properties[fieldName] = { - ...typeDefinition, - ...fieldDefinition, - } as JSONSchema6; - - const renderedField = renderTypeToString(field.type); - fieldDef.properties[fieldName].description = field.description - ? field.description + '\n' + renderedField - : renderedField; - if (options?.useMarkdownDescription) { - const renderedFieldMarkdown = renderTypeToString(field.type, true); - fieldDef.properties[ - fieldName - // @ts-expect-error - ].markdownDescription = field.description - ? field.description + '\n' + renderedFieldMarkdown - : renderedFieldMarkdown; - } + fieldDef.properties[fieldName] = fieldDefinition; if (fieldRequired) { fieldDef.required!.push(fieldName); @@ -244,28 +280,30 @@ function getJSONSchemaFromGraphQLType( } } } - definitions[type.name] = fieldDef; + definitions[baseType.name] = fieldDef; } } - // append descriptions - if ( - 'description' in type && - !isScalarType(type) && - type.description && - !definition.description - ) { - definition.description = type.description + '\n' + renderTypeToString(type); - if (options?.useMarkdownDescription) { - // @ts-expect-error - definition.markdownDescription = - type.description + '\n' + renderTypeToString(type, true); - } - } else { - definition.description = renderTypeToString(type); - if (options?.useMarkdownDescription) { - // @ts-expect-error - definition.markdownDescription = renderTypeToString(type, true); - } + + if ('defaultValue' in fieldOrType && fieldOrType.defaultValue !== undefined) { + definition.default = fieldOrType.defaultValue as + | JSONSchema4Type + | undefined; + } + + // append to type descriptions, or schema description + const { description } = definition; + definition.description = renderDefinitionDescription( + fieldOrType, + false, + description, + ); + if (options?.useMarkdownDescription) { + // @ts-expect-error + definition.markdownDescription = renderDefinitionDescription( + fieldOrType, + true, + description, + ); } return { required, definition, definitions }; @@ -313,7 +351,7 @@ export function getVariablesJSONSchema( options?: JSONSchemaOptions, ): JSONSchema6 { const jsonSchema: PropertiedJSON6 = { - $schema: 'https://json-schema.org/draft/2020-12/schema', + $schema: 'http://json-schema.org/draft-04/schema', type: 'object', properties: {}, required: [], @@ -322,6 +360,10 @@ export function getVariablesJSONSchema( const runtimeOptions: JSONSchemaRunningOptions = { ...options, definitionMarker: new Marker(), + scalarSchemas: { + ...defaultScalarTypesMap, + ...options?.scalarSchemas, + }, }; if (variableToType) { diff --git a/packages/monaco-graphql/src/LanguageService.ts b/packages/monaco-graphql/src/LanguageService.ts index 20b51afe5a7..259c699e853 100644 --- a/packages/monaco-graphql/src/LanguageService.ts +++ b/packages/monaco-graphql/src/LanguageService.ts @@ -274,7 +274,10 @@ export class LanguageService { const documentAST = this.parse(documentText); const operationFacts = getOperationASTFacts(documentAST, schema.schema); if (operationFacts?.variableToType) { - return getVariablesJSONSchema(operationFacts.variableToType, options); + return getVariablesJSONSchema(operationFacts.variableToType, { + ...options, + scalarSchemas: schema.customScalarSchemas, + }); } } catch {} } diff --git a/packages/monaco-graphql/src/typings/index.ts b/packages/monaco-graphql/src/typings/index.ts index d9ed36cb557..630907fd843 100644 --- a/packages/monaco-graphql/src/typings/index.ts +++ b/packages/monaco-graphql/src/typings/index.ts @@ -9,6 +9,7 @@ import { ValidationRule, FragmentDefinitionNode, } from 'graphql'; +import { JSONSchema6 } from 'graphql-language-service'; import type { Options as PrettierConfig } from 'prettier'; export type BaseSchemaConfig = { @@ -64,6 +65,19 @@ export type SchemaConfig = { * A stringified introspection JSON result */ introspectionJSONString?: string; + /** + * JSON schemas ued for custom scalars + * @example + * ```ts + * { + * customScalarSchemas: { + * DateTime: { + * type": "string", + * format": "date-time" + * } + * } + */ + customScalarSchemas?: Record; }; /**