diff --git a/.changeset/wet-foxes-watch.md b/.changeset/wet-foxes-watch.md new file mode 100644 index 000000000..cc031a104 --- /dev/null +++ b/.changeset/wet-foxes-watch.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": minor +--- + +feat: add dedupeEnums option diff --git a/docs/cli.md b/docs/cli.md index 91f0b2009..1c3c8b7c2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -113,6 +113,7 @@ The following flags are supported in the CLI: | `--empty-objects-unknown` | | `false` | Allow arbitrary properties for schema objects with no specified properties, and no specified `additionalProperties` | | `--enum` | | `false` | Generate true [TS enums](https://www.typescriptlang.org/docs/handbook/enums.html) rather than string unions. | | `--enum-values` | | `false` | Export enum values as arrays. | +| `--dedupe-enums` | | `false` | Dedupe enum types when `--enum=true` is set | | `--check` | | `false` | Check that the generated types are up-to-date. | | `--exclude-deprecated` | | `false` | Exclude deprecated fields from types | | `--export-type` | `-t` | `false` | Export `type` instead of `interface` | diff --git a/docs/zh/cli.md b/docs/zh/cli.md index 36d6e7520..6bab66e55 100644 --- a/docs/zh/cli.md +++ b/docs/zh/cli.md @@ -112,7 +112,8 @@ CLI 支持以下参数: | `--empty-objects-unknown` | | `false` | 允许在未指定属性和未指定 `additionalProperties` 的情况下,为模式对象设置任意属性 | | `--enum` | | `false` | 生成真实的 [TS 枚举](https://www.typescriptlang.org/docs/handbook/enums.html),而不是字符串联合。 | | `--enum-values` | | `false` | 将 enum 值导出为数组 | -| `--check` | | `false` | 检查生成的类型是否是最新的 | +| `--dedupe-enums` | | `false` | 将 enum 值导出为数组 | +| `--check` | | `false` | 当 --enum=true 时,去除重复定义的枚举类型 | | `--exclude-deprecated` | | `false` | 从类型中排除已弃用的字段 | | `--export-type` | `-t` | `false` | 导出 `type` 而不是 `interface` | | `--immutable` | | `false` | 生成不可变类型(只读属性和只读数组) | diff --git a/packages/openapi-typescript-helpers/package.json b/packages/openapi-typescript-helpers/package.json index 8b64158fe..04a497713 100644 --- a/packages/openapi-typescript-helpers/package.json +++ b/packages/openapi-typescript-helpers/package.json @@ -23,12 +23,7 @@ }, "./*": "./*" }, - "files": [ - "index.js", - "index.cjs", - "index.d.ts", - "index.d.cts" - ], + "files": ["index.js", "index.cjs", "index.d.ts", "index.d.cts"], "homepage": "https://openapi-ts.dev", "repository": { "type": "git", diff --git a/packages/openapi-typescript/bin/cli.js b/packages/openapi-typescript/bin/cli.js index 9c8c0baee..28c481ade 100755 --- a/packages/openapi-typescript/bin/cli.js +++ b/packages/openapi-typescript/bin/cli.js @@ -17,6 +17,7 @@ Options --output, -o Specify output file (if not specified in redocly.yaml) --enum Export true TS enums instead of unions --enum-values Export enum values as arrays + --dedupe-enums Dedupe enum types when \`--enum=true\` is set --check Check that the generated types are up-to-date. (default: false) --export-type, -t Export top-level \`type\` instead of \`interface\` --immutable Generate readonly types @@ -66,6 +67,7 @@ const flags = parser(args, { "emptyObjectsUnknown", "enum", "enumValues", + "dedupeEnums", "check", "excludeDeprecated", "exportType", @@ -126,6 +128,7 @@ async function generateSchema(schema, { redocly, silent = false }) { emptyObjectsUnknown: flags.emptyObjectsUnknown, enum: flags.enum, enumValues: flags.enumValues, + dedupeEnums: flags.dedupeEnums, excludeDeprecated: flags.excludeDeprecated, exportType: flags.exportType, immutable: flags.immutable, diff --git a/packages/openapi-typescript/src/index.ts b/packages/openapi-typescript/src/index.ts index d631ca2ed..70c6757eb 100644 --- a/packages/openapi-typescript/src/index.ts +++ b/packages/openapi-typescript/src/index.ts @@ -75,6 +75,7 @@ export default async function openapiTS( emptyObjectsUnknown: options.emptyObjectsUnknown ?? false, enum: options.enum ?? false, enumValues: options.enumValues ?? false, + dedupeEnums: options.dedupeEnums ?? false, excludeDeprecated: options.excludeDeprecated ?? false, exportType: options.exportType ?? false, immutable: options.immutable ?? false, diff --git a/packages/openapi-typescript/src/lib/ts.ts b/packages/openapi-typescript/src/lib/ts.ts index 3b2c75555..c090a2eac 100644 --- a/packages/openapi-typescript/src/lib/ts.ts +++ b/packages/openapi-typescript/src/lib/ts.ts @@ -204,20 +204,37 @@ export function tsDedupe(types: ts.TypeNode[]): ts.TypeNode[] { return filteredTypes; } +export const enumCache = new Map(); + /** Create a TS enum (with sanitized name and members) */ export function tsEnum( name: string, members: (string | number)[], metadata?: { name?: string; description?: string }[], - options?: { export?: boolean }, + options?: { export?: boolean; shouldCache?: boolean }, ) { let enumName = sanitizeMemberName(name); enumName = `${enumName[0].toUpperCase()}${enumName.substring(1)}`; - return ts.factory.createEnumDeclaration( + let key = ""; + if (options?.shouldCache) { + key = `${members + .slice(0) + .sort() + .map((v, i) => { + return `${metadata?.[i]?.name ?? String(v)}:${metadata?.[i]?.description || ""}`; + }) + .join(",")}`; + if (enumCache.has(key)) { + return enumCache.get(key) as ts.EnumDeclaration; + } + } + const enumDeclaration = ts.factory.createEnumDeclaration( /* modifiers */ options ? tsModifiers({ export: options.export ?? false }) : undefined, /* name */ enumName, /* members */ members.map((value, i) => tsEnumMember(value, metadata?.[i])), ); + options?.shouldCache && enumCache.set(key, enumDeclaration); + return enumDeclaration; } /** Create an exported TS array literal expression */ diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 59c42b842..ec9369e62 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -102,17 +102,14 @@ export function transformSchemaObjectWithComposition( name: schemaObject["x-enum-varnames"]?.[i] ?? schemaObject["x-enumNames"]?.[i], description: schemaObject["x-enum-descriptions"]?.[i] ?? schemaObject["x-enumDescriptions"]?.[i], })); - const enumType = tsEnum( - enumName, - schemaObject.enum as (string | number)[], - metadata, - - { - export: true, - // readonly: TS enum do not support the readonly modifier - }, - ); - options.ctx.injectFooter.push(enumType); + const enumType = tsEnum(enumName, schemaObject.enum as (string | number)[], metadata, { + shouldCache: options.ctx.dedupeEnums, + export: true, + // readonly: TS enum do not support the readonly modifier + }); + if (!options.ctx.injectFooter.includes(enumType)) { + options.ctx.injectFooter.push(enumType); + } return ts.factory.createTypeReferenceNode(enumType.name); } const enumType = schemaObject.enum.map(tsLiteral); diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index ca4378f2a..73a163973 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -651,6 +651,8 @@ export interface OpenAPITSOptions { enum?: boolean; /** Export union values as arrays */ enumValues?: boolean; + /** Dedupe enum values */ + dedupeEnums?: boolean; /** (optional) Substitute path parameter names with their respective types */ pathParamsAsTypes?: boolean; /** Treat all objects as if they have \`required\` set to all properties by default (default: false) */ @@ -678,6 +680,7 @@ export interface GlobalContext { emptyObjectsUnknown: boolean; enum: boolean; enumValues: boolean; + dedupeEnums: boolean; excludeDeprecated: boolean; exportType: boolean; immutable: boolean; diff --git a/packages/openapi-typescript/test/node-api.test.ts b/packages/openapi-typescript/test/node-api.test.ts index 96c226cca..b0ef5f800 100644 --- a/packages/openapi-typescript/test/node-api.test.ts +++ b/packages/openapi-typescript/test/node-api.test.ts @@ -804,6 +804,93 @@ export type operations = Record;`, options: { enumValues: true }, }, ], + [ + "options > dedupeEnums", + { + given: { + openapi: "3.1", + info: { title: "Test", version: "1.0" }, + paths: { + "/url": { + get: { + parameters: [ + { + name: "status", + in: "query", + schema: { + type: "string", + enum: ["active", "inactive"], + }, + }, + ], + }, + }, + }, + components: { + schemas: { + Status: { + type: "string", + enum: ["active", "inactive"], + }, + StatusReverse: { + type: "string", + enum: ["inactive", "active"], + }, + }, + }, + }, + want: `export interface paths { + "/url": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + status?: PathsUrlGetParametersQueryStatus; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: never; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** @enum {string} */ + Status: PathsUrlGetParametersQueryStatus; + /** @enum {string} */ + StatusReverse: PathsUrlGetParametersQueryStatus; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export enum PathsUrlGetParametersQueryStatus { + active = "active", + inactive = "inactive" +} +export type operations = Record;`, + options: { enum: true, dedupeEnums: true }, + }, + ], [ "snapshot > GitHub", { diff --git a/packages/openapi-typescript/test/test-helpers.ts b/packages/openapi-typescript/test/test-helpers.ts index 81160272b..f6330045c 100644 --- a/packages/openapi-typescript/test/test-helpers.ts +++ b/packages/openapi-typescript/test/test-helpers.ts @@ -15,6 +15,7 @@ export const DEFAULT_CTX: GlobalContext = { emptyObjectsUnknown: false, enum: false, enumValues: false, + dedupeEnums: false, excludeDeprecated: false, exportType: false, immutable: false,