@codewithagents/openapi-server 1.7.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.
@@ -1,155 +1,8 @@
1
- import { toTypeName } from 'openapi-zod-ts';
2
- const SUPPORTED_METHODS = ['get', 'post', 'put', 'patch', 'delete'];
3
- // ── Helpers ───────────────────────────────────────────────────────────────────
4
- function isRef(obj) {
5
- return typeof obj === 'object' && obj !== null && '$ref' in obj;
6
- }
7
- function refToName(ref) {
8
- const parts = ref.split('/');
9
- return toTypeName(parts[parts.length - 1]);
10
- }
11
- function extractPathParamsFromPath(path) {
12
- const matches = path.match(/\{([^}]+)\}/g);
13
- if (matches === null)
14
- return [];
15
- // Keep raw param names: they are used in c.req.param() which must match
16
- // the actual Hono route pattern (e.g. :job-id requires c.req.param('job-id'))
17
- return matches.map((m) => m.slice(1, -1));
18
- }
1
+ import { SUPPORTED_METHODS, isRef, extractPathParamsFromPath, resolveParam, deriveServiceName, deriveMethodName, getQueryParams, getBodyInfo, } from './shared.js';
19
2
  /** Convert OpenAPI path to Hono path: {id} -> :id */
20
3
  function toHonoPath(openapiPath) {
21
4
  return openapiPath.replace(/\{([^}]+)\}/g, ':$1');
22
5
  }
