@codewithagents/openapi-server 1.8.0 → 1.9.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.
@@ -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,102 @@ 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
+ }
74
+ /**
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.number() with optional range modifiers. */
87
+ function queryParamNumberZodBase(param) {
88
+ let base = 'z.number()';
89
+ if (param.minimum !== undefined)
90
+ base += `.min(${param.minimum})`;
91
+ if (param.maximum !== undefined)
92
+ base += `.max(${param.maximum})`;
93
+ if (param.exclusiveMinimum !== undefined)
94
+ base += `.gt(${param.exclusiveMinimum})`;
95
+ if (param.exclusiveMaximum !== undefined)
96
+ base += `.lt(${param.exclusiveMaximum})`;
97
+ return base;
98
+ }
99
+ /** String param: z.string() or z.enum([...]) with optional length/pattern modifiers. */
100
+ function queryParamStringZodBase(param) {
101
+ let base;
102
+ if (param.enum !== undefined && param.enum.length > 0) {
103
+ const members = param.enum.map((v) => JSON.stringify(v)).join(', ');
104
+ base = `z.enum([${members}])`;
105
+ }
106
+ else {
107
+ base = 'z.string()';
108
+ }
109
+ if (param.minLength !== undefined)
110
+ base += `.min(${param.minLength})`;
111
+ if (param.maxLength !== undefined)
112
+ base += `.max(${param.maxLength})`;
113
+ if (param.pattern !== undefined)
114
+ base += `.regex(/${param.pattern}/)`;
115
+ return base;
116
+ }
44
117
  /**
45
- * Build a Zod expression for a query or header parameter based on its schema.
118
+ * Build a Zod expression for a query parameter based on its captured constraints.
46
119
  * Number/integer types use z.number() (after coercion by extraction code).
47
- * String types use z.string(). Boolean types use z.boolean().
120
+ * String types use z.string() with optional format/enum/pattern/length modifiers.
121
+ * Delimited array params use z.array(z.string()).
122
+ * DeepObject params use z.object({...}) with per-property coercion.
48
123
  * Appends .optional() for non-required params.
49
124
  */
