diff --git a/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts b/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts index 227a46da75..eea2570317 100644 --- a/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts +++ b/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts @@ -3,7 +3,11 @@ import { iterableFirst, mapFirst } from "collection-utils"; import { addDescriptionToSchema } from "../../attributes/Description.js"; import { ConvenienceRenderer } from "../../ConvenienceRenderer.js"; import type { Name, Namer } from "../../Naming.js"; -import { defined, panic } from "../../support/Support.js"; +import type { RenderContext } from "../../Renderer.js"; +import type { OptionValues } from "../../RendererOptions/index.js"; +import type { Sourcelike } from "../../Source.js"; +import { assert, defined, panic } from "../../support/Support.js"; +import type { TargetLanguage } from "../../TargetLanguage.js"; import { type EnumType, type ObjectType, @@ -13,6 +17,7 @@ import { } from "../../Type/index.js"; import { matchTypeExhaustive } from "../../Type/TypeUtils.js"; +import type { jsonSchemaOptions } from "./language.js"; import { namingFunction } from "./utils.js"; interface Schema { @@ -21,6 +26,22 @@ interface Schema { } export class JSONSchemaRenderer extends ConvenienceRenderer { + private _currentFilename: string | undefined; + + // The title of the definition currently being rendered, when + // `multiFileOutput` is on. Used by `makeRef` to tell same-file + // references (`#/definitions/X`) apart from cross-file ones + // (`X.schema#/definitions/X`). + private _currentTitle: string | undefined; + + public constructor( + targetLanguage: TargetLanguage, + renderContext: RenderContext, + private readonly _options: OptionValues, + ) { + super(targetLanguage, renderContext); + } + protected makeNamedTypeNamer(): Namer { return namingFunction; } @@ -57,7 +78,30 @@ export class JSONSchemaRenderer extends ConvenienceRenderer { } private makeRef(t: Type): Schema { - return { $ref: `#/definitions/${this.nameForType(t)}` }; + const title = this.nameForType(t); + if ( + this._options.multiFileOutput === true && + title !== this._currentTitle + ) { + return { $ref: `${title}.schema#/definitions/${title}` }; + } + + return { $ref: `#/definitions/${title}` }; + } + + // The title of the sole top-level type, used in multi-file mode to + // decide which file the document root type belongs on. Returns + // undefined when there are multiple top-levels (`FIXME` below). + private topLevelTitle(): string | undefined { + if (this.topLevels.size !== 1) { + return undefined; + } + + let title: string | undefined; + this.forEachTopLevel("none", (_t, name) => { + title = defined(this.names.get(name)); + }); + return title; } private addAttributesToSchema(t: Type, schema: Schema): void { @@ -177,15 +221,47 @@ export class JSONSchemaRenderer extends ConvenienceRenderer { } protected emitSourceStructure(): void { - // FIXME: Find a good way to do multiple top-levels. Maybe multiple files? - const topLevelType = - this.topLevels.size === 1 - ? this.schemaForType(defined(mapFirst(this.topLevels))) - : {}; - const schema: Schema = { - $schema: "http://json-schema.org/draft-06/schema#", - ...topLevelType, - }; + if (this._options.multiFileOutput === true) { + // FIXME: Find a good way to do multiple top-levels. Maybe multiple files? + const rootTitle = this.topLevelTitle(); + let rootTitleUsed = false; + const useAsRoot = (title: string): boolean => { + const isRoot = title === rootTitle; + if (isRoot) rootTitleUsed = true; + return isRoot; + }; + + this.forEachObject("none", (o: ObjectType, name: Name) => { + const title = defined(this.names.get(name)); + this.outputDefinitionFile(title, useAsRoot(title), () => ({ + [title]: this.definitionForObject(o, title), + })); + }); + this.forEachUnion("none", (u, name) => { + if (!this.unionNeedsName(u)) return; + const title = defined(this.names.get(name)); + this.outputDefinitionFile(title, useAsRoot(title), () => ({ + [title]: this.definitionForUnion(u, title), + })); + }); + this.forEachEnum("none", (e, name) => { + const title = defined(this.names.get(name)); + this.outputDefinitionFile(title, useAsRoot(title), () => ({ + [title]: this.definitionForEnum(e, title), + })); + }); + + // The top-level type may not be an object/union/enum with its + // own definition (e.g. a bare array or map), in which case none + // of the files above is "the root". Give it a dedicated file so + // the document root type is never dropped in multi-file mode. + if (rootTitle !== undefined && !rootTitleUsed) { + this.outputDefinitionFile(rootTitle, true, () => ({})); + } + + return; + } + const definitions: { [name: string]: Schema } = {}; this.forEachObject("none", (o: ObjectType, name: Name) => { const title = defined(this.names.get(name)); @@ -200,8 +276,72 @@ export class JSONSchemaRenderer extends ConvenienceRenderer { const title = defined(this.names.get(name)); definitions[title] = this.definitionForEnum(e, title); }); + this.emitMultiline( + JSON.stringify( + this.makeSchema(true, definitions), + undefined, + " ", + ), + ); + } + + // Builds the schema document for one output file. `includeRootType` + // controls whether the document's root also describes the overall + // top-level type: it's true for the single file in single-file mode, + // and for whichever per-definition file corresponds to the top-level + // type in multi-file mode. Every other per-definition file just holds + // its own definition. + private makeSchema( + includeRootType: boolean, + definitions: { [name: string]: Schema }, + ): Schema { + const schema: Schema = { + $schema: "http://json-schema.org/draft-06/schema#", + }; + if (includeRootType) { + Object.assign( + schema, + this.topLevels.size === 1 + ? this.schemaForType(defined(mapFirst(this.topLevels))) + : {}, + ); + } + schema.definitions = definitions; + return schema; + } + + private outputDefinitionFile( + title: string, + includeRootType: boolean, + makeDefinitions: () => { [name: string]: Schema }, + ): void { + this.startFile(title); + this._currentTitle = title; + this.emitMultiline( + JSON.stringify( + this.makeSchema(includeRootType, makeDefinitions()), + undefined, + " ", + ), + ); + this._currentTitle = undefined; + this.endFile(); + } + + /// startFile takes a file name, appends ".schema" to it, and sets it as the current filename. + protected startFile(basename: Sourcelike): void { + assert( + this._currentFilename === undefined, + `Previous file wasn't finished: ${this._currentFilename}`, + ); + this._currentFilename = `${this.sourcelikeToString(basename)}.schema`; + this.initializeEmitContextForFilename(this._currentFilename); + } - this.emitMultiline(JSON.stringify(schema, undefined, " ")); + /// endFile pushes the current file name onto the collection of finished files and then resets the current file name. These finished files are used in index.ts to write the output. + protected endFile(): void { + this.finishFile(defined(this._currentFilename)); + this._currentFilename = undefined; } } diff --git a/packages/quicktype-core/src/language/JSONSchema/language.ts b/packages/quicktype-core/src/language/JSONSchema/language.ts index f0488ae8fc..9d9083e37d 100644 --- a/packages/quicktype-core/src/language/JSONSchema/language.ts +++ b/packages/quicktype-core/src/language/JSONSchema/language.ts @@ -1,4 +1,5 @@ import type { RenderContext } from "../../Renderer.js"; +import { BooleanOption, getOptionValues } from "../../RendererOptions/index.js"; import type { IntegerRange } from "../../support/IntegerRange.js"; import { TargetLanguage } from "../../TargetLanguage.js"; import { @@ -9,6 +10,14 @@ import type { LanguageName, RendererOptions } from "../../types.js"; import { JSONSchemaRenderer } from "./JSONSchemaRenderer.js"; +export const jsonSchemaOptions = { + multiFileOutput: new BooleanOption( + "multi-file-output", + "Renders each definition in its own JSON schema file", + false, + ), +}; + export const JSONSchemaLanguageConfig = { displayName: "JSON Schema", names: ["schema", "json-schema"], @@ -27,8 +36,8 @@ export class JSONSchemaTargetLanguage extends TargetLanguage< super(JSONSchemaLanguageConfig); } - public getOptions(): Record { - return {}; + public getOptions(): typeof jsonSchemaOptions { + return jsonSchemaOptions; } public get stringTypeMapping(): StringTypeMapping { @@ -45,8 +54,12 @@ export class JSONSchemaTargetLanguage extends TargetLanguage< protected makeRenderer( renderContext: RenderContext, - _untypedOptionValues: RendererOptions, + untypedOptionValues: RendererOptions, ): JSONSchemaRenderer { - return new JSONSchemaRenderer(this, renderContext); + return new JSONSchemaRenderer( + this, + renderContext, + getOptionValues(jsonSchemaOptions, untypedOptionValues), + ); } } diff --git a/test/fixtures.ts b/test/fixtures.ts index 84616537e7..bdfcf7151d 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -637,6 +637,44 @@ class JSONToXToYFixture extends JSONFixture { const dateTimeRecognizer = new DefaultDateTimeRecognizer(); +// Builds a fresh Ajv instance configured for the draft-06 schemas quicktype +// generates: the draft-06 meta-schema (Ajv 8 no longer ships it), the +// ajv-formats package (Ajv 8 moved format validators out of core), our +// custom schema keywords (which strict mode would otherwise reject), and +// the non-standard `date-time`/`integer`/`boolean` formats used for +// transformed type kinds. +// FIXME: Unify the date-time format with what's in StringTypes.ts. +function makeJsonSchemaAjv(): typeof Ajv { + const ajv = new Ajv(); + ajv.addMetaSchema(draft06MetaSchema); + addFormats(ajv); + ajv.addVocabulary(["qt-uri-protocols", "qt-uri-extensions"]); + ajv.addFormat("date-time", (s: string) => dateTimeRecognizer.isDateTime(s)); + ajv.addFormat("integer", true); + ajv.addFormat("boolean", true); + return ajv; +} + +// Reads every file in `dir`, parsing each as JSON. Fails the test if any +// file isn't valid JSON, which is the first thing multi-file JSON Schema +// output must get right: every emitted file has to parse on its own. +function readJsonFilesInDir(dir: string): Map { + const files = new Map(); + for (const filename of fs.readdirSync(dir)) { + const content = fs.readFileSync(path.join(dir, filename), "utf8"); + try { + files.set(filename, JSON.parse(content)); + } catch (error) { + failWith("Multi-file JSON Schema output is not valid JSON", { + filename, + error, + }); + } + } + + return files; +} + // This tests generating Schema from JSON, and then generating // target code from that Schema. The target code is then run on // the original JSON. Also generating a Schema from the Schema @@ -662,26 +700,7 @@ class JSONSchemaJSONFixture extends JSONToXToYFixture { fs.readFileSync(this.language.output, "utf8"), ); - const ajv = new Ajv(); - // We generate draft-06 schemas, which Ajv 8 doesn't support out of - // the box anymore. - ajv.addMetaSchema(draft06MetaSchema); - // Ajv 8 moved the format validators into the ajv-formats package; - // its default mode is "full", like the old `format: "full"` option. - addFormats(ajv); - // Our custom schema keywords, which strict mode would reject. - ajv.addVocabulary(["qt-uri-protocols", "qt-uri-extensions"]); - // Make Ajv's date-time compatible with what we recognize. All non-standard - // JSON formats that we use for transformed type kinds must be registered here - // with a validation function. Formats registered with `true` are - // accepted without validating the string. This replaces the old - // `unknownFormats: ["integer", "boolean"]` option. - // FIXME: Unify this with what's in StringTypes.ts. - ajv.addFormat("date-time", (s: string) => - dateTimeRecognizer.isDateTime(s), - ); - ajv.addFormat("integer", true); - ajv.addFormat("boolean", true); + const ajv = makeJsonSchemaAjv(); const valid = ajv.validate(schema, input); if (!valid) { failWith("Generated schema does not validate input JSON.", { @@ -708,6 +727,51 @@ class JSONSchemaJSONFixture extends JSONToXToYFixture { strict: true, }); + // Also generate JSON Schema's multi-file output for the same input, + // and check that it's not just internally well-formed but + // equivalent to the single-file schema above: every emitted file + // must parse as JSON, every same-file and cross-file `$ref` must + // resolve, and exactly one file must carry the document root type + // (per-definition files must not restate it). + const multiDir = "multi-schema"; + mkdirs(multiDir); + await quicktype({ + src: [filename], + srcLang: "json", + lang: this.language.name, + topLevel: this.language.topLevel, + alphabetizeProperties: true, + out: path.join(multiDir, this.language.output), + rendererOptions: { "multi-file-output": true }, + }); + + const multiFiles = readJsonFilesInDir(multiDir); + const rootFiles = Array.from(multiFiles.entries()).filter(([, doc]) => + Object.keys(doc as Record).some( + (key) => key !== "$schema" && key !== "definitions", + ), + ); + if (rootFiles.length !== 1) { + failWith( + "Expected exactly one multi-file JSON Schema output file to carry the document root type", + { filename, rootFiles: rootFiles.map(([f]) => f) }, + ); + } + + const [rootFilename] = rootFiles[0]; + const multiAjv = makeJsonSchemaAjv(); + for (const [multiFilename, doc] of multiFiles) { + multiAjv.addSchema(doc, multiFilename); + } + + const multiValid = multiAjv.validate(rootFilename, input); + if (!multiValid) { + failWith( + "Multi-file JSON Schema output does not validate input JSON (dangling $ref or wrong root type)", + { filename, errors: multiAjv.errors }, + ); + } + return 1; } }