23
- function resolveParam(p, spec) {
24
- if (!isRef(p))
25
- return p;
26
- const refStr = p.$ref;
27
- const name = refToName(refStr);
28
- const components = spec.components;
29
- if (components?.parameters === undefined)
30
- return undefined;
31
- const resolved = components.parameters[name];
32
- if (resolved === undefined || isRef(resolved))
33
- return undefined;
34
- return resolved;
35
- }
36
- function deriveServiceName(spec) {
37
- const title = spec.info?.title ?? '';
38
- const pascal = title
39
- .replace(/[^a-zA-Z0-9 ]/g, '')
40
- .split(/\s+/)
41
- .filter((s) => s.length > 0)
42
- .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
43
- .join('');
44
- if (pascal.length === 0)
45
- return 'ApiService';
46
- // Guard against numeric-start identifiers (e.g. '1Password Connect' -> '_1PasswordConnect')
47
- const safePascal = /^[0-9]/.test(pascal) ? `_${pascal}` : pascal;
48
- if (safePascal.endsWith('Service'))
49
- return safePascal;
50
- return `${safePascal}Service`;
51
- }
52
- /**
53
- * Converts a raw operationId into a valid camelCase JS identifier.
54
- * Handles kebab-case, snake_case, dots, spaces, parens, braces and other
55
- * non-alphanumeric separators found in real-world OpenAPI specs.
56
- * e.g. "post-applePay-sessions" -> "postApplePaySessions"
57
- * e.g. "calendar.calendars.insert" -> "calendarCalendarsInsert"
58
- * e.g. "Get User Profile" -> "getUserProfile"
59
- * e.g. "forgotPassword(oneTimeCode)" -> "forgotPasswordOneTimeCode"
60
- */
61
- function sanitizeOperationId(id) {
62
- const parts = id
63
- .replace(/'/g, '') // strip apostrophes without splitting ("user's" -> "users")
64
- .split(/[^a-zA-Z0-9]+/) // split on any non-alphanumeric sequence
65
- .filter(Boolean);
66
- if (parts.length === 0)
67
- return 'unknown';
68
- const [first = '', ...rest] = parts;
69
- const camel = first.charAt(0).toLowerCase() +
70
- first.slice(1) +
71
- rest.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('');
72
- // If result starts with a digit, prefix with underscore
73
- return /^[0-9]/.test(camel) ? `_${camel}` : camel;
74
- }
75
- function deriveMethodName(operationId, method, path) {
76
- if (operationId !== undefined && operationId.length > 0) {
77
- return sanitizeOperationId(operationId);
78
- }
79
- return deriveOperationName(method, path);
80
- }
81
- function deriveOperationName(method, path) {
82
- const prefixMap = {
83
- get: 'get',
84
- post: 'create',
85
- put: 'update',
86
- patch: 'patch',
87
- delete: 'delete',
88
- };
89
- const prefix = prefixMap[method] ?? method;
90
- const segments = path.replace(/^\/api\/v\d+\//, '').replace(/^\//, '');
91
- const parts = segments.split('/').map((seg) => {
92
- // Handle mixed segments like "{maxLat}.{format}": extract each {param} inside
93
- const paramMatches = seg.match(/\{([^}]+)\}/g);
94
- if (paramMatches !== null && !(seg.startsWith('{') && seg.endsWith('}'))) {
95
- return paramMatches
96
- .map((m) => {
97
- const name = sanitizeOperationId(m.slice(1, -1));
98
- return 'By' + name.charAt(0).toUpperCase() + name.slice(1);
99
- })
100
- .join('');
101
- }
102
- if (seg.startsWith('{') && seg.endsWith('}')) {
103
- const name = seg.slice(1, -1);
104
- const sanitized = sanitizeOperationId(name);
105
- return 'By' + sanitized.charAt(0).toUpperCase() + sanitized.slice(1);
106
- }
107
- return toTypeName(seg);
108
- });
109
- return prefix + parts.join('');
110
- }
111
- /** Normalize a raw query param name to a valid TypeScript identifier.
112
- * Strips trailing [] (array marker), converts separators to camelCase.
113
- */
114
- function normalizeParamName(name) {
115
- // Split on non-alphanumeric sequences to avoid polynomial ReDoS from [^x]+y patterns.
116
- const stripped = name.replace(/\[\]$/, '').replace(/'/g, '');
117
- const parts = stripped.split(/[^a-zA-Z0-9]+/).filter(Boolean);
118
- if (parts.length === 0)
119
- return '_';
120
- const camel = parts
121
- .map((part, i) => (i === 0 ? part : part[0].toUpperCase() + part.slice(1)))
122
- .join('');
123
- return /^[^a-zA-Z_$]/.test(camel) ? `_${camel}` : camel;
124
- }
125
- function schemaToTsType(schema) {
126
- if (schema === undefined || isRef(schema))
127
- return 'string';
128
- const s = schema;
129
- if (s.type === 'number' || s.type === 'integer')
130
- return 'number';
131
- if (s.type === 'boolean')
132
- return 'boolean';
133
- return 'string';
134
- }
135
- function getQueryParams(operation, spec) {
136
- const parameters = operation.parameters;
137
- if (parameters === undefined)
138
- return [];
139
- const result = [];
140
- for (const p of parameters) {
141
- const resolved = resolveParam(p, spec);
142
- if (resolved === undefined || resolved.in !== 'query')
143
- continue;
144
- const schema = resolved.schema;
145
- result.push({
146
- name: normalizeParamName(resolved.name),
147
- tsType: schemaToTsType(schema),
148
- required: resolved.required === true,
149
- });
150
- }
151
- return result;
152
- }
153
6
  /**
154
7
  * Map a schema format string to a Zod chain modifier.
155
8
  * Returns an empty string when no specific format validation is needed.
@@ -171,13 +24,38 @@ function formatToZodModifier(format) {
171
24
  }
172
25
  /**
173
26
  * Build a Zod expression for a path parameter based on its schema.
174
- * Returns undefined when the parameter does not need format validation
175
- * (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.
176
33
  */
177
34
  function pathParamZodExpr(schema) {
178
35
  if (schema === undefined || isRef(schema))
179
36
  return undefined;
180
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
181
59
  if (s.type !== 'string')
182
60
  return undefined;
183
61
  const format = s.format;
@@ -188,33 +66,102 @@ function pathParamZodExpr(schema) {
188
66
  return undefined;
189
67
  return `z.string()${modifier}`;
190
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
+ }
191
117
  /**
192
- * 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.
193
119
  * Number/integer types use z.number() (after coercion by extraction code).
194
- * 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.
195
123
  * Appends .optional() for non-required params.
196
124
  */
197
- function paramZodExpr(tsType, required, schema) {
125
+ function queryParamZodExpr(param) {
198
126
  let base;
199
- if (tsType === 'number') {
200
- 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);
132
+ }
133
+ else if (param.tsType === 'number') {
134
+ base = queryParamNumberZodBase(param);
201
135
  }
202
- else if (tsType === 'boolean') {
136
+ else if (param.tsType === 'boolean') {
203
137
  base = 'z.boolean()';
204
138
  }
205
139
  else {
206
- // string
207
- if (schema !== undefined && !isRef(schema)) {
208
- const s = schema;
209
- const format = s.format;
210
- const modifier = format !== undefined ? formatToZodModifier(format) : '';
211
- base = `z.string()${modifier}`;
212
- }
213
- else {
214
- base = 'z.string()';
215
- }
140
+ base = queryParamStringZodBase(param);
216
141
  }
217
- 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()`;
218
165
  }
219
166
  /**
220
167
  * Collect path parameters that have Zod format constraints.
@@ -245,7 +192,7 @@ function getPathParamValidations(operation, spec, rawPathParamNames) {
245
192
  return result;
246
193
  }
247
194
  /**
248
- * Collect header parameters from an operation.
195
+ * Collect header parameters from an operation, including schema constraints.
249
196
  */
250
197
  function getHeaderParams(operation, spec) {
251
198
  const parameters = operation.parameters;
@@ -256,19 +203,60 @@ function getHeaderParams(operation, spec) {
256
203
  const resolved = resolveParam(p, spec);
257
204
  if (resolved === undefined || resolved.in !== 'header')
258
205
  continue;
259
- result.push({
206
+ const param = {
260
207
  rawName: resolved.name,
261
208
  required: resolved.required === true,
262
- });
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);
263
223
  }
264
224
  return result;
265
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
+ }
266
245
  /**
267
246
  * Determine whether query params need a Zod validation block.
268
- * 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.).
269
249
  */
270
250
  function queryParamsNeedValidation(queryParams) {
271
- 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 ',';
272
260
  }
273
261
  /**
274
262
  * Emit Zod validation lines for query parameters into the handler line buffer.
@@ -281,7 +269,7 @@ function emitQueryValidation(lines, queryParams, indent) {
281
269
  const fieldIndent = `${indent} `;
282
270
  const fields = queryParams
283
271
  .map((q) => {
284
- const expr = paramZodExpr(q.tsType, q.required);
272
+ const expr = queryParamZodExpr(q);
285
273
  return `${fieldIndent}${q.name}: ${expr}`;
286
274
  })
287
275
  .join(',\n');
@@ -342,7 +330,7 @@ function emitHeaderValidation(lines, headerParams, indent, framework) {
342
330
  const schemaFields = headerParams
343
331
  .map((h) => {
344
332
  const key = JSON.stringify(h.rawName);
345
- const expr = h.required ? 'z.string()' : 'z.string().optional()';
333
+ const expr = headerParamZodExpr(h);
346
334
  return `${fieldIndent}${key}: ${expr}`;
347
335
  })
348
336
  .join(',\n');
@@ -369,25 +357,6 @@ function emitHeaderValidation(lines, headerParams, indent, framework) {
369
357
  lines.push(rawFields);
370
358
  lines.push(`${inner}})`);
371
359
  }
372
- function getBodyInfo(operation) {
373
- const requestBody = operation.requestBody;
374
- if (requestBody === undefined)
375
- return undefined;
376
- if (isRef(requestBody))
377
- return { typeName: undefined };
378
- const rb = requestBody;
379
- const content = rb.content;
380
- if (content === undefined)
381
- return { typeName: undefined };
382
- const jsonContent = content['application/json'];
383
- if (jsonContent === undefined || jsonContent.schema === undefined)
384
- return { typeName: undefined };
385
- const schema = jsonContent.schema;
386
- if (isRef(schema)) {
387
- return { typeName: refToName(schema.$ref) };
388
- }
389
- return { typeName: undefined };
390
- }
391
360
  function response200IsVoid(resp) {
392
361
  if (isRef(resp))
393
362
  return false;
@@ -395,22 +364,84 @@ function response200IsVoid(resp) {
395
364
  const content = r.content;
396
365
  return content === undefined || Object.keys(content).length === 0;
397
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
+ }
398
385
  function getResponseStatus(operation, httpMethod) {
399
386
  const responses = operation.responses;
400
387
  if (responses === undefined) {
401
- 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' };
402
416
  }
403
- if (responses['201'] !== undefined)
404
- return { status: 201, isVoid: false };
405
- if (responses['204'] !== undefined)
406
- return { status: 204, isVoid: true };
407
417
  if (responses['200'] !== undefined) {
408
- if (response200IsVoid(responses['200']))
409
- return { status: 204, isVoid: true };
410
- 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) };
411
440
  }
412
441
  // Default: delete -> 204, otherwise 200
413
- 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' };
414
445
  }
415
446
  function collectOperations(spec) {
416
447
  const paths = spec.paths;
@@ -445,12 +476,16 @@ function collectOperations(spec) {
445
476
  }
446
477
  return operations;
447
478
  }
448
- /** 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
+ */
449
483
  function collectSortedBodyTypes(operations) {
450
484
  const bodyTypes = new Set();
451
485
  for (const op of operations) {
452
- if (op.bodyInfo?.typeName !== undefined)
486
+ if (op.bodyInfo?.typeName !== undefined && !op.bodyInfo.isSynthesized) {
453
487
  bodyTypes.add(op.bodyInfo.typeName);
488
+ }
454
489
  }
455
490
  return Array.from(bodyTypes).sort();
456
491
  }
@@ -494,8 +529,30 @@ function buildRouteHandler(op, indent, schemaNames) {
494
529
  }
495
530
  // Query params extraction
496
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
+ }
497
545
  const fields = op.queryParams
498
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
+ }
499
556
  if (q.tsType === 'number') {
500
557
  return ` ${q.name}: c.req.query('${q.name}') !== undefined ? Number(c.req.query('${q.name}')) : undefined`;
501
558
  }
@@ -523,8 +580,43 @@ function buildRouteHandler(op, indent, schemaNames) {
523
580
  // Body extraction
524
581
  let bodyVarName = 'body';
525
582
  if (op.bodyInfo !== undefined) {
526
- const typeAnnotation = op.bodyInfo.typeName !== undefined ? `<${op.bodyInfo.typeName}>` : '';
527
- 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
+ }
528
620
  // Zod validation when schema is available
529
621
  const schemaName = op.bodyInfo.typeName !== undefined ? `${op.bodyInfo.typeName}Schema` : undefined;
530
622
  if (schemaName !== undefined && schemaNames !== undefined && schemaNames.has(schemaName)) {
@@ -549,17 +641,47 @@ function buildRouteHandler(op, indent, schemaNames) {
549
641
  serviceArgs.push('params');
550
642
  }
551
643
  const serviceCall = `service.${op.methodName}(${serviceArgs.join(', ')})`;
552
- // Response
644
+ // Response — wrap in try/catch to map HttpError to its status
645
+ lines.push(`${indent} try {`);
553
646
  if (op.responseStatus.isVoid) {
554
- lines.push(`${indent} await ${serviceCall}`);
555
- 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} })`);
649
+ }
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
+ }
556
672
  }
