Skip to content

Commit 8edc7c6

Browse files
committed
support conditional ts enums
1 parent 0a2793a commit 8edc7c6

File tree

7 files changed

+315
-4
lines changed

7 files changed

+315
-4
lines changed

docs/cli.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ The following flags are supported in the CLI:
113113
| `--empty-objects-unknown` | | `false` | Allow arbitrary properties for schema objects with no specified properties, and no specified `additionalProperties` |
114114
| `--enum` | | `false` | Generate true [TS enums](https://www.typescriptlang.org/docs/handbook/enums.html) rather than string unions. |
115115
| `--enum-values` | | `false` | Export enum values as arrays. |
116+
| `--conditional-enums` | | `false` | Only generate true TS enums when the `x-enum-*` metadata is available. Requires `--enum=true` to be enabled. |
116117
| `--dedupe-enums` | | `false` | Dedupe enum types when `--enum=true` is set |
117118
| `--check` | | `false` | Check that the generated types are up-to-date. |
118119
| `--exclude-deprecated` | | `false` | Exclude deprecated fields from types |

packages/openapi-typescript/bin/cli.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Options
1717
--output, -o Specify output file (if not specified in redocly.yaml)
1818
--enum Export true TS enums instead of unions
1919
--enum-values Export enum values as arrays
20+
--conditional-enums Only generate true TS enums when enum metadata is available (default: false)
2021
--dedupe-enums Dedupe enum types when \`--enum=true\` is set
2122
--check Check that the generated types are up-to-date. (default: false)
2223
--export-type, -t Export top-level \`type\` instead of \`interface\`
@@ -74,6 +75,7 @@ const flags = parser(args, {
7475
"emptyObjectsUnknown",
7576
"enum",
7677
"enumValues",
78+
"conditionalEnums",
7779
"dedupeEnums",
7880
"check",
7981
"excludeDeprecated",
@@ -139,6 +141,7 @@ async function generateSchema(schema, { redocly, silent = false }) {
139141
emptyObjectsUnknown: flags.emptyObjectsUnknown,
140142
enum: flags.enum,
141143
enumValues: flags.enumValues,
144+
conditionalEnums: flags.conditionalEnums,
142145
dedupeEnums: flags.dedupeEnums,
143146
excludeDeprecated: flags.excludeDeprecated,
144147
exportType: flags.exportType,

packages/openapi-typescript/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export default async function openapiTS(
7575
emptyObjectsUnknown: options.emptyObjectsUnknown ?? false,
7676
enum: options.enum ?? false,
7777
enumValues: options.enumValues ?? false,
78+
conditionalEnums: options.conditionalEnums ?? false,
7879
dedupeEnums: options.dedupeEnums ?? false,
7980
excludeDeprecated: options.excludeDeprecated ?? false,
8081
exportType: options.exportType ?? false,

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

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,7 @@ export function transformSchemaObjectWithComposition(
9696
!("additionalProperties" in schemaObject)
9797
) {
9898
// hoist enum to top level if string/number enum and option is enabled
99-
if (
100-
options.ctx.enum &&
101-
schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number" || v === null)
102-
) {
99+
if (shouldTransformToTsEnum(options, schemaObject)) {
103100
let enumName = parseRef(options.path ?? "").pointer.join("/");
104101
// allow #/components/schemas to have simpler names
105102
enumName = enumName.replace("components/schemas", "");
@@ -270,6 +267,35 @@ export function transformSchemaObjectWithComposition(
270267
return finalType;
271268
}
272269

270+
/**
271+
* Check if the given OAPI enum should be transformed to a TypeScript enum
272+
*/
273+
function shouldTransformToTsEnum(options: TransformNodeOptions, schemaObject: SchemaObject): boolean {
274+
// Enum conversion not enabled
275+
if (!options.ctx.enum) {
276+
return false;
277+
}
278+
279+
// Enum must have string, number or null values
280+
if (!schemaObject.enum?.every((v) => typeof v === "string" || typeof v === "number" || v === null)) {
281+
return false;
282+
}
283+
284+
// If conditionalEnums is enabled, only convert if x-enum-* metadata is present
285+
if (options.ctx.conditionalEnums) {
286+
const hasEnumMetadata =
287+
Array.isArray(schemaObject["x-enum-varnames"]) ||
288+
Array.isArray(schemaObject["x-enumNames"]) ||
289+
Array.isArray(schemaObject["x-enum-descriptions"]) ||
290+
Array.isArray(schemaObject["x-enumDescriptions"]);
291+
if (!hasEnumMetadata) {
292+
return false;
293+
}
294+
}
295+
296+
return true;
297+
}
298+
273299
/**
274300
* Handle SchemaObject minus composition (anyOf/allOf/oneOf)
275301
*/

packages/openapi-typescript/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,8 @@ export interface OpenAPITSOptions {
651651
enum?: boolean;
652652
/** Export union values as arrays */
653653
enumValues?: boolean;
654+
/** Only generate TS Enums when `x-enum-*` metadata is available */
655+
conditionalEnums?: boolean;
654656
/** Dedupe enum values */
655657
dedupeEnums?: boolean;
656658
/** (optional) Substitute path parameter names with their respective types */
@@ -688,6 +690,7 @@ export interface GlobalContext {
688690
emptyObjectsUnknown: boolean;
689691
enum: boolean;
690692
enumValues: boolean;
693+
conditionalEnums: boolean;
691694
dedupeEnums: boolean;
692695
excludeDeprecated: boolean;
693696
exportType: boolean;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const DEFAULT_CTX: GlobalContext = {
1515
emptyObjectsUnknown: false,
1616
enum: false,
1717
enumValues: false,
18+
conditionalEnums: false,
1819
dedupeEnums: false,
1920
excludeDeprecated: false,
2021
exportType: false,
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { fileURLToPath } from "node:url";
2+
import { transformSchema } from "../../../src/index.js";
3+
import { astToString } from "../../../src/lib/ts.js";
4+
import type { GlobalContext } from "../../../src/types.js";
5+
import { DEFAULT_CTX, type TestCase } from "../../test-helpers.js";
6+
7+
const DEFAULT_OPTIONS = DEFAULT_CTX;
8+
9+
const schema = {
10+
openapi: "3.0.0",
11+
info: {
12+
title: "Status API",
13+
version: "1.0.0",
14+
},
15+
paths: {
16+
"/status": {
17+
get: {
18+
summary: "Get current status",
19+
responses: {
20+
"200": {
21+
description: "Status response",
22+
content: {
23+
"application/json": {
24+
schema: {
25+
type: "object",
26+
properties: {
27+
required: {
28+
"status": true,
29+
"statusEnum": true,
30+
},
31+
status: {
32+
$ref: "#/components/schemas/StatusResponse",
33+
},
34+
statusEnum: {
35+
$ref: "#/components/schemas/StatusEnumResponse",
36+
}
37+
}
38+
},
39+
},
40+
},
41+
},
42+
},
43+
},
44+
},
45+
},
46+
components: {
47+
schemas: {
48+
StatusResponse: {
49+
type: "object",
50+
properties: {
51+
status: {
52+
$ref: "#/components/schemas/Status",
53+
},
54+
},
55+
},
56+
Status: {
57+
type: "string",
58+
enum: ["pending", "active", "done"],
59+
},
60+
StatusEnumResponse: {
61+
type: "object",
62+
properties: {
63+
status: {
64+
$ref: "#/components/schemas/StatusEnum",
65+
},
66+
},
67+
},
68+
StatusEnum: {
69+
type: "string",
70+
enum: ["pending", "active", "done"],
71+
"x-enum-varnames": ["Pending", "Active", "Done"],
72+
"x-enum-descriptions": [
73+
"The task is pending",
74+
"The task is active",
75+
"The task is done",
76+
],
77+
},
78+
},
79+
},
80+
};
81+
82+
describe("transformComponentsObject", () => {
83+
const tests: TestCase<any, GlobalContext>[] = [
84+
[
85+
"options > enum: true and conditionalEnums: false",
86+
{
87+
given: schema,
88+
want: `export interface paths {
89+
"/status": {
90+
parameters: {
91+
query?: never;
92+
header?: never;
93+
path?: never;
94+
cookie?: never;
95+
};
96+
/** Get current status */
97+
get: {
98+
parameters: {
99+
query?: never;
100+
header?: never;
101+
path?: never;
102+
cookie?: never;
103+
};
104+
requestBody?: never;
105+
responses: {
106+
/** @description Status response */
107+
200: {
108+
headers: {
109+
[name: string]: unknown;
110+
};
111+
content: {
112+
"application/json": {
113+
required?: unknown;
114+
status?: components["schemas"]["StatusResponse"];
115+
statusEnum?: components["schemas"]["StatusEnumResponse"];
116+
};
117+
};
118+
};
119+
};
120+
};
121+
put?: never;
122+
post?: never;
123+
delete?: never;
124+
options?: never;
125+
head?: never;
126+
patch?: never;
127+
trace?: never;
128+
};
129+
}
130+
export type webhooks = Record<string, never>;
131+
export interface components {
132+
schemas: {
133+
StatusResponse: {
134+
status?: components["schemas"]["Status"];
135+
};
136+
/** @enum {string} */
137+
Status: Status;
138+
StatusEnumResponse: {
139+
status?: components["schemas"]["StatusEnum"];
140+
};
141+
/** @enum {string} */
142+
StatusEnum: StatusEnum;
143+
};
144+
responses: never;
145+
parameters: never;
146+
requestBodies: never;
147+
headers: never;
148+
pathItems: never;
149+
}
150+
export type $defs = Record<string, never>;
151+
export enum Status {
152+
pending = "pending",
153+
active = "active",
154+
done = "done"
155+
}
156+
export enum StatusEnum {
157+
// The task is pending
158+
Pending = "pending",
159+
// The task is active
160+
Active = "active",
161+
// The task is done
162+
Done = "done"
163+
}
164+
export type operations = Record<string, never>;`,
165+
options: { ...DEFAULT_OPTIONS, enum: true, conditionalEnums: false },
166+
},
167+
],
168+
[
169+
"options > enum: true and conditionalEnums: true",
170+
{
171+
given: schema,
172+
want: `export interface paths {
173+
"/status": {
174+
parameters: {
175+
query?: never;
176+
header?: never;
177+
path?: never;
178+
cookie?: never;
179+
};
180+
/** Get current status */
181+
get: {
182+
parameters: {
183+
query?: never;
184+
header?: never;
185+
path?: never;
186+
cookie?: never;
187+
};
188+
requestBody?: never;
189+
responses: {
190+
/** @description Status response */
191+
200: {
192+
headers: {
193+
[name: string]: unknown;
194+
};
195+
content: {
196+
"application/json": {
197+
required?: unknown;
198+
status?: components["schemas"]["StatusResponse"];
199+
statusEnum?: components["schemas"]["StatusEnumResponse"];
200+
};
201+
};
202+
};
203+
};
204+
};
205+
put?: never;
206+
post?: never;
207+
delete?: never;
208+
options?: never;
209+
head?: never;
210+
patch?: never;
211+
trace?: never;
212+
};
213+
}
214+
export type webhooks = Record<string, never>;
215+
export interface components {
216+
schemas: {
217+
StatusResponse: {
218+
status?: components["schemas"]["Status"];
219+
};
220+
/** @enum {string} */
221+
Status: "pending" | "active" | "done";
222+
StatusEnumResponse: {
223+
status?: components["schemas"]["StatusEnum"];
224+
};
225+
/** @enum {string} */
226+
StatusEnum: StatusEnum;
227+
};
228+
responses: never;
229+
parameters: never;
230+
requestBodies: never;
231+
headers: never;
232+
pathItems: never;
233+
}
234+
export type $defs = Record<string, never>;
235+
export enum Status {
236+
pending = "pending",
237+
active = "active",
238+
done = "done"
239+
}
240+
export enum StatusEnum {
241+
// The task is pending
242+
Pending = "pending",
243+
// The task is active
244+
Active = "active",
245+
// The task is done
246+
Done = "done"
247+
}
248+
export enum StatusEnum {
249+
// The task is pending
250+
Pending = "pending",
251+
// The task is active
252+
Active = "active",
253+
// The task is done
254+
Done = "done"
255+
}
256+
export type operations = Record<string, never>;`,
257+
options: { ...DEFAULT_OPTIONS, enum: true, conditionalEnums: true },
258+
},
259+
],
260+
];
261+
262+
for (const [testName, { given, want, options, ci }] of tests) {
263+
test.skipIf(ci?.skipIf)(
264+
testName,
265+
async () => {
266+
const result = astToString(transformSchema(given, options ?? DEFAULT_OPTIONS));
267+
if (want instanceof URL) {
268+
await expect(result).toMatchFileSnapshot(fileURLToPath(want));
269+
} else {
270+
expect(result.trim()).toBe(want.trim());
271+
}
272+
},
273+
ci?.timeout,
274+
);
275+
}
276+
});

0 commit comments

Comments
 (0)