UNPKG

8.04 kBPlain TextView Raw
1/**
2 * Copyright (c) 2021 GraphQL Contributors.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import {
9 GraphQLInputField,
10 GraphQLInputType,
11 isEnumType,
12 isInputObjectType,
13 isListType,
14 isNonNullType,
15 isScalarType,
16} from 'graphql';
17
18import type {
19 JSONSchema4Type,
20 JSONSchema6,
21 JSONSchema6Definition,
22 JSONSchema6TypeName,
23} from 'json-schema';
24import type { VariableToType } from './collectVariables';
25
26export type { JSONSchema6, JSONSchema6TypeName };
27
28export type JsonSchemaOptions = {
29 /**
30 * use undocumented `monaco-json` `markdownDescription` field in place of json-schema spec `description` field.
31 */
32 useMarkdownDescription?: boolean;
33};
34
35type PropertiedJSON6 = JSONSchema6 & {
36 properties: {
37 [k: string]: JSONSchema6;
38 };
39};
40
41export type JSONSchemaOptions = {
42 /**
43 * whether to append a non-json schema valid 'markdownDescription` for `monaco-json`
44 */
45 useMarkdownDescription?: boolean;
46};
47
48export const defaultJSONSchemaOptions = {
49 useMarkdownDescription: false,
50};
51
52export type MonacoEditorJSONSchema = JSONSchema6 & {
53 markdownDescription?: string;
54};
55
56export type CombinedSchema = JSONSchema6 | MonacoEditorJSONSchema;
57
58type Definitions = { [k: string]: JSONSchema6Definition };
59
60export type DefinitionResult = {
61 definition: JSONSchema6 | MonacoEditorJSONSchema;
62 required: boolean;
63 definitions?: Definitions;
64};
65
66function text(into: string[], newText: string) {
67 into.push(newText);
68}
69
70function renderType(into: string[], t: GraphQLInputType | GraphQLInputField) {
71 if (isNonNullType(t)) {
72 renderType(into, t.ofType);
73 text(into, '!');
74 } else if (isListType(t)) {
75 text(into, '[');
76 // @ts-ignore
77 renderType(into, t.ofType);
78 text(into, ']');
79 } else {
80 text(into, t.name);
81 }
82}
83
84function renderTypeToString(
85 t: GraphQLInputType | GraphQLInputField,
86 useMarkdown?: boolean,
87) {
88 const into: string[] = [];
89 if (useMarkdown) {
90 text(into, '```graphql\n');
91 }
92 renderType(into, t);
93 if (useMarkdown) {
94 text(into, '\n```');
95 }
96 return into.join('');
97}
98
99const scalarTypesMap: { [key: string]: JSONSchema6TypeName } = {
100 Int: 'integer',
101 String: 'string',
102 Float: 'number',
103 ID: 'string',
104 Boolean: 'boolean',
105 // { "type": "string", "format": "date" } is not compatible with proposed DateTime GraphQL-Scalars.com spec
106 DateTime: 'string',
107};
108
109/**
110 *
111 * @param type {GraphQLInputType}
112 * @returns {DefinitionResult}
113 */
114function getJSONSchemaFromGraphQLType(
115 type: GraphQLInputType | GraphQLInputField,
116 options?: JSONSchemaOptions,
117): DefinitionResult {
118 let required = false;
119 let definition: CombinedSchema = Object.create(null);
120 const definitions: Definitions = Object.create(null);
121
122 // TODO: test that this works?
123 if ('defaultValue' in type && type.defaultValue !== undefined) {
124 definition.default = type.defaultValue as JSONSchema4Type | undefined;
125 }
126 if (isEnumType(type)) {
127 definition.type = 'string';
128 definition.enum = type.getValues().map(val => val.name);
129 }
130
131 if (isScalarType(type)) {
132 // I think this makes sense for custom scalars?
133 definition.type = scalarTypesMap[type.name] ?? 'any';
134 }
135 if (isListType(type)) {
136 definition.type = 'array';
137 const { definition: def, definitions: defs } = getJSONSchemaFromGraphQLType(
138 type.ofType,
139 options,
140 );
141 if (def.$ref) {
142 definition.items = { $ref: def.$ref };
143 } else {
144 definition.items = def;
145 }
146 if (defs) {
147 Object.keys(defs).forEach(defName => {
148 definitions[defName] = defs[defName];
149 });
150 }
151 }
152 if (isNonNullType(type)) {
153 required = true;
154 const { definition: def, definitions: defs } = getJSONSchemaFromGraphQLType(
155 type.ofType,
156 options,
157 );
158 definition = def;
159 if (defs) {
160 Object.keys(defs).forEach(defName => {
161 definitions[defName] = defs[defName];
162 });
163 }
164 }
165 if (isInputObjectType(type)) {
166 definition.$ref = `#/definitions/${type.name}`;
167 const fields = type.getFields();
168
169 const fieldDef: PropertiedJSON6 = {
170 type: 'object',
171 properties: {},
172 required: [],
173 };
174 if (type.description) {
175 fieldDef.description = type.description + `\n` + renderTypeToString(type);
176 if (options?.useMarkdownDescription) {
177 // @ts-expect-error
178 fieldDef.markdownDescription =
179 type.description + `\n` + renderTypeToString(type, true);
180 }
181 } else {
182 fieldDef.description = renderTypeToString(type);
183 if (options?.useMarkdownDescription) {
184 // @ts-expect-error
185 fieldDef.markdownDescription = renderTypeToString(type, true);
186 }
187 }
188
189 Object.keys(fields).forEach(fieldName => {
190 const field = fields[fieldName];
191 const {
192 required: fieldRequired,
193 definition: typeDefinition,
194 definitions: typeDefinitions,
195 } = getJSONSchemaFromGraphQLType(field.type, options);
196
197 const {
198 definition: fieldDefinition,
199 // definitions: fieldDefinitions,
200 } = getJSONSchemaFromGraphQLType(field, options);
201
202 fieldDef.properties[fieldName] = {
203 ...typeDefinition,
204 ...fieldDefinition,
205 } as JSONSchema6;
206
207 const renderedField = renderTypeToString(field.type);
208 fieldDef.properties[fieldName].description = field.description
209 ? field.description + '\n' + renderedField
210 : renderedField;
211 if (options?.useMarkdownDescription) {
212 const renderedFieldMarkdown = renderTypeToString(field.type, true);
213 fieldDef.properties[
214 fieldName
215 // @ts-expect-error
216 ].markdownDescription = field.description
217 ? field.description + '\n' + renderedFieldMarkdown
218 : renderedFieldMarkdown;
219 }
220
221 if (fieldRequired) {
222 fieldDef.required!.push(fieldName);
223 }
224 if (typeDefinitions) {
225 Object.keys(typeDefinitions).map(defName => {
226 definitions[defName] = typeDefinitions[defName];
227 });
228 }
229 });
230 definitions![type.name] = fieldDef;
231 }
232 // append descriptions
233 if (
234 'description' in type &&
235 !isScalarType(type) &&
236 type.description &&
237 !definition.description
238 ) {
239 definition.description = type.description + '\n' + renderTypeToString(type);
240 if (options?.useMarkdownDescription) {
241 // @ts-expect-error
242 definition.markdownDescription =
243 type.description + '\n' + renderTypeToString(type, true);
244 }
245 } else {
246 definition.description = renderTypeToString(type);
247 if (options?.useMarkdownDescription) {
248 // @ts-expect-error
249 definition.markdownDescription = renderTypeToString(type, true);
250 }
251 }
252
253 return { required, definition, definitions };
254}
255/**
256 * Generate a JSONSchema6 valid document from a map of Map<string, GraphQLInputDefinition>
257 *
258 * TODO: optimize with shared definitions.
259 * Otherwise, if you have multiple variables in your operations with the same input type, they are repeated.
260 *
261 * @param facts {OperationFacts} the result of getOperationFacts, or getOperationASTFacts
262 * @returns {JSONSchema6}
263 */
264export function getVariablesJSONSchema(
265 variableToType: VariableToType,
266 options?: JSONSchemaOptions,
267): JSONSchema6 {
268 const jsonSchema: PropertiedJSON6 = {
269 $schema: 'https://json-schema.org/draft/2020-12/schema',
270 type: 'object',
271 properties: {},
272 required: [],
273 };
274
275 if (variableToType) {
276 // I would use a reduce here, but I wanted it to be readable.
277 Object.entries(variableToType).forEach(([variableName, type]) => {
278 const {
279 definition,
280 required,
281 definitions,
282 } = getJSONSchemaFromGraphQLType(type, options);
283 jsonSchema.properties[variableName] = definition;
284 if (required) {
285 jsonSchema.required?.push(variableName);
286 }
287 if (definitions) {
288 jsonSchema.definitions = { ...jsonSchema?.definitions, ...definitions };
289 }
290 });
291 }
292 return jsonSchema;
293}