Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 41 additions & 9 deletions packages/quicktype-core/src/input/JSONSchemaInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1034,17 +1034,48 @@ async function addTypesInSchema(
async function makeArrayType(): Promise<TypeRef> {
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),
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions test/inputs/schema/prefix-items.1.fail.union.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"tuple": [123, "not-a-tuple-member"],
"open": [false, 11, 13]
}
4 changes: 4 additions & 0 deletions test/inputs/schema/prefix-items.1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"tuple": [123, true],
"open": [false, 11, 13]
}
16 changes: 16 additions & 0 deletions test/inputs/schema/prefix-items.schema
Original file line number Diff line number Diff line change
@@ -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"]
}
3 changes: 3 additions & 0 deletions test/languages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
89 changes: 89 additions & 0 deletions test/unit/prefix-items-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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");
});
});
Loading