Skip to content

Commit 5757df6

Browse files
committed
Fix prefixItems / minItems / maxItems tuple generation (openapi-ts#2053)
* Simplify minItems / maxItems tuple generation Closes openapi-ts#2048
1 parent 82e98b4 commit 5757df6

File tree

9 files changed

+546
-117
lines changed

9 files changed

+546
-117
lines changed

.changeset/clean-phones-deliver.md

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"openapi-typescript": major
3+
---
4+
5+
Extract types generation for Array-type schemas to `transformArraySchemaObject` method.
6+
Throw error when OpenAPI `items` is array.
7+
Generate correct number of union members for `minItems` * `maxItems` unions.
8+
Generate readonly tuple members for `minItems` & `maxItems` unions.
9+
Generate readonly spread member for `prefixItems` tuple.
10+
Preserve `prefixItems` type members in `minItems` & `maxItems` tuples.
11+
Generate spread member for `prefixItems` tuple with no `minItems` / `maxItems` constraints.

packages/openapi-typescript/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"yargs-parser": "^21.1.1"
6969
},
7070
"devDependencies": {
71+
"@total-typescript/ts-reset": "^0.6.1",
7172
"@types/degit": "^2.8.6",
7273
"@types/js-yaml": "^4.0.9",
7374
"degit": "^2.8.4",
+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Do not add any other lines of code to this file!
2+
import "@total-typescript/ts-reset";

packages/openapi-typescript/src/transform/schema-object.ts

+93-68
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
tsWithRequired,
2626
} from "../lib/ts.js";
2727
import { createDiscriminatorProperty, createRef, getEntries } from "../lib/utils.js";
28-
import type { ReferenceObject, SchemaObject, TransformNodeOptions } from "../types.js";
28+
import type { ArraySubtype, ReferenceObject, SchemaObject, TransformNodeOptions } from "../types.js";
2929