557
- else if (op.responseStatus.status === 201) {
558
- lines.push(`${indent} return c.json(await ${serviceCall}, 201)`);
673
+ else if (op.responseStatus.status === 200) {
674
+ lines.push(`${indent} return c.json(await ${serviceCall})`);
559
675
  }
560
676
  else {
561
- lines.push(`${indent} return c.json(await ${serviceCall})`);
677
+ lines.push(`${indent} return c.json(await ${serviceCall}, ${op.responseStatus.status})`);
562
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} }`);
563
685
  lines.push(`${indent}})`);
564
686
  return lines.join('\n');
565
687
  }
@@ -577,8 +699,19 @@ function buildExpressRouteHandler(op, indent, schemaNames) {
577
699
  }
578
700
  // Query params extraction
579
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.
580
705
  const fields = op.queryParams
581
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
+ }
582
715
  if (q.tsType === 'number') {
583
716
  return ` ${q.name}: Number(req.query['${q.name}'] as string)`;
584
717
  }
@@ -606,23 +739,38 @@ function buildExpressRouteHandler(op, indent, schemaNames) {
606
739
  lines.push(`${indent} return void res.status(422).json({ error: 'Invalid request headers', issues: _hv.error.issues })`);
607
740
  lines.push(`${indent} }`);
608
741
  }
609
- // 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.
610
747
  let bodyVarName = 'body';
611
748
  if (op.bodyInfo !== undefined) {
612
- const schemaName = op.bodyInfo.typeName !== undefined ? `${op.bodyInfo.typeName}Schema` : undefined;
613
- const useZod = schemaName !== undefined && schemaNames !== undefined && schemaNames.has(schemaName);
614
- if (useZod) {
615
- lines.push(`${indent} // Validate request body: returns 422 with Zod issues on failure`);
616
- lines.push(`${indent} const parseResult = ${schemaName}.safeParse(req.body)`);
617
- lines.push(`${indent} if (!parseResult.success) {`);
618
- lines.push(`${indent} return void res.status(422).json({ error: 'Invalid request body', issues: parseResult.error.issues })`);
619
- lines.push(`${indent} }`);
620
- lines.push(`${indent} const validatedBody = parseResult.data`);
621
- 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`);
622
754
  }
623
755
  else {
624
- const typeAnnotation = op.bodyInfo.typeName !== undefined ? ` as ${op.bodyInfo.typeName}` : '';
625
- 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
+ }
626
774
  }
627
775
  }
628
776
  // Build service call args
@@ -637,17 +785,45 @@ function buildExpressRouteHandler(op, indent, schemaNames) {
637
785
  serviceArgs.push('params');
638
786
  }
639
787
  const serviceCall = `service.${op.methodName}(${serviceArgs.join(', ')})`;
640
- // Response
788
+ // Response — wrap in try/catch to map HttpError to its status
789
+ lines.push(`${indent} try {`);
641
790
  if (op.responseStatus.isVoid) {
642
- lines.push(`${indent} await ${serviceCall}`);
643
- 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()`);
644
793
  }