50
- function paramZodExpr(tsType, required, schema) {
125
+ function queryParamZodExpr(param) {
51
126
  let base;
52
- if (tsType === 'number') {
53
- base = 'z.number()';
127
+ if (param.delimiterStyle !== undefined) {
128
+ base = queryParamDelimitedZodBase(param);
129
+ }
130
+ else if (param.isDeepObject === true && param.deepObjectProperties !== undefined) {
131
+ base = queryParamDeepObjectZodBase(param);
54
132
  }
55
- else if (tsType === 'boolean') {
133
+ else if (param.tsType === 'number') {
134
+ base = queryParamNumberZodBase(param);
135
+ }
136
+ else if (param.tsType === 'boolean') {
56
137
  base = 'z.boolean()';
57
138
  }
58
139
  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
- }
140
+ base = queryParamStringZodBase(param);
69
141
  }
70
- return required ? base : `${base}.optional()`;
142
+ return param.required ? base : `${base}.optional()`;
143
+ }
144
+ /**
145
+ * Build a Zod expression for a header parameter based on its captured constraints.
146
+ * Header values are always strings; emits z.string() or z.enum([...]) with optional
147
+ * pattern/length modifiers. Appends .optional() for non-required params.
148
+ */
149
+ function headerParamZodExpr(param) {
150
+ let base;
151
+ if (param.enum !== undefined && param.enum.length > 0) {
152
+ const members = param.enum.map((v) => JSON.stringify(v)).join(', ');
153
+ base = `z.enum([${members}])`;
154
+ }
155
+ else {
156
+ base = 'z.string()';
157
+ }
158
+ if (param.minLength !== undefined)
159
+ base += `.min(${param.minLength})`;
160
+ if (param.maxLength !== undefined)
161
+ base += `.max(${param.maxLength})`;
162
+ if (param.pattern !== undefined)
163
+ base += `.regex(/${param.pattern}/)`;
164
+ return param.required ? base : `${base}.optional()`;
71
165
  }
72
166
  /**
73
167
  * Collect path parameters that have Zod format constraints.
@@ -98,7 +192,7 @@ function getPathParamValidations(operation, spec, rawPathParamNames) {
98
192
  return result;
99
193
  }
100
194
  /**
101
- * Collect header parameters from an operation.
195
+ * Collect header parameters from an operation, including schema constraints.
102
196
  */
103
197
  function getHeaderParams(operation, spec) {
104
198
  const parameters = operation.parameters;
@@ -109,19 +203,60 @@ function getHeaderParams(operation, spec) {
109
203
  const resolved = resolveParam(p, spec);
110
204
  if (resolved === undefined || resolved.in !== 'header')
111
205
  continue;
112
- result.push({
206
+ const param = {
113
207
  rawName: resolved.name,
114
208
  required: resolved.required === true,
115
- });
209
+ };
210
+ const schema = resolved.schema;
211
+ if (schema !== undefined && !isRef(schema)) {
212
+ const s = schema;
213
+ if (Array.isArray(s.enum))
214
+ param.enum = s.enum;
215
+ if (typeof s.minLength === 'number')
216
+ param.minLength = s.minLength;
217
+ if (typeof s.maxLength === 'number')
218
+ param.maxLength = s.maxLength;
219
+ if (typeof s.pattern === 'string')
220
+ param.pattern = s.pattern;
221
+ }
222
+ result.push(param);
116
223
  }
117
224
  return result;
118
225
  }
226
+ /**
227
+ * Returns true when a query param carries schema constraints beyond basic type/required.
228
+ * These constraints require a Zod validation block even if the param is optional or string.
229
+ */
230
+ function queryParamHasConstraints(q) {
231
+ // Fields that, when defined, indicate schema constraints are present.
232
+ const constraintFields = [
233
+ q.enum,
234
+ q.minimum,
235
+ q.maximum,
236
+ q.exclusiveMinimum,
237
+ q.exclusiveMaximum,
238
+ q.minLength,
239
+ q.maxLength,
240
+ q.pattern,
241
+ q.delimiterStyle,
242
+ ];
243
+ return constraintFields.some((f) => f !== undefined) || q.isDeepObject === true;
244
+ }
119
245
  /**
120
246
  * 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).
247
+ * Triggered when any param is required, has a non-string type (to catch NaN/invalid input),
248
+ * or carries schema constraints (enum, min/max, pattern, etc.).
122
249
  */
123
250
  function queryParamsNeedValidation(queryParams) {
124
- return queryParams.some((q) => q.required || q.tsType !== 'string');
251
+ return queryParams.some((q) => q.required || q.tsType !== 'string' || queryParamHasConstraints(q));
252
+ }
253
+ /** Returns the delimiter character for a delimited-style array query param. */
254
+ function delimiterChar(style) {
255
+ if (style === 'ssv')
256
+ return ' ';
257
+ if (style === 'psv')
258
+ return '|';
259
+ return ',';
125
260
  }
126
261
  /**
127
262
  * Emit Zod validation lines for query parameters into the handler line buffer.
@@ -134,7 +269,7 @@ function emitQueryValidation(lines, queryParams, indent) {
134
269
  const fieldIndent = `${indent} `;
135
270
  const fields = queryParams
136
271
  .map((q) => {
137
- const expr = paramZodExpr(q.tsType, q.required);
272
+ const expr = queryParamZodExpr(q);
138
273
  return `${fieldIndent}${q.name}: ${expr}`;
139
274
  })
140
275
  .join(',\n');
@@ -195,7 +330,7 @@ function emitHeaderValidation(lines, headerParams, indent, framework) {
195
330
  const schemaFields = headerParams
196
331
  .map((h) => {
197
332
  const key = JSON.stringify(h.rawName);
198
- const expr = h.required ? 'z.string()' : 'z.string().optional()';
333
+ const expr = headerParamZodExpr(h);
199
334
  return `${fieldIndent}${key}: ${expr}`;
200
335
  })
201
336
  .join(',\n');
@@ -229,22 +364,84 @@ function response200IsVoid(resp) {
229
364
  const content = r.content;
230
365
  return content === undefined || Object.keys(content).length === 0;
231
366
  }
367
+ /**
368
+ * Detect the success response content type from a ResponseObject.
369
+ * Returns 'text/plain' or 'application/octet-stream' for non-JSON responses,
370
+ * or 'application/json' as the default.
371
+ */
372
+ function detectResponseContentType(resp) {
373
+ if (isRef(resp))
374
+ return 'application/json';
375
+ const r = resp;
376
+ const content = r.content;
377
+ if (content === undefined)
378
+ return 'application/json';
379
+ if ('text/plain' in content)
380
+ return 'text/plain';
381
+ if ('application/octet-stream' in content)
382
+ return 'application/octet-stream';
383
+ return 'application/json';
384
+ }
232
385
  function getResponseStatus(operation, httpMethod) {
233
386
  const responses = operation.responses;
234
387
  if (responses === undefined) {
235
- return httpMethod === 'delete' ? { status: 204, isVoid: true } : { status: 200, isVoid: false };
388
+ return httpMethod === 'delete'
389
+ ? { status: 204, isVoid: true, responseContentType: 'application/json' }
390
+ : { status: 200, isVoid: false, responseContentType: 'application/json' };
391
+ }
392
+ // Multi-status: more than one 2xx response with a body (excluding 204/void).
393
+ // Must be checked before individual 200/201/204 branches so that e.g. 200+202
394
+ // is not absorbed by the responses['200'] early return.
395
+ // The handler selects the status at runtime via a { status, body } envelope.
396
+ const contentfulTwoxxKeys = Object.keys(responses)
397
+ .filter((k) => /^2\d\d$/.test(k) && k !== '204')
398
+ .sort();
399
+ if (contentfulTwoxxKeys.length > 1) {
400
+ return {
401
+ status: 200,
402
+ isVoid: false,
403
+ responseContentType: 'application/json',
404
+ isMultiStatus: true,
405
+ };
406
+ }
407
+ if (responses['201'] !== undefined) {
408
+ return {
409
+ status: 201,
410
+ isVoid: false,
411
+ responseContentType: detectResponseContentType(responses['201']),
412
+ };
413
+ }
414
+ if (responses['204'] !== undefined) {
415
+ return { status: 204, isVoid: true, responseContentType: 'application/json' };
236
416
  }
237
- if (responses['201'] !== undefined)
238
- return { status: 201, isVoid: false };
239
- if (responses['204'] !== undefined)
240
- return { status: 204, isVoid: true };
241
417
  if (responses['200'] !== undefined) {
242
- if (response200IsVoid(responses['200']))
243
- return { status: 204, isVoid: true };
244
- return { status: 200, isVoid: false };
418
+ if (response200IsVoid(responses['200'])) {
419
+ return { status: 204, isVoid: true, responseContentType: 'application/json' };
420
+ }
421
+ return {
422
+ status: 200,
423
+ isVoid: false,
424
+ responseContentType: detectResponseContentType(responses['200']),
425
+ };
426
+ }
427
+ // Single non-200/201/204 2xx declared: honor that exact status code.
428
+ const twoxxKeys = Object.keys(responses).filter((k) => /^2\d\d$/.test(k) && k !== '200' && k !== '201' && k !== '204');
429
+ if (twoxxKeys.length === 1) {
430
+ const code = parseInt(twoxxKeys[0], 10);
431
+ const resp = responses[twoxxKeys[0]];
432
+ const isVoid = isRef(resp)
433
+ ? false
434
+ : (() => {
435
+ const r = resp;
436
+ const content = r.content;
437
+ return content === undefined || Object.keys(content).length === 0;
438
+ })();
439
+ return { status: code, isVoid, responseContentType: detectResponseContentType(resp) };
245
440
  }
246
441
  // Default: delete -> 204, otherwise 200
247
- return httpMethod === 'delete' ? { status: 204, isVoid: true } : { status: 200, isVoid: false };
442
+ return httpMethod === 'delete'
443
+ ? { status: 204, isVoid: true, responseContentType: 'application/json' }
444
+ : { status: 200, isVoid: false, responseContentType: 'application/json' };
248
445
  }
249
446
  function collectOperations(spec) {
250
447
  const paths = spec.paths;
@@ -279,12 +476,16 @@ function collectOperations(spec) {
279
476
  }
280
477
  return operations;
281
478
  }
282
- /** Collect sorted body type names from all operations. */
479
+ /** Collect sorted body type names from all operations.
480
+ * Synthesized names (inline schema, no $ref) are excluded because they have no
481
+ * corresponding entry in models.ts and must not appear in the model import.
482
+ */
283
483
  function collectSortedBodyTypes(operations) {
284
484
  const bodyTypes = new Set();
285
485
  for (const op of operations) {
286
- if (op.bodyInfo?.typeName !== undefined)
486
+ if (op.bodyInfo?.typeName !== undefined && !op.bodyInfo.isSynthesized) {
287
487
  bodyTypes.add(op.bodyInfo.typeName);
488
+ }
288
489
  }
289
490
  return Array.from(bodyTypes).sort();
290
491
  }
@@ -328,8 +529,30 @@ function buildRouteHandler(op, indent, schemaNames) {
328
529
  }
329
530
  // Query params extraction
330
531
  if (op.queryParams.length > 0) {
532
+ // Emit deepObject assembly blocks before the params object.
533
+ // c.req.queries() returns Record<string, string[]> with raw bracket-notation keys.
534
+ const deepObjectParams = op.queryParams.filter((q) => q.isDeepObject === true);
535
+ if (deepObjectParams.length > 0) {
536
+ lines.push(`${indent} const _dq = c.req.queries()`);
537
+ for (const q of deepObjectParams) {
538
+ const prefixLen = q.rawName.length + 1; // e.g. 'filter['.length
539
+ const bracketPrefix = q.rawName + '[';
540
+ lines.push(`${indent} const ${q.name} = Object.fromEntries(`);
541
+ lines.push(`${indent} Object.entries(_dq).filter(([k]) => k.startsWith('${bracketPrefix}') && k.endsWith(']')).map(([k, vs]) => [k.slice(${prefixLen}, -1), vs[0]])`);
542
+ lines.push(`${indent} )`);
543
+ }
544
+ }
331
545
  const fields = op.queryParams
332
546
  .map((q) => {
547
+ if (q.isDeepObject === true) {
548
+ // Already assembled above as a local variable
549
+ return ` ${q.name}`;
550
+ }
551
+ if (q.delimiterStyle !== undefined) {
552
+ // Use rawName to match the actual URL query key (e.g. 'csv', 'ssv', 'psv').
553
+ const delim = JSON.stringify(delimiterChar(q.delimiterStyle));
554
+ return ` ${q.name}: c.req.query('${q.rawName}') !== undefined ? c.req.query('${q.rawName}')!.split(${delim}) : undefined`;
555
+ }
333
556
  if (q.tsType === 'number') {
334
557
  return ` ${q.name}: c.req.query('${q.name}') !== undefined ? Number(c.req.query('${q.name}')) : undefined`;
335
558
  }
@@ -357,8 +580,43 @@ function buildRouteHandler(op, indent, schemaNames) {
357
580
  // Body extraction
358
581
  let bodyVarName = 'body';
359
582
  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}()`);
583
+ // Synthesized names (inline schemas) are schema-only; the TS type is unknown.
584
+ const typeDecl = op.bodyInfo.typeName !== undefined && !op.bodyInfo.isSynthesized
585
+ ? op.bodyInfo.typeName
586
+ : 'unknown';
587
+ if (op.bodyInfo.contentType === 'application/x-www-form-urlencoded') {
588
+ // Form-urlencoded: check Content-Type then decode with parseBody().
589
+ // Values arrive as strings; Zod coercion handles type conversion (e.g. z.coerce.number()).
590
+ lines.push(`${indent} const _ct = c.req.header('content-type') ?? ''`);
591
+ lines.push(`${indent} if (!_ct.toLowerCase().startsWith('application/x-www-form-urlencoded')) {`);
592
+ lines.push(`${indent} return c.json({ error: 'Unsupported Media Type' }, 415)`);
593
+ lines.push(`${indent} }`);
594
+ lines.push(`${indent} const body: unknown = await c.req.parseBody()`);
595
+ }
596
+ else if (op.bodyInfo.contentType === 'multipart/form-data') {
597
+ // Multipart: decode with parseBody({ all: true }) so repeated file fields arrive as arrays.
598
+ // File fields are web-standard File objects; text fields are strings.
599
+ // No manual Content-Type check needed: parseBody handles multipart natively in Hono.
600
+ lines.push(`${indent} // multipart/form-data: parseBody({ all: true }) collects repeated keys into arrays.`);
601
+ lines.push(`${indent} const body: unknown = await c.req.parseBody({ all: true })`);
602
+ }
603
+ else {
604
+ // JSON body: check Content-Type then parse with JSON.parse (not c.req.json()).
605
+ // c.req.text() + JSON.parse() is used instead of c.req.json() because Hono's
606
+ // c.req.json() silently returns null for an empty body instead of throwing,
607
+ // which would pass the try/catch and reach Zod as null, causing a 422 rather
608
+ // than the correct 400. JSON.parse('') always throws SyntaxError.
609
+ lines.push(`${indent} const _ct = c.req.header('content-type') ?? ''`);
610
+ lines.push(`${indent} if (!_ct.toLowerCase().startsWith('application/json')) {`);
611
+ lines.push(`${indent} return c.json({ error: 'Unsupported Media Type' }, 415)`);
612
+ lines.push(`${indent} }`);
613
+ lines.push(`${indent} let body: ${typeDecl}`);
614
+ lines.push(`${indent} try {`);
615
+ lines.push(`${indent} body = JSON.parse(await c.req.text()) as ${typeDecl}`);
616
+ lines.push(`${indent} } catch {`);
617
+ lines.push(`${indent} return c.json({ error: 'Invalid JSON body' }, 400)`);
618
+ lines.push(`${indent} }`);
619
+ }
362
620
  // Zod validation when schema is available
363
621
  const schemaName = op.bodyInfo.typeName !== undefined ? `${op.bodyInfo.typeName}Schema` : undefined;
364
622
  if (schemaName !== undefined && schemaNames !== undefined && schemaNames.has(schemaName)) {
@@ -383,17 +641,47 @@ function buildRouteHandler(op, indent, schemaNames) {
383
641
  serviceArgs.push('params');
384
642
  }
385
643
  const serviceCall = `service.${op.methodName}(${serviceArgs.join(', ')})`;
386
- // Response
644
+ // Response — wrap in try/catch to map HttpError to its status
645
+ lines.push(`${indent} try {`);
387
646
  if (op.responseStatus.isVoid) {
388
- lines.push(`${indent} await ${serviceCall}`);
389
- lines.push(`${indent} return new Response(null, { status: ${op.responseStatus.status} })`);
647
+ lines.push(`${indent} await ${serviceCall}`);
648
+ lines.push(`${indent} return new Response(null, { status: ${op.responseStatus.status} })`);
390
649
  }
391
- else if (op.responseStatus.status === 201) {
392
- lines.push(`${indent} return c.json(await ${serviceCall}, 201)`);
650
+ else if (op.responseStatus.isMultiStatus === true) {
651
+ // Multi-status: service returns { status: number; body: T }; router forwards both.
652
+ lines.push(`${indent} const _envelope = await ${serviceCall}`);
653
+ lines.push(`${indent} return c.json(_envelope.body, _envelope.status as any)`);
654
+ }
655
+ else if (op.responseStatus.responseContentType === 'text/plain') {
656
+ if (op.responseStatus.status === 200) {
657
+ lines.push(`${indent} return c.text(await ${serviceCall})`);
658
+ }
659
+ else {
660
+ lines.push(`${indent} return c.text(await ${serviceCall}, ${op.responseStatus.status})`);
661
+ }
662
+ }
663
+ else if (op.responseStatus.responseContentType === 'application/octet-stream') {
664
+ if (op.responseStatus.status === 200) {
665
+ lines.push(`${indent} const _result = await ${serviceCall}`);
666
+ lines.push(`${indent} return new Response(_result, { headers: { 'content-type': 'application/octet-stream' } })`);
667
+ }
668
+ else {
669
+ lines.push(`${indent} const _result = await ${serviceCall}`);
670
+ lines.push(`${indent} return new Response(_result, { status: ${op.responseStatus.status}, headers: { 'content-type': 'application/octet-stream' } })`);
671
+ }
672
+ }
673
+ else if (op.responseStatus.status === 200) {
674
+ lines.push(`${indent} return c.json(await ${serviceCall})`);
393
675
  }
394
676
  else {
395
- lines.push(`${indent} return c.json(await ${serviceCall})`);
677
+ lines.push(`${indent} return c.json(await ${serviceCall}, ${op.responseStatus.status})`);
396
678
  }
679
+ lines.push(`${indent} } catch (err) {`);
680
+ lines.push(`${indent} if (err instanceof HttpError) {`);
681
+ lines.push(`${indent} return new Response(JSON.stringify({ error: err.message }), { status: err.status, headers: { 'content-type': 'application/json' } })`);
682
+ lines.push(`${indent} }`);
683
+ lines.push(`${indent} throw err`);
684
+ lines.push(`${indent} }`);
397
685
  lines.push(`${indent}})`);
398
686
  return lines.join('\n');
399
687
  }
@@ -411,8 +699,19 @@ function buildExpressRouteHandler(op, indent, schemaNames) {
411
699
  }
412
700
  // Query params extraction
413
701
  if (op.queryParams.length > 0) {
702
+ // Express (qs, extended:true) parses bracket-notation automatically:
703
+ // filter[gte]=10 → req.query.filter = { gte: '10' }.
704
+ // DeepObject params are already assembled; just cast the nested object.
414
705
  const fields = op.queryParams
415
706
  .map((q) => {
707
+ if (q.isDeepObject === true) {
708
+ // Express with qs: req.query['filter'] is already { gte: '10', lte: '20' }
709
+ return ` ${q.name}: (req.query['${q.rawName}'] ?? {}) as Record<string, string | undefined>`;
710
+ }
711
+ if (q.delimiterStyle !== undefined) {
712
+ const delim = JSON.stringify(delimiterChar(q.delimiterStyle));
713
+ return ` ${q.name}: typeof req.query['${q.rawName}'] === 'string' ? (req.query['${q.rawName}'] as string).split(${delim}) : undefined`;
714
+ }
416
715
  if (q.tsType === 'number') {
417
716
  return ` ${q.name}: Number(req.query['${q.name}'] as string)`;
418
717
  }
@@ -440,23 +739,38 @@ function buildExpressRouteHandler(op, indent, schemaNames) {
440
739
  lines.push(`${indent} return void res.status(422).json({ error: 'Invalid request headers', issues: _hv.error.issues })`);
441
740
  lines.push(`${indent} }`);
442
741
  }
443
- // Body extraction, with optional Zod validation
742
+ // Body extraction, with optional Zod validation.
743
+ // For both JSON and form-urlencoded bodies Express pre-populates req.body via middleware
744
+ // (express.json() for JSON, express.urlencoded() for form). The router just reads req.body.
745
+ // For multipart/form-data: assumes multer (or equivalent) middleware is applied before this
746
+ // router, populating req.files and req.body with the parsed multipart fields.
444
747
  let bodyVarName = 'body';
445
748
  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';
749
+ if (op.bodyInfo.contentType === 'multipart/form-data') {
750
+ // Multipart assumption: multer middleware populates req.files (file fields) and
751
+ // req.body (text fields) before this handler runs. Merge them for service consumption.
752
+ lines.push(`${indent} // multipart/form-data: assumes multer middleware has populated req.files + req.body.`);
753
+ lines.push(`${indent} const body = { ...req.body, ...(req as any).files } as unknown`);
456
754
  }
457
755
  else {
458
- const typeAnnotation = op.bodyInfo.typeName !== undefined ? ` as ${op.bodyInfo.typeName}` : '';
459
- lines.push(`${indent} const body = req.body${typeAnnotation}`);
756
+ const schemaName = op.bodyInfo.typeName !== undefined ? `${op.bodyInfo.typeName}Schema` : undefined;
757
+ const useZod = schemaName !== undefined && schemaNames !== undefined && schemaNames.has(schemaName);
758
+ if (useZod) {
759
+ lines.push(`${indent} // Validate request body: returns 422 with Zod issues on failure`);
760
+ lines.push(`${indent} const parseResult = ${schemaName}.safeParse(req.body)`);
761
+ lines.push(`${indent} if (!parseResult.success) {`);
762
+ lines.push(`${indent} return void res.status(422).json({ error: 'Invalid request body', issues: parseResult.error.issues })`);
763
+ lines.push(`${indent} }`);
764
+ lines.push(`${indent} const validatedBody = parseResult.data`);
765
+ bodyVarName = 'validatedBody';
766
+ }
767
+ else {
768
+ // Synthesized names (inline schemas) have no model type — use plain cast to unknown.
769
+ const typeAnnotation = op.bodyInfo.typeName !== undefined && !op.bodyInfo.isSynthesized
770
+ ? ` as ${op.bodyInfo.typeName}`
771
+ : '';
772
+ lines.push(`${indent} const body = req.body${typeAnnotation}`);
773
+ }
460
774
  }
461
775
  }
462
776
  // Build service call args
@@ -471,17 +785,45 @@ function buildExpressRouteHandler(op, indent, schemaNames) {
471
785
  serviceArgs.push('params');
472
786
  }
473
787
  const serviceCall = `service.${op.methodName}(${serviceArgs.join(', ')})`;
474
- // Response
788
+ // Response — wrap in try/catch to map HttpError to its status
789
+ lines.push(`${indent} try {`);
475
790
  if (op.responseStatus.isVoid) {
476
- lines.push(`${indent} await ${serviceCall}`);
477
- lines.push(`${indent} res.status(${op.responseStatus.status}).end()`);
791
+ lines.push(`${indent} await ${serviceCall}`);
792
+ lines.push(`${indent} res.status(${op.responseStatus.status}).end()`);
478
793
  }
479
- else if (op.responseStatus.status === 201) {
480
- lines.push(`${indent} res.status(201).json(await ${serviceCall})`);
794
+ else if (op.responseStatus.isMultiStatus === true) {
795
+ // Multi-status: service returns { status: number; body: T }; router forwards both.
796
+ lines.push(`${indent} const _envelope = await ${serviceCall}`);
797
+ lines.push(`${indent} res.status(_envelope.status).json(_envelope.body)`);
798
+ }
799
+ else if (op.responseStatus.responseContentType === 'text/plain') {
800
+ if (op.responseStatus.status === 200) {
801
+ lines.push(`${indent} res.type('text/plain').send(await ${serviceCall})`);
802
+ }
803
+ else {
804
+ lines.push(`${indent} res.status(${op.responseStatus.status}).type('text/plain').send(await ${serviceCall})`);
805
+ }
806
+ }
807
+ else if (op.responseStatus.responseContentType === 'application/octet-stream') {
808
+ if (op.responseStatus.status === 200) {
809
+ lines.push(`${indent} res.setHeader('Content-Type', 'application/octet-stream').send(Buffer.from(await ${serviceCall}))`);
810
+ }
811
+ else {
812
+ lines.push(`${indent} res.status(${op.responseStatus.status}).setHeader('Content-Type', 'application/octet-stream').send(Buffer.from(await ${serviceCall}))`);
813
+ }
814
+ }
815
+ else if (op.responseStatus.status === 200) {
816
+ lines.push(`${indent} res.json(await ${serviceCall})`);
481
817
  }
482
818
  else {
483
- lines.push(`${indent} res.json(await ${serviceCall})`);
819
+ lines.push(`${indent} res.status(${op.responseStatus.status}).json(await ${serviceCall})`);
484
820
  }
821
+ lines.push(`${indent} } catch (err) {`);
822
+ lines.push(`${indent} if (err instanceof HttpError) {`);
823
+ lines.push(`${indent} return void res.status(err.status).json({ error: err.message })`);
824
+ lines.push(`${indent} }`);
825
+ lines.push(`${indent} throw err`);
826
+ lines.push(`${indent} }`);
485
827
  lines.push(`${indent}})`);
486
828
  return lines.join('\n');
487
829
  }
@@ -492,18 +834,30 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
492
834
  // Build generic type argument
493
835
  const genericParts = [];
494
836
  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} }`);
837
+ // DeepObject and delimited params use bracket-notation keys or raw strings;
838
+ // include them as Record<string, string> or string[] in the Querystring generic.
839
+ const hasDeepOrDelimited = op.queryParams.some((q) => q.isDeepObject === true || q.delimiterStyle !== undefined);
840
+ let querystringType;
841
+ if (hasDeepOrDelimited) {
842
+ // Use a loose Querystring type that allows bracket-notation keys (fast-querystring stores
843
+ // them as literal strings, e.g. 'filter[gte]') and array values for delimited params.
844
+ querystringType = 'Record<string, string | string[] | undefined>';
845
+ }
846
+ else {
847
+ const queryFields = op.queryParams
848
+ .map((q) => {
849
+ if (q.tsType === 'number')
850
+ return `${q.name}?: number`;
851
+ if (q.tsType === 'boolean')
852
+ return `${q.name}?: boolean`;
853
+ return `${q.name}?: string`;
854
+ })
855
+ .join('; ');
856
+ querystringType = `{ ${queryFields} }`;
857
+ }
858
+ genericParts.push(`Querystring: ${querystringType}`);
505
859
  }
506
- if (op.bodyInfo !== undefined && op.bodyInfo.typeName !== undefined) {
860
+ if (op.bodyInfo !== undefined && op.bodyInfo.typeName !== undefined && !op.bodyInfo.isSynthesized) {
507
861
  genericParts.push(`Body: ${op.bodyInfo.typeName}`);
508
862
  }
509
863
  else if (op.bodyInfo !== undefined) {
@@ -527,7 +881,39 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
527
881
  }
528
882
  // Query params extraction
529
883
  if (op.queryParams.length > 0) {
530
- const fields = op.queryParams.map((q) => ` ${q.name}: req.query.${q.name}`).join(',\n');
884
+ // fast-querystring (Fastify default) stores bracket-notation keys as literals:
885
+ // filter[gte]=10 → req.query['filter[gte]'] = '10'.
886
+ // DeepObject and delimited params need raw string access; emit _dq cast once.
887
+ const deepObjectParams = op.queryParams.filter((q) => q.isDeepObject === true);
888
+ const hasDeepOrDelimited = op.queryParams.some((q) => q.isDeepObject === true || q.delimiterStyle !== undefined);
889
+ if (hasDeepOrDelimited) {
890
+ lines.push(`${indent} const _dq = req.query as unknown as Record<string, string | undefined>`);
891
+ }
892
+ if (deepObjectParams.length > 0) {
893
+ for (const q of deepObjectParams) {
894
+ const prefixLen = q.rawName.length + 1; // e.g. 'filter['.length
895
+ const bracketPrefix = q.rawName + '[';
896
+ lines.push(`${indent} const ${q.name} = Object.fromEntries(`);
897
+ lines.push(`${indent} Object.entries(_dq).filter(([k]) => k.startsWith('${bracketPrefix}') && k.endsWith(']')).map(([k, v]) => [k.slice(${prefixLen}, -1), v])`);
898
+ lines.push(`${indent} )`);
899
+ }
900
+ }
901
+ const fields = op.queryParams
902
+ .map((q) => {
903
+ if (q.isDeepObject === true) {
904
+ // Already assembled above as a local variable
905
+ return ` ${q.name}`;
906
+ }
907
+ if (q.delimiterStyle !== undefined) {
908
+ const delim = JSON.stringify(delimiterChar(q.delimiterStyle));
909
+ return ` ${q.name}: typeof _dq['${q.rawName}'] === 'string' ? _dq['${q.rawName}']!.split(${delim}) : undefined`;
910
+ }
911
+ // When _dq is defined, use it for consistent access; otherwise use typed req.query.
912
+ return hasDeepOrDelimited
913
+ ? ` ${q.name}: _dq['${q.rawName}']`
914
+ : ` ${q.name}: req.query.${q.name}`;
915
+ })
916
+ .join(',\n');
531
917
  lines.push(`${indent} const params = {`);
532
918
  lines.push(fields);
533
919
  lines.push(`${indent} }`);
@@ -552,18 +938,29 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
552
938
  lines.push(`${indent} })`);
553
939
  lines.push(`${indent} }`);
554
940
  }
555
- // Body handling, with optional Zod validation
941
+ // 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.
556
945
  let bodyVarName = 'req.body';
557
946
  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';
947
+ 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.`);
951
+ // bodyVarName stays 'req.body'
952
+ }
953
+ else {
954
+ const schemaName = op.bodyInfo.typeName !== undefined ? `${op.bodyInfo.typeName}Schema` : undefined;
955
+ const useZod = schemaName !== undefined && schemaNames !== undefined && schemaNames.has(schemaName);
956
+ if (useZod) {
957
+ lines.push(`${indent} // Validate request body: returns 422 with Zod issues on failure`);
958
+ lines.push(`${indent} const parseResult = ${schemaName}.safeParse(req.body)`);
959
+ lines.push(`${indent} if (!parseResult.success) {`);
960
+ lines.push(`${indent} return reply.status(422).send({ error: 'Invalid request body', issues: parseResult.error.issues })`);
961
+ lines.push(`${indent} }`);
962
+ bodyVarName = 'parseResult.data';
963
+ }
567
964
  }