3030
/**
3131
* Transform SchemaObject nodes (4.8.24)
@@ -273,6 +273,96 @@ export function transformSchemaObjectWithComposition(
273273
return finalType;
274274
}
275275

276+
type ArraySchemaObject = SchemaObject & ArraySubtype;
277+
function isArraySchemaObject(schemaObject: SchemaObject | ArraySchemaObject): schemaObject is ArraySchemaObject {
278+
return schemaObject.type === "array";
279+
}
280+
281+
/**
282+
* Pad out the end of tuple members with item item
283+
* @param prefixTypes The array before any padding occurs
284+
* @param length The length of the returned array
285+
* @param itemType The type to pad out the end of the array with
286+
*/
287+
function padTupleMembers(prefixTypes: readonly ts.TypeNode[], length: number, itemType: ts.TypeNode) {
288+
return Array.from({ length }).map((_, index) =>
289+
index < prefixTypes.length ? prefixTypes[index] : itemType
290+
);
291+
}
292+
293+
function toOptionsReadonly<TMembers extends ts.ArrayTypeNode | ts.TupleTypeNode>(
294+
members: TMembers,
295+
options: TransformNodeOptions,
296+
): TMembers | ts.TypeOperatorNode {
297+
return options.ctx.immutable ? ts.factory.createTypeOperatorNode(ts.SyntaxKind.ReadonlyKeyword, members) : members;
298+
}
299+
300+
/* Transform Array schema object */
301+
function transformArraySchemaObject(
302+
schemaObject: ArraySchemaObject,
303+
options: TransformNodeOptions,
304+
): ts.TypeNode | undefined {
305+
const prefixTypes = (schemaObject.prefixItems ?? []).map((item) => transformSchemaObject(item, options));
306+
307+
if (Array.isArray(schemaObject.items)) {
308+
return ts.factory.createTupleTypeNode(
309+
schemaObject.items.map((tupleItem) => transformSchemaObject(tupleItem, options)),
310+
);
311+
}
312+
313+
const itemType =
314+
// @ts-expect-error TS2367
315+
schemaObject.items === false
316+
? undefined
317+
: schemaObject.items
318+
? transformSchemaObject(schemaObject.items, options)
319+
: UNKNOWN;
320+
321+
// The minimum number of tuple members in the return value
322+
const min: number =
323+
options.ctx.arrayLength && typeof schemaObject.minItems === "number" && schemaObject.minItems >= 0
324+
? schemaObject.minItems
325+
: 0;
326+
const max: number | undefined =
327+
options.ctx.arrayLength &&
328+
typeof schemaObject.maxItems === "number" &&
329+
schemaObject.maxItems >= 0 &&
330+
min <= schemaObject.maxItems
331+
? schemaObject.maxItems
332+
: undefined;
333+
334+
// "30" is an arbitrary number but roughly around when TS starts to struggle with tuple inference in practice
335+
const MAX_CODE_SIZE = 30;
336+
const estimateCodeSize = max === undefined ? min : (max * (max + 1) - min * (min - 1)) / 2;
337+
const shouldGeneratePermutations = (min !== 0 || max !== undefined) && estimateCodeSize < MAX_CODE_SIZE;
338+
339+
// if maxItems is set, then return a union of all permutations of possible tuple types
340+
if (shouldGeneratePermutations && max !== undefined && itemType) {
341+
return tsUnion(
342+
Array.from({ length: max - min + 1 }).map((_, index) => {
343+
return toOptionsReadonly(
344+
ts.factory.createTupleTypeNode(padTupleMembers(prefixTypes, index + min, itemType)),
345+
options,
346+
);
347+
}),
348+
);
349+
}
350+
351+
// if maxItems not set, then return a simple tuple type the length of `min`
352+
const spreadType = itemType ? ts.factory.createArrayTypeNode(itemType) : undefined;
353+
const tupleType =
354+
shouldGeneratePermutations || prefixTypes.length
355+
? ts.factory.createTupleTypeNode(
356+
[
357+
...(itemType ? padTupleMembers(prefixTypes, Math.max(min, prefixTypes.length), itemType) : prefixTypes),
358+
spreadType ? ts.factory.createRestTypeNode(toOptionsReadonly(spreadType, options)) : undefined,
359+
].filter(Boolean),
360+
)
361+
: spreadType;
362+
363+
return tupleType ? toOptionsReadonly(tupleType, options) : undefined;
364+
}
365+
276366
/**
277367
* Handle SchemaObject minus composition (anyOf/allOf/oneOf)
278368
*/
@@ -312,73 +402,8 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
312402
}
313403