645
- else if (op.responseStatus.status === 201) {
646
- 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})`);
647
817
  }
648
818
  else {
649
- lines.push(`${indent} res.json(await ${serviceCall})`);
819
+ lines.push(`${indent} res.status(${op.responseStatus.status}).json(await ${serviceCall})`);
650
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} }`);
651
827
  lines.push(`${indent}})`);
652
828
  return lines.join('\n');
653
829
  }
@@ -658,18 +834,30 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
658
834
  // Build generic type argument
659
835
  const genericParts = [];
660
836
  if (op.queryParams.length > 0) {
661
- const queryFields = op.queryParams
662
- .map((q) => {
663
- if (q.tsType === 'number')
664
- return `${q.name}?: number`;
665
- if (q.tsType === 'boolean')
666
- return `${q.name}?: boolean`;
667
- return `${q.name}?: string`;
668
- })
669
- .join('; ');
670
- 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}`);
671
859
  }
672
- if (op.bodyInfo !== undefined && op.bodyInfo.typeName !== undefined) {
860
+ if (op.bodyInfo !== undefined && op.bodyInfo.typeName !== undefined && !op.bodyInfo.isSynthesized) {
673
861
  genericParts.push(`Body: ${op.bodyInfo.typeName}`);
674
862
  }
675
863
  else if (op.bodyInfo !== undefined) {
@@ -693,7 +881,39 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
693
881
  }
694
882
  // Query params extraction
695
883
  if (op.queryParams.length > 0) {
696
- 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');
697
917
  lines.push(`${indent} const params = {`);
698
918
  lines.push(fields);
699
919
  lines.push(`${indent} }`);
@@ -718,18 +938,29 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
718
938
  lines.push(`${indent} })`);
719
939
  lines.push(`${indent} }`);
720
940
  }
721
- // 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.
722
945
  let bodyVarName = 'req.body';
723
946
  if (op.bodyInfo !== undefined) {
724
- const schemaName = op.bodyInfo.typeName !== undefined ? `${op.bodyInfo.typeName}Schema` : undefined;
725
- const useZod = schemaName !== undefined && schemaNames !== undefined && schemaNames.has(schemaName);
726
- if (useZod) {
727
- lines.push(`${indent} // Validate request body: returns 422 with Zod issues on failure`);
728
- lines.push(`${indent} const parseResult = ${schemaName}.safeParse(req.body)`);
729
- lines.push(`${indent} if (!parseResult.success) {`);
730
- lines.push(`${indent} return reply.status(422).send({ error: 'Invalid request body', issues: parseResult.error.issues })`);
731
- lines.push(`${indent} }`);
732
- 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
+ }
733
964
  }
