From 836e43c494c6150d3098d63ef9083eacd1cd7c11 Mon Sep 17 00:00:00 2001 From: Koral Kulacoglu Date: Thu, 13 Nov 2025 15:36:22 -0500 Subject: [PATCH] feat: add support for x-enumDescriptions OpenAPI extension Implement support for individual enum variant descriptions using the x-enumDescriptions OpenAPI extension. This provides cleaner OpenAPI specs compared to the oneOf approach and is better supported by documentation tools like README.com. Changes: - Add enumDescriptions property to Primitive schema type - Update keyof handler to detect individual enum descriptions - Generate x-enumDescriptions extension in OpenAPI output - Preserve enum descriptions during optimization - Add comprehensive tests for both scenarios: * Enums with descriptions use x-enumDescriptions extension * Enums without descriptions use standard enum format All tests passing (188/188) --- packages/openapi-generator/src/ir.ts | 1 + .../openapi-generator/src/knownImports.ts | 40 ++- packages/openapi-generator/src/openapi.ts | 22 +- packages/openapi-generator/src/optimize.ts | 10 +- .../test/openapi/comments.test.ts | 242 ++++++++++++++++++ 5 files changed, 300 insertions(+), 15 deletions(-) diff --git a/packages/openapi-generator/src/ir.ts b/packages/openapi-generator/src/ir.ts index 536d1d7f..80b98a85 100644 --- a/packages/openapi-generator/src/ir.ts +++ b/packages/openapi-generator/src/ir.ts @@ -13,6 +13,7 @@ export type UndefinedValue = { export type Primitive = { type: 'string' | 'number' | 'integer' | 'boolean' | 'null'; enum?: (string | number | boolean | null | PseudoBigInt)[]; + enumDescriptions?: Record; }; export function isPrimitive(schema: Schema): schema is Primitive { diff --git a/packages/openapi-generator/src/knownImports.ts b/packages/openapi-generator/src/knownImports.ts index d18e88aa..995eaf16 100644 --- a/packages/openapi-generator/src/knownImports.ts +++ b/packages/openapi-generator/src/knownImports.ts @@ -125,14 +125,38 @@ export const KNOWN_IMPORTS: KnownImports = { if (arg.type !== 'object') { return errorLeft(`Unimplemented keyof type ${arg.type}`); } - const schemas: Schema[] = Object.keys(arg.properties).map((prop) => ({ - type: 'string', - enum: [prop], - })); - return E.right({ - type: 'union', - schemas, - }); + + const enumValues = Object.keys(arg.properties); + const enumDescriptions: Record = {}; + let hasDescriptions = false; + + for (const prop of enumValues) { + const propertySchema = arg.properties[prop]; + if (propertySchema?.comment?.description) { + enumDescriptions[prop] = propertySchema.comment.description; + hasDescriptions = true; + } + } + + if (hasDescriptions) { + return E.right({ + type: 'string', + enum: enumValues, + enumDescriptions, + }); + } else { + const schemas: Schema[] = enumValues.map((prop) => { + return { + type: 'string', + enum: [prop], + }; + }); + + return E.right({ + type: 'union', + schemas, + }); + } }, brand: (_, arg) => E.right(arg), UnknownRecord: () => E.right({ type: 'record', codomain: { type: 'any' } }), diff --git a/packages/openapi-generator/src/openapi.ts b/packages/openapi-generator/src/openapi.ts index 15d39626..652a4853 100644 --- a/packages/openapi-generator/src/openapi.ts +++ b/packages/openapi-generator/src/openapi.ts @@ -20,18 +20,32 @@ export function schemaToOpenAPI( switch (schema.type) { case 'boolean': case 'string': - case 'number': - return { + case 'number': { + const result: any = { type: schema.type, ...(schema.enum ? { enum: schema.enum } : {}), ...defaultOpenAPIObject, }; - case 'integer': - return { + + if (schema.enum && schema.enumDescriptions) { + result['x-enumDescriptions'] = schema.enumDescriptions; + } + + return result; + } + case 'integer': { + const result: any = { type: 'number', ...(schema.enum ? { enum: schema.enum } : {}), ...defaultOpenAPIObject, }; + + if (schema.enum && schema.enumDescriptions) { + result['x-enumDescriptions'] = schema.enumDescriptions; + } + + return result; + } case 'null': // TODO: OpenAPI v3 does not have an explicit null type, is there a better way to represent this? // Or should we just conflate explicit null and undefined properties? diff --git a/packages/openapi-generator/src/optimize.ts b/packages/openapi-generator/src/optimize.ts index 181c8362..b8c70dd4 100644 --- a/packages/openapi-generator/src/optimize.ts +++ b/packages/openapi-generator/src/optimize.ts @@ -160,9 +160,13 @@ export function simplifyUnion(schema: Schema, optimize: OptimizeFn): Schema { const remainder: Schema[] = []; innerSchemas.forEach((innerSchema) => { if (isPrimitive(innerSchema) && innerSchema.enum !== undefined) { - innerSchema.enum.forEach((value) => { - literals[innerSchema.type].add(value); - }); + if (innerSchema.comment || innerSchema.enumDescriptions) { + remainder.push(innerSchema); + } else { + innerSchema.enum.forEach((value) => { + literals[innerSchema.type].add(value); + }); + } } else { remainder.push(innerSchema); } diff --git a/packages/openapi-generator/test/openapi/comments.test.ts b/packages/openapi-generator/test/openapi/comments.test.ts index 10bd3342..5b50e16e 100644 --- a/packages/openapi-generator/test/openapi/comments.test.ts +++ b/packages/openapi-generator/test/openapi/comments.test.ts @@ -1440,3 +1440,245 @@ testCase( }, }, ); + +const ROUTE_WITH_INDIVIDUAL_ENUM_DESCRIPTIONS = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** + * Transaction Request State Enum with individual descriptions + */ +export const TransactionRequestState = t.keyof( + { + /** Transaction is waiting for approval from authorized users */ + pendingApproval: 1, + /** Transaction was canceled by the user */ + canceled: 1, + /** Transaction was rejected by approvers */ + rejected: 1, + /** Transaction has been initialized but not yet processed */ + initialized: 1, + /** Transaction is ready to be delivered */ + pendingDelivery: 1, + /** Transaction has been successfully delivered */ + delivered: 1, + }, + 'TransactionRequestState', +); + +/** + * Route to test individual enum variant descriptions + * + * @operationId api.v1.enumVariantDescriptions + * @tag Test Routes + */ +export const route = h.httpRoute({ + path: '/transactions', + method: 'GET', + request: h.httpRequest({ + query: { + states: t.array(TransactionRequestState), + }, + }), + response: { + 200: { + result: t.string + } + }, +}); +`; + +testCase( + 'individual enum variant descriptions use x-enumDescriptions extension', + ROUTE_WITH_INDIVIDUAL_ENUM_DESCRIPTIONS, + { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/transactions': { + get: { + summary: 'Route to test individual enum variant descriptions', + operationId: 'api.v1.enumVariantDescriptions', + tags: ['Test Routes'], + parameters: [ + { + name: 'states', + in: 'query', + required: true, + schema: { + type: 'array', + items: { + type: 'string', + enum: [ + 'pendingApproval', + 'canceled', + 'rejected', + 'initialized', + 'pendingDelivery', + 'delivered', + ], + 'x-enumDescriptions': { + pendingApproval: + 'Transaction is waiting for approval from authorized users', + canceled: 'Transaction was canceled by the user', + rejected: 'Transaction was rejected by approvers', + initialized: + 'Transaction has been initialized but not yet processed', + pendingDelivery: 'Transaction is ready to be delivered', + delivered: 'Transaction has been successfully delivered', + }, + description: + 'Transaction Request State Enum with individual descriptions', + }, + }, + }, + ], + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + result: { + type: 'string', + }, + }, + required: ['result'], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + TransactionRequestState: { + title: 'TransactionRequestState', + description: 'Transaction Request State Enum with individual descriptions', + type: 'string', + enum: [ + 'pendingApproval', + 'canceled', + 'rejected', + 'initialized', + 'pendingDelivery', + 'delivered', + ], + 'x-enumDescriptions': { + pendingApproval: + 'Transaction is waiting for approval from authorized users', + canceled: 'Transaction was canceled by the user', + rejected: 'Transaction was rejected by approvers', + initialized: 'Transaction has been initialized but not yet processed', + pendingDelivery: 'Transaction is ready to be delivered', + delivered: 'Transaction has been successfully delivered', + }, + }, + }, + }, + }, +); + +const ROUTE_WITH_ENUM_WITHOUT_DESCRIPTIONS = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** + * Simple enum without individual descriptions + */ +export const SimpleEnum = t.keyof( + { + value1: 1, + value2: 1, + value3: 1, + }, + 'SimpleEnum', +); + +/** + * Route to test enum without individual descriptions + * + * @operationId api.v1.simpleEnum + * @tag Test Routes + */ +export const route = h.httpRoute({ + path: '/simple', + method: 'GET', + request: h.httpRequest({ + query: { + value: SimpleEnum, + }, + }), + response: { + 200: { + result: t.string + } + }, +}); +`; + +testCase( + 'enum without individual descriptions uses standard enum format', + ROUTE_WITH_ENUM_WITHOUT_DESCRIPTIONS, + { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/simple': { + get: { + summary: 'Route to test enum without individual descriptions', + operationId: 'api.v1.simpleEnum', + tags: ['Test Routes'], + parameters: [ + { + name: 'value', + in: 'query', + required: true, + schema: { + $ref: '#/components/schemas/SimpleEnum', + }, + }, + ], + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + result: { + type: 'string', + }, + }, + required: ['result'], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + SimpleEnum: { + title: 'SimpleEnum', + type: 'string', + enum: ['value1', 'value2', 'value3'], + description: 'Simple enum without individual descriptions', + }, + }, + }, + }, +);