diff --git a/.changeset/every-roses-tickle.md b/.changeset/every-roses-tickle.md new file mode 100644 index 000000000..f90faf590 --- /dev/null +++ b/.changeset/every-roses-tickle.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": minor +--- + +Conditionally generate TS enums diff --git a/docs/cli.md b/docs/cli.md index fb41bd941..f3236f86b 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. | +| `--conditional-enums` | | `false` | Only generate true TS enums when the `x-enum-*` metadata is available. Requires `--enum=true` to be enabled. | | `--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 | diff --git a/packages/openapi-typescript/bin/cli.js b/packages/openapi-typescript/bin/cli.js index fea433124..f1fd46a60 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 + --conditional-enums Only generate true TS enums when enum metadata is available (default: false) --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\` @@ -74,6 +75,7 @@ const flags = parser(args, { "emptyObjectsUnknown", "enum", "enumValues", + "conditionalEnums", "dedupeEnums", "check", "excludeDeprecated", @@ -139,6 +141,7 @@ async function generateSchema(schema, { redocly, silent = false }) { emptyObjectsUnknown: flags.emptyObjectsUnknown, enum: flags.enum, enumValues: flags.enumValues, + conditionalEnums: flags.conditionalEnums, dedupeEnums: flags.dedupeEnums, excludeDeprecated: flags.excludeDeprecated, exportType: flags.exportType, diff --git a/packages/openapi-typescript/src/index.ts b/packages/openapi-typescript/src/index.ts index 8c36fd0ad..c85b355e1 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, + conditionalEnums: options.conditionalEnums ?? false, dedupeEnums: options.dedupeEnums ?? false, excludeDeprecated: options.excludeDeprecated ?? false, exportType: options.exportType ?? false, diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 9c5725126..ea1e21d89 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -96,10 +96,7 @@ export function transformSchemaObjectWithComposition( !("additionalProperties" in schemaObject) ) { // hoist enum to top level if string/number enum and option is enabled - if ( - options.ctx.enum && - schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number" || v === null) - ) { + if (shouldTransformToTsEnum(options, schemaObject)) { let enumName = parseRef(options.path ?? "").pointer.join("/"); // allow #/components/schemas to have simpler names enumName = enumName.replace("components/schemas", ""); @@ -270,6 +267,35 @@ export function transformSchemaObjectWithComposition( return finalType; } +/** + * Check if the given OAPI enum should be transformed to a TypeScript enum + */ +function shouldTransformToTsEnum(options: TransformNodeOptions, schemaObject: SchemaObject): boolean { + // Enum conversion not enabled or no enum present + if (!options.ctx.enum || !schemaObject.enum) { + return false; + } + + // Enum must have string, number or null values + if (!schemaObject.enum.every((v) => ["string", "number", null].includes(typeof v))) { + return false; + } + + // If conditionalEnums is enabled, only convert if x-enum-* metadata is present + if (options.ctx.conditionalEnums) { + const hasEnumMetadata = + Array.isArray(schemaObject["x-enum-varnames"]) || + Array.isArray(schemaObject["x-enumNames"]) || + Array.isArray(schemaObject["x-enum-descriptions"]) || + Array.isArray(schemaObject["x-enumDescriptions"]); + if (!hasEnumMetadata) { + return false; + } + } + + return true; +} + /** * Handle SchemaObject minus composition (anyOf/allOf/oneOf) */ diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index 75d8f8c07..29e11dc33 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; + /** Only generate TS Enums when `x-enum-*` metadata is available */ + conditionalEnums?: boolean; /** Dedupe enum values */ dedupeEnums?: boolean; /** (optional) Substitute path parameter names with their respective types */ @@ -688,6 +690,7 @@ export interface GlobalContext { emptyObjectsUnknown: boolean; enum: boolean; enumValues: boolean; + conditionalEnums: boolean; dedupeEnums: boolean; excludeDeprecated: boolean; exportType: boolean; diff --git a/packages/openapi-typescript/test/test-helpers.ts b/packages/openapi-typescript/test/test-helpers.ts index 72e44fccc..14e9abb26 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, + conditionalEnums: false, dedupeEnums: false, excludeDeprecated: false, exportType: false, diff --git a/packages/openapi-typescript/test/transform/schema-object/enum.test.ts b/packages/openapi-typescript/test/transform/schema-object/enum.test.ts new file mode 100644 index 000000000..9de603479 --- /dev/null +++ b/packages/openapi-typescript/test/transform/schema-object/enum.test.ts @@ -0,0 +1,338 @@ +import { transformSchema } from "../../../src/index.js"; +import { astToString } from "../../../src/lib/ts.js"; +import { DEFAULT_CTX, type TestCase } from "../../test-helpers.js"; + +const tests: TestCase[] = [ + [ + "options > enum: false and conditionalEnums: false", + { + given: mockSchema(), + want: `export interface paths { + "/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get current status */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Status response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + required?: unknown; + status?: components["schemas"]["StatusResponse"]; + statusEnum?: components["schemas"]["StatusEnumResponse"]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + StatusResponse: { + status?: components["schemas"]["Status"]; + }; + /** @enum {string} */ + Status: "pending" | "active" | "done"; + StatusEnumResponse: { + status?: components["schemas"]["StatusEnum"]; + }; + /** @enum {string} */ + StatusEnum: "pending" | "active" | "done"; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record;`, + options: { ctx: createTestContext({ enum: false, conditionalEnums: false }) }, + }, + ], + [ + "options > enum: true and conditionalEnums: false", + { + given: mockSchema(), + want: `export interface paths { + "/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get current status */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Status response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + required?: unknown; + status?: components["schemas"]["StatusResponse"]; + statusEnum?: components["schemas"]["StatusEnumResponse"]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + StatusResponse: { + status?: components["schemas"]["Status"]; + }; + /** @enum {string} */ + Status: Status; + StatusEnumResponse: { + status?: components["schemas"]["StatusEnum"]; + }; + /** @enum {string} */ + StatusEnum: StatusEnum; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export enum Status { + pending = "pending", + active = "active", + done = "done" +} +export enum StatusEnum { + // The task is pending + Pending = "pending", + // The task is active + Active = "active", + // The task is done + Done = "done" +} +export type operations = Record;`, + options: { ctx: createTestContext({ enum: true, conditionalEnums: false }) }, + }, + ], + [ + "options > enum: true and conditionalEnums: true", + { + given: mockSchema(), + want: `export interface paths { + "/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get current status */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Status response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + required?: unknown; + status?: components["schemas"]["StatusResponse"]; + statusEnum?: components["schemas"]["StatusEnumResponse"]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + StatusResponse: { + status?: components["schemas"]["Status"]; + }; + /** @enum {string} */ + Status: "pending" | "active" | "done"; + StatusEnumResponse: { + status?: components["schemas"]["StatusEnum"]; + }; + /** @enum {string} */ + StatusEnum: StatusEnum; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export enum StatusEnum { + // The task is pending + Pending = "pending", + // The task is active + Active = "active", + // The task is done + Done = "done" +} +export type operations = Record;`, + options: { ctx: createTestContext({ enum: true, conditionalEnums: true }) }, + }, + ], +]; + +describe("transformComponentsObject", () => { + describe.each(tests)("Case: %s", (name, { given, want, options, ci }) => { + test.skipIf(ci?.skipIf)( + "it matches the snapshot", + async () => { + assert(typeof want === "string"); + const result = astToString(transformSchema(given, options?.ctx ?? DEFAULT_CTX), { fileName: name }); + expect(result.trim()).toBe(want.trim()); + }, + ci?.timeout, + ); + }); +}); + +function mockSchema() { + return { + openapi: "3.0.0", + info: { + title: "Status API", + version: "1.0.0", + }, + paths: { + "/status": { + get: { + summary: "Get current status", + responses: { + "200": { + description: "Status response", + content: { + "application/json": { + schema: { + type: "object", + properties: { + required: { + status: true, + statusEnum: true, + }, + status: { + $ref: "#/components/schemas/StatusResponse", + }, + statusEnum: { + $ref: "#/components/schemas/StatusEnumResponse", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + StatusResponse: { + type: "object", + properties: { + status: { + $ref: "#/components/schemas/Status", + }, + }, + }, + Status: { + type: "string", + enum: ["pending", "active", "done"], + }, + StatusEnumResponse: { + type: "object", + properties: { + status: { + $ref: "#/components/schemas/StatusEnum", + }, + }, + }, + StatusEnum: { + type: "string", + enum: ["pending", "active", "done"], + "x-enum-varnames": ["Pending", "Active", "Done"], + "x-enum-descriptions": ["The task is pending", "The task is active", "The task is done"], + }, + }, + }, + }; +} + +function createTestContext(overrides: Partial = {}) { + return { + ...DEFAULT_CTX, + ...overrides, + // Deep copy mutable properties to avoid scope pollution + discriminators: { + objects: {}, + refsHandled: [], + }, + injectFooter: [], + }; +}