diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index 66b1c4104..98c4aa423 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -197,9 +197,11 @@ export class Ref { public addressURI: URI | undefined; + public readonly path: readonly PathElement[]; + public constructor( addressURI: URI | undefined, - public readonly path: readonly PathElement[], + path: readonly PathElement[], ) { if (addressURI !== undefined) { assert( @@ -210,6 +212,13 @@ export class Ref { } else { this.addressURI = undefined; } + + // An addressed document root must match an explicit `#/` reference so + // paths appended to either form remain canonical and comparable. + this.path = + addressURI !== undefined && path.length === 0 + ? [{ kind: PathElementKind.Root }] + : path; } public get hasAddress(): boolean { diff --git a/test/inputs/schema/recursive-ref-to-id.1.fail.union.json b/test/inputs/schema/recursive-ref-to-id.1.fail.union.json new file mode 100644 index 000000000..e46630c2b --- /dev/null +++ b/test/inputs/schema/recursive-ref-to-id.1.fail.union.json @@ -0,0 +1,4 @@ +{ + "data": { "id": "root" }, + "children": "not-an-array" +} diff --git a/test/inputs/schema/recursive-ref-to-id.1.json b/test/inputs/schema/recursive-ref-to-id.1.json new file mode 100644 index 000000000..46fb21baa --- /dev/null +++ b/test/inputs/schema/recursive-ref-to-id.1.json @@ -0,0 +1,11 @@ +{ + "data": { "id": "root" }, + "children": [ + { + "data": { "id": "child" }, + "children": [ + { "data": { "id": "grandchild" } } + ] + } + ] +} diff --git a/test/inputs/schema/recursive-ref-to-id.schema b/test/inputs/schema/recursive-ref-to-id.schema new file mode 100644 index 000000000..07d8bb017 --- /dev/null +++ b/test/inputs/schema/recursive-ref-to-id.schema @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "T2", + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + }, + "children": { + "type": "array", + "items": { "$ref": "T2" } + } + } +} diff --git a/test/languages.ts b/test/languages.ts index 120f3170b..1f18f2b7e 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -668,6 +668,7 @@ export const CJSONLanguage: Language = { "multi-type-enum.schema", "nested-intersection-union.schema", "prefix-items.schema", + "recursive-ref-to-id.schema", /* Constraints (min/max and regex) are not supported (for the current implementation, can be added later, should abord parsing and return NULL) */ "minmaxlength.schema", "schema-constraints.schema", @@ -923,6 +924,7 @@ export const ElmLanguage: Language = { "integer-before-number.schema", // Python-specific union-order regression. "union-list.schema", // recursion "list.schema", // recursion + "recursive-ref-to-id.schema", // recursion "ref-remote.schema", // recursion "mutually-recursive.schema", // recursion "postman-collection.schema", // recursion @@ -1759,6 +1761,7 @@ export const DartLanguage: Language = { "mutually-recursive.schema", "postman-collection.schema", "list.schema", + "recursive-ref-to-id.schema", "simple-ref.schema", "keyword-unions.schema", "ref-remote.schema", @@ -1911,6 +1914,7 @@ export const HaskellLanguage: Language = { "nested-intersection-union.schema", "prefix-items.schema", "direct-union.schema", + "recursive-ref-to-id.schema", ...skipsEnumValueValidation, ...skipsMapValueValidation, "intersection.schema", diff --git a/test/unit/recursive-ref-to-id.test.ts b/test/unit/recursive-ref-to-id.test.ts new file mode 100644 index 000000000..a57762480 --- /dev/null +++ b/test/unit/recursive-ref-to-id.test.ts @@ -0,0 +1,42 @@ +import * as fs from "node:fs"; + +import { + FetchingJSONSchemaStore, + InputData, + JSONSchemaInput, + quicktype, +} from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +const schema = fs.readFileSync( + "test/inputs/schema/recursive-ref-to-id.schema", + "utf8", +); + +async function generateTypeScript(schemaText = schema): Promise { + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + await schemaInput.addSource({ name: "Tree", schema: schemaText }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "typescript" }); + return result.lines.join("\n"); +} + +describe("recursive JSON Schema refs to $id (issue #2836)", () => { + test("reuses the top-level type for a self-reference", async () => { + const output = await generateTypeScript(); + + expect(output.match(/^export interface /gm)).toHaveLength(2); + expect(output).toMatch(/children\?:\s+Tree\[\];/); + }); + + test("reuses the top-level type for an empty-fragment self-reference", async () => { + const output = await generateTypeScript( + schema.replace('"$ref": "T2"', '"$ref": "#"'), + ); + + expect(output.match(/^export interface /gm)).toHaveLength(2); + expect(output).toMatch(/children\?:\s+Tree\[\];/); + }); +}); diff --git a/test/unit/rust-cycle-breaker-boxing.test.ts b/test/unit/rust-cycle-breaker-boxing.test.ts index c738c2769..7029e12c2 100644 --- a/test/unit/rust-cycle-breaker-boxing.test.ts +++ b/test/unit/rust-cycle-breaker-boxing.test.ts @@ -36,13 +36,15 @@ describe("Rust cycle-breaker boxing", () => { anyOf: [...recursiveUnionMembers, { type: "null" }], }); - expect(output).toContain("pub next: Option>,"); - expect(output).not.toContain("Box>>"); + expect(output).toContain("Node(Box),"); + expect(output).toContain("pub next: Option,"); + expect(output).not.toContain("Box>"); }); test("keeps a non-nullable cycle-breaking union boxed", async () => { const output = await renderRust({ anyOf: recursiveUnionMembers }, true); - expect(output).toContain("pub next: Box,"); + expect(output).toContain("Node(Box),"); + expect(output).toContain("pub next: Next,"); }); }); diff --git a/test/unit/schema-properties-as-types.test.ts b/test/unit/schema-properties-as-types.test.ts new file mode 100644 index 000000000..98a39b283 --- /dev/null +++ b/test/unit/schema-properties-as-types.test.ts @@ -0,0 +1,39 @@ +import { + FetchingJSONSchemaStore, + InputData, + JSONSchemaInput, + quicktype, +} from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +async function generateTypeScript(name: string, uri: string): Promise { + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + await schemaInput.addSource({ name, uris: [uri] }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "typescript" }); + return result.lines.join("\n"); +} + +describe("schema URI properties-as-types mode", () => { + test("a trailing slash at the document root keeps the document as one type", async () => { + const output = await generateTypeScript( + "Test", + "test/inputs/schema/a/test2.json#/", + ); + + expect(output).toContain("export interface Test"); + expect(output).toMatch(/foo:\s+number/); + }); + + test("a trailing slash below the root makes properties separate types", async () => { + const output = await generateTypeScript( + "Definitions", + "test/inputs/schema/b/test3.json#/definitions/", + ); + + expect(output).toContain("export interface Foo"); + expect(output).toMatch(/foo:\s+number/); + }); +});