Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix prefixItems / minItems / maxItems tuple generation (#2053) #2148

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
10 changes: 10 additions & 0 deletions .changeset/clean-phones-deliver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"openapi-typescript": minor
---

Extract types generation for Array-type schemas to `transformArraySchemaObject` method.
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.
7 changes: 6 additions & 1 deletion packages/openapi-typescript/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ Options
--root-types-no-schema-prefix (optional)
Do not add "Schema" prefix to types at the root level (should only be used with --root-types)
--make-paths-enum Generate ApiPaths enum for all paths

Experimental features
--experimental-spread-array-members
Array schemas with prefixItems spread remaining items
`;

const OUTPUT_FILE = "FILE";
Expand Down Expand Up @@ -77,6 +81,7 @@ const flags = parser(args, {
"dedupeEnums",
"check",
"excludeDeprecated",
"experimentalArraySpreadMembers",
"exportType",
"help",
"immutable",
Expand Down Expand Up @@ -133,7 +138,6 @@ async function generateSchema(schema, { redocly, silent = false }) {
additionalProperties: flags.additionalProperties,
alphabetize: flags.alphabetize,
arrayLength: flags.arrayLength,
contentNever: flags.contentNever,
propertiesRequiredByDefault: flags.propertiesRequiredByDefault,
defaultNonNullable: flags.defaultNonNullable,
emptyObjectsUnknown: flags.emptyObjectsUnknown,
Expand All @@ -142,6 +146,7 @@ async function generateSchema(schema, { redocly, silent = false }) {
dedupeEnums: flags.dedupeEnums,
excludeDeprecated: flags.excludeDeprecated,
exportType: flags.exportType,
experimentalArraySpreadMembers: flags.experimentalArraySpreadMembers,
immutable: flags.immutable,
pathParamsAsTypes: flags.pathParamsAsTypes,
rootTypes: flags.rootTypes,
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"yargs-parser": "^21.1.1"
},
"devDependencies": {
"@total-typescript/ts-reset": "^0.6.1",
"@types/degit": "^2.8.6",
"@types/js-yaml": "^4.0.9",
"degit": "^2.8.4",
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export default async function openapiTS(
enumValues: options.enumValues ?? false,
dedupeEnums: options.dedupeEnums ?? false,
excludeDeprecated: options.excludeDeprecated ?? false,
experimentalArraySpreadMembers: options.experimentalArraySpreadMembers ?? false,
exportType: options.exportType ?? false,
immutable: options.immutable ?? false,
rootTypes: options.rootTypes ?? false,
Expand Down
23 changes: 6 additions & 17 deletions packages/openapi-typescript/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export function resolveRef<T>(
return node;
}

function createDiscriminatorEnum(values: string[], prevSchema?: SchemaObject): SchemaObject {
function createDiscriminatorEnum(values: string[], prevSchema?: SchemaObject | ReferenceObject): SchemaObject {
return {
type: "string",
enum: values,
Expand All @@ -167,7 +167,7 @@ function createDiscriminatorEnum(values: string[], prevSchema?: SchemaObject): S

/** Adds or replaces the discriminator enum with the passed `values` in a schema defined by `ref` */
function patchDiscriminatorEnum(
schema: SchemaObject,
schema: OpenAPI3,
ref: string,
values: string[],
discriminator: DiscriminatorObject,
Expand Down Expand Up @@ -206,7 +206,7 @@ function patchDiscriminatorEnum(
// add/replace the discriminator enum property
resolvedSchema.properties[discriminator.propertyName] = createDiscriminatorEnum(
values,
resolvedSchema.properties[discriminator.propertyName] as SchemaObject,
resolvedSchema.properties[discriminator.propertyName],
);

return true;
Expand Down Expand Up @@ -250,7 +250,7 @@ export function scanDiscriminators(schema: OpenAPI3, options: OpenAPITSOptions)
return;
}

const oneOf: (SchemaObject | ReferenceObject)[] = obj.oneOf;
const oneOf = obj.oneOf as readonly (SchemaObject | ReferenceObject)[];
const mapping: InternalDiscriminatorMapping = {};

// the mapping can be inferred from the oneOf refs next to the discriminator object
Expand Down Expand Up @@ -301,9 +301,7 @@ export function scanDiscriminators(schema: OpenAPI3, options: OpenAPITSOptions)
// biome-ignore lint/style/noNonNullAssertion: we just checked for this
const mappedValues = defined ?? [inferred!];

if (
patchDiscriminatorEnum(schema as unknown as SchemaObject, mappedRef, mappedValues, discriminator, ref, options)
) {
if (patchDiscriminatorEnum(schema, mappedRef, mappedValues, discriminator, ref, options)) {
refsHandled.push(mappedRef);
}
}
Expand Down Expand Up @@ -335,16 +333,7 @@ export function scanDiscriminators(schema: OpenAPI3, options: OpenAPITSOptions)
}

if (mappedValues.length > 0) {
if (
patchDiscriminatorEnum(
schema as unknown as SchemaObject,
ref,
mappedValues,
discriminator,
item.$ref,
options,
)
) {
if (patchDiscriminatorEnum(schema, ref, mappedValues, discriminator, item.$ref, options)) {
refsHandled.push(ref);
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/openapi-typescript/src/reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Do not add any other lines of code to this file!
import "@total-typescript/ts-reset";
162 changes: 94 additions & 68 deletions packages/openapi-typescript/src/transform/schema-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,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)
Expand Down Expand Up @@ -273,6 +273,97 @@ export function transformSchemaObjectWithComposition(
return finalType;
}

type ArraySchemaObject = SchemaObject & ArraySubtype;
function isArraySchemaObject(schemaObject: SchemaObject | ArraySchemaObject): schemaObject is ArraySchemaObject {
return schemaObject.type === "array";
}

/**
* Return an array of tuple members of the given length, either by trimming
* the prefixItems, or by padding out the end of prefixItems with itemType
* @param prefixTypes The array before any padding occurs
* @param length The length of the returned array
* @param itemType The type to pad out the end of the array with
*/
function padTupleMembers(prefixTypes: readonly ts.TypeNode[], length: number, itemType: ts.TypeNode) {
return Array.from({ length }).map((_, index) => (index < prefixTypes.length ? prefixTypes[index] : itemType));
}

function toOptionsReadonly<TMembers extends ts.ArrayTypeNode | ts.TupleTypeNode>(
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 | undefined {
const prefixTypes = (schemaObject.prefixItems ?? []).map((item) => transformSchemaObject(item, options));

if (Array.isArray(schemaObject.items)) {
return ts.factory.createTupleTypeNode(
schemaObject.items.map((tupleItem) => transformSchemaObject(tupleItem, options)),
);
}

const itemType =
// @ts-expect-error TS2367
schemaObject.items === false
? undefined
: schemaObject.items
? transformSchemaObject(schemaObject.items, options)
: UNKNOWN;

// The minimum number of tuple members in the return value
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 && itemType) {
return tsUnion(
Array.from({ length: max - min + 1 }).map((_, index) => {
return toOptionsReadonly(
ts.factory.createTupleTypeNode(padTupleMembers(prefixTypes, index + min, itemType)),
options,
);
}),
);
}

// if maxItems not set, then return a simple tuple type the length of `min`
const spreadType = itemType ? ts.factory.createArrayTypeNode(itemType) : undefined;
const tupleType =
shouldGeneratePermutations || prefixTypes.length
? ts.factory.createTupleTypeNode(
[
...(itemType ? padTupleMembers(prefixTypes, Math.max(min, prefixTypes.length), itemType) : prefixTypes),
spreadType && (prefixTypes.length ? options.ctx.experimentalArraySpreadMembers : true)
? ts.factory.createRestTypeNode(toOptionsReadonly(spreadType, options))
: undefined,
].filter(Boolean),
)
: spreadType;

return tupleType ? toOptionsReadonly(tupleType, options) : undefined;
}

/**
* Handle SchemaObject minus composition (anyOf/allOf/oneOf)
*/
Expand Down Expand Up @@ -312,73 +403,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
Expand Down
3 changes: 3 additions & 0 deletions packages/openapi-typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,8 @@ export interface OpenAPITSOptions {
version?: number;
/** (optional) Export type instead of interface */
exportType?: boolean;
/** (optional) Experimental: Array schemas with prefixItems spread members */
experimentalArraySpreadMembers?: boolean;
/** Export true TypeScript enums instead of unions */
enum?: boolean;
/** Export union values as arrays */
Expand Down Expand Up @@ -690,6 +692,7 @@ export interface GlobalContext {
enumValues: boolean;
dedupeEnums: boolean;
excludeDeprecated: boolean;
experimentalArraySpreadMembers: boolean;
exportType: boolean;
immutable: boolean;
injectFooter: ts.Node[];
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-typescript/test/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const DEFAULT_CTX: GlobalContext = {
emptyObjectsUnknown: false,
enum: false,
enumValues: false,
experimentalArraySpreadMembers: false,
dedupeEnums: false,
excludeDeprecated: false,
exportType: false,
Expand Down
Loading