@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.
@@ -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 format validation
28
- * (simple string with no format constraint or non-string types used as strings in URLs).
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
- * Build a Zod expression for a query or header parameter based on its schema.
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(). Boolean types use z.boolean().
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 paramZodExpr(tsType, required, schema) {
127
+ function queryParamZodExpr(param) {
51
128
  let base;
52
- if (tsType === 'number') {
53
- base = 'z.number()';
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
- // string
60
- if (schema !== undefined && !isRef(schema)) {
61
- const s = schema;
62
- const format = s.format;
63
- const modifier = format !== undefined ? formatToZodModifier(format) : '';
64
- base = `z.string()${modifier}`;
65
- }
66
- else {
67
- base = 'z.string()';
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
- return required ? base : `${base}.optional()`;
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
- * Collect header parameters from an operation.
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
- result.push({
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 or has a non-string type (to catch NaN/invalid input).
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 = paramZodExpr(q.tsType, q.required);
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
- access = `req.params[${JSON.stringify(v.rawName)}]`;
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.required ? 'z.string()' : 'z.string().optional()';
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[${key}] as string | undefined`;
405
+ access = `req.headers[${lookupKey}] as string | undefined`;
211
406
  }
212
407
  else {
213
- access = `req.headers[${key}]`;
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' ? { status: 204, isVoid: true } : { status: 200, isVoid: false };
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
- return { status: 200, isVoid: false };
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' ? { status: 204, isVoid: true } : { status: 200, isVoid: false };
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
- return { sortedBodyTypes, usedSchemaNames, needsZod };
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
- const typeAnnotation = op.bodyInfo.typeName !== undefined ? `<${op.bodyInfo.typeName}>` : '';
361
- lines.push(`${indent} const body = await c.req.json${typeAnnotation}()`);
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
- lines.push(`${indent} const validatedBody = parseResult.data`);
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
- serviceArgs.push('params');
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} await ${serviceCall}`);
389
- lines.push(`${indent} return new Response(null, { status: ${op.responseStatus.status} })`);
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.status === 201) {
392
- lines.push(`${indent} return c.json(await ${serviceCall}, 201)`);
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} return c.json(await ${serviceCall})`);
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
- // Body extraction, with optional Zod validation
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
- const schemaName = op.bodyInfo.typeName !== undefined ? `${op.bodyInfo.typeName}Schema` : undefined;
447
- const useZod = schemaName !== undefined && schemaNames !== undefined && schemaNames.has(schemaName);
448
- if (useZod) {
449
- lines.push(`${indent} // Validate request body: returns 422 with Zod issues on failure`);
450
- lines.push(`${indent} const parseResult = ${schemaName}.safeParse(req.body)`);
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 typeAnnotation = op.bodyInfo.typeName !== undefined ? ` as ${op.bodyInfo.typeName}` : '';
459
- lines.push(`${indent} const body = req.body${typeAnnotation}`);
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
- serviceArgs.push(`req.params['${p}']!`);
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
- serviceArgs.push('params');
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} await ${serviceCall}`);
477
- lines.push(`${indent} res.status(${op.responseStatus.status}).end()`);
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.status === 201) {
480
- lines.push(`${indent} res.status(201).json(await ${serviceCall})`);
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} res.json(await ${serviceCall})`);
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
- const queryFields = op.queryParams
496
- .map((q) => {
497
- if (q.tsType === 'number')
498
- return `${q.name}?: number`;
499
- if (q.tsType === 'boolean')
500
- return `${q.name}?: boolean`;
501
- return `${q.name}?: string`;
502
- })
503
- .join('; ');
504
- genericParts.push(`Querystring: { ${queryFields} }`);
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
- lines.push(`${indent}app.${op.httpMethod}${generic}(${JSON.stringify(op.honoPath)}, async (req, reply) => {`);
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
- const fields = op.queryParams.map((q) => ` ${q.name}: req.query.${q.name}`).join(',\n');
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
- // Body handling, with optional Zod validation
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
- const schemaName = op.bodyInfo.typeName !== undefined ? `${op.bodyInfo.typeName}Schema` : undefined;
559
- const useZod = schemaName !== undefined && schemaNames !== undefined && schemaNames.has(schemaName);
560
- if (useZod) {
561
- lines.push(`${indent} // Validate request body: returns 422 with Zod issues on failure`);
562
- lines.push(`${indent} const parseResult = ${schemaName}.safeParse(req.body)`);
563
- lines.push(`${indent} if (!parseResult.success) {`);
564
- lines.push(`${indent} return reply.status(422).send({ error: 'Invalid request body', issues: parseResult.error.issues })`);
565
- lines.push(`${indent} }`);
566
- bodyVarName = 'parseResult.data';
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
- serviceArgs.push('params');
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} await ${serviceCall}`);
584
- lines.push(`${indent} reply.status(${op.responseStatus.status}).send()`);
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.status === 201) {
587
- lines.push(`${indent} reply.status(201)`);
588
- lines.push(`${indent} return ${serviceCall}`);
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} return ${serviceCall}`);
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, or header 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
- lines.push(`export function createRouter(service: ${serviceName}): Router {`);
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
- lines.push("import type { FastifyInstance } from 'fastify'");
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 (usedSchemaNames.size > 0 && options?.schemaImportPath !== undefined) {
669
- const sortedUsedSchemas = Array.from(usedSchemaNames).sort();
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
- lines.push(`export function createRouter(app: FastifyInstance, service: ${serviceName}): void {`);
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
- lines.push(`export function createRouter(service: ${serviceName}): Hono {`);
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');