@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.
- package/dist/cli.cjs +755 -350
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +35 -21
- package/dist/config.js.map +1 -1
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js +58 -63
- package/dist/generator.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/router.d.ts.map +1 -1
- package/dist/plugins/router.js +544 -260
- package/dist/plugins/router.js.map +1 -1
- package/dist/plugins/service.d.ts.map +1 -1
- package/dist/plugins/service.js +68 -193
- package/dist/plugins/service.js.map +1 -1
- package/dist/plugins/shared.d.ts +90 -0
- package/dist/plugins/shared.d.ts.map +1 -0
- package/dist/plugins/shared.js +290 -0
- package/dist/plugins/shared.js.map +1 -0
- package/package.json +2 -2
package/dist/plugins/router.js
CHANGED
|
@@ -1,155 +1,8 @@
|
|
|
1
|
-
import {
|
|
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
|
|
175
|
-
*
|
|
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
|
|
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()
|
|
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
|
|
125
|
+
function queryParamZodExpr(param) {
|
|
198
126
|
let base;
|
|
199
|
-
if (
|
|
200
|
-
base =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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'
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
527
|
-
|
|
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}
|
|
555
|
-
lines.push(`${indent}
|
|
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 ===
|
|
558
|
-
lines.push(`${indent}
|
|
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}
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
lines.push(`${indent} //
|
|
616
|
-
lines.push(`${indent} const
|
|
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
|
|
625
|
-
|
|
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}
|
|
643
|
-
lines.push(`${indent}
|
|
791
|
+
lines.push(`${indent} await ${serviceCall}`);
|
|
792
|
+
lines.push(`${indent} res.status(${op.responseStatus.status}).end()`);
|
|
644
793
|
}
|
|
645
|
-
else if (op.responseStatus.
|
|
646
|
-
|
|
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}
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
lines.push(`${indent} //
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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}
|
|
750
|
-
lines.push(`${indent}
|
|
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.
|
|
753
|
-
|
|
754
|
-
|
|
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}
|
|
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('');
|