Skip to content

Commit 3d268a7

Browse files
constantantclaude
andcommitted
feat(openapi-resource-gen): emit XxxRevived type and reviveXxxDates() for date/datetime fields
Add dateType option (string | Date | Temporal, default: string). When non-string, the generator collects format:date-time / format:date properties from the primary response schema and emits a typed XxxRevived alias (Omit + intersection) and a reviveXxxDates() helper that converts raw string values at runtime. Array responses are handled by mapping over items; Temporal mode emits Temporal.Instant / Temporal.PlainDate instead of Date. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 463f52c commit 3d268a7

6 files changed

Lines changed: 248 additions & 4 deletions

File tree

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

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

15+
/** A response property with format:date-time or format:date. */
16+
export interface DateField {
17+
name: string;
18+
format: 'date-time' | 'date';
19+
}
20+
1521
export interface WebhookModel {
1622
/** The webhook name key as it appears in the spec, e.g. 'newPet'. */
1723
name: string;
@@ -84,4 +90,8 @@ export interface EndpointModel {
8490
securitySchemeNames: string[];
8591
/** Present when the primary response schema carries a discriminator. */
8692
discriminator: DiscriminatorModel | null;
93+
/** Date/datetime fields on the primary response object, for optional reviver generation. */
94+
dateFields: DateField[];
95+
/** True when the primary JSON response type is an array. */
96+
responseIsArray: boolean;
8797
}

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

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

1155+
describe('date / temporal deserialization', () => {
1156+
const USER_SPEC = {
1157+
paths: {
1158+
'/user': {
1159+
get: {
1160+
operationId: 'getUser',
1161+
tags: ['user'],
1162+
responses: {
1163+
'200': {
1164+
content: {
1165+
'application/json': {
1166+
schema: {
1167+
type: 'object',
1168+
properties: {
1169+
name: { type: 'string' },
1170+
createdAt: { type: 'string', format: 'date-time' },
1171+
dueDate: { type: 'string', format: 'date' },
1172+
},
1173+
},
1174+
},
1175+
},
1176+
},
1177+
},
1178+
},
1179+
},
1180+
},
1181+
};
1182+
1183+
const EVENT_LIST_SPEC = {
1184+
paths: {
1185+
'/events': {
1186+
get: {
1187+
operationId: 'listEvents',
1188+
tags: ['events'],
1189+
responses: {
1190+
'200': {
1191+
content: {
1192+
'application/json': {
1193+
schema: {
1194+
type: 'array',
1195+
items: {
1196+
type: 'object',
1197+
properties: {
1198+
id: { type: 'string' },
1199+
happenedAt: { type: 'string', format: 'date-time' },
1200+
},
1201+
},
1202+
},
1203+
},
1204+
},
1205+
},
1206+
},
1207+
},
1208+
},
1209+
},
1210+
};
1211+
1212+
it('emits XxxRevived type and reviveXxxDates function for Date mode (object response)', async () => {
1213+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(USER_SPEC as never);
1214+
await apiResourceGenerator(tree, {
1215+
specPath: 'specs/petstore.yaml',
1216+
outputDir: 'libs/date-obj/src',
1217+
dateType: 'Date',
1218+
});
1219+
const content = tree.read('libs/date-obj/src/user/get-user.token.ts', 'utf-8')!;
1220+
expect(content).toContain('export type GetUserRevived =');
1221+
expect(content).toContain("Omit<GetUserResponse, 'createdAt' | 'dueDate'>");
1222+
expect(content).toContain('createdAt: Date');
1223+
expect(content).toContain('dueDate: Date');
1224+
expect(content).toContain('export function reviveGetUserDates(');
1225+
expect(content).toContain("obj['createdAt'] != null");
1226+
expect(content).toContain("obj['dueDate'] != null");
1227+
});
1228+
1229+
it('emits array-wrapped XxxRevived and maps over items for Date mode (array response)', async () => {
1230+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(EVENT_LIST_SPEC as never);
1231+
await apiResourceGenerator(tree, {
1232+
specPath: 'specs/petstore.yaml',
1233+
outputDir: 'libs/date-arr/src',
1234+
dateType: 'Date',
1235+
});
1236+
const content = tree.read('libs/date-arr/src/events/list-events.token.ts', 'utf-8')!;
1237+
expect(content).toContain('export type ListEventsRevived =');
1238+
// Array wrapper pattern
1239+
expect(content).toContain('ListEventsResponse extends (infer _I)[] ? _I : never');
1240+
expect(content).toContain(')[];');
1241+
expect(content).toContain('export function reviveListEventsDates(');
1242+
// Uses map over items
1243+
expect(content).toContain('.map(');
1244+
expect(content).toContain("new Date(obj['happenedAt']");
1245+
});
1246+
1247+
it('emits Temporal.Instant for date-time fields in Temporal mode', async () => {
1248+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(USER_SPEC as never);
1249+
await apiResourceGenerator(tree, {
1250+
specPath: 'specs/petstore.yaml',
1251+
outputDir: 'libs/temporal-instant/src',
1252+
dateType: 'Temporal',
1253+
});
1254+
const content = tree.read('libs/temporal-instant/src/user/get-user.token.ts', 'utf-8')!;
1255+
expect(content).toContain('createdAt: Temporal.Instant');
1256+
expect(content).toContain("Temporal.Instant.from(obj['createdAt']");
1257+
});
1258+
1259+
it('emits Temporal.PlainDate for date fields in Temporal mode', async () => {
1260+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(USER_SPEC as never);
1261+
await apiResourceGenerator(tree, {
1262+
specPath: 'specs/petstore.yaml',
1263+
outputDir: 'libs/temporal-plain/src',
1264+
dateType: 'Temporal',
1265+
});
1266+
const content = tree.read('libs/temporal-plain/src/user/get-user.token.ts', 'utf-8')!;
1267+
expect(content).toContain('dueDate: Temporal.PlainDate');
1268+
expect(content).toContain("Temporal.PlainDate.from(obj['dueDate']");
1269+
});
1270+
1271+
it('does not emit reviver when dateType is string (default)', async () => {
1272+
vi.mocked(SwaggerParser.dereference).mockResolvedValue(USER_SPEC as never);
1273+
await apiResourceGenerator(tree, {
1274+
specPath: 'specs/petstore.yaml',
1275+
outputDir: 'libs/date-default/src',
1276+
});
1277+
const content = tree.read('libs/date-default/src/user/get-user.token.ts', 'utf-8')!;
1278+
expect(content).not.toContain('Revived');
1279+
expect(content).not.toContain('reviveGetUser');
1280+
});
1281+
1282+
it('does not emit reviver when the response schema has no date fields', async () => {
1283+
await apiResourceGenerator(tree, {
1284+
specPath: 'specs/petstore.yaml',
1285+
outputDir: 'libs/date-none/src',
1286+
dateType: 'Date',
1287+
});
1288+
// MOCK_SPEC has no date fields
1289+
const content = tree.read('libs/date-none/src/pets/list-pets.token.ts', 'utf-8')!;
1290+
expect(content).not.toContain('Revived');
1291+
expect(content).not.toContain('reviveListPets');
1292+
});
1293+
});
1294+
11551295
describe('descriptive errors', () => {
11561296
it('error message for missing openapi field includes version guidance', () => {
11571297
// Verify the error text is descriptive before we even hit SwaggerParser.

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export interface ApiResourceGeneratorSchema {
3939
specId?: string;
4040
/** Print a summary of created, updated, and deleted files after generation. */
4141
verbose?: boolean;
42+
/** Convert format:date-time / format:date response fields to Date or Temporal objects. */
43+
dateType?: 'string' | 'Date' | 'Temporal';
4244
}
4345

4446
/** Derive a specId from the baseUrlToken: PETSTORE_BASE_URL → petstore */
@@ -314,7 +316,7 @@ export async function apiResourceGenerator(
314316

315317
for (const ep of tagEndpoints) {
316318
const filePath = joinPathFragments(tagDir, `${ep.fileName}.token.ts`);
317-
tree.write(filePath, renderTokenFile(ep, baseUrlToken, providedIn, schemesByName));
319+
tree.write(filePath, renderTokenFile(ep, baseUrlToken, providedIn, schemesByName, options.dateType ?? 'string'));
318320
writtenFiles.add(filePath);
319321

320322
if (includeMocks) {

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

Lines changed: 26 additions & 2 deletions
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 { DiscriminatorModel, DiscriminatorVariant, EndpointModel, SecurityKind, SecuritySchemeModel, SpecialQueryParam, WebhookModel } from './endpoint-model';
3+
import type { DateField, DiscriminatorModel, DiscriminatorVariant, EndpointModel, SecurityKind, SecuritySchemeModel, SpecialQueryParam, WebhookModel } from './endpoint-model';
44

55
const HTTP_METHODS: ReadonlyArray<OpenAPIV3.HttpMethods> = [
66
OpenAPIV3.HttpMethods.GET,
@@ -107,6 +107,20 @@ function extractDiscriminatorFromSchema(
107107
return variants.length > 0 ? { propertyName, variants, isArrayResponse } : null;
108108
}
109109

110+
/** Collect top-level properties with format:date-time or format:date from a schema object. */
111+
function collectDateFields(schema: OpenAPIV3.SchemaObject | null | undefined): DateField[] {
112+
if (!schema?.properties) return [];
113+
return Object.entries(schema.properties)
114+
.filter(([, prop]) => {
115+
const s = prop as OpenAPIV3.SchemaObject;
116+
return s.type === 'string' && (s.format === 'date-time' || s.format === 'date');
117+
})
118+
.map(([name, prop]) => ({
119+
name,
120+
format: (prop as OpenAPIV3.SchemaObject).format as 'date-time' | 'date',
121+
}));
122+
}
123+
110124
export function buildEndpoints(
111125
api: OpenAPIV3.Document,
112126
allowedTags: string[] | null,
@@ -220,8 +234,10 @@ export function buildEndpoints(
220234

221235
const deprecated = operation.deprecated === true;
222236

223-
// Detect discriminated union on the primary response schema.
237+
// Detect discriminated union and collect date fields from the primary response schema.
224238
let discriminator: DiscriminatorModel | null = null;
239+
let dateFields: DateField[] = [];
240+
let responseIsArray = false;
225241
if (responseStatuses.length > 0) {
226242
const primaryResponseObj = operation.responses?.[responseStatuses[0]] as OpenAPIV3.ResponseObject | undefined;
227243
const primarySchema = primaryResponseObj?.content?.['application/json']?.schema as OpenAPIV3.SchemaObject | undefined;
@@ -231,6 +247,12 @@ export function buildEndpoints(
231247
(primarySchema.type === 'array'
232248
? extractDiscriminatorFromSchema(primarySchema.items as OpenAPIV3.SchemaObject, true)
233249
: null);
250+
if (primarySchema.type === 'array' && primarySchema.items) {
251+
responseIsArray = true;
252+
dateFields = collectDateFields(primarySchema.items as OpenAPIV3.SchemaObject);
253+
} else {
254+
dateFields = collectDateFields(primarySchema);
255+
}
234256
}
235257
}
236258

@@ -255,6 +277,8 @@ export function buildEndpoints(
255277
securitySchemeNames,
256278
errorStatuses,
257279
discriminator,
280+
dateFields,
281+
responseIsArray,
258282
});
259283
}
260284
}

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

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,8 @@ export function renderTokenFile(
124124
ep: EndpointModel,
125125
baseUrlToken: string,
126126
providedIn: 'root' | 'none' = 'none',
127-
schemesByName: Map<string, SecuritySchemeModel> = new Map()
127+
schemesByName: Map<string, SecuritySchemeModel> = new Map(),
128+
dateType: 'string' | 'Date' | 'Temporal' = 'string'
128129
): string {
129130
const pascal = toPascalCase(ep.operationId);
130131
const urlTemplate = ep.apiPath.replace(/\{([\w-]+)\}/g, (_, p) => `\${${toCamelCase(p)}}`);
@@ -238,6 +239,67 @@ export function renderTokenFile(
238239
lines.push(`export type ${pascal}Discriminated = ${discriminatedExpr};`, '');
239240
}
240241

242+
// Date/datetime reviver — emitted when dateType != 'string' and the response has date fields.
243+
if (dateType !== 'string' && ep.dateFields.length > 0 && hasResponse) {
244+
const omitKeys = ep.dateFields.map((f) => JSON.stringify(f.name)).join(' | ');
245+
if (ep.responseIsArray) {
246+
lines.push(`export type ${pascal}Revived = (Omit<`);
247+
lines.push(` ${pascal}Response extends (infer _I)[] ? _I : never,`);
248+
lines.push(` ${omitKeys}`);
249+
lines.push(`> & {`);
250+
for (const f of ep.dateFields) {
251+
const tsType = dateType === 'Temporal'
252+
? f.format === 'date-time' ? 'Temporal.Instant' : 'Temporal.PlainDate'
253+
: 'Date';
254+
lines.push(` ${f.name}: ${tsType};`);
255+
}
256+
lines.push(`})[];`);
257+
lines.push('');
258+
lines.push(`export function revive${pascal}Dates(raw: ${pascal}Response): ${pascal}Revived {`);
259+
lines.push(` return (raw as unknown[]).map((item) => {`);
260+
lines.push(` const obj = item as Record<string, unknown>;`);
261+
lines.push(` return {`);
262+
lines.push(` ...obj,`);
263+
for (const f of ep.dateFields) {
264+
const convert = dateType === 'Temporal'
265+
? f.format === 'date-time'
266+
? `Temporal.Instant.from(obj[${JSON.stringify(f.name)}] as string)`
267+
: `Temporal.PlainDate.from(obj[${JSON.stringify(f.name)}] as string)`
268+
: `new Date(obj[${JSON.stringify(f.name)}] as string)`;
269+
lines.push(` ${f.name}: obj[${JSON.stringify(f.name)}] != null ? ${convert} : obj[${JSON.stringify(f.name)}],`);
270+
}
271+
lines.push(` };`);
272+
lines.push(` }) as ${pascal}Revived;`);
273+
lines.push(`}`);
274+
lines.push('');
275+
} else {
276+
lines.push(`export type ${pascal}Revived = Omit<${pascal}Response, ${omitKeys}> & {`);
277+
for (const f of ep.dateFields) {
278+
const tsType = dateType === 'Temporal'
279+
? f.format === 'date-time' ? 'Temporal.Instant' : 'Temporal.PlainDate'
280+
: 'Date';
281+
lines.push(` ${f.name}: ${tsType};`);
282+
}
283+
lines.push(`};`);
284+
lines.push('');
285+
lines.push(`export function revive${pascal}Dates(raw: ${pascal}Response): ${pascal}Revived {`);
286+
lines.push(` const obj = raw as unknown as Record<string, unknown>;`);
287+
lines.push(` return {`);
288+
lines.push(` ...obj,`);
289+
for (const f of ep.dateFields) {
290+
const convert = dateType === 'Temporal'
291+
? f.format === 'date-time'
292+
? `Temporal.Instant.from(obj[${JSON.stringify(f.name)}] as string)`
293+
: `Temporal.PlainDate.from(obj[${JSON.stringify(f.name)}] as string)`
294+
: `new Date(obj[${JSON.stringify(f.name)}] as string)`;
295+
lines.push(` ${f.name}: obj[${JSON.stringify(f.name)}] != null ? ${convert} : obj[${JSON.stringify(f.name)}],`);
296+
}
297+
lines.push(` } as ${pascal}Revived;`);
298+
lines.push(`}`);
299+
lines.push('');
300+
}
301+
}
302+
241303
const responseT = hasResponse ? `${pascal}Response` : 'unknown';
242304
const fnArgs = buildFnArgs(ep, pascal, isGet);
243305

tools/openapi-resource-gen/src/generators/api-resource/schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@
4848
"type": "boolean",
4949
"default": false,
5050
"description": "Print a summary of created, updated, and deleted files after generation."
51+
},
52+
"dateType": {
53+
"type": "string",
54+
"enum": ["string", "Date", "Temporal"],
55+
"default": "string",
56+
"description": "Convert format:date-time / format:date response fields to Date or Temporal objects. Emits a typed XxxRevived alias and reviveXxxDates() helper per affected endpoint."
5157
}
5258
},
5359
"required": ["specPath", "outputDir"]

0 commit comments

Comments
 (0)