@codewithagents/openapi-server 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +239 -2
- package/dist/cli.cjs +835 -141
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -1
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js +6 -2
- package/dist/generator.js.map +1 -1
- package/dist/plugins/router.d.ts +13 -0
- package/dist/plugins/router.d.ts.map +1 -1
- package/dist/plugins/router.js +934 -122
- package/dist/plugins/router.js.map +1 -1
- package/dist/plugins/service.d.ts +17 -1
- package/dist/plugins/service.d.ts.map +1 -1
- package/dist/plugins/service.js +90 -28
- package/dist/plugins/service.js.map +1 -1
- package/dist/plugins/shared.d.ts +46 -0
- package/dist/plugins/shared.d.ts.map +1 -1
- package/dist/plugins/shared.js +135 -12
- package/dist/plugins/shared.js.map +1 -1
- package/package.json +2 -2
package/dist/plugins/router.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SUPPORTED_METHODS, isRef, extractPathParamsFromPath, resolveParam, deriveServiceName, deriveMethodName, getQueryParams, getBodyInfo, } from './shared.js';
|
|
1
|
+
import { SUPPORTED_METHODS, isRef, refToName, extractPathParamsFromPath, resolveParam, deriveServiceName, deriveMethodName, getQueryParams, getBodyInfo, } from './shared.js';
|
|
2
2
|
/** Convert OpenAPI path to Hono path: {id} -> :id */
|
|
3
3
|
function toHonoPath(openapiPath) {
|
|
4
4
|
return openapiPath.replace(/\{([^}]+)\}/g, ':$1');
|
|
@@ -24,13 +24,38 @@ function formatToZodModifier(format) {
|
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
26
|
* Build a Zod expression for a path parameter based on its schema.
|
|
27
|
-
* Returns undefined when the parameter does not need
|
|
28
|
-
*
|
|
27
|
+
* Returns undefined when the parameter does not need validation.
|
|
28
|
+
*
|
|
29
|
+
* String params: validates format (uuid, email, url, date-time) via z.string().format().
|
|
30
|
+
* Integer/number params: validates range (minimum/maximum) via z.coerce.number().min().max().
|
|
31
|
+
* z.coerce.number() is used for path params because c.req.param() always returns a string;
|
|
32
|
+
* coercion converts the URL string to a number before the min/max check.
|
|
29
33
|
*/
|
|
30
34
|
function pathParamZodExpr(schema) {
|
|
31
35
|
if (schema === undefined || isRef(schema))
|
|
32
36
|
return undefined;
|
|
33
37
|
const s = schema;
|
|
38
|
+
// Integer / number path params with range constraints
|
|
39
|
+
if (s.type === 'integer' || s.type === 'number') {
|
|
40
|
+
const hasMin = typeof s.minimum === 'number';
|
|
41
|
+
const hasMax = typeof s.maximum === 'number';
|
|
42
|
+
const hasExcMin = typeof s.exclusiveMinimum === 'number';
|
|
43
|
+
const hasExcMax = typeof s.exclusiveMaximum === 'number';
|
|
44
|
+
if (hasMin || hasMax || hasExcMin || hasExcMax) {
|
|
45
|
+
let expr = 'z.coerce.number()';
|
|
46
|
+
if (hasMin)
|
|
47
|
+
expr += `.min(${s.minimum})`;
|
|
48
|
+
if (hasMax)
|
|
49
|
+
expr += `.max(${s.maximum})`;
|
|
50
|
+
if (hasExcMin)
|
|
51
|
+
expr += `.gt(${s.exclusiveMinimum})`;
|
|
52
|
+
if (hasExcMax)
|
|
53
|
+
expr += `.lt(${s.exclusiveMaximum})`;
|
|
54
|
+
return expr;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
// String path params: only validated when a known format modifier exists
|
|
34
59
|
if (s.type !== 'string')
|
|
35
60
|
return undefined;
|
|
36
61
|
const format = s.format;
|
|
@@ -41,33 +66,104 @@ function pathParamZodExpr(schema) {
|
|
|
41
66
|
return undefined;
|
|
42
67
|
return `z.string()${modifier}`;
|
|
43
68
|
}
|
|
69
|
+
// ── queryParamZodExpr helpers (one per param kind) ───────────────────────────
|
|
70
|
+
/** Delimited array param: value has been split into string[] by the extraction layer. */
|
|
71
|
+
function queryParamDelimitedZodBase(_param) {
|
|
72
|
+
return 'z.array(z.string())';
|
|
73
|
+
}
|
|
44
74
|
/**
|
|
45
|
-
*
|
|
75
|
+
* DeepObject param: assembled into Record<string, string>.
|
|
76
|
+
* Emits z.object({...}) with per-property coercion; all properties are .optional()
|
|
77
|
+
* because their presence is governed by the outer object's required flag.
|
|
78
|
+
*/
|
|
79
|
+
function queryParamDeepObjectZodBase(param) {
|
|
80
|
+
const propFields = (param.deepObjectProperties ?? []).map((p) => {
|
|
81
|
+
const coerced = p.tsType === 'number' ? 'z.coerce.number()' : 'z.string()';
|
|
82
|
+
return `${p.key}: ${coerced}.optional()`;
|
|
83
|
+
});
|
|
84
|
+
return `z.object({ ${propFields.join(', ')} })`;
|
|
85
|
+
}
|
|
86
|
+
/** Number/integer param: z.coerce.number() with optional range modifiers.
|
|
87
|
+
* Uses coerce so that Fastify's raw string values (fast-querystring never converts types)
|
|
88
|
+
* are accepted alongside the already-coerced numbers from Express/Hono extraction. */
|
|
89
|
+
function queryParamNumberZodBase(param) {
|
|
90
|
+
let base = 'z.coerce.number()';
|
|
91
|
+
if (param.minimum !== undefined)
|
|
92
|
+
base += `.min(${param.minimum})`;
|
|
93
|
+
if (param.maximum !== undefined)
|
|
94
|
+
base += `.max(${param.maximum})`;
|
|
95
|
+
if (param.exclusiveMinimum !== undefined)
|
|
96
|
+
base += `.gt(${param.exclusiveMinimum})`;
|
|
97
|
+
if (param.exclusiveMaximum !== undefined)
|
|
98
|
+
base += `.lt(${param.exclusiveMaximum})`;
|
|
99
|
+
return base;
|
|
100
|
+
}
|
|
101
|
+
/** String param: z.string() or z.enum([...]) with optional length/pattern modifiers. */
|
|
102
|
+
function queryParamStringZodBase(param) {
|
|
103
|
+
let base;
|
|
104
|
+
if (param.enum !== undefined && param.enum.length > 0) {
|
|
105
|
+
const members = param.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
106
|
+
base = `z.enum([${members}])`;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
base = 'z.string()';
|
|
110
|
+
}
|
|
111
|
+
if (param.minLength !== undefined)
|
|
112
|
+
base += `.min(${param.minLength})`;
|
|
113
|
+
if (param.maxLength !== undefined)
|
|
114
|
+
base += `.max(${param.maxLength})`;
|
|
115
|
+
if (param.pattern !== undefined)
|
|
116
|
+
base += `.regex(/${param.pattern}/)`;
|
|
117
|
+
return base;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Build a Zod expression for a query parameter based on its captured constraints.
|
|
46
121
|
* Number/integer types use z.number() (after coercion by extraction code).
|
|
47
|
-
* String types use z.string()
|
|
122
|
+
* String types use z.string() with optional format/enum/pattern/length modifiers.
|
|
123
|
+
* Delimited array params use z.array(z.string()).
|
|
124
|
+
* DeepObject params use z.object({...}) with per-property coercion.
|
|
48
125
|
* Appends .optional() for non-required params.
|
|
49
126
|
*/
|
|
50
|
-
function
|
|
127
|
+
function queryParamZodExpr(param) {
|
|
51
128
|
let base;
|
|
52
|
-
if (
|
|
53
|
-
base =
|
|
129
|
+
if (param.delimiterStyle !== undefined) {
|
|
130
|
+
base = queryParamDelimitedZodBase(param);
|
|
131
|
+
}
|
|
132
|
+
else if (param.isDeepObject === true && param.deepObjectProperties !== undefined) {
|
|
133
|
+
base = queryParamDeepObjectZodBase(param);
|
|
134
|
+
}
|
|
135
|
+
else if (param.tsType === 'number') {
|
|
136
|
+
base = queryParamNumberZodBase(param);
|
|
54
137
|
}
|
|
55
|
-
else if (tsType === 'boolean') {
|
|
138
|
+
else if (param.tsType === 'boolean') {
|
|
56
139
|
base = 'z.boolean()';
|
|
57
140
|
}
|
|
58
141
|
else {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
142
|
+
base = queryParamStringZodBase(param);
|
|
143
|
+
}
|
|
144
|
+
return param.required ? base : `${base}.optional()`;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Build a Zod expression for a header parameter based on its captured constraints.
|
|
148
|
+
* Header values are always strings; emits z.string() or z.enum([...]) with optional
|
|
149
|
+
* pattern/length modifiers. Appends .optional() for non-required params.
|
|
150
|
+
*/
|
|
151
|
+
function headerParamZodExpr(param) {
|
|
152
|
+
let base;
|
|
153
|
+
if (param.enum !== undefined && param.enum.length > 0) {
|
|
154
|
+
const members = param.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
155
|
+
base = `z.enum([${members}])`;
|
|
69
156
|
}
|
|
70
|
-
|
|
157
|
+
else {
|
|
158
|
+
base = 'z.string()';
|
|
159
|
+
}
|
|
160
|
+
if (param.minLength !== undefined)
|
|
161
|
+
base += `.min(${param.minLength})`;
|
|
162
|
+
if (param.maxLength !== undefined)
|
|
163
|
+
base += `.max(${param.maxLength})`;
|
|
164
|
+
if (param.pattern !== undefined)
|
|
165
|
+
base += `.regex(/${param.pattern}/)`;
|
|
166
|
+
return param.required ? base : `${base}.optional()`;
|
|
71
167
|
}
|
|
72
168
|
/**
|
|
73
169
|
* Collect path parameters that have Zod format constraints.
|
|
@@ -98,7 +194,29 @@ function getPathParamValidations(operation, spec, rawPathParamNames) {
|
|
|
98
194
|
return result;
|
|
99
195
|
}
|
|
100
196
|
/**
|
|
101
|
-
*
|
|
197
|
+
* Build a Zod expression for a cookie parameter based on its captured constraints.
|
|
198
|
+
* Cookie values are always strings; emits z.string() or z.enum([...]) with optional
|
|
199
|
+
* pattern/length modifiers. Appends .optional() for non-required params.
|
|
200
|
+
*/
|
|
201
|
+
function cookieParamZodExpr(param) {
|
|
202
|
+
let base;
|
|
203
|
+
if (param.enum !== undefined && param.enum.length > 0) {
|
|
204
|
+
const members = param.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
205
|
+
base = `z.enum([${members}])`;
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
base = 'z.string()';
|
|
209
|
+
}
|
|
210
|
+
if (param.minLength !== undefined)
|
|
211
|
+
base += `.min(${param.minLength})`;
|
|
212
|
+
if (param.maxLength !== undefined)
|
|
213
|
+
base += `.max(${param.maxLength})`;
|
|
214
|
+
if (param.pattern !== undefined)
|
|
215
|
+
base += `.regex(/${param.pattern}/)`;
|
|
216
|
+
return param.required ? base : `${base}.optional()`;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Collect header parameters from an operation, including schema constraints.
|
|
102
220
|
*/
|
|
103
221
|
function getHeaderParams(operation, spec) {
|
|
104
222
|
const parameters = operation.parameters;
|
|
@@ -109,19 +227,93 @@ function getHeaderParams(operation, spec) {
|
|
|
109
227
|
const resolved = resolveParam(p, spec);
|
|
110
228
|
if (resolved === undefined || resolved.in !== 'header')
|
|
111
229
|
continue;
|
|
112
|
-
|
|
230
|
+
const param = {
|
|
231
|
+
rawName: resolved.name,
|
|
232
|
+
required: resolved.required === true,
|
|
233
|
+
};
|
|
234
|
+
const schema = resolved.schema;
|
|
235
|
+
if (schema !== undefined && !isRef(schema)) {
|
|
236
|
+
const s = schema;
|
|
237
|
+
if (Array.isArray(s.enum))
|
|
238
|
+
param.enum = s.enum;
|
|
239
|
+
if (typeof s.minLength === 'number')
|
|
240
|
+
param.minLength = s.minLength;
|
|
241
|
+
if (typeof s.maxLength === 'number')
|
|
242
|
+
param.maxLength = s.maxLength;
|
|
243
|
+
if (typeof s.pattern === 'string')
|
|
244
|
+
param.pattern = s.pattern;
|
|
245
|
+
}
|
|
246
|
+
result.push(param);
|
|
247
|
+
}
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Collect cookie parameters (in: cookie) from an operation, including schema constraints.
|
|
252
|
+
* Cookie names are case-sensitive and are used as-is for both the Zod field key and value lookup.
|
|
253
|
+
*/
|
|
254
|
+
function getCookieParams(operation, spec) {
|
|
255
|
+
const parameters = operation.parameters;
|
|
256
|
+
if (parameters === undefined)
|
|
257
|
+
return [];
|
|
258
|
+
const result = [];
|
|
259
|
+
for (const p of parameters) {
|
|
260
|
+
const resolved = resolveParam(p, spec);
|
|
261
|
+
if (resolved === undefined || resolved.in !== 'cookie')
|
|
262
|
+
continue;
|
|
263
|
+
const param = {
|
|
113
264
|
rawName: resolved.name,
|
|
114
265
|
required: resolved.required === true,
|
|
115
|
-
}
|
|
266
|
+
};
|
|
267
|
+
const schema = resolved.schema;
|
|
268
|
+
if (schema !== undefined && !isRef(schema)) {
|
|
269
|
+
const s = schema;
|
|
270
|
+
if (Array.isArray(s.enum))
|
|
271
|
+
param.enum = s.enum;
|
|
272
|
+
if (typeof s.minLength === 'number')
|
|
273
|
+
param.minLength = s.minLength;
|
|
274
|
+
if (typeof s.maxLength === 'number')
|
|
275
|
+
param.maxLength = s.maxLength;
|
|
276
|
+
if (typeof s.pattern === 'string')
|
|
277
|
+
param.pattern = s.pattern;
|
|
278
|
+
}
|
|
279
|
+
result.push(param);
|
|
116
280
|
}
|
|
117
281
|
return result;
|
|
118
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Returns true when a query param carries schema constraints beyond basic type/required.
|
|
285
|
+
* These constraints require a Zod validation block even if the param is optional or string.
|
|
286
|
+
*/
|
|
287
|
+
function queryParamHasConstraints(q) {
|
|
288
|
+
// Fields that, when defined, indicate schema constraints are present.
|
|
289
|
+
const constraintFields = [
|
|
290
|
+
q.enum,
|
|
291
|
+
q.minimum,
|
|
292
|
+
q.maximum,
|
|
293
|
+
q.exclusiveMinimum,
|
|
294
|
+
q.exclusiveMaximum,
|
|
295
|
+
q.minLength,
|
|
296
|
+
q.maxLength,
|
|
297
|
+
q.pattern,
|
|
298
|
+
q.delimiterStyle,
|
|
299
|
+
];
|
|
300
|
+
return constraintFields.some((f) => f !== undefined) || q.isDeepObject === true;
|
|
301
|
+
}
|
|
119
302
|
/**
|
|
120
303
|
* Determine whether query params need a Zod validation block.
|
|
121
|
-
* Triggered when any param is required
|
|
304
|
+
* Triggered when any param is required, has a non-string type (to catch NaN/invalid input),
|
|
305
|
+
* or carries schema constraints (enum, min/max, pattern, etc.).
|
|
122
306
|
*/
|
|
123
307
|
function queryParamsNeedValidation(queryParams) {
|
|
124
|
-
return queryParams.some((q) => q.required || q.tsType !== 'string');
|
|
308
|
+
return queryParams.some((q) => q.required || q.tsType !== 'string' || queryParamHasConstraints(q));
|
|
309
|
+
}
|
|
310
|
+
/** Returns the delimiter character for a delimited-style array query param. */
|
|
311
|
+
function delimiterChar(style) {
|
|
312
|
+
if (style === 'ssv')
|
|
313
|
+
return ' ';
|
|
314
|
+
if (style === 'psv')
|
|
315
|
+
return '|';
|
|
316
|
+
return ',';
|
|
125
317
|
}
|
|
126
318
|
/**
|
|
127
319
|
* Emit Zod validation lines for query parameters into the handler line buffer.
|
|
@@ -134,7 +326,7 @@ function emitQueryValidation(lines, queryParams, indent) {
|
|
|
134
326
|
const fieldIndent = `${indent} `;
|
|
135
327
|
const fields = queryParams
|
|
136
328
|
.map((q) => {
|
|
137
|
-
const expr =
|
|
329
|
+
const expr = queryParamZodExpr(q);
|
|
138
330
|
return `${fieldIndent}${q.name}: ${expr}`;
|
|
139
331
|
})
|
|
140
332
|
.join(',\n');
|
|
@@ -166,7 +358,9 @@ function emitPathValidation(lines, validations, indent, framework) {
|
|
|
166
358
|
access = `c.req.param(${JSON.stringify(v.rawName)})`;
|
|
167
359
|
}
|
|
168
360
|
else if (framework === 'express') {
|
|
169
|
-
|
|
361
|
+
// Cast to string: Express 5 types req.params values as string | string[],
|
|
362
|
+
// but path params are always single strings in practice.
|
|
363
|
+
access = `req.params[${JSON.stringify(v.rawName)}] as string`;
|
|
170
364
|
}
|
|
171
365
|
else {
|
|
172
366
|
access = /[^a-zA-Z0-9_$]/.test(v.rawName)
|
|
@@ -195,22 +389,23 @@ function emitHeaderValidation(lines, headerParams, indent, framework) {
|
|
|
195
389
|
const schemaFields = headerParams
|
|
196
390
|
.map((h) => {
|
|
197
391
|
const key = JSON.stringify(h.rawName);
|
|
198
|
-
const expr = h
|
|
392
|
+
const expr = headerParamZodExpr(h);
|
|
199
393
|
return `${fieldIndent}${key}: ${expr}`;
|
|
200
394
|
})
|
|
201
395
|
.join(',\n');
|
|
202
396
|
const rawFields = headerParams
|
|
203
397
|
.map((h) => {
|
|
204
398
|
const key = JSON.stringify(h.rawName);
|
|
399
|
+
const lookupKey = JSON.stringify(h.rawName.toLowerCase());
|
|
205
400
|
let access;
|
|
206
401
|
if (framework === 'hono') {
|
|
207
402
|
access = `c.req.header(${key})`;
|
|
208
403
|
}
|
|
209
404
|
else if (framework === 'express') {
|
|
210
|
-
access = `req.headers[${
|
|
405
|
+
access = `req.headers[${lookupKey}] as string | undefined`;
|
|
211
406
|
}
|
|
212
407
|
else {
|
|
213
|
-
access = `req.headers[${
|
|
408
|
+
access = `req.headers[${lookupKey}]`;
|
|
214
409
|
}
|
|
215
410
|
return `${fieldIndent}${key}: ${access}`;
|
|
216
411
|
})
|
|
@@ -222,6 +417,56 @@ function emitHeaderValidation(lines, headerParams, indent, framework) {
|
|
|
222
417
|
lines.push(rawFields);
|
|
223
418
|
lines.push(`${inner}})`);
|
|
224
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* Emit Zod validation lines for cookie parameters into the handler line buffer.
|
|
422
|
+
* Cookie names are case-sensitive: the exact name is used for both the Zod field key and
|
|
423
|
+
* the value lookup (unlike headers, which are lowercased for lookup on Express/Fastify).
|
|
424
|
+
*
|
|
425
|
+
* Required plugins per framework:
|
|
426
|
+
* Fastify: register @fastify/cookie before creating the router.
|
|
427
|
+
* Express: apply cookie-parser middleware before mounting this router.
|
|
428
|
+
* Hono: getCookie is imported from 'hono/cookie' (added to generated output automatically).
|
|
429
|
+
*
|
|
430
|
+
* Uses short variable name _ckv to keep the 422 return line under Prettier's print width.
|
|
431
|
+
* @param indent - outer handler indent (e.g. ' ')
|
|
432
|
+
* @param framework - used to generate the correct cookie accessor syntax
|
|
433
|
+
*/
|
|
434
|
+
function emitCookieValidation(lines, cookieParams, indent, framework) {
|
|
435
|
+
const inner = `${indent} `;
|
|
436
|
+
const fieldIndent = `${indent} `;
|
|
437
|
+
const schemaFields = cookieParams
|
|
438
|
+
.map((ck) => {
|
|
439
|
+
const key = JSON.stringify(ck.rawName);
|
|
440
|
+
const expr = cookieParamZodExpr(ck);
|
|
441
|
+
return `${fieldIndent}${key}: ${expr}`;
|
|
442
|
+
})
|
|
443
|
+
.join(',\n');
|
|
444
|
+
const rawFields = cookieParams
|
|
445
|
+
.map((ck) => {
|
|
446
|
+
const key = JSON.stringify(ck.rawName);
|
|
447
|
+
let access;
|
|
448
|
+
if (framework === 'hono') {
|
|
449
|
+
// getCookie is imported from 'hono/cookie' when cookie params are present.
|
|
450
|
+
access = `getCookie(c, ${key})`;
|
|
451
|
+
}
|
|
452
|
+
else if (framework === 'express') {
|
|
453
|
+
// Requires cookie-parser middleware: req.cookies['name']
|
|
454
|
+
access = `req.cookies[${key}] as string | undefined`;
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
// Requires @fastify/cookie plugin: req.cookies['name']
|
|
458
|
+
access = `req.cookies[${key}]`;
|
|
459
|
+
}
|
|
460
|
+
return `${fieldIndent}${key}: ${access}`;
|
|
461
|
+
})
|
|
462
|
+
.join(',\n');
|
|
463
|
+
lines.push(`${inner}// Validate request cookies: returns 422 with Zod issues on failure`);
|
|
464
|
+
lines.push(`${inner}const _ckv = z.object({`);
|
|
465
|
+
lines.push(schemaFields);
|
|
466
|
+
lines.push(`${inner}}).safeParse({`);
|
|
467
|
+
lines.push(rawFields);
|
|
468
|
+
lines.push(`${inner}})`);
|
|
469
|
+
}
|
|
225
470
|
function response200IsVoid(resp) {
|
|
226
471
|
if (isRef(resp))
|
|
227
472
|
return false;
|
|
@@ -229,22 +474,122 @@ function response200IsVoid(resp) {
|
|
|
229
474
|
const content = r.content;
|
|
230
475
|
return content === undefined || Object.keys(content).length === 0;
|
|
231
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* Detect the success response content type from a ResponseObject.
|
|
479
|
+
* Returns 'text/plain' or 'application/octet-stream' for non-JSON responses,
|
|
480
|
+
* or 'application/json' as the default.
|
|
481
|
+
*/
|
|
482
|
+
function detectResponseContentType(resp) {
|
|
483
|
+
if (isRef(resp))
|
|
484
|
+
return 'application/json';
|
|
485
|
+
const r = resp;
|
|
486
|
+
const content = r.content;
|
|
487
|
+
if (content === undefined)
|
|
488
|
+
return 'application/json';
|
|
489
|
+
if ('text/plain' in content)
|
|
490
|
+
return 'text/plain';
|
|
491
|
+
if ('application/octet-stream' in content)
|
|
492
|
+
return 'application/octet-stream';
|
|
493
|
+
return 'application/json';
|
|
494
|
+
}
|
|
232
495
|
function getResponseStatus(operation, httpMethod) {
|
|
233
496
|
const responses = operation.responses;
|
|
234
497
|
if (responses === undefined) {
|
|
235
|
-
return httpMethod === 'delete'
|
|
498
|
+
return httpMethod === 'delete'
|
|
499
|
+
? { status: 204, isVoid: true, responseContentType: 'application/json' }
|
|
500
|
+
: { status: 200, isVoid: false, responseContentType: 'application/json' };
|
|
501
|
+
}
|
|
502
|
+
// Multi-status: more than one 2xx response with a body (excluding 204/void).
|
|
503
|
+
// Must be checked before individual 200/201/204 branches so that e.g. 200+202
|
|
504
|
+
// is not absorbed by the responses['200'] early return.
|
|
505
|
+
// The handler selects the status at runtime via a { status, body } envelope.
|
|
506
|
+
const contentfulTwoxxKeys = Object.keys(responses)
|
|
507
|
+
.filter((k) => /^2\d\d$/.test(k) && k !== '204')
|
|
508
|
+
.sort();
|
|
509
|
+
if (contentfulTwoxxKeys.length > 1) {
|
|
510
|
+
return {
|
|
511
|
+
status: 200,
|
|
512
|
+
isVoid: false,
|
|
513
|
+
responseContentType: 'application/json',
|
|
514
|
+
isMultiStatus: true,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
if (responses['201'] !== undefined) {
|
|
518
|
+
return {
|
|
519
|
+
status: 201,
|
|
520
|
+
isVoid: false,
|
|
521
|
+
responseContentType: detectResponseContentType(responses['201']),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
if (responses['204'] !== undefined) {
|
|
525
|
+
return { status: 204, isVoid: true, responseContentType: 'application/json' };
|
|
236
526
|
}
|
|
237
|
-
if (responses['201'] !== undefined)
|
|
238
|
-
return { status: 201, isVoid: false };
|
|
239
|
-
if (responses['204'] !== undefined)
|
|
240
|
-
return { status: 204, isVoid: true };
|
|
241
527
|
if (responses['200'] !== undefined) {
|
|
242
|
-
if (response200IsVoid(responses['200']))
|
|
243
|
-
return { status: 204, isVoid: true };
|
|
244
|
-
|
|
528
|
+
if (response200IsVoid(responses['200'])) {
|
|
529
|
+
return { status: 204, isVoid: true, responseContentType: 'application/json' };
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
status: 200,
|
|
533
|
+
isVoid: false,
|
|
534
|
+
responseContentType: detectResponseContentType(responses['200']),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
// Single non-200/201/204 2xx declared: honor that exact status code.
|
|
538
|
+
const twoxxKeys = Object.keys(responses).filter((k) => /^2\d\d$/.test(k) && k !== '200' && k !== '201' && k !== '204');
|
|
539
|
+
if (twoxxKeys.length === 1) {
|
|
540
|
+
const code = parseInt(twoxxKeys[0], 10);
|
|
541
|
+
const resp = responses[twoxxKeys[0]];
|
|
542
|
+
const isVoid = isRef(resp)
|
|
543
|
+
? false
|
|
544
|
+
: (() => {
|
|
545
|
+
const r = resp;
|
|
546
|
+
const content = r.content;
|
|
547
|
+
return content === undefined || Object.keys(content).length === 0;
|
|
548
|
+
})();
|
|
549
|
+
return { status: code, isVoid, responseContentType: detectResponseContentType(resp) };
|
|
245
550
|
}
|
|
246
551
|
// Default: delete -> 204, otherwise 200
|
|
247
|
-
return httpMethod === 'delete'
|
|
552
|
+
return httpMethod === 'delete'
|
|
553
|
+
? { status: 204, isVoid: true, responseContentType: 'application/json' }
|
|
554
|
+
: { status: 200, isVoid: false, responseContentType: 'application/json' };
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Resolve the response type name and shape for Fastify schema.response wiring.
|
|
558
|
+
* Returns the PascalCase type name of the first JSON 2xx response if it is a
|
|
559
|
+
* direct $ref or an array-of-$ref. Returns undefined for inline schemas, void,
|
|
560
|
+
* text/plain, or octet-stream responses.
|
|
561
|
+
*
|
|
562
|
+
* Priority order mirrors service.ts getReturnInfo: 200, 201, then other 2xx codes.
|
|
563
|
+
*/
|
|
564
|
+
function getResponseTypeName(operation) {
|
|
565
|
+
const responses = operation.responses;
|
|
566
|
+
if (responses === undefined)
|
|
567
|
+
return undefined;
|
|
568
|
+
const priority = ['200', '201', ...Object.keys(responses).filter((k) => /^2\d\d$/.test(k) && k !== '200' && k !== '201' && k !== '204')];
|
|
569
|
+
for (const code of priority) {
|
|
570
|
+
const response = responses[code];
|
|
571
|
+
if (response === undefined || isRef(response))
|
|
572
|
+
continue;
|
|
573
|
+
const resp = response;
|
|
574
|
+
const content = resp.content;
|
|
575
|
+
if (content === undefined)
|
|
576
|
+
continue;
|
|
577
|
+
const jsonContent = content['application/json'];
|
|
578
|
+
if (jsonContent === undefined || jsonContent.schema === undefined)
|
|
579
|
+
continue;
|
|
580
|
+
const schema = jsonContent.schema;
|
|
581
|
+
if (isRef(schema)) {
|
|
582
|
+
return { typeName: refToName(schema.$ref), isArray: false };
|
|
583
|
+
}
|
|
584
|
+
const s = schema;
|
|
585
|
+
if (s.type === 'array' && s.items !== undefined && isRef(s.items)) {
|
|
586
|
+
return {
|
|
587
|
+
typeName: refToName(s.items.$ref),
|
|
588
|
+
isArray: true,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return undefined;
|
|
248
593
|
}
|
|
249
594
|
function collectOperations(spec) {
|
|
250
595
|
const paths = spec.paths;
|
|
@@ -261,8 +606,10 @@ function collectOperations(spec) {
|
|
|
261
606
|
const pathParamValidations = getPathParamValidations(operation, spec, pathParams);
|
|
262
607
|
const queryParams = getQueryParams(operation, spec);
|
|
263
608
|
const headerParams = getHeaderParams(operation, spec);
|
|
609
|
+
const cookieParams = getCookieParams(operation, spec);
|
|
264
610
|
const bodyInfo = getBodyInfo(operation);
|
|
265
611
|
const responseStatus = getResponseStatus(operation, method);
|
|
612
|
+
const responseTypeInfo = getResponseTypeName(operation);
|
|
266
613
|
operations.push({
|
|
267
614
|
methodName,
|
|
268
615
|
httpMethod: method,
|
|
@@ -272,19 +619,26 @@ function collectOperations(spec) {
|
|
|
272
619
|
pathParamValidations,
|
|
273
620
|
queryParams,
|
|
274
621
|
headerParams,
|
|
622
|
+
cookieParams,
|
|
275
623
|
bodyInfo,
|
|
276
624
|
responseStatus,
|
|
625
|
+
responseTypeName: responseTypeInfo?.typeName,
|
|
626
|
+
responseIsArray: responseTypeInfo?.isArray,
|
|
277
627
|
});
|
|
278
628
|
}
|
|
279
629
|
}
|
|
280
630
|
return operations;
|
|
281
631
|
}
|
|
282
|
-
/** Collect sorted body type names from all operations.
|
|
632
|
+
/** Collect sorted body type names from all operations.
|
|
633
|
+
* Synthesized names (inline schema, no $ref) are excluded because they have no
|
|
634
|
+
* corresponding entry in models.ts and must not appear in the model import.
|
|
635
|
+
*/
|
|
283
636
|
function collectSortedBodyTypes(operations) {
|
|
284
637
|
const bodyTypes = new Set();
|
|
285
638
|
for (const op of operations) {
|
|
286
|
-
if (op.bodyInfo?.typeName !== undefined)
|
|
639
|
+
if (op.bodyInfo?.typeName !== undefined && !op.bodyInfo.isSynthesized) {
|
|
287
640
|
bodyTypes.add(op.bodyInfo.typeName);
|
|
641
|
+
}
|
|
288
642
|
}
|
|
289
643
|
return Array.from(bodyTypes).sort();
|
|
290
644
|
}
|
|
@@ -301,6 +655,26 @@ function collectUsedSchemaNames(operations, schemaNames) {
|
|
|
301
655
|
}
|
|
302
656
|
return used;
|
|
303
657
|
}
|
|
658
|
+
/**
|
|
659
|
+
* Collect the subset of schemaNames used as Fastify response schemas.
|
|
660
|
+
* Only operations with a resolvable $ref response type (direct or array-of-$ref)
|
|
661
|
+
* and a matching schema in schemaNames are included.
|
|
662
|
+
* Multi-status operations are excluded because they cannot be mapped to a single
|
|
663
|
+
* status code in schema.response at generation time.
|
|
664
|
+
*/
|
|
665
|
+
function collectUsedResponseSchemaNames(operations, schemaNames) {
|
|
666
|
+
const used = new Set();
|
|
667
|
+
for (const op of operations) {
|
|
668
|
+
if (op.responseTypeName === undefined)
|
|
669
|
+
continue;
|
|
670
|
+
if (op.responseStatus.isMultiStatus === true)
|
|
671
|
+
continue;
|
|
672
|
+
const schemaName = `${op.responseTypeName}Schema`;
|
|
673
|
+
if (schemaNames.has(schemaName))
|
|
674
|
+
used.add(schemaName);
|
|
675
|
+
}
|
|
676
|
+
return used;
|
|
677
|
+
}
|
|
304
678
|
/**
|
|
305
679
|
* Collect body type names, used schema names, and whether Zod is needed.
|
|
306
680
|
* Shared by all three generator functions to avoid duplication.
|
|
@@ -310,13 +684,23 @@ function collectGeneratorSetup(operations, options) {
|
|
|
310
684
|
const usedSchemaNames = options?.schemaNames !== undefined
|
|
311
685
|
? collectUsedSchemaNames(operations, options.schemaNames)
|
|
312
686
|
: new Set();
|
|
687
|
+
const usedResponseSchemaNames = options?.schemaNames !== undefined
|
|
688
|
+
? collectUsedResponseSchemaNames(operations, options.schemaNames)
|
|
689
|
+
: new Set();
|
|
690
|
+
// Zod is needed for param validation, body schema validation, or array response schemas.
|
|
691
|
+
// Array response schemas emit z.array(XSchema) which requires the z import.
|
|
692
|
+
const hasArrayResponseSchema = usedResponseSchemaNames.size > 0 &&
|
|
693
|
+
operations.some((op) => op.responseIsArray === true &&
|
|
694
|
+
op.responseTypeName !== undefined &&
|
|
695
|
+
usedResponseSchemaNames.has(`${op.responseTypeName}Schema`));
|
|
313
696
|
const needsZod = (usedSchemaNames.size > 0 && options?.schemaImportPath !== undefined) ||
|
|
314
|
-
operationsNeedZodForParams(operations)
|
|
315
|
-
|
|
697
|
+
operationsNeedZodForParams(operations) ||
|
|
698
|
+
(usedResponseSchemaNames.size > 0 && options?.schemaImportPath !== undefined && hasArrayResponseSchema);
|
|
699
|
+
return { sortedBodyTypes, usedSchemaNames, usedResponseSchemaNames, needsZod };
|
|
316
700
|
}
|
|
317
701
|
// ── Hono route handler ────────────────────────────────────────────────────────
|
|
318
702
|
// fallow-ignore-next-line complexity
|
|
319
|
-
function buildRouteHandler(op, indent, schemaNames) {
|
|
703
|
+
function buildRouteHandler(op, indent, schemaNames, contextType) {
|
|
320
704
|
const lines = [];
|
|
321
705
|
lines.push(`${indent}app.${op.httpMethod}(${JSON.stringify(op.honoPath)}, async (c) => {`);
|
|
322
706
|
// Path param format validation (e.g. uuid)
|
|
@@ -328,8 +712,30 @@ function buildRouteHandler(op, indent, schemaNames) {
|
|
|
328
712
|
}
|
|
329
713
|
// Query params extraction
|
|
330
714
|
if (op.queryParams.length > 0) {
|
|
715
|
+
// Emit deepObject assembly blocks before the params object.
|
|
716
|
+
// c.req.queries() returns Record<string, string[]> with raw bracket-notation keys.
|
|
717
|
+
const deepObjectParams = op.queryParams.filter((q) => q.isDeepObject === true);
|
|
718
|
+
if (deepObjectParams.length > 0) {
|
|
719
|
+
lines.push(`${indent} const _dq = c.req.queries()`);
|
|
720
|
+
for (const q of deepObjectParams) {
|
|
721
|
+
const prefixLen = q.rawName.length + 1; // e.g. 'filter['.length
|
|
722
|
+
const bracketPrefix = q.rawName + '[';
|
|
723
|
+
lines.push(`${indent} const ${q.name} = Object.fromEntries(`);
|
|
724
|
+
lines.push(`${indent} Object.entries(_dq).filter(([k]) => k.startsWith('${bracketPrefix}') && k.endsWith(']')).map(([k, vs]) => [k.slice(${prefixLen}, -1), vs[0]])`);
|
|
725
|
+
lines.push(`${indent} )`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
331
728
|
const fields = op.queryParams
|
|
332
729
|
.map((q) => {
|
|
730
|
+
if (q.isDeepObject === true) {
|
|
731
|
+
// Already assembled above as a local variable
|
|
732
|
+
return ` ${q.name}`;
|
|
733
|
+
}
|
|
734
|
+
if (q.delimiterStyle !== undefined) {
|
|
735
|
+
// Use rawName to match the actual URL query key (e.g. 'csv', 'ssv', 'psv').
|
|
736
|
+
const delim = JSON.stringify(delimiterChar(q.delimiterStyle));
|
|
737
|
+
return ` ${q.name}: c.req.query('${q.rawName}') !== undefined ? c.req.query('${q.rawName}')!.split(${delim}) : undefined`;
|
|
738
|
+
}
|
|
333
739
|
if (q.tsType === 'number') {
|
|
334
740
|
return ` ${q.name}: c.req.query('${q.name}') !== undefined ? Number(c.req.query('${q.name}')) : undefined`;
|
|
335
741
|
}
|
|
@@ -354,11 +760,54 @@ function buildRouteHandler(op, indent, schemaNames) {
|
|
|
354
760
|
lines.push(`${indent} return c.json({ error: 'Invalid request headers', issues: _hv.error.issues }, 422)`);
|
|
355
761
|
lines.push(`${indent} }`);
|
|
356
762
|
}
|
|
763
|
+
// Cookie param validation
|
|
764
|
+
// Requires @fastify/cookie on Fastify, cookie-parser on Express, getCookie from hono/cookie on Hono.
|
|
765
|
+
if (op.cookieParams.length > 0) {
|
|
766
|
+
emitCookieValidation(lines, op.cookieParams, indent, 'hono');
|
|
767
|
+
lines.push(`${indent} if (!_ckv.success) {`);
|
|
768
|
+
lines.push(`${indent} return c.json({ error: 'Invalid request cookies', issues: _ckv.error.issues }, 422)`);
|
|
769
|
+
lines.push(`${indent} }`);
|
|
770
|
+
}
|
|
357
771
|
// Body extraction
|
|
358
772
|
let bodyVarName = 'body';
|
|
359
773
|
if (op.bodyInfo !== undefined) {
|
|
360
|
-
|
|
361
|
-
|
|
774
|
+
// Synthesized names (inline schemas) are schema-only; the TS type is unknown.
|
|
775
|
+
const typeDecl = op.bodyInfo.typeName !== undefined && !op.bodyInfo.isSynthesized
|
|
776
|
+
? op.bodyInfo.typeName
|
|
777
|
+
: 'unknown';
|
|
778
|
+
if (op.bodyInfo.contentType === 'application/x-www-form-urlencoded') {
|
|
779
|
+
// Form-urlencoded: check Content-Type then decode with parseBody().
|
|
780
|
+
// Values arrive as strings; Zod coercion handles type conversion (e.g. z.coerce.number()).
|
|
781
|
+
lines.push(`${indent} const _ct = c.req.header('content-type') ?? ''`);
|
|
782
|
+
lines.push(`${indent} if (!_ct.toLowerCase().startsWith('application/x-www-form-urlencoded')) {`);
|
|
783
|
+
lines.push(`${indent} return c.json({ error: 'Unsupported Media Type' }, 415)`);
|
|
784
|
+
lines.push(`${indent} }`);
|
|
785
|
+
lines.push(`${indent} const body: unknown = await c.req.parseBody()`);
|
|
786
|
+
}
|
|
787
|
+
else if (op.bodyInfo.contentType === 'multipart/form-data') {
|
|
788
|
+
// Multipart: decode with parseBody({ all: true }) so repeated file fields arrive as arrays.
|
|
789
|
+
// File fields are web-standard File objects; text fields are strings.
|
|
790
|
+
// No manual Content-Type check needed: parseBody handles multipart natively in Hono.
|
|
791
|
+
lines.push(`${indent} // multipart/form-data: parseBody({ all: true }) collects repeated keys into arrays.`);
|
|
792
|
+
lines.push(`${indent} const body: unknown = await c.req.parseBody({ all: true })`);
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
// JSON body: check Content-Type then parse with JSON.parse (not c.req.json()).
|
|
796
|
+
// c.req.text() + JSON.parse() is used instead of c.req.json() because Hono's
|
|
797
|
+
// c.req.json() silently returns null for an empty body instead of throwing,
|
|
798
|
+
// which would pass the try/catch and reach Zod as null, causing a 422 rather
|
|
799
|
+
// than the correct 400. JSON.parse('') always throws SyntaxError.
|
|
800
|
+
lines.push(`${indent} const _ct = c.req.header('content-type') ?? ''`);
|
|
801
|
+
lines.push(`${indent} if (!_ct.toLowerCase().startsWith('application/json')) {`);
|
|
802
|
+
lines.push(`${indent} return c.json({ error: 'Unsupported Media Type' }, 415)`);
|
|
803
|
+
lines.push(`${indent} }`);
|
|
804
|
+
lines.push(`${indent} let body: ${typeDecl}`);
|
|
805
|
+
lines.push(`${indent} try {`);
|
|
806
|
+
lines.push(`${indent} body = JSON.parse(await c.req.text()) as ${typeDecl}`);
|
|
807
|
+
lines.push(`${indent} } catch {`);
|
|
808
|
+
lines.push(`${indent} return c.json({ error: 'Invalid JSON body' }, 400)`);
|
|
809
|
+
lines.push(`${indent} }`);
|
|
810
|
+
}
|
|
362
811
|
// Zod validation when schema is available
|
|
363
812
|
const schemaName = op.bodyInfo.typeName !== undefined ? `${op.bodyInfo.typeName}Schema` : undefined;
|
|
364
813
|
if (schemaName !== undefined && schemaNames !== undefined && schemaNames.has(schemaName)) {
|
|
@@ -367,7 +816,12 @@ function buildRouteHandler(op, indent, schemaNames) {
|
|
|
367
816
|
lines.push(`${indent} if (!parseResult.success) {`);
|
|
368
817
|
lines.push(`${indent} return c.json({ error: 'Invalid request body', issues: parseResult.error.issues }, 422)`);
|
|
369
818
|
lines.push(`${indent} }`);
|
|
370
|
-
|
|
819
|
+
// Forward the validated/coerced data (parseResult.data), NOT the raw parsed body,
|
|
820
|
+
// so Zod coercion is preserved (e.g. form-urlencoded numeric fields via
|
|
821
|
+
// z.coerce.number()). Cast to the declared model type so the service-call type
|
|
822
|
+
// stays correct even when the schema infers a narrower shape (e.g. z.unknown()
|
|
823
|
+
// for inline-union properties); safeParse above guarantees runtime safety.
|
|
824
|
+
lines.push(`${indent} const validatedBody = parseResult.data as ${typeDecl}`);
|
|
371
825
|
bodyVarName = 'validatedBody';
|
|
372
826
|
}
|
|
373
827
|
}
|
|
@@ -380,26 +834,64 @@ function buildRouteHandler(op, indent, schemaNames) {
|
|
|
380
834
|
serviceArgs.push(bodyVarName);
|
|
381
835
|
}
|
|
382
836
|
if (op.queryParams.length > 0) {
|
|
383
|
-
|
|
837
|
+
// After successful Zod validation _qv.data carries the correct required/typed
|
|
838
|
+
// values (e.g. string[] for delimited arrays, object shape for deepObject params,
|
|
839
|
+
// and non-optional values for required scalar params). Use _qv.data when validation
|
|
840
|
+
// was applied; fall back to params when no validation is needed.
|
|
841
|
+
serviceArgs.push(queryParamsNeedValidation(op.queryParams) ? '_qv.data' : 'params');
|
|
842
|
+
}
|
|
843
|
+
// Context arg: pass the Hono Context object (c) as the final argument when contextType is set.
|
|
844
|
+
if (contextType !== undefined) {
|
|
845
|
+
serviceArgs.push('c');
|
|
384
846
|
}
|
|
385
847
|
const serviceCall = `service.${op.methodName}(${serviceArgs.join(', ')})`;
|
|
386
|
-
// Response
|
|
848
|
+
// Response — wrap in try/catch to map HttpError to its status
|
|
849
|
+
lines.push(`${indent} try {`);
|
|
387
850
|
if (op.responseStatus.isVoid) {
|
|
388
|
-
lines.push(`${indent}
|
|
389
|
-
lines.push(`${indent}
|
|
851
|
+
lines.push(`${indent} await ${serviceCall}`);
|
|
852
|
+
lines.push(`${indent} return new Response(null, { status: ${op.responseStatus.status} })`);
|
|
853
|
+
}
|
|
854
|
+
else if (op.responseStatus.isMultiStatus === true) {
|
|
855
|
+
// Multi-status: service returns { status: number; body: T }; router forwards both.
|
|
856
|
+
lines.push(`${indent} const _envelope = await ${serviceCall}`);
|
|
857
|
+
lines.push(`${indent} return c.json(_envelope.body, _envelope.status as any)`);
|
|
858
|
+
}
|
|
859
|
+
else if (op.responseStatus.responseContentType === 'text/plain') {
|
|
860
|
+
if (op.responseStatus.status === 200) {
|
|
861
|
+
lines.push(`${indent} return c.text(await ${serviceCall})`);
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
lines.push(`${indent} return c.text(await ${serviceCall}, ${op.responseStatus.status})`);
|
|
865
|
+
}
|
|
390
866
|
}
|
|
391
|
-
else if (op.responseStatus.
|
|
392
|
-
|
|
867
|
+
else if (op.responseStatus.responseContentType === 'application/octet-stream') {
|
|
868
|
+
if (op.responseStatus.status === 200) {
|
|
869
|
+
lines.push(`${indent} const _result = await ${serviceCall}`);
|
|
870
|
+
lines.push(`${indent} return new Response(_result, { headers: { 'content-type': 'application/octet-stream' } })`);
|
|
871
|
+
}
|
|
872
|
+
else {
|
|
873
|
+
lines.push(`${indent} const _result = await ${serviceCall}`);
|
|
874
|
+
lines.push(`${indent} return new Response(_result, { status: ${op.responseStatus.status}, headers: { 'content-type': 'application/octet-stream' } })`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
else if (op.responseStatus.status === 200) {
|
|
878
|
+
lines.push(`${indent} return c.json(await ${serviceCall})`);
|
|
393
879
|
}
|
|
394
880
|
else {
|
|
395
|
-
lines.push(`${indent}
|
|
881
|
+
lines.push(`${indent} return c.json(await ${serviceCall}, ${op.responseStatus.status})`);
|
|
396
882
|
}
|
|
883
|
+
lines.push(`${indent} } catch (err) {`);
|
|
884
|
+
lines.push(`${indent} if (err instanceof HttpError) {`);
|
|
885
|
+
lines.push(`${indent} return new Response(JSON.stringify({ error: err.message }), { status: err.status, headers: { 'content-type': 'application/json' } })`);
|
|
886
|
+
lines.push(`${indent} }`);
|
|
887
|
+
lines.push(`${indent} throw err`);
|
|
888
|
+
lines.push(`${indent} }`);
|
|
397
889
|
lines.push(`${indent}})`);
|
|
398
890
|
return lines.join('\n');
|
|
399
891
|
}
|
|
400
892
|
// ── Express route handler ─────────────────────────────────────────────────────
|
|
401
893
|
// fallow-ignore-next-line complexity
|
|
402
|
-
function buildExpressRouteHandler(op, indent, schemaNames) {
|
|
894
|
+
function buildExpressRouteHandler(op, indent, schemaNames, contextType) {
|
|
403
895
|
const lines = [];
|
|
404
896
|
lines.push(`${indent}router.${op.httpMethod}(${JSON.stringify(op.honoPath)}, async (req: Request, res: Response) => {`);
|
|
405
897
|
// Path param format validation (e.g. uuid)
|
|
@@ -411,8 +903,19 @@ function buildExpressRouteHandler(op, indent, schemaNames) {
|
|
|
411
903
|
}
|
|
412
904
|
// Query params extraction
|
|
413
905
|
if (op.queryParams.length > 0) {
|
|
906
|
+
// Express (qs, extended:true) parses bracket-notation automatically:
|
|
907
|
+
// filter[gte]=10 → req.query.filter = { gte: '10' }.
|
|
908
|
+
// DeepObject params are already assembled; just cast the nested object.
|
|
414
909
|
const fields = op.queryParams
|
|
415
910
|
.map((q) => {
|
|
911
|
+
if (q.isDeepObject === true) {
|
|
912
|
+
// Express with qs: req.query['filter'] is already { gte: '10', lte: '20' }
|
|
913
|
+
return ` ${q.name}: (req.query['${q.rawName}'] ?? {}) as Record<string, string | undefined>`;
|
|
914
|
+
}
|
|
915
|
+
if (q.delimiterStyle !== undefined) {
|
|
916
|
+
const delim = JSON.stringify(delimiterChar(q.delimiterStyle));
|
|
917
|
+
return ` ${q.name}: typeof req.query['${q.rawName}'] === 'string' ? (req.query['${q.rawName}'] as string).split(${delim}) : undefined`;
|
|
918
|
+
}
|
|
416
919
|
if (q.tsType === 'number') {
|
|
417
920
|
return ` ${q.name}: Number(req.query['${q.name}'] as string)`;
|
|
418
921
|
}
|
|
@@ -440,70 +943,187 @@ function buildExpressRouteHandler(op, indent, schemaNames) {
|
|
|
440
943
|
lines.push(`${indent} return void res.status(422).json({ error: 'Invalid request headers', issues: _hv.error.issues })`);
|
|
441
944
|
lines.push(`${indent} }`);
|
|
442
945
|
}
|
|
443
|
-
//
|
|
946
|
+
// Cookie param validation
|
|
947
|
+
// Requires cookie-parser middleware: app.use(cookieParser()) before mounting this router.
|
|
948
|
+
if (op.cookieParams.length > 0) {
|
|
949
|
+
emitCookieValidation(lines, op.cookieParams, indent, 'express');
|
|
950
|
+
lines.push(`${indent} if (!_ckv.success) {`);
|
|
951
|
+
lines.push(`${indent} return void res.status(422).json({ error: 'Invalid request cookies', issues: _ckv.error.issues })`);
|
|
952
|
+
lines.push(`${indent} }`);
|
|
953
|
+
}
|
|
954
|
+
// Body extraction, with optional Zod validation.
|
|
955
|
+
// For both JSON and form-urlencoded bodies Express pre-populates req.body via middleware
|
|
956
|
+
// (express.json() for JSON, express.urlencoded() for form). The router just reads req.body.
|
|
957
|
+
// For multipart/form-data: assumes multer (or equivalent) middleware is applied before this
|
|
958
|
+
// router, populating req.files and req.body with the parsed multipart fields.
|
|
444
959
|
let bodyVarName = 'body';
|
|
445
960
|
if (op.bodyInfo !== undefined) {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
lines.push(`${indent} //
|
|
450
|
-
lines.push(`${indent} const
|
|
451
|
-
lines.push(`${indent} if (!parseResult.success) {`);
|
|
452
|
-
lines.push(`${indent} return void res.status(422).json({ error: 'Invalid request body', issues: parseResult.error.issues })`);
|
|
453
|
-
lines.push(`${indent} }`);
|
|
454
|
-
lines.push(`${indent} const validatedBody = parseResult.data`);
|
|
455
|
-
bodyVarName = 'validatedBody';
|
|
961
|
+
if (op.bodyInfo.contentType === 'multipart/form-data') {
|
|
962
|
+
// Multipart assumption: multer middleware populates req.files (file fields) and
|
|
963
|
+
// req.body (text fields) before this handler runs. Merge them for service consumption.
|
|
964
|
+
lines.push(`${indent} // multipart/form-data: assumes multer middleware has populated req.files + req.body.`);
|
|
965
|
+
lines.push(`${indent} const body = { ...req.body, ...(req as any).files } as unknown`);
|
|
456
966
|
}
|
|
457
967
|
else {
|
|
458
|
-
const
|
|
459
|
-
|
|
968
|
+
const schemaName = op.bodyInfo.typeName !== undefined ? `${op.bodyInfo.typeName}Schema` : undefined;
|
|
969
|
+
const useZod = schemaName !== undefined && schemaNames !== undefined && schemaNames.has(schemaName);
|
|
970
|
+
if (useZod) {
|
|
971
|
+
lines.push(`${indent} // Validate request body: returns 422 with Zod issues on failure`);
|
|
972
|
+
lines.push(`${indent} const parseResult = ${schemaName}.safeParse(req.body)`);
|
|
973
|
+
lines.push(`${indent} if (!parseResult.success) {`);
|
|
974
|
+
lines.push(`${indent} return void res.status(422).json({ error: 'Invalid request body', issues: parseResult.error.issues })`);
|
|
975
|
+
lines.push(`${indent} }`);
|
|
976
|
+
// Cast parseResult.data to the declared model type so the service call receives
|
|
977
|
+
// the correct TypeScript type even when the Zod schema infers a narrower or
|
|
978
|
+
// different shape (e.g. z.unknown() for inline-union properties). The cast is
|
|
979
|
+
// safe because safeParse already confirmed the value is structurally valid.
|
|
980
|
+
const typeDecl = op.bodyInfo.typeName !== undefined && !op.bodyInfo.isSynthesized
|
|
981
|
+
? op.bodyInfo.typeName
|
|
982
|
+
: 'unknown';
|
|
983
|
+
lines.push(`${indent} const validatedBody = parseResult.data as ${typeDecl}`);
|
|
984
|
+
bodyVarName = 'validatedBody';
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
// Synthesized names (inline schemas) have no model type — use plain cast to unknown.
|
|
988
|
+
const typeAnnotation = op.bodyInfo.typeName !== undefined && !op.bodyInfo.isSynthesized
|
|
989
|
+
? ` as ${op.bodyInfo.typeName}`
|
|
990
|
+
: '';
|
|
991
|
+
lines.push(`${indent} const body = req.body${typeAnnotation}`);
|
|
992
|
+
}
|
|
460
993
|
}
|
|
461
994
|
}
|
|
462
995
|
// Build service call args
|
|
463
996
|
const serviceArgs = [];
|
|
464
997
|
for (const p of op.pathParams) {
|
|
465
|
-
|
|
998
|
+
// Cast to string: Express 5 types req.params values as string | string[],
|
|
999
|
+
// but path params are always single strings in practice.
|
|
1000
|
+
serviceArgs.push(`(req.params['${p}'] as string)`);
|
|
466
1001
|
}
|
|
467
1002
|
if (op.bodyInfo !== undefined) {
|
|
468
1003
|
serviceArgs.push(bodyVarName);
|
|
469
1004
|
}
|
|
470
1005
|
if (op.queryParams.length > 0) {
|
|
471
|
-
|
|
1006
|
+
// After successful Zod validation _qv.data carries the correct required/typed
|
|
1007
|
+
// values (e.g. string[] for delimited arrays, object shape for deepObject params,
|
|
1008
|
+
// and non-optional values for required scalar params). Use _qv.data when validation
|
|
1009
|
+
// was applied; fall back to params when no validation is needed.
|
|
1010
|
+
serviceArgs.push(queryParamsNeedValidation(op.queryParams) ? '_qv.data' : 'params');
|
|
1011
|
+
}
|
|
1012
|
+
// Context arg: pass the Express Request object (req) as the final argument when contextType is set.
|
|
1013
|
+
if (contextType !== undefined) {
|
|
1014
|
+
serviceArgs.push('req');
|
|
472
1015
|
}
|
|
473
1016
|
const serviceCall = `service.${op.methodName}(${serviceArgs.join(', ')})`;
|
|
474
|
-
// Response
|
|
1017
|
+
// Response — wrap in try/catch to map HttpError to its status
|
|
1018
|
+
lines.push(`${indent} try {`);
|
|
475
1019
|
if (op.responseStatus.isVoid) {
|
|
476
|
-
lines.push(`${indent}
|
|
477
|
-
lines.push(`${indent}
|
|
1020
|
+
lines.push(`${indent} await ${serviceCall}`);
|
|
1021
|
+
lines.push(`${indent} res.status(${op.responseStatus.status}).end()`);
|
|
1022
|
+
}
|
|
1023
|
+
else if (op.responseStatus.isMultiStatus === true) {
|
|
1024
|
+
// Multi-status: service returns { status: number; body: T }; router forwards both.
|
|
1025
|
+
lines.push(`${indent} const _envelope = await ${serviceCall}`);
|
|
1026
|
+
lines.push(`${indent} res.status(_envelope.status).json(_envelope.body)`);
|
|
1027
|
+
}
|
|
1028
|
+
else if (op.responseStatus.responseContentType === 'text/plain') {
|
|
1029
|
+
if (op.responseStatus.status === 200) {
|
|
1030
|
+
lines.push(`${indent} res.type('text/plain').send(await ${serviceCall})`);
|
|
1031
|
+
}
|
|
1032
|
+
else {
|
|
1033
|
+
lines.push(`${indent} res.status(${op.responseStatus.status}).type('text/plain').send(await ${serviceCall})`);
|
|
1034
|
+
}
|
|
478
1035
|
}
|
|
479
|
-
else if (op.responseStatus.
|
|
480
|
-
|
|
1036
|
+
else if (op.responseStatus.responseContentType === 'application/octet-stream') {
|
|
1037
|
+
if (op.responseStatus.status === 200) {
|
|
1038
|
+
lines.push(`${indent} res.setHeader('Content-Type', 'application/octet-stream').send(Buffer.from(await ${serviceCall}))`);
|
|
1039
|
+
}
|
|
1040
|
+
else {
|
|
1041
|
+
lines.push(`${indent} res.status(${op.responseStatus.status}).setHeader('Content-Type', 'application/octet-stream').send(Buffer.from(await ${serviceCall}))`);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
else if (op.responseStatus.status === 200) {
|
|
1045
|
+
lines.push(`${indent} res.json(await ${serviceCall})`);
|
|
481
1046
|
}
|
|
482
1047
|
else {
|
|
483
|
-
lines.push(`${indent}
|
|
1048
|
+
lines.push(`${indent} res.status(${op.responseStatus.status}).json(await ${serviceCall})`);
|
|
484
1049
|
}
|
|
1050
|
+
lines.push(`${indent} } catch (err) {`);
|
|
1051
|
+
lines.push(`${indent} if (err instanceof HttpError) {`);
|
|
1052
|
+
lines.push(`${indent} return void res.status(err.status).json({ error: err.message })`);
|
|
1053
|
+
lines.push(`${indent} }`);
|
|
1054
|
+
lines.push(`${indent} throw err`);
|
|
1055
|
+
lines.push(`${indent} }`);
|
|
485
1056
|
lines.push(`${indent}})`);
|
|
486
1057
|
return lines.join('\n');
|
|
487
1058
|
}
|
|
488
1059
|
// ── Fastify route handler ─────────────────────────────────────────────────────
|
|
1060
|
+
/**
|
|
1061
|
+
* Build the route options object literal string for a Fastify route registration.
|
|
1062
|
+
* Always includes config.operationId so onRequest hooks can identify the operation
|
|
1063
|
+
* via request.routeOptions.config.operationId (issue #309).
|
|
1064
|
+
* When a response schema is available in schemaNames, also includes schema.response
|
|
1065
|
+
* so Fastify validates the response against the Zod schema (issue #308).
|
|
1066
|
+
* Both properties are merged into a single options object literal when both apply.
|
|
1067
|
+
*/
|
|
1068
|
+
function buildFastifyRouteOptions(op, schemaNames) {
|
|
1069
|
+
const parts = [];
|
|
1070
|
+
const responseSchemaExpr = buildFastifyResponseSchemaExpr(op, schemaNames);
|
|
1071
|
+
if (responseSchemaExpr !== undefined) {
|
|
1072
|
+
parts.push(`schema: { response: { ${op.responseStatus.status}: ${responseSchemaExpr} } }`);
|
|
1073
|
+
}
|
|
1074
|
+
parts.push(`config: { operationId: '${op.methodName}' }`);
|
|
1075
|
+
return `{ ${parts.join(', ')} }`;
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Build the Zod schema expression for a Fastify schema.response entry.
|
|
1079
|
+
* Returns undefined when no schema is available or the operation is multi-status.
|
|
1080
|
+
* Direct $ref response: 'PetSchema'
|
|
1081
|
+
* Array-of-$ref response: 'z.array(PetSchema)'
|
|
1082
|
+
*/
|
|
1083
|
+
function buildFastifyResponseSchemaExpr(op, schemaNames) {
|
|
1084
|
+
if (schemaNames === undefined)
|
|
1085
|
+
return undefined;
|
|
1086
|
+
if (op.responseTypeName === undefined)
|
|
1087
|
+
return undefined;
|
|
1088
|
+
if (op.responseStatus.isMultiStatus === true)
|
|
1089
|
+
return undefined;
|
|
1090
|
+
if (op.responseStatus.isVoid)
|
|
1091
|
+
return undefined;
|
|
1092
|
+
const schemaName = `${op.responseTypeName}Schema`;
|
|
1093
|
+
if (!schemaNames.has(schemaName))
|
|
1094
|
+
return undefined;
|
|
1095
|
+
return op.responseIsArray === true ? `z.array(${schemaName})` : schemaName;
|
|
1096
|
+
}
|
|
489
1097
|
// fallow-ignore-next-line complexity
|
|
490
|
-
function buildFastifyRouteHandler(op, indent, schemaNames) {
|
|
1098
|
+
function buildFastifyRouteHandler(op, indent, schemaNames, contextType) {
|
|
491
1099
|
const lines = [];
|
|
492
1100
|
// Build generic type argument
|
|
493
1101
|
const genericParts = [];
|
|
494
1102
|
if (op.queryParams.length > 0) {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
1103
|
+
// DeepObject and delimited params use bracket-notation keys or raw strings;
|
|
1104
|
+
// include them as Record<string, string> or string[] in the Querystring generic.
|
|
1105
|
+
const hasDeepOrDelimited = op.queryParams.some((q) => q.isDeepObject === true || q.delimiterStyle !== undefined);
|
|
1106
|
+
let querystringType;
|
|
1107
|
+
if (hasDeepOrDelimited) {
|
|
1108
|
+
// Use a loose Querystring type that allows bracket-notation keys (fast-querystring stores
|
|
1109
|
+
// them as literal strings, e.g. 'filter[gte]') and array values for delimited params.
|
|
1110
|
+
querystringType = 'Record<string, string | string[] | undefined>';
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
const queryFields = op.queryParams
|
|
1114
|
+
.map((q) => {
|
|
1115
|
+
if (q.tsType === 'number')
|
|
1116
|
+
return `${q.name}?: number`;
|
|
1117
|
+
if (q.tsType === 'boolean')
|
|
1118
|
+
return `${q.name}?: boolean`;
|
|
1119
|
+
return `${q.name}?: string`;
|
|
1120
|
+
})
|
|
1121
|
+
.join('; ');
|
|
1122
|
+
querystringType = `{ ${queryFields} }`;
|
|
1123
|
+
}
|
|
1124
|
+
genericParts.push(`Querystring: ${querystringType}`);
|
|
505
1125
|
}
|
|
506
|
-
if (op.bodyInfo !== undefined && op.bodyInfo.typeName !== undefined) {
|
|
1126
|
+
if (op.bodyInfo !== undefined && op.bodyInfo.typeName !== undefined && !op.bodyInfo.isSynthesized) {
|
|
507
1127
|
genericParts.push(`Body: ${op.bodyInfo.typeName}`);
|
|
508
1128
|
}
|
|
509
1129
|
else if (op.bodyInfo !== undefined) {
|
|
@@ -514,12 +1134,13 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
|
|
|
514
1134
|
genericParts.push(`Params: { ${paramFields} }`);
|
|
515
1135
|
}
|
|
516
1136
|
const generic = genericParts.length > 0 ? `<{ ${genericParts.join('; ')} }>` : '';
|
|
517
|
-
|
|
1137
|
+
const routeOpts = buildFastifyRouteOptions(op, schemaNames);
|
|
1138
|
+
lines.push(`${indent}app.${op.httpMethod}${generic}(${JSON.stringify(op.honoPath)}, ${routeOpts}, async (req, reply) => {`);
|
|
518
1139
|
// Path param format validation (e.g. uuid)
|
|
519
1140
|
if (op.pathParamValidations.length > 0) {
|
|
520
1141
|
emitPathValidation(lines, op.pathParamValidations, indent, 'fastify');
|
|
521
1142
|
lines.push(`${indent} if (!_pv.success) {`);
|
|
522
|
-
lines.push(`${indent} return reply.status(422).send({`);
|
|
1143
|
+
lines.push(`${indent} return (reply as FastifyReply).status(422).send({`);
|
|
523
1144
|
lines.push(`${indent} error: 'Invalid path parameters',`);
|
|
524
1145
|
lines.push(`${indent} issues: _pv.error.issues,`);
|
|
525
1146
|
lines.push(`${indent} })`);
|
|
@@ -527,7 +1148,46 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
|
|
|
527
1148
|
}
|
|
528
1149
|
// Query params extraction
|
|
529
1150
|
if (op.queryParams.length > 0) {
|
|
530
|
-
|
|
1151
|
+
// fast-querystring (Fastify default) stores bracket-notation keys as literals:
|
|
1152
|
+
// filter[gte]=10 → req.query['filter[gte]'] = '10'.
|
|
1153
|
+
// DeepObject and delimited params need raw string access; emit _dq cast once.
|
|
1154
|
+
const deepObjectParams = op.queryParams.filter((q) => q.isDeepObject === true);
|
|
1155
|
+
const hasDeepOrDelimited = op.queryParams.some((q) => q.isDeepObject === true || q.delimiterStyle !== undefined);
|
|
1156
|
+
if (hasDeepOrDelimited) {
|
|
1157
|
+
lines.push(`${indent} const _dq = req.query as unknown as Record<string, string | undefined>`);
|
|
1158
|
+
}
|
|
1159
|
+
if (deepObjectParams.length > 0) {
|
|
1160
|
+
for (const q of deepObjectParams) {
|
|
1161
|
+
const prefixLen = q.rawName.length + 1; // e.g. 'filter['.length
|
|
1162
|
+
const bracketPrefix = q.rawName + '[';
|
|
1163
|
+
lines.push(`${indent} const ${q.name} = Object.fromEntries(`);
|
|
1164
|
+
lines.push(`${indent} Object.entries(_dq).filter(([k]) => k.startsWith('${bracketPrefix}') && k.endsWith(']')).map(([k, v]) => [k.slice(${prefixLen}, -1), v])`);
|
|
1165
|
+
lines.push(`${indent} )`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
const fields = op.queryParams
|
|
1169
|
+
.map((q) => {
|
|
1170
|
+
if (q.isDeepObject === true) {
|
|
1171
|
+
// Already assembled above as a local variable
|
|
1172
|
+
return ` ${q.name}`;
|
|
1173
|
+
}
|
|
1174
|
+
if (q.delimiterStyle !== undefined) {
|
|
1175
|
+
const delim = JSON.stringify(delimiterChar(q.delimiterStyle));
|
|
1176
|
+
return ` ${q.name}: typeof _dq['${q.rawName}'] === 'string' ? _dq['${q.rawName}']!.split(${delim}) : undefined`;
|
|
1177
|
+
}
|
|
1178
|
+
// When _dq is defined, use it for consistent access; otherwise use typed req.query.
|
|
1179
|
+
// Boolean params must be coerced from string at extraction time: z.coerce.boolean()
|
|
1180
|
+
// is unusable because Boolean('false') === true, so mirror the Express === 'true' pattern.
|
|
1181
|
+
if (q.tsType === 'boolean') {
|
|
1182
|
+
return hasDeepOrDelimited
|
|
1183
|
+
? ` ${q.name}: _dq['${q.rawName}'] === 'true'`
|
|
1184
|
+
: ` ${q.name}: (req.query.${q.name} as unknown as string) === 'true'`;
|
|
1185
|
+
}
|
|
1186
|
+
return hasDeepOrDelimited
|
|
1187
|
+
? ` ${q.name}: _dq['${q.rawName}']`
|
|
1188
|
+
: ` ${q.name}: req.query.${q.name}`;
|
|
1189
|
+
})
|
|
1190
|
+
.join(',\n');
|
|
531
1191
|
lines.push(`${indent} const params = {`);
|
|
532
1192
|
lines.push(fields);
|
|
533
1193
|
lines.push(`${indent} }`);
|
|
@@ -535,7 +1195,7 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
|
|
|
535
1195
|
if (queryParamsNeedValidation(op.queryParams)) {
|
|
536
1196
|
emitQueryValidation(lines, op.queryParams, indent);
|
|
537
1197
|
lines.push(`${indent} if (!_qv.success) {`);
|
|
538
|
-
lines.push(`${indent} return reply.status(422).send({`);
|
|
1198
|
+
lines.push(`${indent} return (reply as FastifyReply).status(422).send({`);
|
|
539
1199
|
lines.push(`${indent} error: 'Invalid query parameters',`);
|
|
540
1200
|
lines.push(`${indent} issues: _qv.error.issues,`);
|
|
541
1201
|
lines.push(`${indent} })`);
|
|
@@ -546,24 +1206,65 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
|
|
|
546
1206
|
if (op.headerParams.length > 0) {
|
|
547
1207
|
emitHeaderValidation(lines, op.headerParams, indent, 'fastify');
|
|
548
1208
|
lines.push(`${indent} if (!_hv.success) {`);
|
|
549
|
-
lines.push(`${indent} return reply.status(422).send({`);
|
|
1209
|
+
lines.push(`${indent} return (reply as FastifyReply).status(422).send({`);
|
|
550
1210
|
lines.push(`${indent} error: 'Invalid request headers',`);
|
|
551
1211
|
lines.push(`${indent} issues: _hv.error.issues,`);
|
|
552
1212
|
lines.push(`${indent} })`);
|
|
553
1213
|
lines.push(`${indent} }`);
|
|
554
1214
|
}
|
|
555
|
-
//
|
|
1215
|
+
// Cookie param validation
|
|
1216
|
+
// Requires @fastify/cookie plugin registered before creating the router: fastify.register(fastifyCookie).
|
|
1217
|
+
if (op.cookieParams.length > 0) {
|
|
1218
|
+
emitCookieValidation(lines, op.cookieParams, indent, 'fastify');
|
|
1219
|
+
lines.push(`${indent} if (!_ckv.success) {`);
|
|
1220
|
+
lines.push(`${indent} return (reply as FastifyReply).status(422).send({`);
|
|
1221
|
+
lines.push(`${indent} error: 'Invalid request cookies',`);
|
|
1222
|
+
lines.push(`${indent} issues: _ckv.error.issues,`);
|
|
1223
|
+
lines.push(`${indent} })`);
|
|
1224
|
+
lines.push(`${indent} }`);
|
|
1225
|
+
}
|
|
1226
|
+
// Body handling, with optional Zod validation.
|
|
1227
|
+
// Fastify natively parses only application/json and text/plain.
|
|
1228
|
+
// For application/x-www-form-urlencoded: register @fastify/formbody before this router
|
|
1229
|
+
// so that req.body is populated.
|
|
1230
|
+
// For multipart/form-data: register @fastify/multipart with attachFieldsToBody: true before
|
|
1231
|
+
// this router so that req.body is populated with parsed fields. Without attachFieldsToBody,
|
|
1232
|
+
// @fastify/multipart v10 exposes files via async iterators (request.parts()), not req.body.
|
|
556
1233
|
let bodyVarName = 'req.body';
|
|
557
1234
|
if (op.bodyInfo !== undefined) {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
lines.push(`${indent}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1235
|
+
if (op.bodyInfo.contentType === 'multipart/form-data') {
|
|
1236
|
+
// multipart/form-data: @fastify/multipart must be registered with attachFieldsToBody: true.
|
|
1237
|
+
// Without that option, files are only accessible via async iterators (request.parts()),
|
|
1238
|
+
// NOT via req.body. The consumer must register the plugin before creating this router.
|
|
1239
|
+
lines.push(`${indent} // multipart/form-data: requires @fastify/multipart registered with { attachFieldsToBody: true }.`);
|
|
1240
|
+
// bodyVarName stays 'req.body'
|
|
1241
|
+
}
|
|
1242
|
+
else if (op.bodyInfo.contentType === 'application/octet-stream') {
|
|
1243
|
+
// application/octet-stream: req.body is a Buffer (parsed by the addContentTypeParser
|
|
1244
|
+
// registration emitted in the createRouter body above). Forward it to the service as-is.
|
|
1245
|
+
lines.push(`${indent} // application/octet-stream: req.body is a Buffer from the registered content-type parser.`);
|
|
1246
|
+
// bodyVarName stays 'req.body'
|
|
1247
|
+
}
|
|
1248
|
+
else {
|
|
1249
|
+
const schemaName = op.bodyInfo.typeName !== undefined ? `${op.bodyInfo.typeName}Schema` : undefined;
|
|
1250
|
+
const useZod = schemaName !== undefined && schemaNames !== undefined && schemaNames.has(schemaName);
|
|
1251
|
+
if (useZod) {
|
|
1252
|
+
lines.push(`${indent} // Validate request body: returns 422 with Zod issues on failure`);
|
|
1253
|
+
lines.push(`${indent} const parseResult = ${schemaName}.safeParse(req.body)`);
|
|
1254
|
+
lines.push(`${indent} if (!parseResult.success) {`);
|
|
1255
|
+
lines.push(`${indent} return (reply as FastifyReply).status(422).send({ error: 'Invalid request body', issues: parseResult.error.issues })`);
|
|
1256
|
+
lines.push(`${indent} }`);
|
|
1257
|
+
// Forward the validated/coerced data (parseResult.data), NOT the raw req.body,
|
|
1258
|
+
// so Zod coercion is preserved (e.g. form-urlencoded numeric fields via
|
|
1259
|
+
// z.coerce.number()). Cast to the declared model type so the service-call type
|
|
1260
|
+
// stays correct even when the schema infers a narrower shape (e.g. z.unknown()
|
|
1261
|
+
// for inline-union properties); safeParse above guarantees runtime safety.
|
|
1262
|
+
const bodyCastType = op.bodyInfo.typeName !== undefined && !op.bodyInfo.isSynthesized
|
|
1263
|
+
? op.bodyInfo.typeName
|
|
1264
|
+
: 'unknown';
|
|
1265
|
+
lines.push(`${indent} const validatedBody = parseResult.data as ${bodyCastType}`);
|
|
1266
|
+
bodyVarName = 'validatedBody';
|
|
1267
|
+
}
|
|
567
1268
|
}
|
|
568
1269
|
}
|
|
569
1270
|
// Build service call args
|
|
@@ -575,28 +1276,81 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
|
|
|
575
1276
|
serviceArgs.push(bodyVarName);
|
|
576
1277
|
}
|
|
577
1278
|
if (op.queryParams.length > 0) {
|
|
578
|
-
|
|
1279
|
+
// After successful Zod validation _qv.data carries the correct required/typed
|
|
1280
|
+
// values (e.g. string[] for delimited arrays, object shape for deepObject params,
|
|
1281
|
+
// and non-optional values for required scalar params). Use _qv.data when validation
|
|
1282
|
+
// was applied; fall back to params when no validation is needed.
|
|
1283
|
+
serviceArgs.push(queryParamsNeedValidation(op.queryParams) ? '_qv.data' : 'params');
|
|
1284
|
+
}
|
|
1285
|
+
// Context arg: pass the Fastify Request object (req) as the final argument when contextType is set.
|
|
1286
|
+
if (contextType !== undefined) {
|
|
1287
|
+
serviceArgs.push('req');
|
|
579
1288
|
}
|
|
580
1289
|
const serviceCall = `service.${op.methodName}(${serviceArgs.join(', ')})`;
|
|
581
|
-
// Response
|
|
1290
|
+
// Response — wrap in try/catch to map HttpError to its status
|
|
1291
|
+
lines.push(`${indent} try {`);
|
|
582
1292
|
if (op.responseStatus.isVoid) {
|
|
583
|
-
lines.push(`${indent}
|
|
584
|
-
lines.push(`${indent}
|
|
1293
|
+
lines.push(`${indent} await ${serviceCall}`);
|
|
1294
|
+
lines.push(`${indent} reply.status(${op.responseStatus.status}).send()`);
|
|
1295
|
+
}
|
|
1296
|
+
else if (op.responseStatus.isMultiStatus === true) {
|
|
1297
|
+
// Multi-status: service returns { status: number; body: T }; router forwards both.
|
|
1298
|
+
lines.push(`${indent} const _envelope = await ${serviceCall}`);
|
|
1299
|
+
lines.push(`${indent} return reply.status(_envelope.status).send(_envelope.body)`);
|
|
1300
|
+
}
|
|
1301
|
+
else if (op.responseStatus.responseContentType === 'text/plain') {
|
|
1302
|
+
if (op.responseStatus.status === 200) {
|
|
1303
|
+
lines.push(`${indent} return reply.type('text/plain').send(await ${serviceCall})`);
|
|
1304
|
+
}
|
|
1305
|
+
else {
|
|
1306
|
+
lines.push(`${indent} return reply.status(${op.responseStatus.status}).type('text/plain').send(await ${serviceCall})`);
|
|
1307
|
+
}
|
|
585
1308
|
}
|
|
586
|
-
else if (op.responseStatus.
|
|
587
|
-
|
|
588
|
-
|
|
1309
|
+
else if (op.responseStatus.responseContentType === 'application/octet-stream') {
|
|
1310
|
+
if (op.responseStatus.status === 200) {
|
|
1311
|
+
lines.push(`${indent} return reply.type('application/octet-stream').send(Buffer.from(await ${serviceCall}))`);
|
|
1312
|
+
}
|
|
1313
|
+
else {
|
|
1314
|
+
lines.push(`${indent} return reply.status(${op.responseStatus.status}).type('application/octet-stream').send(Buffer.from(await ${serviceCall}))`);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
else if (op.responseStatus.status === 200) {
|
|
1318
|
+
lines.push(`${indent} return await ${serviceCall}`);
|
|
589
1319
|
}
|
|
590
1320
|
else {
|
|
591
|
-
lines.push(`${indent}
|
|
1321
|
+
lines.push(`${indent} reply.status(${op.responseStatus.status})`);
|
|
1322
|
+
lines.push(`${indent} return await ${serviceCall}`);
|
|
592
1323
|
}
|
|
1324
|
+
lines.push(`${indent} } catch (err) {`);
|
|
1325
|
+
lines.push(`${indent} if (err instanceof HttpError) {`);
|
|
1326
|
+
lines.push(`${indent} return (reply as FastifyReply).status(err.status).send({ error: err.message })`);
|
|
1327
|
+
lines.push(`${indent} }`);
|
|
1328
|
+
lines.push(`${indent} throw err`);
|
|
1329
|
+
lines.push(`${indent} }`);
|
|
593
1330
|
lines.push(`${indent}})`);
|
|
594
1331
|
return lines.join('\n');
|
|
595
1332
|
}
|
|
1333
|
+
// ── HttpError class ───────────────────────────────────────────────────────────
|
|
1334
|
+
/**
|
|
1335
|
+
* Lines that emit the exported HttpError class into a generated router file.
|
|
1336
|
+
* Services throw `new HttpError(404, 'Not found')` and the generated router
|
|
1337
|
+
* catches it, returning the matching HTTP status instead of a generic 500.
|
|
1338
|
+
*/
|
|
1339
|
+
function httpErrorClassLines() {
|
|
1340
|
+
return [
|
|
1341
|
+
'export class HttpError extends Error {',
|
|
1342
|
+
' constructor(public readonly status: number, message: string) {',
|
|
1343
|
+
' super(message)',
|
|
1344
|
+
" this.name = 'HttpError'",
|
|
1345
|
+
' }',
|
|
1346
|
+
'}',
|
|
1347
|
+
];
|
|
1348
|
+
}
|
|
596
1349
|
// ── Zod import helpers ────────────────────────────────────────────────────────
|
|
597
1350
|
/**
|
|
598
1351
|
* Returns true when any operation in the list generates param validation code
|
|
599
|
-
* that requires Zod (path format validation, required/typed query params,
|
|
1352
|
+
* that requires Zod (path format validation, required/typed query params, header params,
|
|
1353
|
+
* or cookie params).
|
|
600
1354
|
*/
|
|
601
1355
|
function operationsNeedZodForParams(operations) {
|
|
602
1356
|
for (const op of operations) {
|
|
@@ -606,6 +1360,8 @@ function operationsNeedZodForParams(operations) {
|
|
|
606
1360
|
return true;
|
|
607
1361
|
if (op.headerParams.length > 0)
|
|
608
1362
|
return true;
|
|
1363
|
+
if (op.cookieParams.length > 0)
|
|
1364
|
+
return true;
|
|
609
1365
|
}
|
|
610
1366
|
return false;
|
|
611
1367
|
}
|
|
@@ -615,6 +1371,7 @@ export function generateExpressRouter(spec, options) {
|
|
|
615
1371
|
const serviceName = deriveServiceName(spec);
|
|
616
1372
|
const operations = collectOperations(spec);
|
|
617
1373
|
const { sortedBodyTypes, usedSchemaNames, needsZod } = collectGeneratorSetup(operations, options);
|
|
1374
|
+
// usedResponseSchemaNames is Fastify-only; not used in Express generator.
|
|
618
1375
|
const lines = [];
|
|
619
1376
|
lines.push('// This file is auto-generated. Do not edit manually.');
|
|
620
1377
|
lines.push('// Express: apply express.json() middleware before mounting this router so req.body is populated.');
|
|
@@ -624,6 +1381,8 @@ export function generateExpressRouter(spec, options) {
|
|
|
624
1381
|
if (sortedBodyTypes.length > 0) {
|
|
625
1382
|
lines.push(`import type { ${sortedBodyTypes.join(', ')} } from './models.js'`);
|
|
626
1383
|
}
|
|
1384
|
+
const ctx = options?.contextType;
|
|
1385
|
+
const serviceRef = ctx !== undefined ? `${serviceName}<${ctx}>` : serviceName;
|
|
627
1386
|
lines.push(`import type { ${serviceName} } from './service.js'`);
|
|
628
1387
|
if (needsZod) {
|
|
629
1388
|
lines.push(`import { z } from 'zod'`);
|
|
@@ -633,11 +1392,14 @@ export function generateExpressRouter(spec, options) {
|
|
|
633
1392
|
lines.push(`import { ${sortedUsedSchemas.join(', ')} } from '${options.schemaImportPath}'`);
|
|
634
1393
|
}
|
|
635
1394
|
lines.push('');
|
|
636
|
-
|
|
1395
|
+
for (const l of httpErrorClassLines())
|
|
1396
|
+
lines.push(l);
|
|
1397
|
+
lines.push('');
|
|
1398
|
+
lines.push(`export function createRouter(service: ${serviceRef}): Router {`);
|
|
637
1399
|
lines.push(' const router = Router()');
|
|
638
1400
|
lines.push('');
|
|
639
1401
|
for (const op of operations) {
|
|
640
|
-
lines.push(buildExpressRouteHandler(op, ' ', options?.schemaNames));
|
|
1402
|
+
lines.push(buildExpressRouteHandler(op, ' ', options?.schemaNames, ctx));
|
|
641
1403
|
lines.push('');
|
|
642
1404
|
}
|
|
643
1405
|
lines.push(' return router');
|
|
@@ -653,11 +1415,20 @@ export function generateExpressRouter(spec, options) {
|
|
|
653
1415
|
export function generateFastifyRouter(spec, options) {
|
|
654
1416
|
const serviceName = deriveServiceName(spec);
|
|
655
1417
|
const operations = collectOperations(spec);
|
|
656
|
-
const { sortedBodyTypes, usedSchemaNames, needsZod } = collectGeneratorSetup(operations, options);
|
|
1418
|
+
const { sortedBodyTypes, usedSchemaNames, usedResponseSchemaNames, needsZod } = collectGeneratorSetup(operations, options);
|
|
1419
|
+
// Merge body and response schema imports into a single sorted import list.
|
|
1420
|
+
const allUsedSchemaNames = new Set([...usedSchemaNames, ...usedResponseSchemaNames]);
|
|
1421
|
+
// Detect operations that need the octet-stream request body parser.
|
|
1422
|
+
const hasOctetStreamRequestBody = operations.some((op) => op.bodyInfo?.contentType === 'application/octet-stream');
|
|
657
1423
|
const lines = [];
|
|
658
1424
|
lines.push('// This file is auto-generated. Do not edit manually.');
|
|
1425
|
+
lines.push('// Fastify natively parses only application/json and text/plain request bodies.');
|
|
1426
|
+
lines.push('// For application/x-www-form-urlencoded bodies, register @fastify/formbody before this router.');
|
|
1427
|
+
lines.push('// For multipart/form-data bodies, register @fastify/multipart with { attachFieldsToBody: true } before this router.');
|
|
659
1428
|
lines.push('');
|
|
660
|
-
|
|
1429
|
+
const ctx = options?.contextType;
|
|
1430
|
+
const serviceRef = ctx !== undefined ? `${serviceName}<${ctx}>` : serviceName;
|
|
1431
|
+
lines.push("import type { FastifyInstance, FastifyReply } from 'fastify'");
|
|
661
1432
|
if (sortedBodyTypes.length > 0) {
|
|
662
1433
|
lines.push(`import type { ${sortedBodyTypes.join(', ')} } from './models.js'`);
|
|
663
1434
|
}
|
|
@@ -665,15 +1436,46 @@ export function generateFastifyRouter(spec, options) {
|
|
|
665
1436
|
if (needsZod) {
|
|
666
1437
|
lines.push(`import { z } from 'zod'`);
|
|
667
1438
|
}
|
|
668
|
-
if (
|
|
669
|
-
const sortedUsedSchemas = Array.from(
|
|
1439
|
+
if (allUsedSchemaNames.size > 0 && options?.schemaImportPath !== undefined) {
|
|
1440
|
+
const sortedUsedSchemas = Array.from(allUsedSchemaNames).sort();
|
|
670
1441
|
lines.push(`import { ${sortedUsedSchemas.join(', ')} } from '${options.schemaImportPath}'`);
|
|
671
1442
|
}
|
|
672
1443
|
lines.push('');
|
|
673
|
-
|
|
1444
|
+
// Augment FastifyContextConfig so that config: { operationId } on each route is type-safe.
|
|
1445
|
+
// Without this, TypeScript rejects the operationId property because FastifyContextConfig
|
|
1446
|
+
// is empty by default. The augmentation is scoped to the generated router module.
|
|
1447
|
+
lines.push("declare module 'fastify' {");
|
|
1448
|
+
lines.push(' interface FastifyContextConfig {');
|
|
1449
|
+
lines.push(' operationId?: string');
|
|
1450
|
+
lines.push(' }');
|
|
1451
|
+
lines.push('}');
|
|
1452
|
+
lines.push('');
|
|
1453
|
+
for (const l of httpErrorClassLines())
|
|
1454
|
+
lines.push(l);
|
|
1455
|
+
lines.push('');
|
|
1456
|
+
lines.push(`export function createRouter(app: FastifyInstance, service: ${serviceRef}): void {`);
|
|
1457
|
+
// Register a built-in content-type parser for application/octet-stream so that
|
|
1458
|
+
// req.body is a Buffer. Fastify 5 does not parse octet-stream natively; without
|
|
1459
|
+
// this registration the framework returns a 415 before the handler runs.
|
|
1460
|
+
// addContentTypeParser is a core Fastify API and requires no extra dependency.
|
|
1461
|
+
if (hasOctetStreamRequestBody) {
|
|
1462
|
+
lines.push(" app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (req, body, done) => done(null, body))");
|
|
1463
|
+
}
|
|
1464
|
+
// Wire a Zod-aware serializer compiler when response schemas are present.
|
|
1465
|
+
// Fastify's default serializer does not understand Zod schemas; this compiler
|
|
1466
|
+
// detects any Zod schema by its .parse method and validates before serializing.
|
|
1467
|
+
if (usedResponseSchemaNames.size > 0 && options?.schemaImportPath !== undefined) {
|
|
1468
|
+
lines.push(' app.setSerializerCompiler(({ schema }) => {');
|
|
1469
|
+
lines.push(" if (schema !== null && typeof schema === 'object' && 'parse' in schema) {");
|
|
1470
|
+
lines.push(' const zodSchema = schema as { parse: (data: unknown) => unknown }');
|
|
1471
|
+
lines.push(' return (data: unknown) => JSON.stringify(zodSchema.parse(data))');
|
|
1472
|
+
lines.push(' }');
|
|
1473
|
+
lines.push(' return (data: unknown) => JSON.stringify(data)');
|
|
1474
|
+
lines.push(' })');
|
|
1475
|
+
}
|
|
674
1476
|
for (const op of operations) {
|
|
675
1477
|
lines.push('');
|
|
676
|
-
lines.push(buildFastifyRouteHandler(op, ' ', options?.schemaNames));
|
|
1478
|
+
lines.push(buildFastifyRouteHandler(op, ' ', options?.schemaNames, ctx));
|
|
677
1479
|
}
|
|
678
1480
|
lines.push('}');
|
|
679
1481
|
lines.push('');
|
|
@@ -691,7 +1493,14 @@ export function generateRouter(spec, options) {
|
|
|
691
1493
|
const lines = [];
|
|
692
1494
|
lines.push('// This file is auto-generated. Do not edit manually.');
|
|
693
1495
|
lines.push('');
|
|
1496
|
+
const ctx = options?.contextType;
|
|
1497
|
+
const serviceRef = ctx !== undefined ? `${serviceName}<${ctx}>` : serviceName;
|
|
1498
|
+
// getCookie from hono/cookie is needed when any operation declares cookie params.
|
|
1499
|
+
const needsGetCookie = operations.some((op) => op.cookieParams.length > 0);
|
|
694
1500
|
lines.push("import { Hono } from 'hono'");
|
|
1501
|
+
if (needsGetCookie) {
|
|
1502
|
+
lines.push("import { getCookie } from 'hono/cookie'");
|
|
1503
|
+
}
|
|
695
1504
|
if (sortedBodyTypes.length > 0) {
|
|
696
1505
|
lines.push(`import type { ${sortedBodyTypes.join(', ')} } from './models.js'`);
|
|
697
1506
|
}
|
|
@@ -704,11 +1513,14 @@ export function generateRouter(spec, options) {
|
|
|
704
1513
|
lines.push(`import { ${sortedUsedSchemas.join(', ')} } from '${options.schemaImportPath}'`);
|
|
705
1514
|
}
|
|
706
1515
|
lines.push('');
|
|
707
|
-
|
|
1516
|
+
for (const l of httpErrorClassLines())
|
|
1517
|
+
lines.push(l);
|
|
1518
|
+
lines.push('');
|
|
1519
|
+
lines.push(`export function createRouter(service: ${serviceRef}): Hono {`);
|
|
708
1520
|
lines.push(' const app = new Hono()');
|
|
709
1521
|
lines.push('');
|
|
710
1522
|
for (const op of operations) {
|
|
711
|
-
lines.push(buildRouteHandler(op, ' ', options?.schemaNames));
|
|
1523
|
+
lines.push(buildRouteHandler(op, ' ', options?.schemaNames, ctx));
|
|
712
1524
|
lines.push('');
|
|
713
1525
|
}
|
|
714
1526
|
lines.push(' return app');
|