Skip to content

Commit bc5dec6

Browse files
constantantclaude
andcommitted
feat(openapi-resource-gen): emit enum label/description maps from x-enum-varnames / x-enum-descriptions
For each query, path, or header parameter annotated with x-enum-varnames or x-enum-descriptions vendor extensions, emit a typed as-const object map alongside the token — e.g. listPetsStatusLabels / listPetsStatusDescriptions. Keys are the raw enum values; values are the human-readable names or descriptions. Enables dropdown label generation without hand-maintaining a parallel mapping. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 72d69f9 commit bc5dec6

4 files changed

Lines changed: 173 additions & 1 deletion

File tree

tools/openapi-resource-gen/src/generators/api-resource/endpoint-model.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ export interface DiscriminatorModel {
1212
isArrayResponse: boolean;
1313
}
1414

15+
/** An enum parameter or response property annotated with x-enum-varnames / x-enum-descriptions. */
16+
export interface EnumExtension {
17+
/** Parameter or property name, e.g. 'status'. */
18+
paramName: string;
19+
/** Raw enum values from the spec, e.g. ['placed', 'approved']. */
20+
values: string[];
21+
/** Human-readable names from x-enum-varnames, parallel to values. */
22+
varnames?: string[];
23+
/** Long-form descriptions from x-enum-descriptions, parallel to values. */
24+
descriptions?: string[];
25+
}
26+
1527
/** A response property with format:date-time or format:date. */
1628
export interface DateField {
1729
name: string;
@@ -90,6 +102,8 @@ export interface EndpointModel {
90102
securitySchemeNames: string[];
91103
/** Present when the primary response schema carries a discriminator. */
92104
discriminator: DiscriminatorModel | null;
105+
/** Enum parameters/properties with x-enum-varnames or x-enum-descriptions vendor extensions. */
106+
enumExtensions: EnumExtension[];
93107
/** Date/datetime fields on the primary response object, for optional reviver generation. */
94108
dateFields: DateField[];
95109
/** True when the primary JSON response type is an array. */

tools/openapi-resource-gen/src/generators/api-resource/generator.spec.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,6 +1152,119 @@ describe('api-resource generator', () => {
11521152
});
11531153
});
11541154

