From 3fabebef52e2f8083f0f7d585e20ab2f5dbab034 Mon Sep 17 00:00:00 2001 From: Parker Van Roy Date: Mon, 14 Jul 2025 03:54:56 -0400 Subject: [PATCH] Enum with Root Types Filtering Recursive Reference --- .changeset/mighty-zebras-move.md | 5 ++ .../examples/enum-root-types.ts | 38 ++++++++++ .../examples/enum-root-types.yaml | 21 ++++++ .../src/transform/components-object.ts | 69 ++++++++++++++----- packages/openapi-typescript/test/cli.test.ts | 8 +++ .../test/transform/components-object.test.ts | 68 +++++++++++++++++- .../openapi-typescript/tsconfig.examples.json | 2 +- 7 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 .changeset/mighty-zebras-move.md create mode 100644 packages/openapi-typescript/examples/enum-root-types.ts create mode 100644 packages/openapi-typescript/examples/enum-root-types.yaml diff --git a/.changeset/mighty-zebras-move.md b/.changeset/mighty-zebras-move.md new file mode 100644 index 000000000..91b4bcf23 --- /dev/null +++ b/.changeset/mighty-zebras-move.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": patch +--- + +Fix behavior when using enum and export-type flags diff --git a/packages/openapi-typescript/examples/enum-root-types.ts b/packages/openapi-typescript/examples/enum-root-types.ts new file mode 100644 index 000000000..31b7cf3e9 --- /dev/null +++ b/packages/openapi-typescript/examples/enum-root-types.ts @@ -0,0 +1,38 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export type paths = Record; +export type webhooks = Record; +export interface components { + schemas: { + /** @enum {string} */ + Status: Status; + /** @enum {number} */ + Priority: Priority; + Item: { + name?: string; + status?: components["schemas"]["Status"]; + priority?: components["schemas"]["Priority"]; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type Item = components['schemas']['Item']; +export type $defs = Record; +export enum Status { + active = "active", + inactive = "inactive", + pending = "pending" +} +export enum Priority { + Value1 = 1, + Value2 = 2, + Value3 = 3 +} +export type operations = Record; diff --git a/packages/openapi-typescript/examples/enum-root-types.yaml b/packages/openapi-typescript/examples/enum-root-types.yaml new file mode 100644 index 000000000..6302deeb9 --- /dev/null +++ b/packages/openapi-typescript/examples/enum-root-types.yaml @@ -0,0 +1,21 @@ +openapi: 3.0.0 +info: + title: Enum Root Types Test + version: 1.0.0 +components: + schemas: + Status: + type: string + enum: [active, inactive, pending] + Priority: + type: number + enum: [1, 2, 3] + Item: + type: object + properties: + name: + type: string + status: + $ref: '#/components/schemas/Status' + priority: + $ref: '#/components/schemas/Priority' diff --git a/packages/openapi-typescript/src/transform/components-object.ts b/packages/openapi-typescript/src/transform/components-object.ts index e5c100d26..88ae44896 100644 --- a/packages/openapi-typescript/src/transform/components-object.ts +++ b/packages/openapi-typescript/src/transform/components-object.ts @@ -11,6 +11,34 @@ import transformRequestBodyObject from "./request-body-object.js"; import transformResponseObject from "./response-object.js"; import transformSchemaObject from "./schema-object.js"; +/** + * Determines if a schema object represents an enum type to prevent duplicate exports + * when using --root-types and --enum flags together. + * + * When both flags are enabled: + * - --enum flag generates TypeScript enums at the bottom of the file + * - --root-types flag would normally also export these as root type aliases + * - This results in duplicate exports (both enum and type alias for the same schema) + * + * This function identifies enum schemas so they can be excluded from root type generation, + * allowing only the TypeScript enum to be generated. + * + * @param schema The schema object to check + * @returns true if the schema represents an enum type + */ +export function isEnumSchema(schema: unknown): boolean { + return ( + typeof schema === "object" && + schema !== null && + !Array.isArray(schema) && + "enum" in schema && + Array.isArray((schema as any).enum) && + (!("type" in schema) || (schema as any).type !== "object") && + !("properties" in schema) && + !("additionalProperties" in schema) + ); +} + type ComponentTransforms = keyof Omit; const transformers: Record ts.TypeNode> = { @@ -68,27 +96,32 @@ export default function transformComponentsObject(componentsObject: ComponentsOb items.push(property); if (ctx.rootTypes) { - const componentKey = changeCase.pascalCase(singularizeComponentKey(key)); - let aliasName = `${componentKey}${changeCase.pascalCase(name)}`; + // Skip enum schemas when generating root types to prevent duplication (only when --enum flag is enabled) + const shouldSkipEnumSchema = ctx.enum && key === "schemas" && isEnumSchema(item); - // Add counter suffix (e.g. "_2") if conflict in name - let conflictCounter = 1; + if (!shouldSkipEnumSchema) { + const componentKey = changeCase.pascalCase(singularizeComponentKey(key)); + let aliasName = `${componentKey}${changeCase.pascalCase(name)}`; - while (rootTypeAliases[aliasName] !== undefined) { - conflictCounter++; - aliasName = `${componentKey}${changeCase.pascalCase(name)}_${conflictCounter}`; - } - const ref = ts.factory.createTypeReferenceNode(`components['${key}']['${name}']`); - if (ctx.rootTypesNoSchemaPrefix && key === "schemas") { - aliasName = aliasName.replace(componentKey, ""); + // Add counter suffix (e.g. "_2") if conflict in name + let conflictCounter = 1; + + while (rootTypeAliases[aliasName] !== undefined) { + conflictCounter++; + aliasName = `${componentKey}${changeCase.pascalCase(name)}_${conflictCounter}`; + } + const ref = ts.factory.createTypeReferenceNode(`components['${key}']['${name}']`); + if (ctx.rootTypesNoSchemaPrefix && key === "schemas") { + aliasName = aliasName.replace(componentKey, ""); + } + const typeAlias = ts.factory.createTypeAliasDeclaration( + /* modifiers */ tsModifiers({ export: true }), + /* name */ aliasName, + /* typeParameters */ undefined, + /* type */ ref, + ); + rootTypeAliases[aliasName] = typeAlias; } - const typeAlias = ts.factory.createTypeAliasDeclaration( - /* modifiers */ tsModifiers({ export: true }), - /* name */ aliasName, - /* typeParameters */ undefined, - /* type */ ref, - ); - rootTypeAliases[aliasName] = typeAlias; } } } diff --git a/packages/openapi-typescript/test/cli.test.ts b/packages/openapi-typescript/test/cli.test.ts index f282d2823..139fc262c 100644 --- a/packages/openapi-typescript/test/cli.test.ts +++ b/packages/openapi-typescript/test/cli.test.ts @@ -76,6 +76,14 @@ describe("CLI", () => { ci: { timeout: TIMEOUT }, }, ], + [ + "snapshot > enum root types filtering", + { + given: ["./examples/enum-root-types.yaml", "--root-types", "--root-types-no-schema-prefix", "--enum"], + want: new URL("./examples/enum-root-types.ts", root), + ci: { timeout: TIMEOUT }, + }, + ], ]; for (const [testName, { given, want, ci }] of tests) { diff --git a/packages/openapi-typescript/test/transform/components-object.test.ts b/packages/openapi-typescript/test/transform/components-object.test.ts index 43116c8a7..dc2e6426c 100644 --- a/packages/openapi-typescript/test/transform/components-object.test.ts +++ b/packages/openapi-typescript/test/transform/components-object.test.ts @@ -1,7 +1,7 @@ import { fileURLToPath } from "node:url"; import ts from "typescript"; import { NULL, astToString } from "../../src/lib/ts.js"; -import transformComponentsObject from "../../src/transform/components-object.js"; +import transformComponentsObject, { isEnumSchema } from "../../src/transform/components-object.js"; import type { GlobalContext } from "../../src/types.js"; import { DEFAULT_CTX, type TestCase } from "../test-helpers.js"; @@ -871,3 +871,69 @@ export type Error = components['schemas']['Error']; ); } }); + +describe("isEnumSchema", () => { + test("returns true for string enum schema", () => { + const schema = { + type: "string", + enum: ["active", "inactive", "pending"], + }; + expect(isEnumSchema(schema)).toBe(true); + }); + + test("returns true for number enum schema", () => { + const schema = { + type: "number", + enum: [1, 2, 3], + }; + expect(isEnumSchema(schema)).toBe(true); + }); + + test("returns true for mixed enum schema without explicit type", () => { + const schema = { + enum: ["high", 0, null], + }; + expect(isEnumSchema(schema)).toBe(true); + }); + + test("returns false for object schema with properties", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + }; + expect(isEnumSchema(schema)).toBe(false); + }); + + test("returns false for object schema with enum (object enums not supported)", () => { + const schema = { + type: "object", + enum: [{ value: "test" }], + }; + expect(isEnumSchema(schema)).toBe(false); + }); + + test("returns false for schema with additionalProperties", () => { + const schema = { + enum: ["test"], + additionalProperties: true, + }; + expect(isEnumSchema(schema)).toBe(false); + }); + + test("returns false for schema without enum", () => { + const schema = { + type: "string", + }; + expect(isEnumSchema(schema)).toBe(false); + }); + + test("returns false for null, undefined, or non-object inputs", () => { + expect(isEnumSchema(null)).toBe(false); + expect(isEnumSchema(undefined)).toBe(false); + expect(isEnumSchema("string")).toBe(false); + expect(isEnumSchema(123)).toBe(false); + expect(isEnumSchema([])).toBe(false); + }); +}); diff --git a/packages/openapi-typescript/tsconfig.examples.json b/packages/openapi-typescript/tsconfig.examples.json index 789652e76..0d749573d 100644 --- a/packages/openapi-typescript/tsconfig.examples.json +++ b/packages/openapi-typescript/tsconfig.examples.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", "include": ["examples"], - "exclude": ["examples/digital-ocean-api.ts"] + "exclude": ["examples/digital-ocean-api.ts", "examples/enum-root-types.ts"] }