Skip to content
Open
164 changes: 152 additions & 12 deletions packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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<typeof jsonSchemaOptions>,
) {
super(targetLanguage, renderContext);
}

protected makeNamedTypeNamer(): Namer {
return namingFunction;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
Expand All @@ -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;
}
}
21 changes: 17 additions & 4 deletions packages/quicktype-core/src/language/JSONSchema/language.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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"],
Expand All @@ -27,8 +36,8 @@ export class JSONSchemaTargetLanguage extends TargetLanguage<
super(JSONSchemaLanguageConfig);
}

public getOptions(): Record<string, never> {
return {};
public getOptions(): typeof jsonSchemaOptions {
return jsonSchemaOptions;
}

public get stringTypeMapping(): StringTypeMapping {
Expand All @@ -45,8 +54,12 @@ export class JSONSchemaTargetLanguage extends TargetLanguage<

protected makeRenderer<Lang extends LanguageName = "json-schema">(
renderContext: RenderContext,
_untypedOptionValues: RendererOptions<Lang>,
untypedOptionValues: RendererOptions<Lang>,
): JSONSchemaRenderer {
return new JSONSchemaRenderer(this, renderContext);
return new JSONSchemaRenderer(
this,
renderContext,
getOptionValues(jsonSchemaOptions, untypedOptionValues),
);
}
}
104 changes: 84 additions & 20 deletions test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
const files = new Map<string, unknown>();
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
Expand All @@ -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.", {
Expand All @@ -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<string, unknown>).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;
}
}
Expand Down