734
965
  }
735
966
  // Build service call args
@@ -744,21 +975,65 @@ function buildFastifyRouteHandler(op, indent, schemaNames) {
744
975
  serviceArgs.push('params');
745
976
  }
746
977
  const serviceCall = `service.${op.methodName}(${serviceArgs.join(', ')})`;
747
- // Response
978
+ // Response — wrap in try/catch to map HttpError to its status
979
+ lines.push(`${indent} try {`);
748
980
  if (op.responseStatus.isVoid) {
749
- lines.push(`${indent} await ${serviceCall}`);
750
- 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()`);
983
+ }
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)`);
751
988
  }
752
- else if (op.responseStatus.status === 201) {
753
- lines.push(`${indent} reply.status(201)`);
754
- lines.push(`${indent} return ${serviceCall}`);
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}`);
755
1007
  }
756
1008
  else {
757
- lines.push(`${indent} return ${serviceCall}`);
1009
+ lines.push(`${indent} reply.status(${op.responseStatus.status})`);
1010
+ lines.push(`${indent} return ${serviceCall}`);
758
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} }`);
759
1018
  lines.push(`${indent}})`);
760
1019
  return lines.join('\n');
761
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
+ }
762
1037
  // ── Zod import helpers ────────────────────────────────────────────────────────
