diff --git a/docs/cli.md b/docs/cli.md index a7ee5b9f4..61dda1b37 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -112,6 +112,7 @@ The following flags are supported in the CLI: | `--properties-required-by-default` | | `false` | Treat schema objects without `required` as having all properties required. | | `--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. | | `--exclude-deprecated` | | `false` | Exclude deprecated fields from types | | `--export-type` | `-t` | `false` | Export `type` instead of `interface` | | `--immutable` | | `false` | Generates immutable types (readonly properties and readonly array) | diff --git a/docs/zh/cli.md b/docs/zh/cli.md index 73b0dd336..b02ff1f93 100644 --- a/docs/zh/cli.md +++ b/docs/zh/cli.md @@ -111,6 +111,7 @@ CLI 支持以下参数: | `--default-non-nullable` | | `false` | 将带有默认值的模式对象视为非可空 | | `--empty-objects-unknown` | | `false` | 允许在未指定属性和未指定 `additionalProperties` 的情况下,为模式对象设置任意属性 | | `--enum` | | `false` | 生成真实的 [TS 枚举](https://www.typescriptlang.org/docs/handbook/enums.html),而不是字符串联合。 | +| `--enum-values | | `false` | | | `--exclude-deprecated` | | `false` | 从类型中排除已弃用的字段 | | `--export-type` | `-t` | `false` | 导出 `type` 而不是 `interface` | | `--immutable` | | `false` | 生成不可变类型(只读属性和只读数组) | diff --git a/packages/openapi-typescript/bin/cli.js b/packages/openapi-typescript/bin/cli.js index e8cce4b6a..b9d37dbb0 100755 --- a/packages/openapi-typescript/bin/cli.js +++ b/packages/openapi-typescript/bin/cli.js @@ -1,10 +1,10 @@ #!/usr/bin/env node -import { loadConfig, findConfig, createConfig } from "@redocly/openapi-core"; +import { createConfig, findConfig, loadConfig } from "@redocly/openapi-core"; import fs from "node:fs"; import path from "node:path"; import parser from "yargs-parser"; -import openapiTS, { astToString, c, COMMENT_HEADER, error, formatTime, warn } from "../dist/index.js"; +import openapiTS, { COMMENT_HEADER, astToString, c, error, formatTime, warn } from "../dist/index.js"; const HELP = `Usage $ openapi-typescript [input] [options] @@ -15,6 +15,7 @@ Options --redocly [path], -c Specify path to Redocly config (default: redocly.yaml) --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 --export-type, -t Export top-level \`type\` instead of \`interface\` --immutable Generate readonly types --additional-properties Treat schema objects as if \`additionalProperties: true\` is set @@ -62,6 +63,7 @@ const flags = parser(args, { "propertiesRequiredByDefault", "emptyObjectsUnknown", "enum", + "enumValues", "excludeDeprecated", "exportType", "help", @@ -91,6 +93,7 @@ async function generateSchema(schema, { redocly, silent = false }) { defaultNonNullable: flags.defaultNonNullable, emptyObjectsUnknown: flags.emptyObjectsUnknown, enum: flags.enum, + enumValues: flags.enumValues, 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 5af86c66b..07e4230c4 100644 --- a/packages/openapi-typescript/src/index.ts +++ b/packages/openapi-typescript/src/index.ts @@ -70,6 +70,7 @@ export default async function openapiTS( discriminators: scanDiscriminators(schema, options), emptyObjectsUnknown: options.emptyObjectsUnknown ?? false, enum: options.enum ?? false, + enumValues: options.enumValues ?? 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 812cfd4a4..135b81a70 100644 --- a/packages/openapi-typescript/src/lib/ts.ts +++ b/packages/openapi-typescript/src/lib/ts.ts @@ -206,13 +206,7 @@ export function tsEnum( metadata?: { name?: string; description?: string }[], options?: { export?: boolean }, ) { - let enumName = name.replace(JS_ENUM_INVALID_CHARS_RE, (c) => { - const last = c[c.length - 1]; - return JS_PROPERTY_INDEX_INVALID_CHARS_RE.test(last) ? "" : last.toUpperCase(); - }); - if (Number(name[0]) >= 0) { - enumName = `Value${name}`; - } + let enumName = sanitizeMemberName(name); enumName = `${enumName[0].toUpperCase()}${enumName.substring(1)}`; return ts.factory.createEnumDeclaration( /* modifiers */ options ? tsModifiers({ export: options.export ?? false }) : undefined, @@ -221,6 +215,62 @@ export function tsEnum( ); } +/** Create an exported TS array literal expression */ +export function tsArrayLiteralExpression( + name: string, + elementType: ts.TypeNode, + values: (string | number)[], + options?: { export?: boolean; readonly?: boolean }, +) { + let variableName = sanitizeMemberName(name); + variableName = `${variableName[0].toLowerCase()}${variableName.substring(1)}`; + + const arrayType = options?.readonly + ? ts.factory.createTypeReferenceNode("ReadonlyArray", [elementType]) + : ts.factory.createArrayTypeNode(elementType); + + return ts.factory.createVariableStatement( + options ? tsModifiers({ export: options.export ?? false }) : undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + variableName, + undefined, + arrayType, + ts.factory.createArrayLiteralExpression( + values.map((value) => { + if (typeof value === "number") { + if (value < 0) { + return ts.factory.createPrefixUnaryExpression( + ts.SyntaxKind.MinusToken, + ts.factory.createNumericLiteral(Math.abs(value)), + ); + } else { + return ts.factory.createNumericLiteral(value); + } + } else { + return ts.factory.createStringLiteral(value); + } + }), + ), + ), + ], + ts.NodeFlags.Const, + ), + ); +} + +function sanitizeMemberName(name: string) { + let sanitizedName = name.replace(JS_ENUM_INVALID_CHARS_RE, (c) => { + const last = c[c.length - 1]; + return JS_PROPERTY_INDEX_INVALID_CHARS_RE.test(last) ? "" : last.toUpperCase(); + }); + if (Number(name[0]) >= 0) { + sanitizedName = `Value${name}`; + } + return sanitizedName; +} + /** Sanitize TS enum member expression */ export function tsEnumMember(value: string | number, metadata: { name?: string; description?: string } = {}) { let name = metadata.name ?? String(value); diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 3997afdb4..68646e24b 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -11,6 +11,7 @@ import { UNKNOWN, addJSDocComment, oapiRef, + tsArrayLiteralExpression, tsEnum, tsIntersection, tsIsPrimitive, @@ -118,7 +119,30 @@ export function transformSchemaObjectWithComposition( if ((Array.isArray(schemaObject.type) && schemaObject.type.includes("null")) || schemaObject.nullable) { enumType.push(NULL); } - return tsUnion(enumType); + + const unionType = tsUnion(enumType); + + // hoist array with valid enum values to top level if string/number enum and option is enabled + if (options.ctx.enumValues && schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number")) { + let enumValuesVariableName = parseRef(options.path ?? "").pointer.join("/"); + // allow #/components/schemas to have simpler names + enumValuesVariableName = enumValuesVariableName.replace("components/schemas", ""); + enumValuesVariableName = `${enumValuesVariableName}Values`; + + const enumValuesArray = tsArrayLiteralExpression( + enumValuesVariableName, + oapiRef(options.path ?? ""), + schemaObject.enum as (string | number)[], + { + export: true, + readonly: true, + }, + ); + + options.ctx.injectFooter.push(enumValuesArray); + } + + return unionType; } /** diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index c5fc80bec..8313824a3 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -649,6 +649,8 @@ export interface OpenAPITSOptions { exportType?: boolean; /** Export true TypeScript enums instead of unions */ enum?: boolean; + /** Export union values as arrays */ + enumValues?: 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) */ @@ -673,6 +675,7 @@ export interface GlobalContext { }; emptyObjectsUnknown: boolean; enum: boolean; + enumValues: boolean; excludeDeprecated: boolean; exportType: boolean; immutable: boolean; diff --git a/packages/openapi-typescript/test/lib/ts.test.ts b/packages/openapi-typescript/test/lib/ts.test.ts index 324fdcb42..031a49c39 100644 --- a/packages/openapi-typescript/test/lib/ts.test.ts +++ b/packages/openapi-typescript/test/lib/ts.test.ts @@ -7,6 +7,7 @@ import { STRING, astToString, oapiRef, + tsArrayLiteralExpression, tsEnum, tsIsPrimitive, tsLiteral, @@ -201,6 +202,60 @@ describe("tsEnum", () => { }); }); +describe("tsArrayLiteralExpression", () => { + test("string members", () => { + expect( + astToString( + tsArrayLiteralExpression("-my-color-Values", oapiRef("#/components/schemas/Color"), ["green", "red", "blue"]), + ).trim(), + ).toBe(`const myColorValues: components["schemas"]["Color"][] = ["green", "red", "blue"];`); + }); + + test("with setting: export", () => { + expect( + astToString( + tsArrayLiteralExpression("-my-color-Values", oapiRef("#/components/schemas/Color"), ["green", "red", "blue"], { + export: true, + }), + ).trim(), + ).toBe(`export const myColorValues: components["schemas"]["Color"][] = ["green", "red", "blue"];`); + }); + + test("with setting: readonly", () => { + expect( + astToString( + tsArrayLiteralExpression("-my-color-Values", oapiRef("#/components/schemas/Color"), ["green", "red", "blue"], { + readonly: true, + }), + ).trim(), + ).toBe(`const myColorValues: ReadonlyArray = ["green", "red", "blue"];`); + }); + + test("name from path", () => { + expect( + astToString( + tsArrayLiteralExpression( + "#/paths/url/get/parameters/query/status/Values", + oapiRef("#/components/schemas/Status"), + ["active", "inactive"], + ), + ).trim(), + ).toBe(`const pathsUrlGetParametersQueryStatusValues: components["schemas"]["Status"][] = ["active", "inactive"];`); + }); + + test("number members", () => { + expect( + astToString( + tsArrayLiteralExpression( + ".Error.code.Values", + oapiRef("#/components/schemas/ErrorCode"), + [100, 101, 102, -100], + ), + ).trim(), + ).toBe(`const errorCodeValues: components["schemas"]["ErrorCode"][] = [100, 101, 102, -100];`); + }); +}); + describe("tsPropertyIndex", () => { test("numbers -> number literals", () => { expect(astToString(tsPropertyIndex(200)).trim()).toBe("200"); diff --git a/packages/openapi-typescript/test/node-api.test.ts b/packages/openapi-typescript/test/node-api.test.ts index 8c2b0c5eb..ef29893b7 100644 --- a/packages/openapi-typescript/test/node-api.test.ts +++ b/packages/openapi-typescript/test/node-api.test.ts @@ -704,6 +704,92 @@ export type operations = Record;`, options: { enum: true }, }, ], + [ + "options > enumValues", + { + 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"], + }, + ErrorCode: { + type: "number", + enum: [100, 101, 102, 103, 104, 105], + }, + }, + }, + }, + want: `export interface paths { + "/url": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + status?: "active" | "inactive"; + }; + 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: "active" | "inactive"; + /** @enum {number} */ + ErrorCode: 100 | 101 | 102 | 103 | 104 | 105; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export const pathsUrlGetParametersQueryStatusValues: ReadonlyArray = ["active", "inactive"]; +export const statusValues: ReadonlyArray = ["active", "inactive"]; +export const errorCodeValues: ReadonlyArray = [100, 101, 102, 103, 104, 105]; +export type operations = Record;`, + options: { enumValues: true }, + }, + ], [ "snapshot > GitHub", { diff --git a/packages/openapi-typescript/test/test-helpers.ts b/packages/openapi-typescript/test/test-helpers.ts index 730dd0964..81160272b 100644 --- a/packages/openapi-typescript/test/test-helpers.ts +++ b/packages/openapi-typescript/test/test-helpers.ts @@ -14,6 +14,7 @@ export const DEFAULT_CTX: GlobalContext = { }, emptyObjectsUnknown: false, enum: false, + enumValues: false, excludeDeprecated: false, exportType: false, immutable: false,