diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index 893893d0ad..d3a0e4feef 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -1034,17 +1034,48 @@ async function addTypesInSchema( async function makeArrayType(): Promise { const singularAttributes = singularizeTypeNames(typeAttributes); const items = schema.items; + // JSON Schema 2020-12 renamed the array (tuple) form of `items` to + // `prefixItems`; treat it the same as a draft-07 array-valued + // `items` so tuple schemas produced by e.g. Pydantic v2 and + // schemars are not silently dropped. + const prefixItems = schema.prefixItems; + const tupleItems = Array.isArray(prefixItems) ? prefixItems : items; + const tupleKey = Array.isArray(prefixItems) + ? "prefixItems" + : "items"; let itemType: TypeRef; - if (Array.isArray(items)) { - const itemsLoc = loc.push("items"); - const itemTypes = await arrayMapSync(items, async (item, i) => { - const itemLoc = itemsLoc.push(i.toString()); - return await toType( - checkJSONSchema(item, itemLoc.canonicalRef), - itemLoc, - singularAttributes, + if (Array.isArray(tupleItems)) { + const itemsLoc = loc.push(tupleKey); + const itemTypes = await arrayMapSync( + tupleItems, + async (item, i) => { + const itemLoc = itemsLoc.push(i.toString()); + return await toType( + checkJSONSchema(item, itemLoc.canonicalRef), + itemLoc, + singularAttributes, + ); + }, + ); + // In 2020-12 an object-form `items` next to `prefixItems` + // describes the rest elements of an open tuple. quicktype + // models tuples as arrays of a union of the member types, so + // the rest type joins that union. A boolean `items` (`false` + // closes the tuple, `true` allows anything) is ignored. + if ( + tupleKey === "prefixItems" && + typeof items === "object" && + !Array.isArray(items) + ) { + const restItemsLoc = loc.push("items"); + itemTypes.push( + await toType( + checkJSONSchema(items, restItemsLoc.canonicalRef), + restItemsLoc, + singularAttributes, + ), ); - }); + } itemType = typeBuilder.getUnionType( emptyTypeAttributes, new Set(itemTypes), @@ -1229,6 +1260,7 @@ async function addTypesInSchema( schema.properties !== undefined || schema.additionalProperties !== undefined || schema.items !== undefined || + schema.prefixItems !== undefined || schema.required !== undefined || enumArray !== undefined || isConst; diff --git a/test/inputs/schema/prefix-items.1.fail.union.json b/test/inputs/schema/prefix-items.1.fail.union.json new file mode 100644 index 0000000000..b0603b2e33 --- /dev/null +++ b/test/inputs/schema/prefix-items.1.fail.union.json @@ -0,0 +1,4 @@ +{ + "tuple": [123, "not-a-tuple-member"], + "open": [false, 11, 13] +} diff --git a/test/inputs/schema/prefix-items.1.json b/test/inputs/schema/prefix-items.1.json new file mode 100644 index 0000000000..4dcd7ebe58 --- /dev/null +++ b/test/inputs/schema/prefix-items.1.json @@ -0,0 +1,4 @@ +{ + "tuple": [123, true], + "open": [false, 11, 13] +} diff --git a/test/inputs/schema/prefix-items.schema b/test/inputs/schema/prefix-items.schema new file mode 100644 index 0000000000..6c6d5e4f19 --- /dev/null +++ b/test/inputs/schema/prefix-items.schema @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "tuple": { + "type": "array", + "prefixItems": [{ "type": "integer" }, { "type": "boolean" }] + }, + "open": { + "type": "array", + "prefixItems": [{ "type": "boolean" }], + "items": { "type": "integer" } + } + }, + "required": ["tuple", "open"] +} diff --git a/test/languages.ts b/test/languages.ts index e268fae95f..9ed081dc8b 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -547,6 +547,7 @@ export const CJSONLanguage: Language = { "go-schema-pattern-properties.schema", "multi-type-enum.schema", "nested-intersection-union.schema", + "prefix-items.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", "optional-const-ref.schema", @@ -1021,6 +1022,7 @@ I havea no idea how to encode these tests correctly. // The test driver prints the circe DecodingFailure and exits 0, so // expected-failure samples cannot be detected. "nested-intersection-union.schema", + "prefix-items.schema", "date-time-or-string.schema", "implicit-one-of.schema", "go-schema-pattern-properties.schema", @@ -1466,6 +1468,7 @@ export const HaskellLanguage: Language = { // The test driver encodes the Maybe result, so a failed decode prints // "null" and exits 0 — expected-failure samples cannot be detected. "nested-intersection-union.schema", + "prefix-items.schema", "direct-union.schema", ...skipsEnumValueValidation, "go-schema-pattern-properties.schema", diff --git a/test/unit/prefix-items-schema.test.ts b/test/unit/prefix-items-schema.test.ts new file mode 100644 index 0000000000..307c47b507 --- /dev/null +++ b/test/unit/prefix-items-schema.test.ts @@ -0,0 +1,89 @@ +// Regression test for issue #2811: JSON Schema 2020-12 tuple schemas that use +// `prefixItems` must be handled the same as draft-07's array-valued `items`. +// +// End-to-end coverage lives in the fixture test +// `test/inputs/schema/prefix-items.schema` (with a `.fail.union.json` sample +// that catches an `any[]` degradation). What fixtures cannot express is that +// `$ref`d tuple member types survive into the generated code, so we assert +// that directly here. + +import { + FetchingJSONSchemaStore, + InputData, + JSONSchemaInput, + quicktype, +} from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +// The `$ref`'d member schemas carry distinctively named properties so we can +// assert they are present in the output rather than collapsed to `any[]`. +const defs = { + Bar: { + type: "object", + properties: { barField: { type: "integer" } }, + required: ["barField"], + }, + Baz: { + type: "object", + properties: { bazField: { type: "string" } }, + required: ["bazField"], + }, +}; + +async function generateTypeScript(schema: object): Promise { + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + await schemaInput.addSource({ + name: "Foo", + schema: JSON.stringify(schema), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "typescript" }); + return result.lines.join("\n"); +} + +describe("JSON Schema prefixItems tuples (issue #2811)", () => { + test("a 2020-12 prefixItems tuple keeps its member types", async () => { + const output = await generateTypeScript({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "array", + prefixItems: [{ $ref: "#/$defs/Bar" }, { $ref: "#/$defs/Baz" }], + $defs: defs, + }); + + // Before the fix this generated a bare `any[]` with no member types. + expect(output).toContain("barField"); + expect(output).toContain("bazField"); + }); + + test("an object-form `items` next to `prefixItems` joins the union", async () => { + const output = await generateTypeScript({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "array", + prefixItems: [{ $ref: "#/$defs/Bar" }], + items: { $ref: "#/$defs/Baz" }, + $defs: defs, + }); + + // The rest (`items`) type must not be dropped: both the prefix + // member and the rest member appear in the element union. + expect(output).toContain("barField"); + expect(output).toContain("bazField"); + }); + + test("draft-07 array-valued items still works (no regression)", async () => { + const output = await generateTypeScript({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "array", + items: [ + { $ref: "#/definitions/Bar" }, + { $ref: "#/definitions/Baz" }, + ], + definitions: defs, + }); + + expect(output).toContain("barField"); + expect(output).toContain("bazField"); + }); +});