UNPKG

graphql-language-service

Version:

The official, runtime independent Language Service for GraphQL

389 lines (348 loc) 10.5 kB
/** * Copyright (c) 2021 GraphQL Contributors. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ import { GraphQLInputField, GraphQLInputType, isEnumType, isInputObjectType, isListType, isNonNullType, isScalarType, } from 'graphql'; import type { JSONSchema4Type, JSONSchema6, JSONSchema6Definition, JSONSchema6TypeName, } from 'json-schema'; import type { VariableToType } from './collectVariables'; export type { JSONSchema6, JSONSchema6TypeName }; export type JsonSchemaOptions = { /** * use undocumented `monaco-json` `markdownDescription` field in place of json-schema spec `description` field. */ useMarkdownDescription?: boolean; }; type PropertiedJSON6 = JSONSchema6 & { properties: { [k: string]: JSONSchema6; }; }; export type JSONSchemaOptions = { /** * whether to append a non-json schema valid 'markdownDescription` for `monaco-json` */ useMarkdownDescription?: boolean; /** * Scalar schema mappings. */ scalarSchemas?: Record<string, JSONSchema6>; }; type JSONSchemaRunningOptions = JSONSchemaOptions & { definitionMarker: Marker; }; export const defaultJSONSchemaOptions = { useMarkdownDescription: false, }; export type MonacoEditorJSONSchema = JSONSchema6 & { markdownDescription?: string; }; export type CombinedSchema = JSONSchema6 | MonacoEditorJSONSchema; type Definitions = { [k: string]: JSONSchema6Definition }; export type DefinitionResult = { definition: JSONSchema6 | MonacoEditorJSONSchema; required: boolean; definitions?: Definitions; }; function text(into: string[], newText: string) { into.push(newText); } function renderType(into: string[], t: GraphQLInputType | GraphQLInputField) { if (isNonNullType(t)) { renderType(into, t.ofType); text(into, '!'); } else if (isListType(t)) { text(into, '['); // @ts-ignore renderType(into, t.ofType); text(into, ']'); } else { text(into, t.name); } } 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, ) { const into: string[] = []; if (useMarkdown) { text(into, '```graphql\n'); } renderType(into, t); if (useMarkdown) { text(into, '\n```'); } return into.join(''); } 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: { type: 'string' }, }; class Marker { private set = new Set<string>(); mark(name: string): boolean { if (this.set.has(name)) { return false; } this.set.add(name); return true; } } /** * * @param type {GraphQLInputType} * @param options * @returns {DefinitionResult} */ function getJSONSchemaFromGraphQLType( fieldOrType: GraphQLInputType | GraphQLInputField, options?: JSONSchemaRunningOptions, ): DefinitionResult { let definition: CombinedSchema = Object.create(null); const definitions: Definitions = Object.create(null); // 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(baseType)) { // scalars if (options?.scalarSchemas?.[baseType.name]) { // deep clone definition = JSON.parse( JSON.stringify(options.scalarSchemas[baseType.name]), ); } else { // any definition.type = ['string', 'number', 'boolean', 'integer']; } 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' }], }; } } } 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( baseType.ofType, options, ); definition.items = def; if (defs) { for (const defName of Object.keys(defs)) { definitions[defName] = defs[defName]; } } } 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: [], }; 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: fieldDefinition, definitions: typeDefinitions, } = getJSONSchemaFromGraphQLType(field, options); fieldDef.properties[fieldName] = fieldDefinition; if (fieldRequired) { fieldDef.required!.push(fieldName); } if (typeDefinitions) { for (const [defName, value] of Object.entries(typeDefinitions)) { definitions[defName] = value; } } } definitions[baseType.name] = fieldDef; } } 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 }; } /** * Generates a JSONSchema6 valid document for operation(s) from a map of Map<string, GraphQLInputType>. * * It generates referenced Definitions for each type, so that no graphql types are repeated. * * Note: you must install `@types/json-schema` if you want a valid result type * * @param facts {OperationFacts} the result of getOperationFacts, or getOperationASTFacts * @returns {JSONSchema6}' * * @example * simple usage: * * ```ts * import { parse } from 'graphql' * import { collectVariables, getVariablesJSONSchema } from 'graphql-language-service' * const variablesToType = collectVariables(parse(query), schema) * const JSONSchema6Result = getVariablesJSONSchema(variablesToType, schema) * ``` * * @example * advanced usage: * ```ts * * import { parse } from 'graphql' * import { collectVariables, getVariablesJSONSchema } from 'graphql-language-service' * const variablesToType = collectVariables(parse(query), schema) * * // you can append `markdownDescription` to JSON schema, which monaco-json uses. * const JSONSchema6Result = getVariablesJSONSchema(variablesToType, schema, { useMarkdownDescription: true }) * * // let's say we want to use it with an IDE extension that expects a JSON file * // the resultant object literal can be written to string * import fs from 'fs/promises' * await fs.writeFile('operation-schema.json', JSON.stringify(JSONSchema6Result, null, 2)) * ``` */ export function getVariablesJSONSchema( variableToType: VariableToType, options?: JSONSchemaOptions, ): JSONSchema6 { const jsonSchema: PropertiedJSON6 = { // this gets monaco-json validation working again // otherwise it shows an error for newer schema draft versions // variables and graphql types are simple and compatible with all versions of json schema // since draft 4. package.json and many other schemas still use draft 4 $schema: 'http://json-schema.org/draft-04/schema', type: 'object', properties: {}, required: [], }; const runtimeOptions: JSONSchemaRunningOptions = { ...options, definitionMarker: new Marker(), scalarSchemas: { ...defaultScalarTypesMap, ...options?.scalarSchemas, }, }; if (variableToType) { // I would use a reduce here, but I wanted it to be readable. for (const [variableName, type] of Object.entries(variableToType)) { const { definition, required, definitions } = getJSONSchemaFromGraphQLType(type, runtimeOptions); jsonSchema.properties[variableName] = definition; if (required) { jsonSchema.required?.push(variableName); } if (definitions) { jsonSchema.definitions = { ...jsonSchema?.definitions, ...definitions }; } } } return jsonSchema; }