314404
// type: array (with support for tuples)
315-
if (schemaObject.type === "array") {
316-
// default to `unknown[]`
317-
let itemType: ts.TypeNode = UNKNOWN;
318-
// tuple type
319-
if (schemaObject.prefixItems || Array.isArray(schemaObject.items)) {
320-
const prefixItems = schemaObject.prefixItems ?? (schemaObject.items as (SchemaObject | ReferenceObject)[]);
321-
itemType = ts.factory.createTupleTypeNode(prefixItems.map((item) => transformSchemaObject(item, options)));
322-
}
323-
// standard array type
324-
else if (schemaObject.items) {
325-
if ("type" in schemaObject.items && schemaObject.items.type === "array") {
326-
itemType = ts.factory.createArrayTypeNode(transformSchemaObject(schemaObject.items, options));
327-
} else {
328-
itemType = transformSchemaObject(schemaObject.items, options);
329-
}
330-
}
331-
332-
const min: number =
333-
typeof schemaObject.minItems === "number" && schemaObject.minItems >= 0 ? schemaObject.minItems : 0;
334-
const max: number | undefined =
335-
typeof schemaObject.maxItems === "number" && schemaObject.maxItems >= 0 && min <= schemaObject.maxItems
336-
? schemaObject.maxItems
337-
: undefined;
338-
const estimateCodeSize = typeof max !== "number" ? min : (max * (max + 1) - min * (min - 1)) / 2;
339-
if (
340-
options.ctx.arrayLength &&
341-
(min !== 0 || max !== undefined) &&
342-
estimateCodeSize < 30 // "30" is an arbitrary number but roughly around when TS starts to struggle with tuple inference in practice
343-
) {
344-
if (min === max) {
345-
const elements: ts.TypeNode[] = [];
346-
for (let i = 0; i < min; i++) {
347-
elements.push(itemType);
348-
}
349-
return tsUnion([ts.factory.createTupleTypeNode(elements)]);
350-
} else if ((schemaObject.maxItems as number) > 0) {
351-
// if maxItems is set, then return a union of all permutations of possible tuple types
352-
const members: ts.TypeNode[] = [];
353-
// populate 1 short of min …
354-
for (let i = 0; i <= (max ?? 0) - min; i++) {
355-
const elements: ts.TypeNode[] = [];
356-
for (let j = min; j < i + min; j++) {
357-
elements.push(itemType);
358-
}
359-
members.push(ts.factory.createTupleTypeNode(elements));
360-
}
361-
return tsUnion(members);
362-
}
363-
// if maxItems not set, then return a simple tuple type the length of `min`
364-
else {
365-
const elements: ts.TypeNode[] = [];
366-
for (let i = 0; i < min; i++) {
367-
elements.push(itemType);
368-
}
369-
elements.push(ts.factory.createRestTypeNode(ts.factory.createArrayTypeNode(itemType)));
370-
return ts.factory.createTupleTypeNode(elements);
371-
}
372-
}
373-
374-
const finalType =
375-
ts.isTupleTypeNode(itemType) || ts.isArrayTypeNode(itemType)
376-
? itemType
377-
: ts.factory.createArrayTypeNode(itemType); // wrap itemType in array type, but only if not a tuple or array already
378-
379-
return options.ctx.immutable
380-
? ts.factory.createTypeOperatorNode(ts.SyntaxKind.ReadonlyKeyword, finalType)
381-
: finalType;
405+
if (isArraySchemaObject(schemaObject)) {
406+
return transformArraySchemaObject(schemaObject, options);
382407
}
383408

384409
// polymorphic, or 3.1 nullable

packages/openapi-typescript/test/cli.test.ts

+60-30
Original file line numberDiff line numberDiff line change
@@ -89,24 +89,32 @@ describe("CLI", () => {
8989
expect(stdout).toBe(`${want}\n`);
9090
}
9191
},
92-
ci?.timeout,
92+
ci?.timeout
9393
);
9494
}
9595

9696
test(
9797
"stdin",
9898
async () => {
99-
const input = fs.readFileSync(new URL("./examples/stripe-api.yaml", root));
99+
const input = fs.readFileSync(
100+
new URL("./examples/stripe-api.yaml", root)
101+
);
100102
const { stdout } = await execa(cmd, { input, cwd });
101-
expect(stdout).toMatchFileSnapshot(fileURLToPath(new URL("./examples/stripe-api.ts", root)));
103+
expect(stdout).toMatchFileSnapshot(
104+
fileURLToPath(new URL("./examples/stripe-api.ts", root))
105+
);
102106
},
103-
TIMEOUT,
107+
TIMEOUT
104108
);
105109

