diff --git a/.changeset/clean-phones-deliver.md b/.changeset/clean-phones-deliver.md new file mode 100644 index 000000000..953a0d10e --- /dev/null +++ b/.changeset/clean-phones-deliver.md @@ -0,0 +1,11 @@ +--- +"openapi-typescript": major +--- + +Extract types generation for Array-type schemas to `transformArraySchemaObject` method. +Throw error when OpenAPI `items` is array. +Generate correct number of union members for `minItems` * `maxItems` unions. +Generate readonly tuple members for `minItems` & `maxItems` unions. +Generate readonly spread member for `prefixItems` tuple. +Preserve `prefixItems` type members in `minItems` & `maxItems` tuples. +Generate spread member for `prefixItems` tuple with no `minItems` / `maxItems` constraints. diff --git a/packages/openapi-typescript/examples/simple-example.ts b/packages/openapi-typescript/examples/simple-example.ts index 0c2580003..82a2427ea 100644 --- a/packages/openapi-typescript/examples/simple-example.ts +++ b/packages/openapi-typescript/examples/simple-example.ts @@ -245,7 +245,8 @@ export interface operations { }; content: { "application/json": [ - string + string, + ...unknown[] ]; }; }; diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 80ec56b51..db75306fb 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -10,6 +10,7 @@ import { UNDEFINED, UNKNOWN, addJSDocComment, + astToString, oapiRef, tsArrayLiteralExpression, tsEnum, @@ -25,7 +26,7 @@ import { tsWithRequired, } from "../lib/ts.js"; import { createDiscriminatorProperty, createRef, getEntries } from "../lib/utils.js"; -import type { ReferenceObject, SchemaObject, TransformNodeOptions } from "../types.js"; +import type { ArraySubtype, ReferenceObject, SchemaObject, TransformNodeOptions } from "../types.js"; /** * Transform SchemaObject nodes (4.8.24) @@ -277,6 +278,74 @@ export function transformSchemaObjectWithComposition( } } +type ArraySchemaObject = SchemaObject & ArraySubtype; +function isArraySchemaObject(schemaObject: SchemaObject | ArraySchemaObject): schemaObject is ArraySchemaObject { + return schemaObject.type === "array"; +} + +function padTupleMembers(length: number, itemType: ts.TypeNode, prefixTypes: readonly ts.TypeNode[]) { + return Array.from({ length }).map((_, index) => { + return prefixTypes[index] ?? itemType; + }); +} + +function toOptionsReadonly( + members: TMembers, + options: TransformNodeOptions, +): TMembers | ts.TypeOperatorNode { + return options.ctx.immutable ? ts.factory.createTypeOperatorNode(ts.SyntaxKind.ReadonlyKeyword, members) : members; +} + +/* Transform Array schema object */ +function transformArraySchemaObject(schemaObject: ArraySchemaObject, options: TransformNodeOptions): ts.TypeNode { + const prefixTypes = (schemaObject.prefixItems ?? []).map((item) => transformSchemaObject(item, options)); + + if (Array.isArray(schemaObject.items)) { + throw new Error(`${options.path}: invalid property items. Expected Schema Object, got Array`); + } + + const itemType = schemaObject.items ? transformSchemaObject(schemaObject.items, options) : UNKNOWN; + + // The minimum number of tuple members to return + const min: number = + options.ctx.arrayLength && typeof schemaObject.minItems === "number" && schemaObject.minItems >= 0 + ? schemaObject.minItems + : 0; + const max: number | undefined = + options.ctx.arrayLength && + typeof schemaObject.maxItems === "number" && + schemaObject.maxItems >= 0 && + min <= schemaObject.maxItems + ? schemaObject.maxItems + : undefined; + + // "30" is an arbitrary number but roughly around when TS starts to struggle with tuple inference in practice + const MAX_CODE_SIZE = 30; + const estimateCodeSize = max === undefined ? min : (max * (max + 1) - min * (min - 1)) / 2; + const shouldGeneratePermutations = (min !== 0 || max !== undefined) && estimateCodeSize < MAX_CODE_SIZE; + + // if maxItems is set, then return a union of all permutations of possible tuple types + if (shouldGeneratePermutations && max !== undefined) { + return tsUnion( + Array.from({ length: max - min + 1 }).map((_, index) => + toOptionsReadonly(ts.factory.createTupleTypeNode(padTupleMembers(index + min, itemType, prefixTypes)), options), + ), + ); + } + + // if maxItems not set, then return a simple tuple type the length of `min` + const spreadType = ts.factory.createArrayTypeNode(itemType); + const tupleType = + shouldGeneratePermutations || prefixTypes.length + ? ts.factory.createTupleTypeNode([ + ...padTupleMembers(Math.max(min, prefixTypes.length), itemType, prefixTypes), + ts.factory.createRestTypeNode(toOptionsReadonly(spreadType, options)), + ]) + : spreadType; + + return toOptionsReadonly(tupleType, options); +} + /** * Handle SchemaObject minus composition (anyOf/allOf/oneOf) */ @@ -316,73 +385,8 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor } // type: array (with support for tuples) - if (schemaObject.type === "array") { - // default to `unknown[]` - let itemType: ts.TypeNode = UNKNOWN; - // tuple type - if (schemaObject.prefixItems || Array.isArray(schemaObject.items)) { - const prefixItems = schemaObject.prefixItems ?? (schemaObject.items as (SchemaObject | ReferenceObject)[]); - itemType = ts.factory.createTupleTypeNode(prefixItems.map((item) => transformSchemaObject(item, options))); - } - // standard array type - else if (schemaObject.items) { - if ("type" in schemaObject.items && schemaObject.items.type === "array") { - itemType = ts.factory.createArrayTypeNode(transformSchemaObject(schemaObject.items, options)); - } else { - itemType = transformSchemaObject(schemaObject.items, options); - } - } - - const min: number = - typeof schemaObject.minItems === "number" && schemaObject.minItems >= 0 ? schemaObject.minItems : 0; - const max: number | undefined = - typeof schemaObject.maxItems === "number" && schemaObject.maxItems >= 0 && min <= schemaObject.maxItems - ? schemaObject.maxItems - : undefined; - const estimateCodeSize = typeof max !== "number" ? min : (max * (max + 1) - min * (min - 1)) / 2; - if ( - options.ctx.arrayLength && - (min !== 0 || max !== undefined) && - estimateCodeSize < 30 // "30" is an arbitrary number but roughly around when TS starts to struggle with tuple inference in practice - ) { - if (min === max) { - const elements: ts.TypeNode[] = []; - for (let i = 0; i < min; i++) { - elements.push(itemType); - } - return tsUnion([ts.factory.createTupleTypeNode(elements)]); - } else if ((schemaObject.maxItems as number) > 0) { - // if maxItems is set, then return a union of all permutations of possible tuple types - const members: ts.TypeNode[] = []; - // populate 1 short of min … - for (let i = 0; i <= (max ?? 0) - min; i++) { - const elements: ts.TypeNode[] = []; - for (let j = min; j < i + min; j++) { - elements.push(itemType); - } - members.push(ts.factory.createTupleTypeNode(elements)); - } - return tsUnion(members); - } - // if maxItems not set, then return a simple tuple type the length of `min` - else { - const elements: ts.TypeNode[] = []; - for (let i = 0; i < min; i++) { - elements.push(itemType); - } - elements.push(ts.factory.createRestTypeNode(ts.factory.createArrayTypeNode(itemType))); - return ts.factory.createTupleTypeNode(elements); - } - } - - const finalType = - ts.isTupleTypeNode(itemType) || ts.isArrayTypeNode(itemType) - ? itemType - : ts.factory.createArrayTypeNode(itemType); // wrap itemType in array type, but only if not a tuple or array already - - return options.ctx.immutable - ? ts.factory.createTypeOperatorNode(ts.SyntaxKind.ReadonlyKeyword, finalType) - : finalType; + if (isArraySchemaObject(schemaObject)) { + return transformArraySchemaObject(schemaObject, options); } // polymorphic, or 3.1 nullable diff --git a/packages/openapi-typescript/test/transform/schema-object/array.test.ts b/packages/openapi-typescript/test/transform/schema-object/array.test.ts index 59a2fddb7..454ef4ff0 100644 --- a/packages/openapi-typescript/test/transform/schema-object/array.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/array.test.ts @@ -19,19 +19,13 @@ describe("transformSchemaObject > array", () => { }, ], [ - "tuple > tuple items", + "array > heterogeneous items", { given: { type: "array", - items: [{ type: "string" }, { type: "number" }], - minItems: 2, - maxItems: 2, + items: { anyOf: [{ type: "number" }, { type: "string" }] }, }, - want: `[ - string, - number -]`, - // options: DEFAULT_OPTIONS, + want: "(number | string)[]", }, ], [ @@ -45,7 +39,8 @@ describe("transformSchemaObject > array", () => { want: `[ number, number, - number + number, + ...number[] ]`, // options: DEFAULT_OPTIONS, }, @@ -93,6 +88,28 @@ describe("transformSchemaObject > array", () => { // options: DEFAULT_OPTIONS, }, ], + [ + "options > arrayLength: false > minItems: 0", + { + given: { type: "array", items: { type: "string" }, minItems: 0 }, + want: "string[]", + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: false }, + }, + }, + ], + [ + "options > arrayLength: false > minItems: 1", + { + given: { type: "array", items: { type: "string" }, minItems: 1 }, + want: "string[]", + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: false }, + }, + }, + ], [ "options > arrayLength: true > default", { @@ -104,6 +121,28 @@ describe("transformSchemaObject > array", () => { }, }, ], + [ + "options > arrayLength: true > minItems: 0", + { + given: { type: "array", items: { type: "string" }, minItems: 0 }, + want: "string[]", + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true, immutable: true > minItems: 0", + { + given: { type: "array", items: { type: "string" }, minItems: 0 }, + want: "readonly string[]", + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true, immutable: true }, + }, + }, + ], [ "options > arrayLength: true > minItems: 1", { @@ -118,6 +157,50 @@ describe("transformSchemaObject > array", () => { }, }, ], + [ + "options > arrayLength: true, immutable: true > minItems: 1", + { + given: { type: "array", items: { type: "string" }, minItems: 1 }, + want: `readonly [ + string, + ...readonly string[] +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true, immutable: true }, + }, + }, + ], + [ + "options > arrayLength: true > minItems: 2", + { + given: { type: "array", items: { type: "string" }, minItems: 2 }, + want: `[ + string, + string, + ...string[] +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true, immutable: true > minItems: 2", + { + given: { type: "array", items: { type: "string" }, minItems: 2 }, + want: `readonly [ + string, + string, + ...readonly string[] +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true, immutable: true }, + }, + }, + ], [ "options > arrayLength: true > maxItems: 2", { @@ -135,6 +218,55 @@ describe("transformSchemaObject > array", () => { }, }, ], + [ + "options > arrayLength: true, immutable: true > maxItems: 2", + { + given: { type: "array", items: { type: "string" }, maxItems: 2 }, + want: `readonly [ +] | readonly [ + string +] | readonly [ + string, + string +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true, immutable: true }, + }, + }, + ], + [ + "options > arrayLength: true > minItems: 1, maxItems: 2", + { + given: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 2 }, + want: `[ + string +] | [ + string, + string +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true, immutable: true > minItems: 1, maxItems: 2", + { + given: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 2 }, + want: `readonly [ + string +] | readonly [ + string, + string +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true, immutable: true }, + }, + }, + ], [ "options > arrayLength: true > maxItems: 20", { @@ -146,6 +278,17 @@ describe("transformSchemaObject > array", () => { }, }, ], + [ + "options > arrayLength: true, immutable: true > maxItems: 20", + { + given: { type: "array", items: { type: "string" }, maxItems: 20 }, + want: "readonly string[]", + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true, immutable: true }, + }, + }, + ], [ "options > arrayLength: true > minItems: 2, maxItems: 2", { @@ -160,6 +303,193 @@ describe("transformSchemaObject > array", () => { }, }, ], + [ + "options > arrayLength: true > prefixItems, minItems: 2, maxItems: 2", + { + given: { + type: "array", + items: { type: "string" }, + prefixItems: [ + { type: "string", enum: ["calcium"] }, + { type: "string", enum: ["magnesium"] }, + ], + minItems: 2, + maxItems: 2, + }, + want: `[ + "calcium", + "magnesium" +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > no items, prefixItems, minItems: 2, maxItems: 3", + { + given: { + type: "array", + prefixItems: [ + { type: "string", enum: ["calcium"] }, + { type: "string", enum: ["magnesium"] }, + ], + minItems: 2, + maxItems: 3, + }, + want: `[ + "calcium", + "magnesium" +] | [ + "calcium", + "magnesium", + unknown +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > no items, prefixItems, minItems: 3", + { + given: { + type: "array", + prefixItems: [ + { type: "string", enum: ["calcium"] }, + { type: "string", enum: ["magnesium"] }, + ], + minItems: 3, + }, + want: `[ + "calcium", + "magnesium", + unknown, + ...unknown[] +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > no items, prefixItems, minItems: 2, maxItems: 5", + { + given: { + type: "array", + prefixItems: [ + { type: "string", enum: ["calcium"] }, + { type: "string", enum: ["magnesium"] }, + { type: "string", enum: ["tungsten"] }, + ], + minItems: 2, + maxItems: 5, + }, + want: `[ + "calcium", + "magnesium" +] | [ + "calcium", + "magnesium", + "tungsten" +] | [ + "calcium", + "magnesium", + "tungsten", + unknown +] | [ + "calcium", + "magnesium", + "tungsten", + unknown, + unknown +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true, immutable: true > prefixItems, minItems: 3", + { + given: { + type: "array", + items: { type: "string" }, + prefixItems: [{ type: "string" }, { type: "number" }], + minItems: 3, + }, + want: `readonly [ + string, + number, + string, + ...readonly string[] +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true, immutable: true }, + }, + }, + ], + [ + "options > arrayLength: true, immutable: true > prefixItems, minItems: 3, maxItems: 3", + { + given: { + type: "array", + items: { type: "string" }, + prefixItems: [{ type: "string" }, { type: "number" }], + minItems: 3, + maxItems: 3, + }, + want: `readonly [ + string, + number, + string +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true, immutable: true }, + }, + }, + ], + [ + "options > arrayLength: false, immutable: true > prefixItems, minItems: 3, maxItems: 5", + { + given: { + type: "array", + items: { type: "string" }, + prefixItems: [{ type: "string" }, { type: "number" }], + minItems: 3, + maxItems: 5, + }, + want: `readonly [ + string, + number, + ...readonly string[] +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: false, immutable: true }, + }, + }, + ], + [ + "options > arrayLength: false > prefixItems, items: false", + { + given: { + type: "array", + items: false, + prefixItems: [{ type: "string", enum: ["professor"] }], + }, + want: `[ + "professor", + ...unknown[] +]`, + }, + ], [ "options > immutable: true", { @@ -185,7 +515,8 @@ describe("transformSchemaObject > array", () => { want: `readonly [ number, number, - number + number, + ...readonly number[] ]`, options: { ...DEFAULT_OPTIONS, @@ -200,6 +531,7 @@ describe("transformSchemaObject > array", () => { testName, async () => { const result = astToString(transformSchemaObject(given, options)); + // console.log(result); if (want instanceof URL) { expect(result).toMatchFileSnapshot(fileURLToPath(want)); } else { @@ -209,4 +541,25 @@ describe("transformSchemaObject > array", () => { ci?.timeout, ); } + + const invalidTests: TestCase[] = [ + [ + "error > items is array", + { + given: { + type: "array", + items: [{ type: "number" }, { type: "string" }], + }, + want: "#/components/schemas/schema-object: invalid property items. Expected Schema Object, got Array", + }, + ], + ]; + + for (const [testName, { given, want, options = DEFAULT_OPTIONS, ci }] of invalidTests) { + test.skipIf(ci?.skipIf)(testName, () => { + expect(() => { + transformSchemaObject(given, options); + }).toThrowError(want.toString()); + }); + } }); diff --git a/packages/openapi-typescript/test/transform/schema-object/object.test.ts b/packages/openapi-typescript/test/transform/schema-object/object.test.ts index 60290fc57..801dadb9e 100644 --- a/packages/openapi-typescript/test/transform/schema-object/object.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/object.test.ts @@ -376,7 +376,7 @@ describe("transformSchemaObject > object", () => { }, ], [ - "options > two-dimensional array", + "options > array of tuples", { given: { type: "object", @@ -384,7 +384,7 @@ describe("transformSchemaObject > object", () => { array: { type: "array", items: { - items: [ + prefixItems: [ { type: "string", }, @@ -407,7 +407,7 @@ describe("transformSchemaObject > object", () => { }`, options: { ...DEFAULT_OPTIONS, - ctx: { ...DEFAULT_OPTIONS.ctx }, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, }, }, ],