568
965
  }
569
966
  // Build service call args
@@ -578,21 +975,65 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
578
975
  serviceArgs.push('params');
579
976
  }
580
977
  const serviceCall = `service.${op.methodName}(${serviceArgs.join(', ')})`;
581
- // Response
978
+ // Response — wrap in try/catch to map HttpError to its status
979
+ lines.push(`${indent} try {`);
582
980
  if (op.responseStatus.isVoid) {
583
- lines.push(`${indent} await ${serviceCall}`);
584
- lines.push(`${indent} reply.status(${op.responseStatus.status}).send()`);
981
+ lines.push(`${indent} await ${serviceCall}`);
982
+ lines.push(`${indent} reply.status(${op.responseStatus.status}).send()`);
585
983
  }
586
- else if (op.responseStatus.status === 201) {
587
- lines.push(`${indent} reply.status(201)`);
588
- lines.push(`${indent} return ${serviceCall}`);
984
+ else if (op.responseStatus.isMultiStatus === true) {
985
+ // Multi-status: service returns { status: number; body: T }; router forwards both.
986
+ lines.push(`${indent} const _envelope = await ${serviceCall}`);
987
+ lines.push(`${indent} return reply.status(_envelope.status).send(_envelope.body)`);
988
+ }
989
+ else if (op.responseStatus.responseContentType === 'text/plain') {
990
+ if (op.responseStatus.status === 200) {
991
+ lines.push(`${indent} return reply.type('text/plain').send(await ${serviceCall})`);
992
+ }
993
+ else {
994
+ lines.push(`${indent} return reply.status(${op.responseStatus.status}).type('text/plain').send(await ${serviceCall})`);
995
+ }
996
+ }
997
+ else if (op.responseStatus.responseContentType === 'application/octet-stream') {
998
+ if (op.responseStatus.status === 200) {
999
+ lines.push(`${indent} return reply.type('application/octet-stream').send(Buffer.from(await ${serviceCall}))`);
1000
+ }
1001
+ else {
1002
+ lines.push(`${indent} return reply.status(${op.responseStatus.status}).type('application/octet-stream').send(Buffer.from(await ${serviceCall}))`);
1003
+ }
1004
+ }
1005
+ else if (op.responseStatus.status === 200) {
1006
+ lines.push(`${indent} return ${serviceCall}`);
589
1007
  }
590
1008
  else {
591
- lines.push(`${indent} return ${serviceCall}`);
1009
+ lines.push(`${indent} reply.status(${op.responseStatus.status})`);
1010
+ lines.push(`${indent} return ${serviceCall}`);
592
1011
  }
1012
+ lines.push(`${indent} } catch (err) {`);
1013
+ lines.push(`${indent} if (err instanceof HttpError) {`);
1014
+ lines.push(`${indent} return reply.status(err.status).send({ error: err.message })`);
1015
+ lines.push(`${indent} }`);
1016
+ lines.push(`${indent} throw err`);
1017
+ lines.push(`${indent} }`);
593
1018
  lines.push(`${indent}})`);
594
1019
  return lines.join('\n');
595
1020
  }