106110
describe("flags", () => {
107111
test("--help", async () => {
108112
const { stdout } = await execa(cmd, ["--help"], { cwd });
109-
expect(stdout).toEqual(expect.stringMatching(/^Usage\n\s+\$ openapi-typescript \[input\] \[options\]/));
113+
expect(stdout).toEqual(
114+
expect.stringMatching(
115+
/^Usage\n\s+\$ openapi-typescript \[input\] \[options\]/
116+
)
117+
);
110118
});
111119

112120
test("--version", async () => {
@@ -117,48 +125,70 @@ describe("CLI", () => {
117125
test(
118126
"--properties-required-by-default",
119127
async () => {
120-
const { stdout } = await execa(cmd, ["--properties-required-by-default=true", "./examples/github-api.yaml"], {
121-
cwd,
122-
});
123-
expect(stdout).toMatchFileSnapshot(fileURLToPath(new URL("./examples/github-api-required.ts", root)));
128+
const { stdout } = await execa(
129+
cmd,
130+
[
131+
"--properties-required-by-default=true",
132+
"./examples/github-api.yaml",
133+
],
134+
{
135+
cwd,
136+
}
137+
);
138+
expect(stdout).toMatchFileSnapshot(
139+
fileURLToPath(new URL("./examples/github-api-required.ts", root))
140+
);
124141
},
125-
TIMEOUT,
142+
TIMEOUT
126143
);
127144
});
128145

129-
describe("Redocly config", () => {
130-
test.skipIf(os.platform() === "win32")("automatic config", async () => {
146+
describe.only("Redocly config", () => {
147+
describe.skipIf(os.platform() === "win32")("automatic config", () => {
131148
const cwd = new URL("./fixtures/redocly/", import.meta.url);
132149

133-
await execa("../../../bin/cli.js", {
134-
cwd: fileURLToPath(cwd),
150+
beforeAll(async () => {
151+
await execa("../../../bin/cli.js", { cwd: fileURLToPath(cwd) });
135152
});
136-
for (const schema of ["a", "b", "c"]) {
137-
expect(fs.readFileSync(new URL(`./output/${schema}.ts`, cwd), "utf8")).toMatchFileSnapshot(
138-
fileURLToPath(new URL("../../../examples/simple-example.ts", cwd)),
153+
154+
test.each(["a", "b", "c"])("test schema %s", (schema) => {
155+
expect(
156+
fs.readFileSync(new URL(`./output/${schema}.ts`, cwd), "utf8")
157+
).toMatchFileSnapshot(
158+
fileURLToPath(new URL("../../../examples/simple-example.ts", cwd))
139159
);
140-
}
160+
});
141161
});
142162

143-
test("--redocly config", async () => {
144-
await execa(cmd, ["--redocly", "test/fixtures/redocly-flag/redocly.yaml"], {
145-
cwd,
163+
describe("--redocly config", async () => {
164+
beforeAll(async () => {
165+
await execa(
166+
cmd,
167+
["--redocly", "test/fixtures/redocly-flag/redocly.yaml"],
168+
{
169+
cwd,
170+
}
171+
);
172+
});
173+
174+
test.each(["a", "b", "c"])("test schema %s", async (schema) => {
175+
await expect(
176+
fs.readFileSync(
177+
new URL(`./test/fixtures/redocly-flag/output/${schema}.ts`, root),
178+
"utf8"
179+
)
180+
).toMatchFileSnapshot(
181+
fileURLToPath(new URL("./examples/simple-example.ts", root))
182+
);
146183
});
147-
for (const schema of ["a", "b", "c"]) {
148-
expect(
149-
fs.readFileSync(new URL(`./test/fixtures/redocly-flag/output/${schema}.ts`, root), "utf8"),
150-
).toMatchFileSnapshot(fileURLToPath(new URL("./examples/simple-example.ts", root)));
151-
}
152184
});
153185

154186
test.skipIf(os.platform() === "win32")("lint error", async () => {
187+
expect.assertions(1);
155188
const cwd = new URL("./fixtures/redocly-lint-error", import.meta.url);
156189

157190
try {
158-
await execa("../../../bin/cli.js", {
159-
cwd: fileURLToPath(cwd),
160-
});
161-
throw new Error("Linting should have thrown an error");
191+
await execa("../../../bin/cli.js", { cwd: fileURLToPath(cwd) });
162192
} catch (err) {
163193
expect(stripAnsi(String(err))).toMatch(/ {2}Servers must be present/);
164194
}

packages/openapi-typescript/test/node-api.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -982,7 +982,7 @@ export type operations = Record<string, never>;`,
982982
async () => {
983983
const result = astToString(await openapiTS(given, options));
984984
if (want instanceof URL) {
985-
expect(`${COMMENT_HEADER}${result}`).toMatchFileSnapshot(fileURLToPath(want));
985+
await expect(`${COMMENT_HEADER}${result}`).toMatchFileSnapshot(fileURLToPath(want));
986986
} else {
987987
expect(result).toBe(`${want}\n`);
988988
}

0 commit comments

Comments
 (0)