diff --git a/.changeset/chilled-news-design.md b/.changeset/chilled-news-design.md new file mode 100644 index 000000000..e97ea0f04 --- /dev/null +++ b/.changeset/chilled-news-design.md @@ -0,0 +1,5 @@ +--- +"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 bf323a5a9..95e508cdd 100644 --- a/packages/openapi-typescript/src/lib/ts.ts +++ b/packages/openapi-typescript/src/lib/ts.ts @@ -229,6 +229,7 @@ export function tsDedupe(types: ts.TypeNode[]): ts.TypeNode[] { export function tsEnum( name: string, members: (string | number)[], + metadata?: { name?: string; description?: string }[], options?: { readonly?: boolean; export?: boolean }, ) { let enumName = name.replace(JS_ENUM_INVALID_CHARS_RE, (c) => { @@ -249,28 +250,47 @@ export function tsEnum( }) : undefined, /* name */ enumName, - /* members */ members.map(tsEnumMember), + /* members */ members.map((value, i) => + tsEnumMember(value, metadata?.[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, + metadata: { name?: string; description?: string } = {}, +) { + let name = metadata.name ?? 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 (metadata.description == undefined) { + return member; + } + + return ts.addSyntheticLeadingComment( + member, + ts.SyntaxKind.SingleLineCommentTrivia, + " ".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 126be13e6..2c8e5c3c9 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -124,9 +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)[], + 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 dfa3e7b38..db11f2b64 100644 --- a/packages/openapi-typescript/test/lib/ts.test.ts +++ b/packages/openapi-typescript/test/lib/ts.test.ts @@ -127,6 +127,111 @@ describe("tsEnum", () => { Value100 = 100, Value101 = 101, Value102 = 102 +}`); + }); + + it("number members with x-enum-descriptions", () => { + expect( + astToString( + tsEnum( + ".Error.code.", + [100, 101, 102], + [ + { description: "Code 100" }, + { description: "Code 101" }, + { description: "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], + [ + { name: "Unauthorized" }, + { name: "NotFound" }, + { name: "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], + [{ name: "0a" }, { name: "1b" }, { name: "2c" }], + ), + ).trim(), + ).toBe(`enum ErrorCode { + Value0a = 100, + Value1b = 101, + Value2c = 102 +}`); + }); + + 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], + [ + { name: "Unauthorized", description: "User is unauthorized" }, + { name: "NotFound", description: "Item not found" }, + { + name: "PermissionDenied", + description: "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 }`); }); });