From 5465aae3e28d689798572052b8793f74d1b6ec81 Mon Sep 17 00:00:00 2001 From: Dereck Tu Date: Tue, 30 Dec 2025 12:48:26 -0500 Subject: [PATCH] feat: allow for enums to be deprecated Ticket: DX-2535 This commit enables the ability to mark individual enum variants as deprecated --- packages/openapi-generator/README.md | 37 +++++ packages/openapi-generator/src/ir.ts | 1 + .../openapi-generator/src/knownImports.ts | 9 +- packages/openapi-generator/src/openapi.ts | 8 + packages/openapi-generator/src/optimize.ts | 6 +- .../test/openapi/comments.test.ts | 146 ++++++++++++++++++ 6 files changed, 204 insertions(+), 3 deletions(-) diff --git a/packages/openapi-generator/README.md b/packages/openapi-generator/README.md index 1f39b062..05544ef8 100644 --- a/packages/openapi-generator/README.md +++ b/packages/openapi-generator/README.md @@ -497,3 +497,40 @@ const Schema = t.type({ fieldWithFormattedDescription: t.string, }); ``` + +#### 6.2.4 Deprecated Enum Values + +When using `t.keyof` to define enums, you can mark specific enum values as deprecated +using the `@deprecated` tag. Deprecated values will be collected into an +`x-enumsDeprecated` array in the OpenAPI specification. + +```typescript +import * as t from 'io-ts'; + +/** + * Transaction status values + */ +export const TransactionStatus = t.keyof( + { + pendingApproval: 1, + /** @deprecated */ + canceled: 1, + /** @deprecated */ + rejected: 1, + completed: 1, + }, + 'TransactionStatus', +); +``` + +This will generate the following OpenAPI schema: + +```json +{ + "TransactionStatus": { + "type": "string", + "enum": ["pendingApproval", "canceled", "rejected", "completed"], + "x-enumsDeprecated": ["canceled", "rejected"] + } +} +``` diff --git a/packages/openapi-generator/src/ir.ts b/packages/openapi-generator/src/ir.ts index 80b98a85..09b7cb4f 100644 --- a/packages/openapi-generator/src/ir.ts +++ b/packages/openapi-generator/src/ir.ts @@ -14,6 +14,7 @@ export type Primitive = { type: 'string' | 'number' | 'integer' | 'boolean' | 'null'; enum?: (string | number | boolean | null | PseudoBigInt)[]; enumDescriptions?: Record; + enumsDeprecated?: string[]; }; 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 db289f5a..29042a32 100644 --- a/packages/openapi-generator/src/knownImports.ts +++ b/packages/openapi-generator/src/knownImports.ts @@ -129,6 +129,7 @@ export const KNOWN_IMPORTS: KnownImports = { const enumValues = Object.keys(arg.properties); const enumDescriptions: Record = {}; + const enumsDeprecated: string[] = []; let hasDescriptions = false; for (const prop of enumValues) { @@ -139,14 +140,18 @@ export const KNOWN_IMPORTS: KnownImports = { enumDescriptions[prop] = jsdoc.tags.description; hasDescriptions = true; } + if (jsdoc.tags && 'deprecated' in jsdoc.tags) { + enumsDeprecated.push(prop); + } } } - if (hasDescriptions) { + if (hasDescriptions || enumsDeprecated.length > 0) { return E.right({ type: 'string', enum: enumValues, - enumDescriptions, + ...(hasDescriptions ? { enumDescriptions } : {}), + ...(enumsDeprecated.length > 0 ? { enumsDeprecated } : {}), }); } else { const schemas: Schema[] = enumValues.map((prop) => { diff --git a/packages/openapi-generator/src/openapi.ts b/packages/openapi-generator/src/openapi.ts index b7dd89b1..5c01eeff 100644 --- a/packages/openapi-generator/src/openapi.ts +++ b/packages/openapi-generator/src/openapi.ts @@ -31,6 +31,10 @@ export function schemaToOpenAPI( result['x-enumDescriptions'] = schema.enumDescriptions; } + if (schema.enum && schema.enumsDeprecated) { + result['x-enumsDeprecated'] = schema.enumsDeprecated; + } + return result; } case 'integer': { @@ -44,6 +48,10 @@ export function schemaToOpenAPI( result['x-enumDescriptions'] = schema.enumDescriptions; } + if (schema.enum && schema.enumsDeprecated) { + result['x-enumsDeprecated'] = schema.enumsDeprecated; + } + return result; } case 'null': diff --git a/packages/openapi-generator/src/optimize.ts b/packages/openapi-generator/src/optimize.ts index b8c70dd4..cb877d44 100644 --- a/packages/openapi-generator/src/optimize.ts +++ b/packages/openapi-generator/src/optimize.ts @@ -160,7 +160,11 @@ export function simplifyUnion(schema: Schema, optimize: OptimizeFn): Schema { const remainder: Schema[] = []; innerSchemas.forEach((innerSchema) => { if (isPrimitive(innerSchema) && innerSchema.enum !== undefined) { - if (innerSchema.comment || innerSchema.enumDescriptions) { + if ( + innerSchema.comment || + innerSchema.enumDescriptions || + innerSchema.enumsDeprecated + ) { remainder.push(innerSchema); } else { innerSchema.enum.forEach((value) => { diff --git a/packages/openapi-generator/test/openapi/comments.test.ts b/packages/openapi-generator/test/openapi/comments.test.ts index 7950bc17..547248a6 100644 --- a/packages/openapi-generator/test/openapi/comments.test.ts +++ b/packages/openapi-generator/test/openapi/comments.test.ts @@ -1813,3 +1813,149 @@ testCase( }, }, ); + +const ROUTE_WITH_ENUM_DEPRECATED = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** + * Enum with @deprecated tags - should generate x-enumsDeprecated + */ +export const StatusWithDeprecated = t.keyof( + { + /** + * @description Transaction is waiting for approval from authorized users + */ + pendingApproval: 1, + /** + * @description Transaction was canceled by the user + * @deprecated + */ + canceled: 1, + /** + * @description Transaction was rejected by approvers + * @deprecated + */ + rejected: 1, + }, + 'StatusWithDeprecated', +); + +/** + * Enum with only @deprecated tags - should generate x-enumsDeprecated + */ +export const StatusOnlyDeprecated = t.keyof( + { + /** @deprecated */ + old: 1, + current: 1, + /** @deprecated */ + legacy: 1, + }, + 'StatusOnlyDeprecated', +); + +/** + * Route to test enum deprecated scenarios + * + * @operationId api.v1.enumDeprecatedScenarios + * @tag Test Routes + */ +export const route = h.httpRoute({ + path: '/enum-deprecated', + method: 'GET', + request: h.httpRequest({ + query: { + withDeprecated: StatusWithDeprecated, + onlyDeprecated: StatusOnlyDeprecated, + }, + }), + response: { + 200: { + result: t.string + } + }, +}); +`; + +testCase( + 'enum deprecated scenarios - @deprecated tags with and without @description', + ROUTE_WITH_ENUM_DEPRECATED, + { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/enum-deprecated': { + get: { + summary: 'Route to test enum deprecated scenarios', + operationId: 'api.v1.enumDeprecatedScenarios', + tags: ['Test Routes'], + parameters: [ + { + name: 'withDeprecated', + in: 'query', + required: true, + schema: { + $ref: '#/components/schemas/StatusWithDeprecated', + }, + }, + { + name: 'onlyDeprecated', + in: 'query', + required: true, + schema: { + $ref: '#/components/schemas/StatusOnlyDeprecated', + }, + }, + ], + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + result: { + type: 'string', + }, + }, + required: ['result'], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + StatusWithDeprecated: { + title: 'StatusWithDeprecated', + description: 'Enum with @deprecated tags - should generate x-enumsDeprecated', + type: 'string', + enum: ['pendingApproval', 'canceled', 'rejected'], + 'x-enumDescriptions': { + pendingApproval: + 'Transaction is waiting for approval from authorized users', + canceled: 'Transaction was canceled by the user', + rejected: 'Transaction was rejected by approvers', + }, + 'x-enumsDeprecated': ['canceled', 'rejected'], + }, + StatusOnlyDeprecated: { + title: 'StatusOnlyDeprecated', + description: + 'Enum with only @deprecated tags - should generate x-enumsDeprecated', + type: 'string', + enum: ['old', 'current', 'legacy'], + 'x-enumsDeprecated': ['old', 'legacy'], + }, + }, + }, + }, +);