Skip to content

Commit 6ea1b32

Browse files
add flag for exporting enum value arrays #1616 (#1661)
1 parent e4e099d commit 6ea1b32

File tree

10 files changed

+235
-10
lines changed

10 files changed

+235
-10
lines changed

docs/cli.md

+1
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ The following flags are supported in the CLI:
112112
| `--properties-required-by-default` | | `false` | Treat schema objects without `required` as having all properties required. |
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. |
115+
| `--enum-values` | | `false` | Export enum values as arrays. |
115116
| `--exclude-deprecated` | | `false` | Exclude deprecated fields from types |
116117
| `--export-type` | `-t` | `false` | Export `type` instead of `interface` |
117118
| `--immutable` | | `false` | Generates immutable types (readonly properties and readonly array) |

docs/zh/cli.md

+1
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ CLI 支持以下参数:
111111
| `--default-non-nullable` | | `false` | 将带有默认值的模式对象视为非可空 |
112112
| `--empty-objects-unknown` | | `false` | 允许在未指定属性和未指定 `additionalProperties` 的情况下,为模式对象设置任意属性 |
113113
| `--enum` | | `false` | 生成真实的 [TS 枚举](https://www.typescriptlang.org/docs/handbook/enums.html),而不是字符串联合。 |
114+
| `--enum-values | | `false` | |
114115
| `--exclude-deprecated` | | `false` | 从类型中排除已弃用的字段 |
115116
| `--export-type` | `-t` | `false` | 导出 `type` 而不是 `interface` |
116117
| `--immutable` | | `false` | 生成不可变类型(只读属性和只读数组) |

packages/openapi-typescript/bin/cli.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
#!/usr/bin/env node
22

3-
import { loadConfig, findConfig, createConfig } from "@redocly/openapi-core";
3+
import { createConfig, findConfig, loadConfig } from "@redocly/openapi-core";
44
import fs from "node:fs";
55
import path from "node:path";
66
import parser from "yargs-parser";
7-
import openapiTS, { astToString, c, COMMENT_HEADER, error, formatTime, warn } from "../dist/index.js";
7+
import openapiTS, { COMMENT_HEADER, astToString, c, error, formatTime, warn } from "../dist/index.js";
88

99
const HELP = `Usage
1010
$ openapi-typescript [input] [options]
@@ -15,6 +15,7 @@ Options
1515
--redocly [path], -c Specify path to Redocly config (default: redocly.yaml)
1616
--output, -o Specify output file (if not specified in redocly.yaml)
1717
--enum Export true TS enums instead of unions
18+
--enum-values Export enum values as arrays
1819
--export-type, -t Export top-level \`type\` instead of \`interface\`
1920
--immutable Generate readonly types
2021
--additional-properties Treat schema objects as if \`additionalProperties: true\` is set
@@ -62,6 +63,7 @@ const flags = parser(args, {
6263
"propertiesRequiredByDefault",
6364
"emptyObjectsUnknown",
6465
"enum",
66+
"enumValues",
6567
"excludeDeprecated",
6668
"exportType",
6769
"help",
@@ -91,6 +93,7 @@ async function generateSchema(schema, { redocly, silent = false }) {
9193
defaultNonNullable: flags.defaultNonNullable,
9294
emptyObjectsUnknown: flags.emptyObjectsUnknown,
9395
enum: flags.enum,
96+
enumValues: flags.enumValues,
9497
excludeDeprecated: flags.excludeDeprecated,
9598
exportType: flags.exportType,
9699
immutable: flags.immutable,

packages/openapi-typescript/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export default async function openapiTS(
7070
discriminators: scanDiscriminators(schema, options),
7171
emptyObjectsUnknown: options.emptyObjectsUnknown ?? false,
7272
enum: options.enum ?? false,
73+
enumValues: options.enumValues ?? false,
7374
excludeDeprecated: options.excludeDeprecated ?? false,
7475
exportType: options.exportType ?? false,
7576
immutable: options.immutable ?? false,

packages/openapi-typescript/src/lib/ts.ts

+57-7
Original file line numberDiff line numberDiff line change
@@ -206,13 +206,7 @@ export function tsEnum(
206206
metadata?: { name?: string; description?: string }[],
207207
options?: { export?: boolean },
208208
) {
209-
let enumName = name.replace(JS_ENUM_INVALID_CHARS_RE, (c) => {
210-
const last = c[c.length - 1];
211-
return JS_PROPERTY_INDEX_INVALID_CHARS_RE.test(last) ? "" : last.toUpperCase();
212-
});
213-
if (Number(name[0]) >= 0) {
214-
enumName = `Value${name}`;
215-
}
209+
let enumName = sanitizeMemberName(name);
216210
enumName = `${enumName[0].toUpperCase()}${enumName.substring(1)}`;
217211
return ts.factory.createEnumDeclaration(
218212
/* modifiers */ options ? tsModifiers({ export: options.export ?? false }) : undefined,
@@ -221,6 +215,62 @@ export function tsEnum(
221215
);
222216
}
223217

218+
/** Create an exported TS array literal expression */
219+
export function tsArrayLiteralExpression(
220+
name: string,
221+
elementType: ts.TypeNode,
222+
values: (string | number)[],
223+
options?: { export?: boolean; readonly?: boolean },
224+
) {
225+
let variableName = sanitizeMemberName(name);
226+
variableName = `${variableName[0].toLowerCase()}${variableName.substring(1)}`;
227+
228+
const arrayType = options?.readonly
229+
? ts.factory.createTypeReferenceNode("ReadonlyArray", [elementType])
230+
: ts.factory.createArrayTypeNode(elementType);
231+
232+
return ts.factory.createVariableStatement(
233+
options ? tsModifiers({ export: options.export ?? false }) : undefined,
234+
ts.factory.createVariableDeclarationList(
235+
[
236+
ts.factory.createVariableDeclaration(
237+
variableName,
238+
undefined,
239+
arrayType,
240+
ts.factory.createArrayLiteralExpression(
241+
values.map((value) => {
242+
if (typeof value === "number") {
243+
if (value < 0) {
244+
return ts.factory.createPrefixUnaryExpression(
245+
ts.SyntaxKind.MinusToken,
246+
ts.factory.createNumericLiteral(Math.abs(value)),
247+
);
248+
} else {
249+
return ts.factory.createNumericLiteral(value);
250+
}
251+
} else {
252+
return ts.factory.createStringLiteral(value);
253+
}
254+
}),
255+
),
256+
),
257+
],
258+
ts.NodeFlags.Const,
259+
),
260+
);
261+
}
262+
263+
function sanitizeMemberName(name: string) {
264+
let sanitizedName = name.replace(JS_ENUM_INVALID_CHARS_RE, (c) => {
265+
const last = c[c.length - 1];
266+
return JS_PROPERTY_INDEX_INVALID_CHARS_RE.test(last) ? "" : last.toUpperCase();
267+
});
268+
if (Number(name[0]) >= 0) {
269+
sanitizedName = `Value${name}`;
270+
}
271+
return sanitizedName;
272+
}
273+
224274
/** Sanitize TS enum member expression */
225275
export function tsEnumMember(value: string | number, metadata: { name?: string; description?: string } = {}) {
226276
let name = metadata.name ?? String(value);

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

+25-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
UNKNOWN,
1212
addJSDocComment,
1313
oapiRef,
14+
tsArrayLiteralExpression,
1415
tsEnum,
1516
tsIntersection,
1617
tsIsPrimitive,
@@ -118,7 +119,30 @@ export function transformSchemaObjectWithComposition(
118119
if ((Array.isArray(schemaObject.type) && schemaObject.type.includes("null")) || schemaObject.nullable) {
119120
enumType.push(NULL);
120121
}
121-
return tsUnion(enumType);
122+
123+
const unionType = tsUnion(enumType);
124+
125+
// hoist array with valid enum values to top level if string/number enum and option is enabled
126+
if (options.ctx.enumValues && schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number")) {
127+
let enumValuesVariableName = parseRef(options.path ?? "").pointer.join("/");
128+
// allow #/components/schemas to have simpler names
129+
enumValuesVariableName = enumValuesVariableName.replace("components/schemas", "");
130+
enumValuesVariableName = `${enumValuesVariableName}Values`;
131+
132+
const enumValuesArray = tsArrayLiteralExpression(
133+
enumValuesVariableName,
134+
oapiRef(options.path ?? ""),
135+
schemaObject.enum as (string | number)[],
136+
{
137+
export: true,
138+
readonly: true,
139+
},
140+
);
141+
142+
options.ctx.injectFooter.push(enumValuesArray);
143+
}
144+
145+
return unionType;
122146
}
123147

124148
/**

packages/openapi-typescript/src/types.ts

+3
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,8 @@ export interface OpenAPITSOptions {
649649
exportType?: boolean;
650650
/** Export true TypeScript enums instead of unions */
651651
enum?: boolean;
652+
/** Export union values as arrays */
653+
enumValues?: boolean;
652654
/** (optional) Substitute path parameter names with their respective types */
653655
pathParamsAsTypes?: boolean;
654656
/** Treat all objects as if they have \`required\` set to all properties by default (default: false) */
@@ -673,6 +675,7 @@ export interface GlobalContext {
673675
};
674676
emptyObjectsUnknown: boolean;
675677
enum: boolean;
678+
enumValues: boolean;
676679
excludeDeprecated: boolean;
677680
exportType: boolean;
678681
immutable: boolean;

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

+55
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
STRING,
88
astToString,
99
oapiRef,
10+
tsArrayLiteralExpression,
1011
tsEnum,
1112
tsIsPrimitive,
1213
tsLiteral,
@@ -201,6 +202,60 @@ describe("tsEnum", () => {
201202
});
202203
});
203204

205+
describe("tsArrayLiteralExpression", () => {
206+
test("string members", () => {
207+
expect(
208+
astToString(
209+
tsArrayLiteralExpression("-my-color-Values", oapiRef("#/components/schemas/Color"), ["green", "red", "blue"]),
210+
).trim(),
211+
).toBe(`const myColorValues: components["schemas"]["Color"][] = ["green", "red", "blue"];`);
212+
});
213+
214+
test("with setting: export", () => {
215+
expect(
216+
astToString(
217+
tsArrayLiteralExpression("-my-color-Values", oapiRef("#/components/schemas/Color"), ["green", "red", "blue"], {
218+
export: true,
219+
}),
220+
).trim(),
221+
).toBe(`export const myColorValues: components["schemas"]["Color"][] = ["green", "red", "blue"];`);
222+
});
223+
224+
test("with setting: readonly", () => {
225+
expect(
226+
astToString(
227+
tsArrayLiteralExpression("-my-color-Values", oapiRef("#/components/schemas/Color"), ["green", "red", "blue"], {
228+
readonly: true,
229+
}),
230+
).trim(),
231+
).toBe(`const myColorValues: ReadonlyArray<components["schemas"]["Color"]> = ["green", "red", "blue"];`);
232+
});
233+
234+
test("name from path", () => {
235+
expect(
236+
astToString(
237+
tsArrayLiteralExpression(
238+
"#/paths/url/get/parameters/query/status/Values",
239+
oapiRef("#/components/schemas/Status"),
240+
["active", "inactive"],
241+
),
242+
).trim(),
243+
).toBe(`const pathsUrlGetParametersQueryStatusValues: components["schemas"]["Status"][] = ["active", "inactive"];`);
244+
});
245+
246+
test("number members", () => {
247+
expect(
248+
astToString(
249+
tsArrayLiteralExpression(
250+
".Error.code.Values",
251+
oapiRef("#/components/schemas/ErrorCode"),
252+
[100, 101, 102, -100],
253+
),
254+
).trim(),
255+
).toBe(`const errorCodeValues: components["schemas"]["ErrorCode"][] = [100, 101, 102, -100];`);
256+
});
257+
});
258+
204259
describe("tsPropertyIndex", () => {
205260
test("numbers -> number literals", () => {
206261
expect(astToString(tsPropertyIndex(200)).trim()).toBe("200");

packages/openapi-typescript/test/node-api.test.ts

+86
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,92 @@ export type operations = Record<string, never>;`,
704704
options: { enum: true },
705705
},
706706
],
707+
[
708+
"options > enumValues",
709+
{
710+
given: {
711+
openapi: "3.1",
712+
info: { title: "Test", version: "1.0" },
713+
paths: {
714+
"/url": {
715+
get: {
716+
parameters: [
717+
{
718+
name: "status",
719+
in: "query",
720+
schema: {
721+
type: "string",
722+
enum: ["active", "inactive"],
723+
},
724+
},
725+
],
726+
},
727+
},
728+
},
729+
components: {
730+
schemas: {
731+
Status: {
732+
type: "string",
733+
enum: ["active", "inactive"],
734+
},
735+
ErrorCode: {
736+
type: "number",
737+
enum: [100, 101, 102, 103, 104, 105],
738+
},
739+
},
740+
},
741+
},
742+
want: `export interface paths {
743+
"/url": {
744+
parameters: {
745+
query?: never;
746+
header?: never;
747+
path?: never;
748+
cookie?: never;
749+
};
750+
get: {
751+
parameters: {
752+
query?: {
753+
status?: "active" | "inactive";
754+
};
755+
header?: never;
756+
path?: never;
757+
cookie?: never;
758+
};
759+
requestBody?: never;
760+
responses: never;
761+
};
762+
put?: never;
763+
post?: never;
764+
delete?: never;
765+
options?: never;
766+
head?: never;
767+
patch?: never;
768+
trace?: never;
769+
};
770+
}
771+
export type webhooks = Record<string, never>;
772+
export interface components {
773+
schemas: {
774+
/** @enum {string} */
775+
Status: "active" | "inactive";
776+
/** @enum {number} */
777+
ErrorCode: 100 | 101 | 102 | 103 | 104 | 105;
778+
};
779+
responses: never;
780+
parameters: never;
781+
requestBodies: never;
782+
headers: never;
783+
pathItems: never;
784+
}
785+
export type $defs = Record<string, never>;
786+
export const pathsUrlGetParametersQueryStatusValues: ReadonlyArray<paths["/url"]["get"]["parameters"]["query"]["status"]> = ["active", "inactive"];
787+
export const statusValues: ReadonlyArray<components["schemas"]["Status"]> = ["active", "inactive"];
788+
export const errorCodeValues: ReadonlyArray<components["schemas"]["ErrorCode"]> = [100, 101, 102, 103, 104, 105];
789+
export type operations = Record<string, never>;`,
790+
options: { enumValues: true },
791+
},
792+
],
707793
[
708794
"snapshot > GitHub",
709795
{

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

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const DEFAULT_CTX: GlobalContext = {
1414
},
1515
emptyObjectsUnknown: false,
1616
enum: false,
17+
enumValues: false,
1718
excludeDeprecated: false,
1819
exportType: false,
1920
immutable: false,

0 commit comments

Comments
 (0)