1155+
describe('x-enum-varnames / x-enum-descriptions', () => {
1156+
const ENUM_SPEC = {
1157+
paths: {
1158+
'/pets': {
1159+
get: {
1160+
operationId: 'listPets',
1161+
tags: ['pets'],
1162+
parameters: [
1163+
{
1164+
in: 'query',
1165+
name: 'status',
1166+
schema: {
1167+
type: 'string',
1168+
enum: ['available', 'pending', 'sold'],
1169+
'x-enum-varnames': ['Available', 'Pending', 'Sold'],
1170+
'x-enum-descriptions': [
1171+
'Pet is available',
1172+
'Pet is pending sale',
1173+
'Pet has been sold',
1174+
],
1175+
},
1176+
},
1177+
],
1178+
responses: { '200': { content: { 'application/json': { schema: {} } } } },
1179+
},
1180+
},
1181+
},
1182+
};
1183+
1184+
it('emits a labels map for a param with x-enum-varnames', async () => {
1185+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(ENUM_SPEC as never);
1186+
await apiResourceGenerator(tree, {
1187+
specPath: 'specs/petstore.yaml',
1188+
outputDir: 'libs/enum-labels/src',
1189+
});
1190+
const content = tree.read('libs/enum-labels/src/pets/list-pets.token.ts', 'utf-8')!;
1191+
expect(content).toContain('export const listPetsStatusLabels = {');
1192+
expect(content).toContain("available: 'Available'");
1193+
expect(content).toContain("pending: 'Pending'");
1194+
expect(content).toContain("sold: 'Sold'");
1195+
expect(content).toContain('} as const;');
1196+
});
1197+
1198+
it('emits a descriptions map for a param with x-enum-descriptions', async () => {
1199+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(ENUM_SPEC as never);
1200+
await apiResourceGenerator(tree, {
1201+
specPath: 'specs/petstore.yaml',
1202+
outputDir: 'libs/enum-desc/src',
1203+
});
1204+
const content = tree.read('libs/enum-desc/src/pets/list-pets.token.ts', 'utf-8')!;
1205+
expect(content).toContain('export const listPetsStatusDescriptions = {');
1206+
expect(content).toContain("available: 'Pet is available'");
1207+
expect(content).toContain("sold: 'Pet has been sold'");
1208+
expect(content).toContain('} as const;');
1209+
});
1210+
1211+
it('emits both labels and descriptions when both extensions are present', async () => {
1212+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(ENUM_SPEC as never);
1213+
await apiResourceGenerator(tree, {
1214+
specPath: 'specs/petstore.yaml',
1215+
outputDir: 'libs/enum-both/src',
1216+
});
1217+
const content = tree.read('libs/enum-both/src/pets/list-pets.token.ts', 'utf-8')!;
1218+
expect(content).toContain('listPetsStatusLabels');
1219+
expect(content).toContain('listPetsStatusDescriptions');
1220+
});
1221+
1222+
it('handles path params with x-enum-varnames', async () => {
1223+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
1224+
paths: {
1225+
'/pets/{type}': {
1226+
get: {
1227+
operationId: 'getPetByType',
1228+
tags: ['pets'],
1229+
parameters: [
1230+
{
1231+
in: 'path',
1232+
name: 'type',
1233+
required: true,
1234+
schema: {
1235+
type: 'string',
1236+
enum: ['cat', 'dog'],
1237+
'x-enum-varnames': ['Cat', 'Dog'],
1238+
},
1239+
},
1240+
],
1241+
responses: { '200': { content: { 'application/json': { schema: {} } } } },
1242+
},
1243+
},
1244+
},
1245+
} as never);
1246+
await apiResourceGenerator(tree, {
1247+
specPath: 'specs/petstore.yaml',
1248+
outputDir: 'libs/enum-path/src',
1249+
});
1250+
const content = tree.read('libs/enum-path/src/pets/get-pet-by-type.token.ts', 'utf-8')!;
1251+
expect(content).toContain('export const getPetByTypeTypeLabels = {');
1252+
expect(content).toContain("cat: 'Cat'");
1253+
expect(content).toContain("dog: 'Dog'");
1254+
});
1255+
1256+
it('does not emit enum maps when no vendor extensions are present', async () => {
1257+
await apiResourceGenerator(tree, {
1258+
specPath: 'specs/petstore.yaml',
1259+
outputDir: 'libs/enum-none/src',
1260+
});
1261+
// MOCK_SPEC has a limit query param but no x-enum-varnames
1262+
const content = tree.read('libs/enum-none/src/pets/list-pets.token.ts', 'utf-8')!;
1263+
expect(content).not.toContain('Labels =');
1264+
expect(content).not.toContain('Descriptions =');
1265+
});
1266+
});
1267+
11551268
describe('readonly response types', () => {
11561269
it('wraps XxxResponse in Readonly<> when readonlyResponses is true', async () => {
11571270
await apiResourceGenerator(tree, {

tools/openapi-resource-gen/src/generators/api-resource/parse-spec.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import SwaggerParser from '@apidevtools/swagger-parser';
22
import { OpenAPIV3 } from 'openapi-types';
3-
import type { DateField, DiscriminatorModel, DiscriminatorVariant, EndpointModel, SecurityKind, SecuritySchemeModel, SpecialQueryParam, WebhookModel } from './endpoint-model';
3+
import type { DateField, DiscriminatorModel, DiscriminatorVariant, EndpointModel, EnumExtension, SecurityKind, SecuritySchemeModel, SpecialQueryParam, WebhookModel } from './endpoint-model';
44

55
const HTTP_METHODS: ReadonlyArray<OpenAPIV3.HttpMethods> = [
66
OpenAPIV3.HttpMethods.GET,
@@ -121,6 +121,29 @@ function collectDateFields(schema: OpenAPIV3.SchemaObject | null | undefined): D
121121
}));
122122
}
123123

124+
/** Collect x-enum-varnames / x-enum-descriptions from the query/path/header parameters of an operation. */
125+
function collectEnumExtensions(params: OpenAPIV3.ParameterObject[]): EnumExtension[] {
126+
const result: EnumExtension[] = [];
127+
for (const p of params) {
128+
const schema = p.schema as (OpenAPIV3.SchemaObject & {
129+
'x-enum-varnames'?: string[];
130+
'x-enum-descriptions'?: string[];
131+
}) | undefined;
132+
if (!schema || !Array.isArray(schema.enum)) continue;
133+
const varnames = schema['x-enum-varnames'];
134+
const descriptions = schema['x-enum-descriptions'];
135+
if (!varnames && !descriptions) continue;
136+
const values = (schema.enum as unknown[]).map(String);
137+
result.push({
138+
paramName: p.name,
139+
values,
140+
...(varnames ? { varnames: varnames.slice(0, values.length) } : {}),
141+
...(descriptions ? { descriptions: descriptions.slice(0, values.length) } : {}),
142+
});
143+
}
144+
return result;
145+
}
146+
124147
export function buildEndpoints(
125148
api: OpenAPIV3.Document,
126149
allowedTags: string[] | null,
@@ -188,6 +211,8 @@ export function buildEndpoints(
188211
return [];
189212
});
190213

214+
const enumExtensions = collectEnumExtensions(allParams);
215+
191216
// Operation-level security overrides global; [] means explicitly no security.
192217
const operationSecurity = operation.security as
193218
| OpenAPIV3.SecurityRequirementObject[]
@@ -277,6 +302,7 @@ export function buildEndpoints(
277302
securitySchemeNames,
278303
errorStatuses,
279304
discriminator,
305+
enumExtensions,
280306
dateFields,
281307
responseIsArray,
282308
});

tools/openapi-resource-gen/src/generators/api-resource/render-token.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,25 @@ export function renderTokenFile(
164164
''
165165
);
166166
}
167+
168+
// Enum label / description maps from x-enum-varnames / x-enum-descriptions vendor extensions.
169+
for (const ext of ep.enumExtensions) {
170+
const mapPrefix = `${toCamelCase(ep.operationId)}${toPascalCase(ext.paramName)}`;
171+
if (ext.varnames) {
172+
lines.push(`export const ${mapPrefix}Labels = {`);
173+
for (let i = 0; i < ext.values.length; i++) {
174+
lines.push(` ${JSON.stringify(ext.values[i])}: ${JSON.stringify(ext.varnames[i] ?? ext.values[i])},`);
175+
}
176+
lines.push(`} as const;`, '');
177+
}
178+
if (ext.descriptions) {
179+
lines.push(`export const ${mapPrefix}Descriptions = {`);
180+
for (let i = 0; i < ext.values.length; i++) {
181+
lines.push(` ${JSON.stringify(ext.values[i])}: ${JSON.stringify(ext.descriptions[i] ?? '')},`);
182+
}
183+
lines.push(`} as const;`, '');
184+
}
185+
}
167186
if (!isGet && ep.hasBody && ep.bodyContentType) {
168187
if (ep.isBinaryBody) {
169188
// Binary content (octet-stream, pdf, image/*…): use Blob | ArrayBuffer directly.

0 commit comments

Comments
 (0)