1021
+ // ── HttpError class ───────────────────────────────────────────────────────────
1022
+ /**
1023
+ * Lines that emit the exported HttpError class into a generated router file.
1024
+ * Services throw `new HttpError(404, 'Not found')` and the generated router
1025
+ * catches it, returning the matching HTTP status instead of a generic 500.
1026
+ */
1027
+ function httpErrorClassLines() {
1028
+ return [
1029
+ 'export class HttpError extends Error {',
1030
+ ' constructor(public readonly status: number, message: string) {',
1031
+ ' super(message)',
1032
+ " this.name = 'HttpError'",
1033
+ ' }',
1034
+ '}',
1035
+ ];
1036
+ }
596
1037
  // ── Zod import helpers ────────────────────────────────────────────────────────
597
1038
  /**
598
1039
  * Returns true when any operation in the list generates param validation code
@@ -633,6 +1074,9 @@ export function generateExpressRouter(spec, options) {
633
1074
  lines.push(`import { ${sortedUsedSchemas.join(', ')} } from '${options.schemaImportPath}'`);
634
1075
  }
635
1076
  lines.push('');
1077
+ for (const l of httpErrorClassLines())
1078
+ lines.push(l);
1079
+ lines.push('');
636
1080
  lines.push(`export function createRouter(service: ${serviceName}): Router {`);
637
1081
  lines.push(' const router = Router()');
638
1082
  lines.push('');
@@ -670,6 +1114,9 @@ export function generateFastifyRouter(spec, options) {
670
1114
  lines.push(`import { ${sortedUsedSchemas.join(', ')} } from '${options.schemaImportPath}'`);
671
1115
  }
672
1116
  lines.push('');
1117
+ for (const l of httpErrorClassLines())
1118
+ lines.push(l);
1119
+ lines.push('');
673
1120
  lines.push(`export function createRouter(app: FastifyInstance, service: ${serviceName}): void {`);
674
1121
  for (const op of operations) {
675
1122
  lines.push('');
@@ -704,6 +1151,9 @@ export function generateRouter(spec, options) {
704
1151
  lines.push(`import { ${sortedUsedSchemas.join(', ')} } from '${options.schemaImportPath}'`);
705
1152
  }
706
1153
  lines.push('');
1154
+ for (const l of httpErrorClassLines())
1155
+ lines.push(l);
1156
+ lines.push('');
707
1157
  lines.push(`export function createRouter(service: ${serviceName}): Hono {`);
708
1158
  lines.push(' const app = new Hono()');
709
1159
  lines.push('');