@codewithagents/openapi-server 1.9.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');
@@ -83,9 +83,11 @@ function queryParamDeepObjectZodBase(param) {
83
83
  });
84
84
  return `z.object({ ${propFields.join(', ')} })`;
85
85
  }
86
- /** Number/integer param: z.number() with optional range modifiers. */
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. */
87
89
  function queryParamNumberZodBase(param) {
88
- let base = 'z.number()';
90
+ let base = 'z.coerce.number()';
89
91
  if (param.minimum !== undefined)
90
92
  base += `.min(${param.minimum})`;
91
93
  if (param.maximum !== undefined)
@@ -191,6 +193,28 @@ function getPathParamValidations(operation, spec, rawPathParamNames) {
191
193
  }
192
194
  return result;
193
195
  }
196
+ /**
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
+ }
194
218
  /**
195
219
  * Collect header parameters from an operation, including schema constraints.
196
220
  */
@@ -223,6 +247,39 @@ function getHeaderParams(operation, spec) {
223
247
  }
224
248
  return result;
225
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 = {
264
+ rawName: resolved.name,
265
+ required: resolved.required === true,
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);
280
+ }
281
+ return result;
282
+ }
226
283
  /**
227
284
  * Returns true when a query param carries schema constraints beyond basic type/required.
228
285
  * These constraints require a Zod validation block even if the param is optional or string.
@@ -301,7 +358,9 @@ function emitPathValidation(lines, validations, indent, framework) {
301
358
  access = `c.req.param(${JSON.stringify(v.rawName)})`;
302
359
  }
303
360
  else if (framework === 'express') {
304
- 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`;
305
364
  }
306
365
  else {
307
366
  access = /[^a-zA-Z0-9_$]/.test(v.rawName)
@@ -337,15 +396,16 @@ function emitHeaderValidation(lines, headerParams, indent, framework) {
337
396
  const rawFields = headerParams
338
397
  .map((h) => {
339
398
  const key = JSON.stringify(h.rawName);
399
+ const lookupKey = JSON.stringify(h.rawName.toLowerCase());
340
400
  let access;
341
401
  if (framework === 'hono') {
342
402
  access = `c.req.header(${key})`;
343
403
  }
344
404
  else if (framework === 'express') {
345
- access = `req.headers[${key}] as string | undefined`;
405
+ access = `req.headers[${lookupKey}] as string | undefined`;
346
406
  }
347
407
  else {
348
- access = `req.headers[${key}]`;
408
+ access = `req.headers[${lookupKey}]`;
349
409
  }
350
410
  return `${fieldIndent}${key}: ${access}`;
351
411
  })
@@ -357,6 +417,56 @@ function emitHeaderValidation(lines, headerParams, indent, framework) {
357
417
  lines.push(rawFields);
358
418
  lines.push(`${inner}})`);
359
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
+ }
360
470
  function response200IsVoid(resp) {
361
471
  if (isRef(resp))
362
472
  return false;
@@ -443,6 +553,44 @@ function getResponseStatus(operation, httpMethod) {
443
553
  ? { status: 204, isVoid: true, responseContentType: 'application/json' }
444
554
  : { status: 200, isVoid: false, responseContentType: 'application/json' };
445
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;
593
+ }
446
594
  function collectOperations(spec) {
447
595
  const paths = spec.paths;
448
596
  if (paths === undefined)
@@ -458,8 +606,10 @@ function collectOperations(spec) {
458
606
  const pathParamValidations = getPathParamValidations(operation, spec, pathParams);
459
607
  const queryParams = getQueryParams(operation, spec);
460
608
  const headerParams = getHeaderParams(operation, spec);
609
+ const cookieParams = getCookieParams(operation, spec);
461
610
  const bodyInfo = getBodyInfo(operation);
462
611
  const responseStatus = getResponseStatus(operation, method);
612
+ const responseTypeInfo = getResponseTypeName(operation);
463
613
  operations.push({
464
614
  methodName,
465
615
  httpMethod: method,
@@ -469,8 +619,11 @@ function collectOperations(spec) {
469
619
  pathParamValidations,
470
620
  queryParams,
471
621
  headerParams,
622
+ cookieParams,
472
623
  bodyInfo,
473
624
  responseStatus,
625
+ responseTypeName: responseTypeInfo?.typeName,
626
+ responseIsArray: responseTypeInfo?.isArray,
474
627
  });
475
628
  }
476
629
  }
@@ -502,6 +655,26 @@ function collectUsedSchemaNames(operations, schemaNames) {
502
655
  }
503
656
  return used;
504
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
+ }
505
678
  /**
506
679
  * Collect body type names, used schema names, and whether Zod is needed.
507
680
  * Shared by all three generator functions to avoid duplication.
@@ -511,13 +684,23 @@ function collectGeneratorSetup(operations, options) {
511
684
  const usedSchemaNames = options?.schemaNames !== undefined
512
685
  ? collectUsedSchemaNames(operations, options.schemaNames)
513
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`));
514
696
  const needsZod = (usedSchemaNames.size > 0 && options?.schemaImportPath !== undefined) ||
515
- operationsNeedZodForParams(operations);
516
- return { sortedBodyTypes, usedSchemaNames, needsZod };
697
+ operationsNeedZodForParams(operations) ||
698
+ (usedResponseSchemaNames.size > 0 && options?.schemaImportPath !== undefined && hasArrayResponseSchema);
699
+ return { sortedBodyTypes, usedSchemaNames, usedResponseSchemaNames, needsZod };
517
700
  }
518
701
  // ── Hono route handler ────────────────────────────────────────────────────────
519
702
  // fallow-ignore-next-line complexity
520
- function buildRouteHandler(op, indent, schemaNames) {
703
+ function buildRouteHandler(op, indent, schemaNames, contextType) {
521
704
  const lines = [];
522
705
  lines.push(`${indent}app.${op.httpMethod}(${JSON.stringify(op.honoPath)}, async (c) => {`);
523
706
  // Path param format validation (e.g. uuid)
@@ -577,6 +760,14 @@ function buildRouteHandler(op, indent, schemaNames) {
577
760
  lines.push(`${indent} return c.json({ error: 'Invalid request headers', issues: _hv.error.issues }, 422)`);
578
761
  lines.push(`${indent} }`);
579
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
+ }
580
771
  // Body extraction
581
772
  let bodyVarName = 'body';
582
773
  if (op.bodyInfo !== undefined) {
@@ -625,7 +816,12 @@ function buildRouteHandler(op, indent, schemaNames) {
625
816
  lines.push(`${indent} if (!parseResult.success) {`);
626
817
  lines.push(`${indent} return c.json({ error: 'Invalid request body', issues: parseResult.error.issues }, 422)`);
627
818
  lines.push(`${indent} }`);
628
- 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}`);
629
825
  bodyVarName = 'validatedBody';
630
826
  }
631
827
  }
@@ -638,7 +834,15 @@ function buildRouteHandler(op, indent, schemaNames) {
638
834
  serviceArgs.push(bodyVarName);
639
835
  }
640
836
  if (op.queryParams.length > 0) {
641
- 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');
642
846
  }
643
847
  const serviceCall = `service.${op.methodName}(${serviceArgs.join(', ')})`;
644
848
  // Response — wrap in try/catch to map HttpError to its status
@@ -687,7 +891,7 @@ function buildRouteHandler(op, indent, schemaNames) {
687
891
  }
688
892
  // ── Express route handler ─────────────────────────────────────────────────────
689
893
  // fallow-ignore-next-line complexity
690
- function buildExpressRouteHandler(op, indent, schemaNames) {
894
+ function buildExpressRouteHandler(op, indent, schemaNames, contextType) {
691
895
  const lines = [];
692
896
  lines.push(`${indent}router.${op.httpMethod}(${JSON.stringify(op.honoPath)}, async (req: Request, res: Response) => {`);
693
897
  // Path param format validation (e.g. uuid)
@@ -739,6 +943,14 @@ function buildExpressRouteHandler(op, indent, schemaNames) {
739
943
  lines.push(`${indent} return void res.status(422).json({ error: 'Invalid request headers', issues: _hv.error.issues })`);
740
944
  lines.push(`${indent} }`);
741
945
  }
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
+ }
742
954
  // Body extraction, with optional Zod validation.
743
955
  // For both JSON and form-urlencoded bodies Express pre-populates req.body via middleware
744
956
  // (express.json() for JSON, express.urlencoded() for form). The router just reads req.body.
@@ -761,7 +973,14 @@ function buildExpressRouteHandler(op, indent, schemaNames) {
761
973
  lines.push(`${indent} if (!parseResult.success) {`);
762
974
  lines.push(`${indent} return void res.status(422).json({ error: 'Invalid request body', issues: parseResult.error.issues })`);
763
975
  lines.push(`${indent} }`);
764
- lines.push(`${indent} const validatedBody = parseResult.data`);
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}`);
765
984
  bodyVarName = 'validatedBody';
766
985
  }
767
986
  else {
@@ -776,13 +995,23 @@ function buildExpressRouteHandler(op, indent, schemaNames) {
776
995
  // Build service call args
777
996
  const serviceArgs = [];
778
997
  for (const p of op.pathParams) {
779
- 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)`);
780
1001
  }
781
1002
  if (op.bodyInfo !== undefined) {
782
1003
  serviceArgs.push(bodyVarName);
783
1004
  }
784
1005
  if (op.queryParams.length > 0) {
785
- 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');
786
1015
  }
787
1016
  const serviceCall = `service.${op.methodName}(${serviceArgs.join(', ')})`;
788
1017
  // Response — wrap in try/catch to map HttpError to its status
@@ -828,8 +1057,45 @@ function buildExpressRouteHandler(op, indent, schemaNames) {
828
1057
  return lines.join('\n');
829
1058
  }
830
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
+ }
831
1097
  // fallow-ignore-next-line complexity
832
- function buildFastifyRouteHandler(op, indent, schemaNames) {
1098
+ function buildFastifyRouteHandler(op, indent, schemaNames, contextType) {
833
1099
  const lines = [];
834
1100
  // Build generic type argument
835
1101
  const genericParts = [];
@@ -868,12 +1134,13 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
868
1134
  genericParts.push(`Params: { ${paramFields} }`);
869
1135
  }
870
1136
  const generic = genericParts.length > 0 ? `<{ ${genericParts.join('; ')} }>` : '';
871
- 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) => {`);
872
1139
  // Path param format validation (e.g. uuid)
873
1140
  if (op.pathParamValidations.length > 0) {
874
1141
  emitPathValidation(lines, op.pathParamValidations, indent, 'fastify');
875
1142
  lines.push(`${indent} if (!_pv.success) {`);
876
- lines.push(`${indent} return reply.status(422).send({`);
1143
+ lines.push(`${indent} return (reply as FastifyReply).status(422).send({`);
877
1144
  lines.push(`${indent} error: 'Invalid path parameters',`);
878
1145
  lines.push(`${indent} issues: _pv.error.issues,`);
879
1146
  lines.push(`${indent} })`);
@@ -909,6 +1176,13 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
909
1176
  return ` ${q.name}: typeof _dq['${q.rawName}'] === 'string' ? _dq['${q.rawName}']!.split(${delim}) : undefined`;
910
1177
  }
911
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
+ }
912
1186
  return hasDeepOrDelimited
913
1187
  ? ` ${q.name}: _dq['${q.rawName}']`
914
1188
  : ` ${q.name}: req.query.${q.name}`;
@@ -921,7 +1195,7 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
921
1195
  if (queryParamsNeedValidation(op.queryParams)) {
922
1196
  emitQueryValidation(lines, op.queryParams, indent);
923
1197
  lines.push(`${indent} if (!_qv.success) {`);
924
- lines.push(`${indent} return reply.status(422).send({`);
1198
+ lines.push(`${indent} return (reply as FastifyReply).status(422).send({`);
925
1199
  lines.push(`${indent} error: 'Invalid query parameters',`);
926
1200
  lines.push(`${indent} issues: _qv.error.issues,`);
927
1201
  lines.push(`${indent} })`);
@@ -932,22 +1206,43 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
932
1206
  if (op.headerParams.length > 0) {
933
1207
  emitHeaderValidation(lines, op.headerParams, indent, 'fastify');
934
1208
  lines.push(`${indent} if (!_hv.success) {`);
935
- lines.push(`${indent} return reply.status(422).send({`);
1209
+ lines.push(`${indent} return (reply as FastifyReply).status(422).send({`);
936
1210
  lines.push(`${indent} error: 'Invalid request headers',`);
937
1211
  lines.push(`${indent} issues: _hv.error.issues,`);
938
1212
  lines.push(`${indent} })`);
939
1213
  lines.push(`${indent} }`);
940
1214
  }
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
+ }
941
1226
  // Body handling, with optional Zod validation.
942
- // Fastify pre-parses req.body for both JSON and form-urlencoded bodies via plugins.
943
- // For multipart/form-data: assumes @fastify/multipart (or equivalent) plugin is registered
944
- // so that req.body contains the parsed fields and file parts.
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.
945
1233
  let bodyVarName = 'req.body';
946
1234
  if (op.bodyInfo !== undefined) {
947
1235
  if (op.bodyInfo.contentType === 'multipart/form-data') {
948
- // Multipart assumption: @fastify/multipart plugin has populated req.body before this
949
- // handler runs. The body is forwarded to the service as-is.
950
- lines.push(`${indent} // multipart/form-data: assumes @fastify/multipart plugin has populated req.body.`);
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.`);
951
1246
  // bodyVarName stays 'req.body'
952
1247
  }
953
1248
  else {
@@ -957,9 +1252,18 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
957
1252
  lines.push(`${indent} // Validate request body: returns 422 with Zod issues on failure`);
958
1253
  lines.push(`${indent} const parseResult = ${schemaName}.safeParse(req.body)`);
959
1254
  lines.push(`${indent} if (!parseResult.success) {`);
960
- lines.push(`${indent} return reply.status(422).send({ error: 'Invalid request body', issues: parseResult.error.issues })`);
1255
+ lines.push(`${indent} return (reply as FastifyReply).status(422).send({ error: 'Invalid request body', issues: parseResult.error.issues })`);
961
1256
  lines.push(`${indent} }`);
962
- bodyVarName = 'parseResult.data';
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';
963
1267
  }
964
1268
  }
965
1269
  }
@@ -972,7 +1276,15 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
972
1276
  serviceArgs.push(bodyVarName);
973
1277
  }
974
1278
  if (op.queryParams.length > 0) {
975
- 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');
976
1288
  }
977
1289
  const serviceCall = `service.${op.methodName}(${serviceArgs.join(', ')})`;
978
1290
  // Response — wrap in try/catch to map HttpError to its status
@@ -1003,15 +1315,15 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
1003
1315
  }
1004
1316
  }
1005
1317
  else if (op.responseStatus.status === 200) {
1006
- lines.push(`${indent} return ${serviceCall}`);
1318
+ lines.push(`${indent} return await ${serviceCall}`);
1007
1319
  }
1008
1320
  else {
1009
1321
  lines.push(`${indent} reply.status(${op.responseStatus.status})`);
1010
- lines.push(`${indent} return ${serviceCall}`);
1322
+ lines.push(`${indent} return await ${serviceCall}`);
1011
1323
  }
1012
1324
  lines.push(`${indent} } catch (err) {`);
1013
1325
  lines.push(`${indent} if (err instanceof HttpError) {`);
1014
- lines.push(`${indent} return reply.status(err.status).send({ error: err.message })`);
1326
+ lines.push(`${indent} return (reply as FastifyReply).status(err.status).send({ error: err.message })`);
1015
1327
  lines.push(`${indent} }`);
1016
1328
  lines.push(`${indent} throw err`);
1017
1329
  lines.push(`${indent} }`);
@@ -1037,7 +1349,8 @@ function httpErrorClassLines() {
1037
1349
  // ── Zod import helpers ────────────────────────────────────────────────────────
1038
1350
  /**
1039
1351
  * Returns true when any operation in the list generates param validation code
1040
- * 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).
1041
1354
  */
