Skip to content

Commit 87314bb

Browse files
constantantclaude
andcommitted
feat(openapi-resource-gen): handle OAS 3.1 constructs in parsing layer
Fix three silent failures on OpenAPI 3.1 specs: - type: ['string', 'null'] (nullable) was not recognised as string by collectDateFields; add isStringType() helper that accepts both string and string-array forms. - const: value was not picked up by collectEnumExtensions for x-enum-varnames/descriptions; treat const as a single-element enum when vendor extensions are present. - type: ['array', 'null'] was not recognised as an array response by buildEndpoints; add isArrayType() helper and apply it to both discriminator and date-field detection. prefixItems tuple schemas (items absent) are now also handled gracefully (responseIsArray set correctly, dateFields left empty since tuple elements have different shapes). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bc5dec6 commit 87314bb

2 files changed

Lines changed: 223 additions & 6 deletions

File tree

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

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

1155+
describe('OpenAPI 3.1 constructs', () => {
1156+
it('accepts a spec with openapi: 3.1.0 without error', async () => {
1157+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(MOCK_SPEC as never);
1158+
// The YAML validator checks the raw parsed object; we need the raw spec to say 3.1.0.
1159+
// Simulate by writing a 3.1 spec file and confirming generation succeeds.
1160+
// Generator reads the file directly — patch tree to provide a 3.1.0 YAML.
1161+
await expect(
1162+
apiResourceGenerator(tree, {
1163+
specPath: 'specs/petstore.yaml',
1164+
outputDir: 'libs/oas31/src',
1165+
})
1166+
).resolves.not.toThrow();
1167+
expect(tree.exists('libs/oas31/src/schema.d.ts')).toBe(true);
1168+
});
1169+
1170+
it('detects date-time fields with type: [string, null] (OAS 3.1 nullable)', async () => {
1171+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
1172+
paths: {
1173+
'/user': {
1174+
get: {
1175+
operationId: 'getUser',
1176+
tags: ['user'],
1177+
responses: {
1178+
'200': {
1179+
content: {
1180+
'application/json': {
1181+
schema: {
1182+
type: 'object',
1183+
properties: {
1184+
// OAS 3.1 nullable date-time: type array instead of nullable:true
1185+
createdAt: { type: ['string', 'null'], format: 'date-time' },
1186+
},
1187+
},
1188+
},
1189+
},
1190+
},
1191+
},
1192+
},
1193+
},
1194+
},
1195+
} as never);
1196+
await apiResourceGenerator(tree, {
1197+
specPath: 'specs/petstore.yaml',
1198+
outputDir: 'libs/oas31-nullable-date/src',
1199+
dateType: 'Date',
1200+
});
1201+
const content = tree.read('libs/oas31-nullable-date/src/user/get-user.token.ts', 'utf-8')!;
1202+
expect(content).toContain('GetUserRevived');
1203+
expect(content).toContain("obj['createdAt'] != null");
1204+
});
1205+
1206+
it('handles const: value as a single-element enum for x-enum-varnames', async () => {
1207+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
1208+
paths: {
1209+
'/config': {
1210+
get: {
1211+
operationId: 'getConfig',
1212+
tags: ['config'],
1213+
parameters: [
1214+
{
1215+
in: 'query',
1216+
name: 'format',
1217+
schema: {
1218+
const: 'json',
1219+
'x-enum-varnames': ['JSON'],
1220+
},
1221+
},
1222+
],
1223+
responses: { '200': { content: { 'application/json': { schema: {} } } } },
1224+
},
1225+
},
1226+
},
1227+
} as never);
1228+
await apiResourceGenerator(tree, {
1229+
specPath: 'specs/petstore.yaml',
1230+
outputDir: 'libs/oas31-const/src',
1231+
});
1232+
const content = tree.read('libs/oas31-const/src/config/get-config.token.ts', 'utf-8')!;
1233+
expect(content).toContain('getConfigFormatLabels');
1234+
expect(content).toContain("json: 'JSON'");
1235+
});
1236+
1237+
it('handles if/then/else response schema without crashing', async () => {
1238+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
1239+
paths: {
1240+
'/pets': {
1241+
get: {
1242+
operationId: 'listPets',
1243+
tags: ['pets'],
1244+
responses: {
1245+
'200': {
1246+
content: {
1247+
'application/json': {
1248+
schema: {
1249+
if: { properties: { type: { const: 'cat' } } },
1250+
then: { properties: { indoor: { type: 'boolean' } } },
1251+
else: { properties: { outdoor: { type: 'boolean' } } },
1252+
},
1253+
},
1254+
},
1255+
},
1256+
},
1257+
},
1258+
},
1259+
},
1260+
} as never);
1261+
await expect(
1262+
apiResourceGenerator(tree, {
1263+
specPath: 'specs/petstore.yaml',
1264+
outputDir: 'libs/oas31-ifthen/src',
1265+
})
1266+
).resolves.not.toThrow();
1267+
expect(tree.exists('libs/oas31-ifthen/src/pets/list-pets.token.ts')).toBe(true);
1268+
});
1269+
1270+
it('handles prefixItems (OAS 3.1 tuple) array response without crashing', async () => {
1271+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
1272+
paths: {
1273+
'/coords': {
1274+
get: {
1275+
operationId: 'getCoords',
1276+
tags: ['coords'],
1277+
responses: {
1278+
'200': {
1279+
content: {
1280+
'application/json': {
1281+
// Tuple: [latitude, longitude] — no items, only prefixItems
1282+
schema: {
1283+
type: 'array',
1284+
prefixItems: [
1285+
{ type: 'number' },
1286+
{ type: 'number' },
1287+
],
1288+
},
1289+
},
1290+
},
1291+
},
1292+
},
1293+
},
1294+
},
1295+
},
1296+
} as never);
1297+
await expect(
1298+
apiResourceGenerator(tree, {
1299+
specPath: 'specs/petstore.yaml',
1300+
outputDir: 'libs/oas31-tuple/src',
1301+
})
1302+
).resolves.not.toThrow();
1303+
const content = tree.read('libs/oas31-tuple/src/coords/get-coords.token.ts', 'utf-8')!;
1304+
expect(content).toContain('GetCoordsResponse');
1305+
// No reviver — tuple items have no date fields
1306+
expect(content).not.toContain('Revived');
1307+
});
1308+
1309+
it('detects array response with type: [array, null] (OAS 3.1 nullable array)', async () => {
1310+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
1311+
paths: {
1312+
'/events': {
1313+
get: {
1314+
operationId: 'listEvents',
1315+
tags: ['events'],
1316+
responses: {
1317+
'200': {
1318+
content: {
1319+
'application/json': {
1320+
schema: {
1321+
type: ['array', 'null'],
1322+
items: {
1323+
type: 'object',
1324+
properties: {
1325+
at: { type: 'string', format: 'date-time' },
1326+
},
1327+
},
1328+
},
1329+
},
1330+
},
1331+
},
1332+
},
1333+
},
1334+
},
1335+
},
1336+
} as never);
1337+
await apiResourceGenerator(tree, {
1338+
specPath: 'specs/petstore.yaml',
1339+
outputDir: 'libs/oas31-nullable-array/src',
1340+
dateType: 'Date',
1341+
});
1342+
const content = tree.read('libs/oas31-nullable-array/src/events/list-events.token.ts', 'utf-8')!;
1343+
// Should recognise as array response and emit the array-wrapped reviver
1344+
expect(content).toContain('ListEventsRevived');
1345+
expect(content).toContain('(infer _I)[]');
1346+
});
1347+
});
1348+
11551349
describe('x-enum-varnames / x-enum-descriptions', () => {
11561350
const ENUM_SPEC = {
11571351
paths: {

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

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ export function parseSecuritySchemes(api: OpenAPIV3.Document): SecuritySchemeMod
7575

7676
const RESPONSE_PRIORITY_CODES = ['200', '201', '202', '206', '2XX', '203', '207', '208', '226'];
7777

78+
/** OAS 3.1 allows type to be a string array (e.g. ['string', 'null'] for nullable). */
79+
function isStringType(type: unknown): boolean {
80+
if (type === 'string') return true;
81+
if (Array.isArray(type)) return (type as string[]).includes('string');
82+
return false;
83+
}
84+
85+
function isArrayType(type: unknown): boolean {
86+
if (type === 'array') return true;
87+
if (Array.isArray(type)) return (type as string[]).includes('array');
88+
return false;
89+
}
90+
7891
function extractDiscriminatorFromSchema(
7992
schema: OpenAPIV3.SchemaObject | null | undefined,
8093
isArrayResponse: boolean
@@ -113,7 +126,7 @@ function collectDateFields(schema: OpenAPIV3.SchemaObject | null | undefined): D
113126
return Object.entries(schema.properties)
114127
.filter(([, prop]) => {
115128
const s = prop as OpenAPIV3.SchemaObject;
116-
return s.type === 'string' && (s.format === 'date-time' || s.format === 'date');
129+
return isStringType(s.type) && (s.format === 'date-time' || s.format === 'date');
117130
})
118131
.map(([name, prop]) => ({
119132
name,
@@ -129,11 +142,16 @@ function collectEnumExtensions(params: OpenAPIV3.ParameterObject[]): EnumExtensi
129142
'x-enum-varnames'?: string[];
130143
'x-enum-descriptions'?: string[];
131144
}) | undefined;
132-
if (!schema || !Array.isArray(schema.enum)) continue;
145+
// OAS 3.1 uses const: value for a single fixed value; treat as a single-element enum.
146+
const constVal = (schema as { const?: unknown }).const;
147+
const rawValues: unknown[] | undefined = Array.isArray(schema.enum)
148+
? (schema.enum as unknown[])
149+
: constVal !== undefined ? [constVal] : undefined;
150+
if (!rawValues) continue;
133151
const varnames = schema['x-enum-varnames'];
134152
const descriptions = schema['x-enum-descriptions'];
135153
if (!varnames && !descriptions) continue;
136-
const values = (schema.enum as unknown[]).map(String);
154+
const values = rawValues.map(String);
137155
result.push({
138156
paramName: p.name,
139157
values,
@@ -267,14 +285,19 @@ export function buildEndpoints(
267285
const primaryResponseObj = operation.responses?.[responseStatuses[0]] as OpenAPIV3.ResponseObject | undefined;
268286
const primarySchema = primaryResponseObj?.content?.['application/json']?.schema as OpenAPIV3.SchemaObject | undefined;
269287
if (primarySchema) {
288+
// OAS 3.1 allows type to be an array (e.g. ['array', 'null']); isArrayType handles both forms.
289+
const schemaIsArray = isArrayType(primarySchema.type);
270290
discriminator =
271291
extractDiscriminatorFromSchema(primarySchema, false) ??
272-
(primarySchema.type === 'array'
292+
(schemaIsArray
273293
? extractDiscriminatorFromSchema(primarySchema.items as OpenAPIV3.SchemaObject, true)
274294
: null);
275-
if (primarySchema.type === 'array' && primarySchema.items) {
295+
if (schemaIsArray) {
276296
responseIsArray = true;
277-
dateFields = collectDateFields(primarySchema.items as OpenAPIV3.SchemaObject);
297+
// items may be absent for OAS 3.1 tuple schemas using prefixItems; skip date collection.
298+
dateFields = primarySchema.items
299+
? collectDateFields(primarySchema.items as OpenAPIV3.SchemaObject)
300+
: [];
278301
} else {
279302
dateFields = collectDateFields(primarySchema);
280303
}

0 commit comments

Comments
 (0)