763
1038
  /**
764
1039
  * Returns true when any operation in the list generates param validation code
@@ -799,6 +1074,9 @@ export function generateExpressRouter(spec, options) {
799
1074
  lines.push(`import { ${sortedUsedSchemas.join(', ')} } from '${options.schemaImportPath}'`);
800
1075
  }
801
1076
  lines.push('');
1077
+ for (const l of httpErrorClassLines())
1078
+ lines.push(l);
1079
+ lines.push('');
802
1080
  lines.push(`export function createRouter(service: ${serviceName}): Router {`);
803
1081
  lines.push(' const router = Router()');
804
1082
  lines.push('');
@@ -836,6 +1114,9 @@ export function generateFastifyRouter(spec, options) {
836
1114
  lines.push(`import { ${sortedUsedSchemas.join(', ')} } from '${options.schemaImportPath}'`);
837
1115
  }
838
1116
  lines.push('');
1117
+ for (const l of httpErrorClassLines())
1118
+ lines.push(l);
1119
+ lines.push('');
839
1120
  lines.push(`export function createRouter(app: FastifyInstance, service: ${serviceName}): void {`);
840
1121
  for (const op of operations) {
841
1122
  lines.push('');
@@ -870,6 +1151,9 @@ export function generateRouter(spec, options) {
870
1151
  lines.push(`import { ${sortedUsedSchemas.join(', ')} } from '${options.schemaImportPath}'`);
871
1152
  }
872
1153
  lines.push('');
1154
+ for (const l of httpErrorClassLines())
1155
+ lines.push(l);
1156
+ lines.push('');
873
1157
  lines.push(`export function createRouter(service: ${serviceName}): Hono {`);
874
1158
  lines.push(' const app = new Hono()');
875
1159
  lines.push('');