From 02269a19e7720355160fec883c47368c11c666b1 Mon Sep 17 00:00:00 2001 From: Eugene Dzhumak Date: Sun, 8 Oct 2023 12:11:35 +0400 Subject: [PATCH 1/2] Add support for x-enum-varnames and x-enum-descriptions --- .changeset/chilled-news-design.md | 5 ++ packages/openapi-typescript/src/lib/ts.ts | 48 +++++++++---- .../src/transform/schema-object.ts | 2 + .../openapi-typescript/test/lib/ts.test.ts | 71 +++++++++++++++++++ 4 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 .changeset/chilled-news-design.md diff --git a/.changeset/chilled-news-design.md b/.changeset/chilled-news-design.md new file mode 100644 index 000000000..44b5414d3 --- /dev/null +++ b/.changeset/chilled-news-design.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": major +--- + +Add support for x-enum-varnames and x-enum-descriptions diff --git a/packages/openapi-typescript/src/lib/ts.ts b/packages/openapi-typescript/src/lib/ts.ts index bf323a5a9..fdbe8c95e 100644 --- a/packages/openapi-typescript/src/lib/ts.ts +++ b/packages/openapi-typescript/src/lib/ts.ts @@ -229,6 +229,8 @@ export function tsDedupe(types: ts.TypeNode[]): ts.TypeNode[] { export function tsEnum( name: string, members: (string | number)[], + membersNames?: string[], + membersDescriptions?: string[], options?: { readonly?: boolean; export?: boolean }, ) { let enumName = name.replace(JS_ENUM_INVALID_CHARS_RE, (c) => { @@ -249,28 +251,48 @@ export function tsEnum( }) : undefined, /* name */ enumName, - /* members */ members.map(tsEnumMember), + /* members */ members.map((value, i) => + tsEnumMember(value, membersNames?.[i], membersDescriptions?.[i]), + ), ); } /** Sanitize TS enum member expression */ -export function tsEnumMember(value: string | number) { - if (typeof value === "number") { - return ts.factory.createEnumMember( - `Value${String(value)}`.replace(".", "_"), // don’t forget decimals - ts.factory.createNumericLiteral(value), - ); - } - let name = value; +export function tsEnumMember( + value: string | number, + memberName?: string, + description?: string, +) { + let name = memberName ?? String(value); if (!JS_PROPERTY_INDEX_RE.test(name)) { if (Number(name[0]) >= 0) { - name = `Value${name}`; + name = `Value${name}`.replace(".", "_"); // don't forged decimals; } name = name.replace(JS_PROPERTY_INDEX_INVALID_CHARS_RE, "_"); } - return ts.factory.createEnumMember( - name, - ts.factory.createStringLiteral(value), + + let member; + if (typeof value === "number") { + member = ts.factory.createEnumMember( + name, + ts.factory.createNumericLiteral(value), + ); + } else { + member = ts.factory.createEnumMember( + name, + ts.factory.createStringLiteral(value), + ); + } + + if (description == undefined) { + return member; + } + + return ts.addSyntheticLeadingComment( + member, + ts.SyntaxKind.SingleLineCommentTrivia, + " ".concat(description.trim()), + true, ); } diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 126be13e6..8bfbc3e1f 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -127,6 +127,8 @@ export function transformSchemaObjectWithComposition( const enumType = tsEnum( enumName, schemaObject.enum as (string | number)[], + schemaObject["x-enum-varnames"], + schemaObject["x-enum-descriptions"], { export: true, readonly: options.ctx.immutable }, ); options.ctx.injectFooter.push(enumType); diff --git a/packages/openapi-typescript/test/lib/ts.test.ts b/packages/openapi-typescript/test/lib/ts.test.ts index dfa3e7b38..be262d275 100644 --- a/packages/openapi-typescript/test/lib/ts.test.ts +++ b/packages/openapi-typescript/test/lib/ts.test.ts @@ -127,6 +127,77 @@ describe("tsEnum", () => { Value100 = 100, Value101 = 101, Value102 = 102 +}`); + }); + + it("number members with x-enum-descriptions", () => { + expect( + astToString( + tsEnum(".Error.code.", [100, 101, 102], undefined, [ + "Code 100", + "Code 101", + "Code 102", + ]), + ).trim(), + ).toBe(`enum ErrorCode { + // Code 100 + Value100 = 100, + // Code 101 + Value101 = 101, + // Code 102 + Value102 = 102 +}`); + }); + + it("x-enum-varnames", () => { + expect( + astToString( + tsEnum( + ".Error.code.", + [100, 101, 102], + ["Unauthorized", "NotFound", "PermissionDenied"], + ), + ).trim(), + ).toBe(`enum ErrorCode { + Unauthorized = 100, + NotFound = 101, + PermissionDenied = 102 +}`); + }); + + it("x-enum-varnames with numeric prefix", () => { + expect( + astToString( + tsEnum(".Error.code.", [100, 101, 102], ["0a", "1b", "2c"]), + ).trim(), + ).toBe(`enum ErrorCode { + Value0a = 100, + Value1b = 101, + Value2c = 102 +}`); + }); + + it("x-enum-descriptions with x-enum-varnames", () => { + expect( + astToString( + tsEnum( + ".Error.code.", + [100, 101, 102], + ["Unauthorized", "NotFound", "PermissionDenied"], + [ + "User is unauthorized", + "Item not found", + "User doesn't have permissions", + ], + ), + ).trim(), + ).toBe(`enum ErrorCode { + // User is unauthorized + Unauthorized = 100, + // Item not found + NotFound = 101, + // User doesn't have permissions + PermissionDenied = 102 }`); }); }); From 5d9f2762cd0572388bf6432e78e9a37c6dbda3c7 Mon Sep 17 00:00:00 2001 From: Eugene Dzhumak Date: Tue, 10 Oct 2023 22:51:33 +0400 Subject: [PATCH 2/2] Refactor ts enum members --- .changeset/chilled-news-design.md | 2 +- docs/src/content/docs/advanced.md | 41 ++++++++++++++ packages/openapi-typescript/src/lib/ts.ts | 14 ++--- .../src/transform/schema-object.ts | 7 ++- .../openapi-typescript/test/lib/ts.test.ts | 56 +++++++++++++++---- 5 files changed, 98 insertions(+), 22 deletions(-) diff --git a/.changeset/chilled-news-design.md b/.changeset/chilled-news-design.md index 44b5414d3..e97ea0f04 100644 --- a/.changeset/chilled-news-design.md +++ b/.changeset/chilled-news-design.md @@ -1,5 +1,5 @@ --- -"openapi-typescript": major +"openapi-typescript": minor --- Add support for x-enum-varnames and x-enum-descriptions diff --git a/docs/src/content/docs/advanced.md b/docs/src/content/docs/advanced.md index 96ebaf113..8e86c9445 100644 --- a/docs/src/content/docs/advanced.md +++ b/docs/src/content/docs/advanced.md @@ -521,3 +521,44 @@ Cat: { type?: "cat"; } & components["schemas"]["PetCommonProperties"]; _Note: you optionally could provide `discriminator.propertyName: "type"` on `Pet` ([docs](https://spec.openapis.org/oas/v3.1.0#discriminator-object)) to automatically generate the `type` key, but is less explicit._ While the schema permits you to use composition in any way you like, it’s good to always take a look at the generated types and see if there’s a simpler way to express your unions & intersections. Limiting the use of `oneOf` is not the only way to do that, but often yields the greatest benefits. + +### Enum with custom names and descriptions + +`x-enum-varnames` can be used to have another enum name for the corresponding value. This is used to define names of the enum items. + +`x-enum-descriptions` can be used to provide an individual description for each value. This is used for comments in the code (like javadoc if the target language is java). + +`x-enum-descriptions` and `x-enum-varnames` are each expected to be list of items containing the same number of items as enum. The order of the items in the list matters: their position is used to group them together. + +Example: + +```yaml +ErrorCode: + type: integer + format: int32 + enum: + - 100 + - 200 + - 300 + x-enum-varnames: + - Unauthorized + - AccessDenied + - Unknown + x-enum-descriptions: + - "User is not authorized" + - "User has no access to this resource" + - "Something went wrong" +``` + +Will result in: + +```ts +enum ErrorCode { + // User is not authorized + Unauthorized = 100 + // User has no access to this resource + AccessDenied = 200 + // Something went wrong + Unknown = 300 +} +``` diff --git a/packages/openapi-typescript/src/lib/ts.ts b/packages/openapi-typescript/src/lib/ts.ts index fdbe8c95e..95e508cdd 100644 --- a/packages/openapi-typescript/src/lib/ts.ts +++ b/packages/openapi-typescript/src/lib/ts.ts @@ -229,8 +229,7 @@ export function tsDedupe(types: ts.TypeNode[]): ts.TypeNode[] { export function tsEnum( name: string, members: (string | number)[], - membersNames?: string[], - membersDescriptions?: string[], + metadata?: { name?: string; description?: string }[], options?: { readonly?: boolean; export?: boolean }, ) { let enumName = name.replace(JS_ENUM_INVALID_CHARS_RE, (c) => { @@ -252,7 +251,7 @@ export function tsEnum( : undefined, /* name */ enumName, /* members */ members.map((value, i) => - tsEnumMember(value, membersNames?.[i], membersDescriptions?.[i]), + tsEnumMember(value, metadata?.[i]), ), ); } @@ -260,10 +259,9 @@ export function tsEnum( /** Sanitize TS enum member expression */ export function tsEnumMember( value: string | number, - memberName?: string, - description?: string, + metadata: { name?: string; description?: string } = {}, ) { - let name = memberName ?? String(value); + let name = metadata.name ?? String(value); if (!JS_PROPERTY_INDEX_RE.test(name)) { if (Number(name[0]) >= 0) { name = `Value${name}`.replace(".", "_"); // don't forged decimals; @@ -284,14 +282,14 @@ export function tsEnumMember( ); } - if (description == undefined) { + if (metadata.description == undefined) { return member; } return ts.addSyntheticLeadingComment( member, ts.SyntaxKind.SingleLineCommentTrivia, - " ".concat(description.trim()), + " ".concat(metadata.description.trim()), true, ); } diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 8bfbc3e1f..2c8e5c3c9 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -124,11 +124,14 @@ export function transformSchemaObjectWithComposition( let enumName = parseRef(options.path ?? "").pointer.join("/"); // allow #/components/schemas to have simpler names enumName = enumName.replace("components/schemas", ""); + const metadata = schemaObject.enum.map((_, i) => ({ + name: schemaObject["x-enum-varnames"]?.[i], + description: schemaObject["x-enum-descriptions"]?.[i], + })); const enumType = tsEnum( enumName, schemaObject.enum as (string | number)[], - schemaObject["x-enum-varnames"], - schemaObject["x-enum-descriptions"], + metadata, { export: true, readonly: options.ctx.immutable }, ); options.ctx.injectFooter.push(enumType); diff --git a/packages/openapi-typescript/test/lib/ts.test.ts b/packages/openapi-typescript/test/lib/ts.test.ts index be262d275..db11f2b64 100644 --- a/packages/openapi-typescript/test/lib/ts.test.ts +++ b/packages/openapi-typescript/test/lib/ts.test.ts @@ -133,11 +133,15 @@ describe("tsEnum", () => { it("number members with x-enum-descriptions", () => { expect( astToString( - tsEnum(".Error.code.", [100, 101, 102], undefined, [ - "Code 100", - "Code 101", - "Code 102", - ]), + tsEnum( + ".Error.code.", + [100, 101, 102], + [ + { description: "Code 100" }, + { description: "Code 101" }, + { description: "Code 102" }, + ], + ), ).trim(), ).toBe(`enum ErrorCode { // Code 100 @@ -155,7 +159,11 @@ describe("tsEnum", () => { tsEnum( ".Error.code.", [100, 101, 102], - ["Unauthorized", "NotFound", "PermissionDenied"], + [ + { name: "Unauthorized" }, + { name: "NotFound" }, + { name: "PermissionDenied" }, + ], ), ).trim(), ).toBe(`enum ErrorCode { @@ -168,7 +176,11 @@ describe("tsEnum", () => { it("x-enum-varnames with numeric prefix", () => { expect( astToString( - tsEnum(".Error.code.", [100, 101, 102], ["0a", "1b", "2c"]), + tsEnum( + ".Error.code.", + [100, 101, 102], + [{ name: "0a" }, { name: "1b" }, { name: "2c" }], + ), ).trim(), ).toBe(`enum ErrorCode { Value0a = 100, @@ -177,17 +189,39 @@ describe("tsEnum", () => { }`); }); + it("partial x-enum-varnames and x-enum-descriptions", () => { + expect( + astToString( + tsEnum( + ".Error.code.", + [100, 101, 102], + [ + { name: "Unauthorized", description: "User is unauthorized" }, + { name: "NotFound" }, + ], + ), + ).trim(), + ).toBe(`enum ErrorCode { + // User is unauthorized + Unauthorized = 100, + NotFound = 101, + Value102 = 102 +}`); + }); + it("x-enum-descriptions with x-enum-varnames", () => { expect( astToString( tsEnum( ".Error.code.", [100, 101, 102], - ["Unauthorized", "NotFound", "PermissionDenied"], [ - "User is unauthorized", - "Item not found", - "User doesn't have permissions", + { name: "Unauthorized", description: "User is unauthorized" }, + { name: "NotFound", description: "Item not found" }, + { + name: "PermissionDenied", + description: "User doesn't have permissions", + }, ], ), ).trim(),