From 31c6d5223a6a1dc205d48519fa71c50a00f729f8 Mon Sep 17 00:00:00 2001 From: Kim T Date: Sun, 3 Mar 2024 22:35:20 -0800 Subject: [PATCH 1/2] Enable JSON Schema multi file output --- .../quicktype-core/src/language/JSONSchema.ts | 81 ++++++++++++++++--- 1 file changed, 69 insertions(+), 12 deletions(-) diff --git a/packages/quicktype-core/src/language/JSONSchema.ts b/packages/quicktype-core/src/language/JSONSchema.ts index 2b8a447ba8..efed62ef4a 100644 --- a/packages/quicktype-core/src/language/JSONSchema.ts +++ b/packages/quicktype-core/src/language/JSONSchema.ts @@ -12,11 +12,20 @@ import { firstUpperWordStyle, allUpperWordStyle } from "../support/Strings"; -import { defined, panic } from "../support/Support"; +import { assert, defined, panic } from "../support/Support"; import { StringTypeMapping, getNoStringTypeMapping } from "../TypeBuilder"; import { addDescriptionToSchema } from "../attributes/Description"; -import { Option } from "../RendererOptions"; +import { BooleanOption, Option, OptionValues, getOptionValues } from "../RendererOptions"; import { RenderContext } from "../Renderer"; +import { Sourcelike } from "Source"; + +export const jsonSchemaOptions = { + multiFileOutput: new BooleanOption( + "multi-file-output", + "Renders each top-level object in its own JSON schema file", + false + ) +}; export class JSONSchemaTargetLanguage extends TargetLanguage { constructor() { @@ -24,7 +33,7 @@ export class JSONSchemaTargetLanguage extends TargetLanguage { } protected getOptions(): Option[] { - return []; + return [jsonSchemaOptions.multiFileOutput]; } get stringTypeMapping(): StringTypeMapping { @@ -41,9 +50,9 @@ export class JSONSchemaTargetLanguage extends TargetLanguage { protected makeRenderer( renderContext: RenderContext, - _untypedOptionValues: { [name: string]: any } + untypedOptionValues: { [name: string]: any } ): JSONSchemaRenderer { - return new JSONSchemaRenderer(this, renderContext); + return new JSONSchemaRenderer(this, renderContext, getOptionValues(jsonSchemaOptions, untypedOptionValues)); } } @@ -68,6 +77,16 @@ function jsonNameStyle(original: string): string { type Schema = { [name: string]: any }; export class JSONSchemaRenderer extends ConvenienceRenderer { + private _currentFilename: string | undefined; + + constructor( + targetLanguage: TargetLanguage, + renderContext: RenderContext, + private readonly _options: OptionValues + ) { + super(targetLanguage, renderContext); + } + protected makeNamedTypeNamer(): Namer { return namingFunction; } @@ -199,25 +218,63 @@ 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 = Object.assign({ $schema: "http://json-schema.org/draft-06/schema#" }, topLevelType); const definitions: { [name: string]: Schema } = {}; this.forEachObject("none", (o: ObjectType, name: Name) => { const title = defined(this.names.get(name)); - definitions[title] = this.definitionForObject(o, title); + if (this._options.multiFileOutput === true) + this.outputFile(title, { + [title]: this.definitionForObject(o, title) + }); + else definitions[title] = this.definitionForObject(o, title); }); this.forEachUnion("none", (u, name) => { if (!this.unionNeedsName(u)) return; const title = defined(this.names.get(name)); - definitions[title] = this.definitionForUnion(u, title); + if (this._options.multiFileOutput === true) + this.outputFile(title, { + [title]: this.definitionForUnion(u, title) + }); + else definitions[title] = this.definitionForUnion(u, title); }); this.forEachEnum("none", (e, name) => { const title = defined(this.names.get(name)); - definitions[title] = this.definitionForEnum(e, title); + if (this._options.multiFileOutput === true) + this.outputFile(title, { + [title]: this.definitionForEnum(e, title) + }); + else definitions[title] = this.definitionForEnum(e, title); }); - schema.definitions = definitions; + if (this._options.multiFileOutput === false) this.outputFile("stdout", definitions); + } + outputFile(title: string, definitions: { [name: string]: Schema }) { + // 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 = Object.assign({ $schema: "http://json-schema.org/draft-06/schema#" }, topLevelType); + schema.definitions = definitions; + this.startFile(title); this.emitMultiline(JSON.stringify(schema, undefined, " ")); + this.endFile(); + } + + /// startFile takes a file name, lowercases it, appends ".schema" to it, and sets it as the current filename. + protected startFile(basename: Sourcelike): void { + if (this._options.multiFileOutput === false) { + return; + } + + assert(this._currentFilename === undefined, "Previous file wasn't finished: " + this._currentFilename); + this._currentFilename = `${this.sourcelikeToString(basename)}.schema`; + this.initializeEmitContextForFilename(this._currentFilename); + } + + /// 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 { + if (this._options.multiFileOutput === false) { + return; + } + + this.finishFile(defined(this._currentFilename)); + this._currentFilename = undefined; } } From 8c8c9f7398ac4137cdd30b7d04b687300065584c Mon Sep 17 00:00:00 2001 From: Kim T Date: Sat, 15 Aug 2026 15:03:47 -0700 Subject: [PATCH 2/2] Fix multi-file JSON Schema output: cross-file $refs and root-type placement Addresses PR review feedback: - makeRef now emits file-qualified refs (Other.schema#/definitions/Other) for cross-file references in multi-file mode, instead of always emitting a same-document pointer that dangled once each definition moved to its own file. - Only the file that corresponds to the document's top-level type now carries the root $schema/type; other per-definition files no longer restate it. A dedicated root file is emitted when the top-level type isn't itself a named definition (e.g. a bare array or map), so the root type is never dropped. - Adds fixture coverage (test/fixtures.ts) that generates multi-file output alongside the existing schema-json fixture and verifies every emitted file parses as JSON, every $ref resolves, and the reassembled schema still validates the original input via Ajv. - Drops the now-dead multiFileOutput guards in startFile/endFile and fixes the startFile doc comment, which incorrectly claimed filenames are lowercased. --- .../language/JSONSchema/JSONSchemaRenderer.ts | 161 +++++++++++++----- .../src/language/JSONSchema/language.ts | 2 +- test/fixtures.ts | 104 ++++++++--- 3 files changed, 202 insertions(+), 65 deletions(-) diff --git a/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts b/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts index a19a885bd0..eea2570317 100644 --- a/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts +++ b/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts @@ -28,6 +28,12 @@ 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, @@ -72,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 { @@ -192,68 +221,116 @@ export class JSONSchemaRenderer extends ConvenienceRenderer { } protected emitSourceStructure(): void { + 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)); - if (this._options.multiFileOutput === true) { - this.outputFile(title, { - [title]: this.definitionForObject(o, title), - }); - } else { - definitions[title] = this.definitionForObject(o, title); - } + definitions[title] = this.definitionForObject(o, title); }); this.forEachUnion("none", (u, name) => { if (!this.unionNeedsName(u)) return; const title = defined(this.names.get(name)); - if (this._options.multiFileOutput === true) { - this.outputFile(title, { - [title]: this.definitionForUnion(u, title), - }); - } else { - definitions[title] = this.definitionForUnion(u, title); - } + definitions[title] = this.definitionForUnion(u, title); }); this.forEachEnum("none", (e, name) => { const title = defined(this.names.get(name)); - if (this._options.multiFileOutput === true) { - this.outputFile(title, { - [title]: this.definitionForEnum(e, title), - }); - } else { - definitions[title] = this.definitionForEnum(e, title); - } + definitions[title] = this.definitionForEnum(e, title); }); - if (this._options.multiFileOutput === false) { - this.outputFile("stdout", definitions); - } + this.emitMultiline( + JSON.stringify( + this.makeSchema(true, definitions), + undefined, + " ", + ), + ); } - private outputFile( - title: string, + // 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 }, - ): 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))) - : {}; + ): Schema { const schema: Schema = { $schema: "http://json-schema.org/draft-06/schema#", - ...topLevelType, }; + 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.emitMultiline(JSON.stringify(schema, undefined, " ")); + this._currentTitle = title; + this.emitMultiline( + JSON.stringify( + this.makeSchema(includeRootType, makeDefinitions()), + undefined, + " ", + ), + ); + this._currentTitle = undefined; this.endFile(); } - /// startFile takes a file name, lowercases it, appends ".schema" to it, and sets it as the current filename. + /// startFile takes a file name, appends ".schema" to it, and sets it as the current filename. protected startFile(basename: Sourcelike): void { - if (this._options.multiFileOutput === false) { - return; - } - assert( this._currentFilename === undefined, `Previous file wasn't finished: ${this._currentFilename}`, @@ -264,10 +341,6 @@ export class JSONSchemaRenderer extends ConvenienceRenderer { /// 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 { - if (this._options.multiFileOutput === false) { - return; - } - 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 7fcb3ed963..9d9083e37d 100644 --- a/packages/quicktype-core/src/language/JSONSchema/language.ts +++ b/packages/quicktype-core/src/language/JSONSchema/language.ts @@ -13,7 +13,7 @@ import { JSONSchemaRenderer } from "./JSONSchemaRenderer.js"; export const jsonSchemaOptions = { multiFileOutput: new BooleanOption( "multi-file-output", - "Renders each top-level object in its own JSON schema file", + "Renders each definition in its own JSON schema file", false, ), }; 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; } }