1042
1355
  function operationsNeedZodForParams(operations) {
1043
1356
  for (const op of operations) {
@@ -1047,6 +1360,8 @@ function operationsNeedZodForParams(operations) {
1047
1360
  return true;
1048
1361
  if (op.headerParams.length > 0)
1049
1362
  return true;
1363
+ if (op.cookieParams.length > 0)
1364
+ return true;
1050
1365
  }
1051
1366
  return false;
1052
1367
  }
@@ -1056,6 +1371,7 @@ export function generateExpressRouter(spec, options) {
1056
1371
  const serviceName = deriveServiceName(spec);
1057
1372
  const operations = collectOperations(spec);
1058
1373
  const { sortedBodyTypes, usedSchemaNames, needsZod } = collectGeneratorSetup(operations, options);
1374
+ // usedResponseSchemaNames is Fastify-only; not used in Express generator.
1059
1375
  const lines = [];
1060
1376
  lines.push('// This file is auto-generated. Do not edit manually.');
1061
1377
  lines.push('// Express: apply express.json() middleware before mounting this router so req.body is populated.');
@@ -1065,6 +1381,8 @@ export function generateExpressRouter(spec, options) {
1065
1381
  if (sortedBodyTypes.length > 0) {
1066
1382
  lines.push(`import type { ${sortedBodyTypes.join(', ')} } from './models.js'`);
1067
1383
  }
1384
+ const ctx = options?.contextType;
1385
+ const serviceRef = ctx !== undefined ? `${serviceName}<${ctx}>` : serviceName;
1068
1386
  lines.push(`import type { ${serviceName} } from './service.js'`);
1069
1387
  if (needsZod) {
1070
1388
  lines.push(`import { z } from 'zod'`);
@@ -1077,11 +1395,11 @@ export function generateExpressRouter(spec, options) {
1077
1395
  for (const l of httpErrorClassLines())
1078
1396
  lines.push(l);
1079
1397
  lines.push('');
1080
- lines.push(`export function createRouter(service: ${serviceName}): Router {`);
1398
+ lines.push(`export function createRouter(service: ${serviceRef}): Router {`);
1081
1399
  lines.push(' const router = Router()');
1082
1400
  lines.push('');
1083
1401
  for (const op of operations) {
1084
- lines.push(buildExpressRouteHandler(op, ' ', options?.schemaNames));
1402
+ lines.push(buildExpressRouteHandler(op, ' ', options?.schemaNames, ctx));
1085
1403
  lines.push('');
1086
1404
  }
1087
1405
  lines.push(' return router');
@@ -1097,11 +1415,20 @@ export function generateExpressRouter(spec, options) {
1097
1415
  export function generateFastifyRouter(spec, options) {
1098
1416
  const serviceName = deriveServiceName(spec);
1099
1417
  const operations = collectOperations(spec);
1100
- 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');
1101
1423
  const lines = [];
1102
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.');
1103
1428
  lines.push('');
1104
- 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'");
1105
1432
  if (sortedBodyTypes.length > 0) {
1106
1433
  lines.push(`import type { ${sortedBodyTypes.join(', ')} } from './models.js'`);
1107
1434
  }
@@ -1109,18 +1436,46 @@ export function generateFastifyRouter(spec, options) {
1109
1436
  if (needsZod) {
1110
1437
  lines.push(`import { z } from 'zod'`);
1111
1438
  }
1112
- if (usedSchemaNames.size > 0 && options?.schemaImportPath !== undefined) {
1113
- const sortedUsedSchemas = Array.from(usedSchemaNames).sort();
1439
+ if (allUsedSchemaNames.size > 0 && options?.schemaImportPath !== undefined) {
1440
+ const sortedUsedSchemas = Array.from(allUsedSchemaNames).sort();
1114
1441
  lines.push(`import { ${sortedUsedSchemas.join(', ')} } from '${options.schemaImportPath}'`);
1115
1442
  }
1116
1443
  lines.push('');
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('');
1117
1453
  for (const l of httpErrorClassLines())
1118
1454
  lines.push(l);
1119
1455
  lines.push('');
1120
- lines.push(`export function createRouter(app: FastifyInstance, service: ${serviceName}): void {`);
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
+ }
1121
1476
  for (const op of operations) {
1122
1477
  lines.push('');
1123
- lines.push(buildFastifyRouteHandler(op, ' ', options?.schemaNames));
1478
+ lines.push(buildFastifyRouteHandler(op, ' ', options?.schemaNames, ctx));
1124
1479
  }
1125
1480
  lines.push('}');
1126
1481
  lines.push('');
@@ -1138,7 +1493,14 @@ export function generateRouter(spec, options) {
1138
1493
  const lines = [];
1139
1494
  lines.push('// This file is auto-generated. Do not edit manually.');
1140
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);
1141
1500
  lines.push("import { Hono } from 'hono'");
1501
+ if (needsGetCookie) {
1502
+ lines.push("import { getCookie } from 'hono/cookie'");
1503
+ }
1142
1504
  if (sortedBodyTypes.length > 0) {
1143
1505
  lines.push(`import type { ${sortedBodyTypes.join(', ')} } from './models.js'`);
1144
1506
  }
@@ -1154,11 +1516,11 @@ export function generateRouter(spec, options) {
1154
1516
  for (const l of httpErrorClassLines())
1155
1517
  lines.push(l);
1156
1518
  lines.push('');
1157
- lines.push(`export function createRouter(service: ${serviceName}): Hono {`);
1519
+ lines.push(`export function createRouter(service: ${serviceRef}): Hono {`);
1158
1520
  lines.push(' const app = new Hono()');
1159
1521
  lines.push('');
1160
1522
  for (const op of operations) {
1161
- lines.push(buildRouteHandler(op, ' ', options?.schemaNames));
1523
+ lines.push(buildRouteHandler(op, ' ', options?.schemaNames, ctx));
1162
1524
  lines.push('');
1163
1525
  }
1164
1526
  lines.push(' return app');