Skip to content

Commit 836e43c

Browse files
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)
1 parent e19d3ef commit 836e43c

File tree

5 files changed

+300
-15
lines changed

5 files changed

+300
-15
lines changed

packages/openapi-generator/src/ir.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type UndefinedValue = {
1313
export type Primitive = {
1414
type: 'string' | 'number' | 'integer' | 'boolean' | 'null';
1515
enum?: (string | number | boolean | null | PseudoBigInt)[];
16+
enumDescriptions?: Record<string, string>;
1617
};
1718

1819
export function isPrimitive(schema: Schema): schema is Primitive {

packages/openapi-generator/src/knownImports.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,14 +125,38 @@ export const KNOWN_IMPORTS: KnownImports = {
125125
if (arg.type !== 'object') {
126126
return errorLeft(`Unimplemented keyof type ${arg.type}`);
127127
}
128-
const schemas: Schema[] = Object.keys(arg.properties).map((prop) => ({
129-
type: 'string',
130-
enum: [prop],
131-
}));
132-
return E.right({
133-
type: 'union',
134-
schemas,
135-
});
128+
129+
const enumValues = Object.keys(arg.properties);
130+
const enumDescriptions: Record<string, string> = {};
131+
let hasDescriptions = false;
132+
133+
for (const prop of enumValues) {
134+
const propertySchema = arg.properties[prop];
135+
if (propertySchema?.comment?.description) {
136+
enumDescriptions[prop] = propertySchema.comment.description;
137+
hasDescriptions = true;
138+
}
139+
}
140+
141+
if (hasDescriptions) {
142+
return E.right({
143+
type: 'string',
144+
enum: enumValues,
145+
enumDescriptions,
146+
});
147+
} else {
148+
const schemas: Schema[] = enumValues.map((prop) => {
149+
return {
150+
type: 'string',
151+
enum: [prop],
152+
};
153+
});
154+
155+
return E.right({
156+
type: 'union',
157+
schemas,
158+
});
159+
}
136160
},
137161
brand: (_, arg) => E.right(arg),
138162
UnknownRecord: () => E.right({ type: 'record', codomain: { type: 'any' } }),

packages/openapi-generator/src/openapi.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,32 @@ export function schemaToOpenAPI(
2020
switch (schema.type) {
2121
case 'boolean':
2222
case 'string':
23-
case 'number':
24-
return {
23+
case 'number': {
24+
const result: any = {
2525
type: schema.type,
2626
...(schema.enum ? { enum: schema.enum } : {}),
2727
...defaultOpenAPIObject,
2828
};
29-
case 'integer':
30-
return {
29+
30+
if (schema.enum && schema.enumDescriptions) {
31+
result['x-enumDescriptions'] = schema.enumDescriptions;
32+
}
33+
34+
return result;
35+
}
36+
case 'integer': {
37+
const result: any = {
3138
type: 'number',
3239
...(schema.enum ? { enum: schema.enum } : {}),
3340
...defaultOpenAPIObject,
3441
};
42+
43+
if (schema.enum && schema.enumDescriptions) {
44+
result['x-enumDescriptions'] = schema.enumDescriptions;
45+
}
46+
47+
return result;
48+
}
3549
case 'null':
3650
// TODO: OpenAPI v3 does not have an explicit null type, is there a better way to represent this?
3751
// Or should we just conflate explicit null and undefined properties?

packages/openapi-generator/src/optimize.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,13 @@ export function simplifyUnion(schema: Schema, optimize: OptimizeFn): Schema {
160160
const remainder: Schema[] = [];
161161
innerSchemas.forEach((innerSchema) => {
162162
if (isPrimitive(innerSchema) && innerSchema.enum !== undefined) {
163-
innerSchema.enum.forEach((value) => {
164-
literals[innerSchema.type].add(value);
165-
});
163+
if (innerSchema.comment || innerSchema.enumDescriptions) {
164+
remainder.push(innerSchema);
165+
} else {
166+
innerSchema.enum.forEach((value) => {
167+
literals[innerSchema.type].add(value);
168+
});
169+
}
166170
} else {
167171
remainder.push(innerSchema);
168172
}

packages/openapi-generator/test/openapi/comments.test.ts

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,3 +1440,245 @@ testCase(
14401440
},
14411441
},
14421442
);
1443+
1444+
const ROUTE_WITH_INDIVIDUAL_ENUM_DESCRIPTIONS = `
1445+
import * as t from 'io-ts';
1446+
import * as h from '@api-ts/io-ts-http';
1447+
1448+
/**
1449+
* Transaction Request State Enum with individual descriptions
1450+
*/
1451+
export const TransactionRequestState = t.keyof(
1452+
{
1453+
/** Transaction is waiting for approval from authorized users */
1454+
pendingApproval: 1,
1455+
/** Transaction was canceled by the user */
1456+
canceled: 1,
1457+
/** Transaction was rejected by approvers */
1458+
rejected: 1,
1459+
/** Transaction has been initialized but not yet processed */
1460+
initialized: 1,
1461+
/** Transaction is ready to be delivered */
1462+
pendingDelivery: 1,
1463+
/** Transaction has been successfully delivered */
1464+
delivered: 1,
1465+
},
1466+
'TransactionRequestState',
1467+
);
1468+
1469+
/**
1470+
* Route to test individual enum variant descriptions
1471+
*
1472+
* @operationId api.v1.enumVariantDescriptions
1473+
* @tag Test Routes
1474+
*/
1475+
export const route = h.httpRoute({
1476+
path: '/transactions',
1477+
method: 'GET',
1478+
request: h.httpRequest({
1479+
query: {
1480+
states: t.array(TransactionRequestState),
1481+
},
1482+
}),
1483+
response: {
1484+
200: {
1485+
result: t.string
1486+
}
1487+
},
1488+
});
1489+
`;
1490+
1491+
testCase(
1492+
'individual enum variant descriptions use x-enumDescriptions extension',
1493+
ROUTE_WITH_INDIVIDUAL_ENUM_DESCRIPTIONS,
1494+
{
1495+
openapi: '3.0.3',
1496+
info: {
1497+
title: 'Test',
1498+
version: '1.0.0',
1499+
},
1500+
paths: {
1501+
'/transactions': {
1502+
get: {
1503+
summary: 'Route to test individual enum variant descriptions',
1504+
operationId: 'api.v1.enumVariantDescriptions',
1505+
tags: ['Test Routes'],
1506+
parameters: [
1507+
{
1508+
name: 'states',
1509+
in: 'query',
1510+
required: true,
1511+
schema: {
1512+
type: 'array',
1513+
items: {
1514+
type: 'string',
1515+
enum: [
1516+
'pendingApproval',
1517+
'canceled',
1518+
'rejected',
1519+
'initialized',
1520+
'pendingDelivery',
1521+
'delivered',
1522+
],
1523+
'x-enumDescriptions': {
1524+
pendingApproval:
1525+
'Transaction is waiting for approval from authorized users',
1526+
canceled: 'Transaction was canceled by the user',
1527+
rejected: 'Transaction was rejected by approvers',
1528+
initialized:
1529+
'Transaction has been initialized but not yet processed',
1530+
pendingDelivery: 'Transaction is ready to be delivered',
1531+
delivered: 'Transaction has been successfully delivered',
1532+
},
1533+
description:
1534+
'Transaction Request State Enum with individual descriptions',
1535+
},
1536+
},
1537+
},
1538+
],
1539+
responses: {
1540+
200: {
1541+
description: 'OK',
1542+
content: {
1543+
'application/json': {
1544+
schema: {
1545+
type: 'object',
1546+
properties: {
1547+
result: {
1548+
type: 'string',
1549+
},
1550+
},
1551+
required: ['result'],
1552+
},
1553+
},
1554+
},
1555+
},
1556+
},
1557+
},
1558+
},
1559+
},
1560+
components: {
1561+
schemas: {
1562+
TransactionRequestState: {
1563+
title: 'TransactionRequestState',
1564+
description: 'Transaction Request State Enum with individual descriptions',
1565+
type: 'string',
1566+
enum: [
1567+
'pendingApproval',
1568+
'canceled',
1569+
'rejected',
1570+
'initialized',
1571+
'pendingDelivery',
1572+
'delivered',
1573+
],
1574+
'x-enumDescriptions': {
1575+
pendingApproval:
1576+
'Transaction is waiting for approval from authorized users',
1577+
canceled: 'Transaction was canceled by the user',
1578+
rejected: 'Transaction was rejected by approvers',
1579+
initialized: 'Transaction has been initialized but not yet processed',
1580+
pendingDelivery: 'Transaction is ready to be delivered',
1581+
delivered: 'Transaction has been successfully delivered',
1582+
},
1583+
},
1584+
},
1585+
},
1586+
},
1587+
);
1588+
1589+
const ROUTE_WITH_ENUM_WITHOUT_DESCRIPTIONS = `
1590+
import * as t from 'io-ts';
1591+
import * as h from '@api-ts/io-ts-http';
1592+
1593+
/**
1594+
* Simple enum without individual descriptions
1595+
*/
1596+
export const SimpleEnum = t.keyof(
1597+
{
1598+
value1: 1,
1599+
value2: 1,
1600+
value3: 1,
1601+
},
1602+
'SimpleEnum',
1603+
);
1604+
1605+
/**
1606+
* Route to test enum without individual descriptions
1607+
*
1608+
* @operationId api.v1.simpleEnum
1609+
* @tag Test Routes
1610+
*/
1611+
export const route = h.httpRoute({
1612+
path: '/simple',
1613+
method: 'GET',
1614+
request: h.httpRequest({
1615+
query: {
1616+
value: SimpleEnum,
1617+
},
1618+
}),
1619+
response: {
1620+
200: {
1621+
result: t.string
1622+
}
1623+
},
1624+
});
1625+
`;
1626+
1627+
testCase(
1628+
'enum without individual descriptions uses standard enum format',
1629+
ROUTE_WITH_ENUM_WITHOUT_DESCRIPTIONS,
1630+
{
1631+
openapi: '3.0.3',
1632+
info: {
1633+
title: 'Test',
1634+
version: '1.0.0',
1635+
},
1636+
paths: {
1637+
'/simple': {
1638+
get: {
1639+
summary: 'Route to test enum without individual descriptions',
1640+
operationId: 'api.v1.simpleEnum',
1641+
tags: ['Test Routes'],
1642+
parameters: [
1643+
{
1644+
name: 'value',
1645+
in: 'query',
1646+
required: true,
1647+
schema: {
1648+
$ref: '#/components/schemas/SimpleEnum',
1649+
},
1650+
},
1651+
],
1652+
responses: {
1653+
200: {
1654+
description: 'OK',
1655+
content: {
1656+
'application/json': {
1657+
schema: {
1658+
type: 'object',
1659+
properties: {
1660+
result: {
1661+
type: 'string',
1662+
},
1663+
},
1664+
required: ['result'],
1665+
},
1666+
},
1667+
},
1668+
},
1669+
},
1670+
},
1671+
},
1672+
},
1673+
components: {
1674+
schemas: {
1675+
SimpleEnum: {
1676+
title: 'SimpleEnum',
1677+
type: 'string',
1678+
enum: ['value1', 'value2', 'value3'],
1679+
description: 'Simple enum without individual descriptions',
1680+
},
1681+
},
1682+
},
1683+
},
1684+
);

0 commit comments

Comments
 (0)