ts-typed-api 0.1.21 → 0.1.23
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/README_HONO_ADAPTER.md +136 -0
- package/dist/hono-cloudflare-workers.d.ts +33 -0
- package/dist/hono-cloudflare-workers.js +474 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -1
- package/dist/openapi-self.js +39 -2
- package/examples/hono-cloudflare-worker-example.ts +66 -0
- package/examples/test-hono-server.ts +125 -0
- package/package.json +3 -2
- package/src/hono-cloudflare-workers.ts +552 -0
- package/src/index.ts +3 -0
- package/src/openapi-self.ts +42 -2
- package/tests/advanced-api.test.ts +11 -4
- package/tests/openapi-spec.test.ts +130 -0
- package/tests/setup.ts +324 -176
- package/tests/simple-api.test.ts +33 -3
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { Hono, Context, MiddlewareHandler } from 'hono';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ApiDefinitionSchema, RouteSchema, UnifiedError, FileUploadConfig } from './definition';
|
|
4
|
+
import { TypedRequest, TypedResponse } from './router';
|
|
5
|
+
import { SpecificRouteHandler } from './handler';
|
|
6
|
+
import { ObjectHandlers, AnyMiddleware, EndpointMiddleware, SimpleMiddleware } from './object-handlers';
|
|
7
|
+
|
|
8
|
+
// Hono-specific file type for Cloudflare Workers
|
|
9
|
+
export type HonoFile = File;
|
|
10
|
+
|
|
11
|
+
// Hono-compatible file schema for Workers environment
|
|
12
|
+
export const honoFileSchema = z.object({
|
|
13
|
+
fieldname: z.string(),
|
|
14
|
+
originalname: z.string(),
|
|
15
|
+
encoding: z.string(),
|
|
16
|
+
mimetype: z.string(),
|
|
17
|
+
size: z.number(),
|
|
18
|
+
buffer: z.instanceof(Uint8Array).optional(), // Workers use Uint8Array instead of Buffer
|
|
19
|
+
file: z.instanceof(File).optional(), // Direct File object access
|
|
20
|
+
destination: z.string().optional(),
|
|
21
|
+
filename: z.string().optional(),
|
|
22
|
+
path: z.string().optional(),
|
|
23
|
+
stream: z.any().optional(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type HonoFileType = z.infer<typeof honoFileSchema>;
|
|
27
|
+
|
|
28
|
+
// Typed Hono Context that matches our Express-like API
|
|
29
|
+
export type HonoTypedContext<
|
|
30
|
+
TDef extends ApiDefinitionSchema,
|
|
31
|
+
TDomain extends keyof TDef['endpoints'],
|
|
32
|
+
TRouteKey extends keyof TDef['endpoints'][TDomain]
|
|
33
|
+
> = Context & {
|
|
34
|
+
// Add typed request properties
|
|
35
|
+
params: TypedRequest<TDef, TDomain, TRouteKey>['params'];
|
|
36
|
+
query: TypedRequest<TDef, TDomain, TRouteKey>['query'];
|
|
37
|
+
body: TypedRequest<TDef, TDomain, TRouteKey>['body'];
|
|
38
|
+
file?: HonoFile;
|
|
39
|
+
files?: HonoFile[] | { [fieldname: string]: HonoFile[] };
|
|
40
|
+
|
|
41
|
+
// Add typed response method
|
|
42
|
+
respond: TypedResponse<TDef, TDomain, TRouteKey>['respond'];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Helper function to preprocess query parameters for type coercion
|
|
46
|
+
function preprocessQueryParams(query: any, querySchema?: z.ZodTypeAny): any {
|
|
47
|
+
if (!querySchema || !query) return query;
|
|
48
|
+
|
|
49
|
+
const processedQuery = { ...query };
|
|
50
|
+
|
|
51
|
+
if (querySchema instanceof z.ZodObject) {
|
|
52
|
+
const shape = querySchema.shape;
|
|
53
|
+
|
|
54
|
+
for (const [key, value] of Object.entries(processedQuery)) {
|
|
55
|
+
if (typeof value === 'string' && shape[key]) {
|
|
56
|
+
const fieldSchema = shape[key];
|
|
57
|
+
|
|
58
|
+
let innerSchema = fieldSchema;
|
|
59
|
+
if (fieldSchema instanceof z.ZodOptional) {
|
|
60
|
+
innerSchema = fieldSchema._def.innerType;
|
|
61
|
+
}
|
|
62
|
+
if (fieldSchema instanceof z.ZodDefault) {
|
|
63
|
+
innerSchema = fieldSchema._def.innerType;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
while (innerSchema instanceof z.ZodOptional || innerSchema instanceof z.ZodDefault) {
|
|
67
|
+
if (innerSchema instanceof z.ZodOptional) {
|
|
68
|
+
innerSchema = innerSchema._def.innerType;
|
|
69
|
+
} else if (innerSchema instanceof z.ZodDefault) {
|
|
70
|
+
innerSchema = innerSchema._def.innerType;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (innerSchema instanceof z.ZodNumber) {
|
|
75
|
+
const numValue = Number(value);
|
|
76
|
+
if (!isNaN(numValue)) {
|
|
77
|
+
processedQuery[key] = numValue;
|
|
78
|
+
}
|
|
79
|
+
} else if (innerSchema instanceof z.ZodBoolean) {
|
|
80
|
+
if (value === 'true') {
|
|
81
|
+
processedQuery[key] = true;
|
|
82
|
+
} else if (value === 'false') {
|
|
83
|
+
processedQuery[key] = false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return processedQuery;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Helper function to create file upload middleware for Hono/Workers
|
|
94
|
+
function createHonoFileUploadMiddleware(config: FileUploadConfig): MiddlewareHandler {
|
|
95
|
+
return async (c: any, next: any) => {
|
|
96
|
+
try {
|
|
97
|
+
if (config.single) {
|
|
98
|
+
const formData = await c.req.parseBody({ all: false });
|
|
99
|
+
const file = formData[config.single.fieldName];
|
|
100
|
+
|
|
101
|
+
if (file instanceof File) {
|
|
102
|
+
// Validate file
|
|
103
|
+
if (config.single.maxSize && file.size > config.single.maxSize) {
|
|
104
|
+
return c.json({
|
|
105
|
+
data: null,
|
|
106
|
+
error: [{ field: 'file', message: `File size exceeds ${config.single.maxSize} bytes`, type: 'body' }]
|
|
107
|
+
}, 422);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (config.single.allowedMimeTypes && !config.single.allowedMimeTypes.includes(file.type)) {
|
|
111
|
+
return c.json({
|
|
112
|
+
data: null,
|
|
113
|
+
error: [{ field: 'file', message: `File type ${file.type} not allowed`, type: 'body' }]
|
|
114
|
+
}, 422);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Attach file to context
|
|
118
|
+
(c as any).file = {
|
|
119
|
+
fieldname: config.single.fieldName,
|
|
120
|
+
originalname: file.name,
|
|
121
|
+
encoding: '7bit',
|
|
122
|
+
mimetype: file.type,
|
|
123
|
+
size: file.size,
|
|
124
|
+
buffer: new Uint8Array(await file.arrayBuffer()),
|
|
125
|
+
file: file
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
} else if (config.array) {
|
|
129
|
+
const formData = await c.req.parseBody({ all: true });
|
|
130
|
+
const files = formData[config.array.fieldName];
|
|
131
|
+
|
|
132
|
+
if (Array.isArray(files)) {
|
|
133
|
+
// Validate files
|
|
134
|
+
if (config.array.maxCount && files.length > config.array.maxCount) {
|
|
135
|
+
return c.json({
|
|
136
|
+
data: null,
|
|
137
|
+
error: [{ field: 'file', message: `Maximum ${config.array.maxCount} files allowed`, type: 'body' }]
|
|
138
|
+
}, 422);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const processedFiles = [];
|
|
142
|
+
for (const file of files) {
|
|
143
|
+
if (file instanceof File) {
|
|
144
|
+
if (config.array.maxSize && file.size > config.array.maxSize) {
|
|
145
|
+
return c.json({
|
|
146
|
+
data: null,
|
|
147
|
+
error: [{ field: 'file', message: `File size exceeds ${config.array.maxSize} bytes`, type: 'body' }]
|
|
148
|
+
}, 422);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (config.array.allowedMimeTypes && !config.array.allowedMimeTypes.includes(file.type)) {
|
|
152
|
+
return c.json({
|
|
153
|
+
data: null,
|
|
154
|
+
error: [{ field: 'file', message: `File type ${file.type} not allowed`, type: 'body' }]
|
|
155
|
+
}, 422);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
processedFiles.push({
|
|
159
|
+
fieldname: config.array.fieldName,
|
|
160
|
+
originalname: file.name,
|
|
161
|
+
encoding: '7bit',
|
|
162
|
+
mimetype: file.type,
|
|
163
|
+
size: file.size,
|
|
164
|
+
buffer: new Uint8Array(await file.arrayBuffer()),
|
|
165
|
+
file: file
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
(c as any).files = processedFiles;
|
|
171
|
+
}
|
|
172
|
+
} else if (config.fields) {
|
|
173
|
+
const formData = await c.req.parseBody({ all: true });
|
|
174
|
+
const filesMap: { [fieldname: string]: HonoFileType[] } = {};
|
|
175
|
+
|
|
176
|
+
for (const fieldConfig of config.fields) {
|
|
177
|
+
const files = formData[fieldConfig.fieldName];
|
|
178
|
+
if (Array.isArray(files)) {
|
|
179
|
+
if (fieldConfig.maxCount && files.length > fieldConfig.maxCount) {
|
|
180
|
+
return c.json({
|
|
181
|
+
data: null,
|
|
182
|
+
error: [{ field: fieldConfig.fieldName, message: `Maximum ${fieldConfig.maxCount} files allowed`, type: 'body' }]
|
|
183
|
+
}, 422);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const processedFiles = [];
|
|
187
|
+
for (const file of files) {
|
|
188
|
+
if (file instanceof File) {
|
|
189
|
+
if (fieldConfig.maxSize && file.size > fieldConfig.maxSize) {
|
|
190
|
+
return c.json({
|
|
191
|
+
data: null,
|
|
192
|
+
error: [{ field: fieldConfig.fieldName, message: `File size exceeds ${fieldConfig.maxSize} bytes`, type: 'body' }]
|
|
193
|
+
}, 422);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (fieldConfig.allowedMimeTypes && !fieldConfig.allowedMimeTypes.includes(file.type)) {
|
|
197
|
+
return c.json({
|
|
198
|
+
data: null,
|
|
199
|
+
error: [{ field: fieldConfig.fieldName, message: `File type ${file.type} not allowed`, type: 'body' }]
|
|
200
|
+
}, 422);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
processedFiles.push({
|
|
204
|
+
fieldname: fieldConfig.fieldName,
|
|
205
|
+
originalname: file.name,
|
|
206
|
+
encoding: '7bit',
|
|
207
|
+
mimetype: file.type,
|
|
208
|
+
size: file.size,
|
|
209
|
+
buffer: new Uint8Array(await file.arrayBuffer()),
|
|
210
|
+
file: file
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
filesMap[fieldConfig.fieldName] = processedFiles;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
(c as any).files = filesMap;
|
|
220
|
+
} else if (config.any) {
|
|
221
|
+
const formData = await c.req.parseBody({ all: true });
|
|
222
|
+
|
|
223
|
+
// Process all files
|
|
224
|
+
const allFiles: HonoFileType[] = [];
|
|
225
|
+
for (const [key, value] of Object.entries(formData)) {
|
|
226
|
+
if (value instanceof File) {
|
|
227
|
+
if (config.any.maxSize && value.size > config.any.maxSize) {
|
|
228
|
+
return c.json({
|
|
229
|
+
data: null,
|
|
230
|
+
error: [{ field: key, message: `File size exceeds ${config.any.maxSize} bytes`, type: 'body' }]
|
|
231
|
+
}, 422);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (config.any.allowedMimeTypes && !config.any.allowedMimeTypes.includes(value.type)) {
|
|
235
|
+
return c.json({
|
|
236
|
+
data: null,
|
|
237
|
+
error: [{ field: key, message: `File type ${value.type} not allowed`, type: 'body' }]
|
|
238
|
+
}, 422);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
allFiles.push({
|
|
242
|
+
fieldname: key,
|
|
243
|
+
originalname: value.name,
|
|
244
|
+
encoding: '7bit',
|
|
245
|
+
mimetype: value.type,
|
|
246
|
+
size: value.size,
|
|
247
|
+
buffer: new Uint8Array(await value.arrayBuffer()),
|
|
248
|
+
file: value
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
(c as any).files = allFiles;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
await next();
|
|
257
|
+
} catch (error) {
|
|
258
|
+
console.error('File upload middleware error:', error);
|
|
259
|
+
return c.json({
|
|
260
|
+
data: null,
|
|
261
|
+
error: [{ field: 'file', message: 'File upload processing failed', type: 'body' }]
|
|
262
|
+
}, 422);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Register route handlers with Hono, now generic over TDef
|
|
268
|
+
export function registerHonoRouteHandlers<TDef extends ApiDefinitionSchema>(
|
|
269
|
+
app: Hono,
|
|
270
|
+
apiDefinition: TDef,
|
|
271
|
+
routeHandlers: Array<SpecificRouteHandler<TDef>>,
|
|
272
|
+
middlewares?: EndpointMiddleware<TDef>[]
|
|
273
|
+
) {
|
|
274
|
+
routeHandlers.forEach((specificHandlerIterationItem) => {
|
|
275
|
+
const { domain, routeKey, handler } = specificHandlerIterationItem as any;
|
|
276
|
+
|
|
277
|
+
const currentDomain = domain as string;
|
|
278
|
+
const currentRouteKey = routeKey as string;
|
|
279
|
+
|
|
280
|
+
const routeDefinition = apiDefinition.endpoints[currentDomain][currentRouteKey] as RouteSchema;
|
|
281
|
+
|
|
282
|
+
if (!routeDefinition) {
|
|
283
|
+
console.error(`Route definition not found for domain "${String(currentDomain)}" and routeKey "${String(currentRouteKey)}"`);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const { path, method } = routeDefinition;
|
|
288
|
+
|
|
289
|
+
// Apply prefix from API definition if it exists
|
|
290
|
+
const fullPath = apiDefinition.prefix
|
|
291
|
+
? `${apiDefinition.prefix.startsWith('/') ? apiDefinition.prefix : `/${apiDefinition.prefix}`}${path}`.replace(/\/+/g, '/')
|
|
292
|
+
: path;
|
|
293
|
+
|
|
294
|
+
const honoMiddleware = async (
|
|
295
|
+
c: HonoTypedContext<TDef, typeof currentDomain, typeof currentRouteKey>
|
|
296
|
+
) => {
|
|
297
|
+
try {
|
|
298
|
+
// Parse and validate request
|
|
299
|
+
const parsedParams = ('params' in routeDefinition && routeDefinition.params)
|
|
300
|
+
? (routeDefinition.params as z.ZodTypeAny).parse(c.req.param())
|
|
301
|
+
: c.req.param();
|
|
302
|
+
|
|
303
|
+
const preprocessedQuery = ('query' in routeDefinition && routeDefinition.query)
|
|
304
|
+
? preprocessQueryParams(c.req.query(), routeDefinition.query as z.ZodTypeAny)
|
|
305
|
+
: c.req.query();
|
|
306
|
+
|
|
307
|
+
const parsedQuery = ('query' in routeDefinition && routeDefinition.query)
|
|
308
|
+
? (routeDefinition.query as z.ZodTypeAny).parse(preprocessedQuery)
|
|
309
|
+
: preprocessedQuery;
|
|
310
|
+
|
|
311
|
+
let parsedBody: any = undefined;
|
|
312
|
+
if (method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH') {
|
|
313
|
+
if ('body' in routeDefinition && routeDefinition.body) {
|
|
314
|
+
// For JSON requests
|
|
315
|
+
if (c.req.header('content-type')?.includes('application/json')) {
|
|
316
|
+
parsedBody = (routeDefinition.body as z.ZodTypeAny).parse(await c.req.json());
|
|
317
|
+
} else {
|
|
318
|
+
// For form data or other body types
|
|
319
|
+
parsedBody = (routeDefinition.body as z.ZodTypeAny).parse(await c.req.parseBody());
|
|
320
|
+
}
|
|
321
|
+
} else {
|
|
322
|
+
parsedBody = await c.req.parseBody();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Attach parsed data to context
|
|
327
|
+
(c as any).params = parsedParams;
|
|
328
|
+
(c as any).query = parsedQuery;
|
|
329
|
+
(c as any).body = parsedBody;
|
|
330
|
+
|
|
331
|
+
// Add respond method to context
|
|
332
|
+
(c as any).respond = (status: number, data: any) => {
|
|
333
|
+
const responseSchema = routeDefinition.responses[status];
|
|
334
|
+
|
|
335
|
+
if (!responseSchema) {
|
|
336
|
+
console.error(`No response schema defined for status ${status} in route ${String(currentDomain)}/${String(currentRouteKey)}`);
|
|
337
|
+
(c as any).__response = c.json({
|
|
338
|
+
data: null,
|
|
339
|
+
error: [{ field: "general", type: "general", message: "Internal server error: Undefined response schema for status." }]
|
|
340
|
+
}, 500);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let responseBody: any;
|
|
345
|
+
|
|
346
|
+
if (status === 422) {
|
|
347
|
+
responseBody = {
|
|
348
|
+
data: null,
|
|
349
|
+
error: data
|
|
350
|
+
};
|
|
351
|
+
} else {
|
|
352
|
+
// Always use unified response format since CreateResponses wraps all schemas
|
|
353
|
+
responseBody = {
|
|
354
|
+
data: data,
|
|
355
|
+
error: null
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const validationResult = responseSchema.safeParse(responseBody);
|
|
360
|
+
|
|
361
|
+
if (validationResult.success) {
|
|
362
|
+
// Handle 204 responses specially - they must not have a body
|
|
363
|
+
if (status === 204) {
|
|
364
|
+
(c as any).__response = new Response(null, { status: status as any });
|
|
365
|
+
} else {
|
|
366
|
+
(c as any).__response = c.json(validationResult.data, status as any);
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
console.error(
|
|
370
|
+
`FATAL: Constructed response body failed Zod validation for status ${status} in route ${String(currentDomain)}/${String(currentRouteKey)}.`,
|
|
371
|
+
validationResult.error.issues,
|
|
372
|
+
'Expected schema shape:', (responseSchema._def as any)?.shape,
|
|
373
|
+
'Provided data:', data,
|
|
374
|
+
'Constructed response body:', responseBody
|
|
375
|
+
);
|
|
376
|
+
(c as any).__response = c.json({
|
|
377
|
+
data: null,
|
|
378
|
+
error: [{ field: "general", type: "general", message: "Internal server error: Constructed response failed validation." }]
|
|
379
|
+
}, 500);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// Create Express-like req/res objects for handler compatibility
|
|
384
|
+
const fakeReq = {
|
|
385
|
+
params: parsedParams,
|
|
386
|
+
query: parsedQuery,
|
|
387
|
+
body: parsedBody,
|
|
388
|
+
file: (c as any).file,
|
|
389
|
+
files: (c as any).files,
|
|
390
|
+
headers: c.req.header(),
|
|
391
|
+
ip: c.req.header('CF-Connecting-IP') || '127.0.0.1',
|
|
392
|
+
method: c.req.method,
|
|
393
|
+
path: c.req.path,
|
|
394
|
+
originalUrl: c.req.url
|
|
395
|
+
} as TypedRequest<TDef, typeof currentDomain, typeof currentRouteKey>;
|
|
396
|
+
|
|
397
|
+
const fakeRes = {
|
|
398
|
+
respond: (c as any).respond
|
|
399
|
+
} as TypedResponse<TDef, typeof currentDomain, typeof currentRouteKey>;
|
|
400
|
+
|
|
401
|
+
const specificHandlerFn = handler as (
|
|
402
|
+
req: TypedRequest<TDef, typeof currentDomain, typeof currentRouteKey>,
|
|
403
|
+
res: TypedResponse<TDef, typeof currentDomain, typeof currentRouteKey>
|
|
404
|
+
) => Promise<void> | void;
|
|
405
|
+
|
|
406
|
+
await specificHandlerFn(fakeReq, fakeRes);
|
|
407
|
+
|
|
408
|
+
// Return the response created by the handler
|
|
409
|
+
if ((c as any).__response) {
|
|
410
|
+
return (c as any).__response;
|
|
411
|
+
} else {
|
|
412
|
+
console.error('No response was set by the handler');
|
|
413
|
+
return c.json({
|
|
414
|
+
data: null,
|
|
415
|
+
error: [{ field: "general", type: "general", message: "Internal server error: No response set by handler." }]
|
|
416
|
+
}, 500);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (error instanceof z.ZodError) {
|
|
421
|
+
const mappedErrors: UnifiedError = error.issues.map(err => {
|
|
422
|
+
let errorType: 'param' | 'query' | 'body' | 'general' = 'general';
|
|
423
|
+
const pathZero = String(err.path[0]);
|
|
424
|
+
if (pathZero === 'params') errorType = 'param';
|
|
425
|
+
else if (pathZero === 'query') errorType = 'query';
|
|
426
|
+
else if (pathZero === 'body') errorType = 'body';
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
field: err.path.join('.') || 'request',
|
|
430
|
+
message: err.message,
|
|
431
|
+
type: errorType,
|
|
432
|
+
};
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const errorResponseBody = { data: null, error: mappedErrors };
|
|
436
|
+
const schema422 = routeDefinition.responses[422];
|
|
437
|
+
|
|
438
|
+
if (schema422) {
|
|
439
|
+
const validationResult = schema422.safeParse(errorResponseBody);
|
|
440
|
+
if (validationResult.success) {
|
|
441
|
+
return c.json(validationResult.data, 422);
|
|
442
|
+
} else {
|
|
443
|
+
console.error("FATAL: Constructed 422 error response failed its own schema validation.", validationResult.error.issues);
|
|
444
|
+
return c.json({ error: [{ field: "general", type: "general", message: "Internal server error constructing validation error response." }] }, 500);
|
|
445
|
+
}
|
|
446
|
+
} else {
|
|
447
|
+
console.error("Error: 422 schema not found for route, sending raw Zod errors.");
|
|
448
|
+
return c.json({ error: mappedErrors }, 422);
|
|
449
|
+
}
|
|
450
|
+
} else if (error instanceof Error) {
|
|
451
|
+
console.error(`Error in ${method} ${path}:`, error.message);
|
|
452
|
+
return c.json({ error: [{ field: "general", type: "general", message: 'Internal server error' }] }, 500);
|
|
453
|
+
} else {
|
|
454
|
+
console.error(`Unknown error in ${method} ${path}:`, error);
|
|
455
|
+
return c.json({ error: [{ field: "general", type: "general", message: 'An unknown error occurred' }] }, 500);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// Create middleware wrappers
|
|
461
|
+
const middlewareWrappers: MiddlewareHandler[] = [];
|
|
462
|
+
|
|
463
|
+
// Add file upload middleware if configured
|
|
464
|
+
if (routeDefinition.fileUpload) {
|
|
465
|
+
try {
|
|
466
|
+
const fileUploadMiddleware = createHonoFileUploadMiddleware(routeDefinition.fileUpload);
|
|
467
|
+
middlewareWrappers.push(fileUploadMiddleware);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
console.error(`Error creating file upload middleware for ${currentDomain}.${currentRouteKey}:`, error);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (middlewares && middlewares.length > 0) {
|
|
475
|
+
middlewares.forEach(middleware => {
|
|
476
|
+
const wrappedMiddleware: MiddlewareHandler = async (c: any, next: any) => {
|
|
477
|
+
try {
|
|
478
|
+
await middleware(c.req as any, c.res as any, next, { domain: currentDomain, routeKey: currentRouteKey } as any);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
console.error('Middleware error:', error);
|
|
481
|
+
return next();
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
middlewareWrappers.push(wrappedMiddleware);
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Register route with middlewares
|
|
489
|
+
const allHandlers = [...middlewareWrappers, honoMiddleware];
|
|
490
|
+
|
|
491
|
+
// Register with Hono
|
|
492
|
+
switch (method.toUpperCase()) {
|
|
493
|
+
case 'GET': app.get(fullPath, ...(allHandlers as any)); break;
|
|
494
|
+
case 'POST': app.post(fullPath, ...(allHandlers as any)); break;
|
|
495
|
+
case 'PATCH': app.patch(fullPath, ...(allHandlers as any)); break;
|
|
496
|
+
case 'PUT': app.put(fullPath, ...(allHandlers as any)); break;
|
|
497
|
+
case 'DELETE': app.delete(fullPath, ...(allHandlers as any)); break;
|
|
498
|
+
default:
|
|
499
|
+
console.warn(`Unsupported HTTP method: ${method} for path ${fullPath}`);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Transform object-based handlers to array format
|
|
505
|
+
function transformObjectHandlersToArray<TDef extends ApiDefinitionSchema>(
|
|
506
|
+
objectHandlers: ObjectHandlers<TDef>
|
|
507
|
+
): Array<SpecificRouteHandler<TDef>> {
|
|
508
|
+
const handlerArray: Array<SpecificRouteHandler<TDef>> = [];
|
|
509
|
+
|
|
510
|
+
for (const domain in objectHandlers) {
|
|
511
|
+
if (Object.prototype.hasOwnProperty.call(objectHandlers, domain)) {
|
|
512
|
+
const domainHandlers = objectHandlers[domain];
|
|
513
|
+
|
|
514
|
+
for (const routeKey in domainHandlers) {
|
|
515
|
+
if (Object.prototype.hasOwnProperty.call(domainHandlers, routeKey)) {
|
|
516
|
+
const handler = domainHandlers[routeKey];
|
|
517
|
+
|
|
518
|
+
handlerArray.push({
|
|
519
|
+
domain,
|
|
520
|
+
routeKey,
|
|
521
|
+
handler
|
|
522
|
+
} as SpecificRouteHandler<TDef>);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return handlerArray;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Main utility function that registers object-based handlers with Hono
|
|
532
|
+
export function RegisterHonoHandlers<TDef extends ApiDefinitionSchema>(
|
|
533
|
+
app: Hono,
|
|
534
|
+
apiDefinition: TDef,
|
|
535
|
+
objectHandlers: ObjectHandlers<TDef>,
|
|
536
|
+
middlewares?: AnyMiddleware<TDef>[]
|
|
537
|
+
): void {
|
|
538
|
+
const handlerArray = transformObjectHandlersToArray(objectHandlers);
|
|
539
|
+
|
|
540
|
+
// Convert AnyMiddleware to EndpointMiddleware by checking function arity
|
|
541
|
+
const endpointMiddlewares: EndpointMiddleware<TDef>[] = middlewares?.map(middleware => {
|
|
542
|
+
if (middleware.length === 4) {
|
|
543
|
+
return middleware as EndpointMiddleware<TDef>;
|
|
544
|
+
} else {
|
|
545
|
+
return ((req, res, next) => {
|
|
546
|
+
return (middleware as SimpleMiddleware)(req, res, next);
|
|
547
|
+
}) as EndpointMiddleware<TDef>;
|
|
548
|
+
}
|
|
549
|
+
}) || [];
|
|
550
|
+
|
|
551
|
+
registerHonoRouteHandlers(app, apiDefinition, handlerArray, endpointMiddlewares);
|
|
552
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,3 +5,6 @@ export { CreateApiDefinition, CreateResponses, ApiDefinitionSchema } from './def
|
|
|
5
5
|
export { RegisterHandlers, EndpointMiddleware, UniversalEndpointMiddleware, SimpleMiddleware, EndpointInfo } from './object-handlers';
|
|
6
6
|
export { File as UploadedFile } from './router';
|
|
7
7
|
export { z as ZodSchema } from 'zod';
|
|
8
|
+
|
|
9
|
+
// Hono adapter for Cloudflare Workers
|
|
10
|
+
export { RegisterHonoHandlers, registerHonoRouteHandlers, HonoFile, HonoFileType, honoFileSchema, HonoTypedContext } from './hono-cloudflare-workers';
|
package/src/openapi-self.ts
CHANGED
|
@@ -270,10 +270,50 @@ class SchemaRegistry {
|
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
if (zodSchema instanceof ZodArray) {
|
|
273
|
-
|
|
273
|
+
// Try multiple ways to get the array item type
|
|
274
|
+
let itemType: ZodTypeAny | undefined;
|
|
275
|
+
|
|
276
|
+
// Method 1: Try _def.element (this is the correct property for ZodArray)
|
|
277
|
+
try {
|
|
278
|
+
const def = getZodDef(zodSchema);
|
|
279
|
+
if (def && def.element && typeof def.element === 'object' && def.element.constructor) {
|
|
280
|
+
itemType = def.element;
|
|
281
|
+
}
|
|
282
|
+
} catch (error) {
|
|
283
|
+
// Continue to next method
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Method 2: Try getZodType if method 1 failed
|
|
287
|
+
if (!itemType) {
|
|
288
|
+
const typeResult = getZodType(zodSchema);
|
|
289
|
+
if (typeResult && typeof typeResult === 'object' && typeResult.constructor) {
|
|
290
|
+
itemType = typeResult;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Method 3: Direct access to _def.element as fallback
|
|
295
|
+
if (!itemType) {
|
|
296
|
+
try {
|
|
297
|
+
const directElement = (zodSchema as any)._def?.element;
|
|
298
|
+
if (directElement && typeof directElement === 'object' && directElement.constructor) {
|
|
299
|
+
itemType = directElement;
|
|
300
|
+
}
|
|
301
|
+
} catch (error) {
|
|
302
|
+
// Continue to fallback
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (itemType) {
|
|
307
|
+
return {
|
|
308
|
+
type: 'array',
|
|
309
|
+
items: this.zodToOpenAPI(itemType, shouldRegister)
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Ultimate fallback
|
|
274
314
|
return {
|
|
275
315
|
type: 'array',
|
|
276
|
-
items:
|
|
316
|
+
items: { type: 'string' }
|
|
277
317
|
};
|
|
278
318
|
}
|
|
279
319
|
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import { describe, test, expect } from '@jest/globals';
|
|
1
|
+
import { describe, test, expect, beforeEach } from '@jest/globals';
|
|
2
2
|
import { ApiClient } from '../src';
|
|
3
3
|
import { PublicApiDefinition, PrivateApiDefinition } from '../examples/advanced/definitions';
|
|
4
|
-
import { ADVANCED_PORT } from './setup';
|
|
4
|
+
import { ADVANCED_PORT, ADVANCED_HONO_PORT, resetMockData } from './setup';
|
|
5
5
|
|
|
6
|
-
describe(
|
|
7
|
-
|
|
6
|
+
describe.each([
|
|
7
|
+
['Express', ADVANCED_PORT],
|
|
8
|
+
['Hono', ADVANCED_HONO_PORT]
|
|
9
|
+
])('Advanced API Tests - %s', (serverName, port) => {
|
|
10
|
+
const baseUrl = `http://localhost:${port}`;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
resetMockData();
|
|
14
|
+
});
|
|
8
15
|
|
|
9
16
|
describe('Public API - Authentication', () => {
|
|
10
17
|
const client = new ApiClient(baseUrl, PublicApiDefinition);
|