ts-typed-api 0.2.20 → 0.2.22
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/definition.d.ts +2 -0
- package/dist/handler.d.ts +2 -2
- package/dist/handler.js +40 -23
- package/dist/hono-cloudflare-workers.js +3 -1
- package/dist/index.d.ts +1 -1
- package/dist/object-handlers.d.ts +1 -1
- package/dist/object-handlers.js +2 -2
- package/dist/router.d.ts +1 -0
- package/examples/simple/definitions.ts +14 -0
- package/package.json +1 -1
- package/src/definition.ts +10 -0
- package/src/handler.ts +45 -24
- package/src/hono-cloudflare-workers.ts +3 -1
- package/src/index.ts +1 -1
- package/src/object-handlers.ts +3 -2
- package/src/router.ts +2 -0
- package/tests/error-handler.test.ts +146 -0
- package/tests/setup.ts +19 -0
- package/tests/simple-api.test.ts +56 -0
package/dist/definition.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z, ZodTypeAny } from 'zod';
|
|
2
|
+
import express from 'express';
|
|
2
3
|
export declare class TsTypeMarker<T> {
|
|
3
4
|
readonly _isTsTypeMarker = true;
|
|
4
5
|
readonly _type: T;
|
|
@@ -17,6 +18,7 @@ declare const unifiedErrorSchema: z.ZodNullable<z.ZodArray<z.ZodObject<{
|
|
|
17
18
|
message: z.ZodString;
|
|
18
19
|
}, z.core.$strip>>>;
|
|
19
20
|
export type UnifiedError = z.infer<typeof unifiedErrorSchema>;
|
|
21
|
+
export type ErrorHandler = (error: unknown, routeDefinition: RouteSchema, method: string, path: string, expressRes: express.Response) => boolean | void;
|
|
20
22
|
declare const errorUnifiedResponseSchema: z.ZodObject<{
|
|
21
23
|
error: z.ZodNullable<z.ZodArray<z.ZodObject<{
|
|
22
24
|
field: z.ZodString;
|
package/dist/handler.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApiDefinitionSchema } from "./definition";
|
|
1
|
+
import { ApiDefinitionSchema, ErrorHandler } from "./definition";
|
|
2
2
|
import { createRouteHandler } from "./router";
|
|
3
3
|
import { MiddlewareResponse } from "./object-handlers";
|
|
4
4
|
import express from "express";
|
|
@@ -15,4 +15,4 @@ export type EndpointMiddleware<TDef extends ApiDefinitionSchema = ApiDefinitionS
|
|
|
15
15
|
}[keyof TDef['endpoints']]) => void | Promise<void>;
|
|
16
16
|
export declare function registerRouteHandlers<TDef extends ApiDefinitionSchema>(app: express.Express, apiDefinition: TDef, // Pass the actual API definition object
|
|
17
17
|
routeHandlers: Array<SpecificRouteHandler<TDef>>, // Use the generic handler type
|
|
18
|
-
middlewares?: EndpointMiddleware<TDef>[]): void;
|
|
18
|
+
middlewares?: EndpointMiddleware<TDef>[], errorHandler?: ErrorHandler): void;
|
package/dist/handler.js
CHANGED
|
@@ -306,7 +306,7 @@ function createFileUploadMiddleware(config) {
|
|
|
306
306
|
// Register route handlers with Express, now generic over TDef
|
|
307
307
|
function registerRouteHandlers(app, apiDefinition, // Pass the actual API definition object
|
|
308
308
|
routeHandlers, // Use the generic handler type
|
|
309
|
-
middlewares) {
|
|
309
|
+
middlewares, errorHandler) {
|
|
310
310
|
routeHandlers.forEach((specificHandlerIterationItem) => {
|
|
311
311
|
const { domain, routeKey, handler } = specificHandlerIterationItem; // Use 'as any' for simplicity in destructuring union
|
|
312
312
|
const currentDomain = domain;
|
|
@@ -343,28 +343,37 @@ middlewares) {
|
|
|
343
343
|
? routeDefinition.body.parse(expressReq.body)
|
|
344
344
|
: expressReq.body;
|
|
345
345
|
// Construct TypedRequest using TDef, currentDomain, currentRouteKey
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
346
|
+
// Create a new object that inherits from expressReq to preserve prototype methods like .on()
|
|
347
|
+
const finalTypedReq = Object.create(expressReq, {
|
|
348
|
+
// Core parsed/validated properties
|
|
349
|
+
params: { value: parsedParams, writable: true, enumerable: true, configurable: true },
|
|
350
|
+
query: { value: parsedQuery, writable: true, enumerable: true, configurable: true },
|
|
351
|
+
body: { value: parsedBody, writable: true, enumerable: true, configurable: true },
|
|
352
|
+
ctx: { value: expressReq.ctx, writable: true, enumerable: true, configurable: true },
|
|
353
|
+
// Unified API for client disconnection
|
|
354
|
+
onClose: {
|
|
355
|
+
value: (callback) => expressReq.on('close', callback),
|
|
356
|
+
writable: false,
|
|
357
|
+
enumerable: true,
|
|
358
|
+
configurable: false
|
|
359
|
+
},
|
|
360
|
+
// Restore original Express request properties for full compatibility
|
|
361
|
+
headers: { value: expressReq.headers, writable: false, enumerable: true, configurable: false },
|
|
362
|
+
cookies: { value: expressReq.cookies, writable: false, enumerable: true, configurable: false },
|
|
363
|
+
ip: { value: expressReq.ip, writable: false, enumerable: true, configurable: false },
|
|
364
|
+
ips: { value: expressReq.ips, writable: false, enumerable: true, configurable: false },
|
|
365
|
+
hostname: { value: expressReq.hostname, writable: false, enumerable: true, configurable: false },
|
|
366
|
+
protocol: { value: expressReq.protocol, writable: false, enumerable: true, configurable: false },
|
|
367
|
+
secure: { value: expressReq.secure, writable: false, enumerable: true, configurable: false },
|
|
368
|
+
xhr: { value: expressReq.xhr, writable: false, enumerable: true, configurable: false },
|
|
369
|
+
fresh: { value: expressReq.fresh, writable: false, enumerable: true, configurable: false },
|
|
370
|
+
stale: { value: expressReq.stale, writable: false, enumerable: true, configurable: false },
|
|
371
|
+
subdomains: { value: expressReq.subdomains, writable: false, enumerable: true, configurable: false },
|
|
372
|
+
path: { value: expressReq.path, writable: false, enumerable: true, configurable: false },
|
|
373
|
+
originalUrl: { value: expressReq.originalUrl, writable: false, enumerable: true, configurable: false },
|
|
374
|
+
baseUrl: { value: expressReq.baseUrl, writable: false, enumerable: true, configurable: false },
|
|
375
|
+
url: { value: expressReq.url, writable: false, enumerable: true, configurable: false },
|
|
376
|
+
});
|
|
368
377
|
// Augment expressRes with the .respond and .setHeader methods, using TDef
|
|
369
378
|
const typedExpressRes = expressRes;
|
|
370
379
|
typedExpressRes.respond = (status, dataForResponse) => {
|
|
@@ -456,6 +465,14 @@ middlewares) {
|
|
|
456
465
|
await specificHandlerFn(finalTypedReq, typedExpressRes);
|
|
457
466
|
}
|
|
458
467
|
catch (error) {
|
|
468
|
+
// Check if custom error handler is provided
|
|
469
|
+
if (errorHandler) {
|
|
470
|
+
const handled = errorHandler(error, routeDefinition, method, path, expressRes);
|
|
471
|
+
if (handled) {
|
|
472
|
+
return; // Error was handled by custom handler
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// Default error handling
|
|
459
476
|
if (error instanceof zod_1.z.ZodError) {
|
|
460
477
|
const mappedErrors = error.issues.map(err => {
|
|
461
478
|
let errorType = 'general';
|
|
@@ -389,7 +389,9 @@ function registerHonoRouteHandlers(app, apiDefinition, routeHandlers, middleware
|
|
|
389
389
|
ip: c.req.header('CF-Connecting-IP') || '127.0.0.1',
|
|
390
390
|
method: c.req.method,
|
|
391
391
|
path: c.req.path,
|
|
392
|
-
originalUrl: c.req.url
|
|
392
|
+
originalUrl: c.req.url,
|
|
393
|
+
// Hono doesn't support long-lived connections, so onClose is undefined
|
|
394
|
+
onClose: undefined
|
|
393
395
|
};
|
|
394
396
|
const fakeRes = {
|
|
395
397
|
respond: c.respond,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { ApiClient, FetchHttpClientAdapter } from './client';
|
|
2
2
|
export { generateOpenApiSpec } from './openapi';
|
|
3
3
|
export { generateOpenApiSpec as generateOpenApiSpec2 } from './openapi-self';
|
|
4
|
-
export { CreateApiDefinition, CreateResponses, ApiDefinitionSchema } from './definition';
|
|
4
|
+
export { CreateApiDefinition, CreateResponses, ApiDefinitionSchema, ErrorHandler } from './definition';
|
|
5
5
|
export { RegisterHandlers, EndpointMiddleware, UniversalEndpointMiddleware, SimpleMiddleware, EndpointInfo, MiddlewareResponse } from './object-handlers';
|
|
6
6
|
export { File as UploadedFile } from './router';
|
|
7
7
|
export { z as ZodSchema } from 'zod';
|
|
@@ -31,6 +31,6 @@ export type ObjectHandlers<TDef extends ApiDefinitionSchema, Ctx extends Record<
|
|
|
31
31
|
[TRouteKey in keyof TDef['endpoints'][TDomain]]: HandlerFunction<TDef, TDomain, TRouteKey, Ctx>;
|
|
32
32
|
};
|
|
33
33
|
};
|
|
34
|
-
export declare function RegisterHandlers<TDef extends ApiDefinitionSchema, Ctx extends Record<string, any> = Record<string, any>>(app: express.Express, apiDefinition: TDef, objectHandlers: ObjectHandlers<TDef, Ctx>, middlewares?: AnyMiddleware<TDef>[]): void;
|
|
34
|
+
export declare function RegisterHandlers<TDef extends ApiDefinitionSchema, Ctx extends Record<string, any> = Record<string, any>>(app: express.Express, apiDefinition: TDef, objectHandlers: ObjectHandlers<TDef, Ctx>, middlewares?: AnyMiddleware<TDef>[], errorHandler?: import('./definition').ErrorHandler): void;
|
|
35
35
|
export declare function makeObjectHandlerRegistrar<TDef extends ApiDefinitionSchema>(apiDefinition: TDef): (app: express.Express, objectHandlers: ObjectHandlers<TDef>, middlewares?: EndpointMiddleware<TDef>[]) => void;
|
|
36
36
|
export {};
|
package/dist/object-handlers.js
CHANGED
|
@@ -27,7 +27,7 @@ function transformObjectHandlersToArray(objectHandlers) {
|
|
|
27
27
|
return handlerArray;
|
|
28
28
|
}
|
|
29
29
|
// Main utility function that registers object-based handlers
|
|
30
|
-
function RegisterHandlers(app, apiDefinition, objectHandlers, middlewares) {
|
|
30
|
+
function RegisterHandlers(app, apiDefinition, objectHandlers, middlewares, errorHandler) {
|
|
31
31
|
const handlerArray = transformObjectHandlersToArray(objectHandlers);
|
|
32
32
|
// Convert AnyMiddleware to EndpointMiddleware by checking function arity
|
|
33
33
|
const endpointMiddlewares = middlewares?.map(middleware => {
|
|
@@ -43,7 +43,7 @@ function RegisterHandlers(app, apiDefinition, objectHandlers, middlewares) {
|
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
45
|
}) || [];
|
|
46
|
-
(0, handler_1.registerRouteHandlers)(app, apiDefinition, handlerArray, endpointMiddlewares);
|
|
46
|
+
(0, handler_1.registerRouteHandlers)(app, apiDefinition, handlerArray, endpointMiddlewares, errorHandler);
|
|
47
47
|
}
|
|
48
48
|
// Factory function to create a typed handler registrar for a specific API definition
|
|
49
49
|
function makeObjectHandlerRegistrar(apiDefinition) {
|
package/dist/router.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ P extends ApiParams<TDef, TDomain, TRouteKey> = ApiParams<TDef, TDomain, TRouteK
|
|
|
9
9
|
[fieldname: string]: File[];
|
|
10
10
|
};
|
|
11
11
|
ctx?: Ctx;
|
|
12
|
+
onClose?: (callback: () => void) => void;
|
|
12
13
|
};
|
|
13
14
|
type ResponseDataForStatus<TDef extends ApiDefinitionSchema, TDomain extends keyof TDef['endpoints'], TRouteName extends keyof TDef['endpoints'][TDomain], TStatus extends keyof TDef['endpoints'][TDomain][TRouteName]['responses'] & number> = InferDataFromUnifiedResponse<TDef['endpoints'][TDomain][TRouteName]['responses'][TStatus]>;
|
|
14
15
|
type RespondFunction<TDef extends ApiDefinitionSchema, TDomain extends keyof TDef['endpoints'], TRouteName extends keyof TDef['endpoints'][TDomain]> = <TStatusLocal extends keyof TDef['endpoints'][TDomain][TRouteName]['responses'] & number>(status: TStatusLocal, data: ResponseDataForStatus<TDef, TDomain, TRouteName, TStatusLocal>) => void;
|
|
@@ -77,6 +77,20 @@ export const PublicApiDefinition = CreateApiDefinition({
|
|
|
77
77
|
200: z.string() // Raw SSE data
|
|
78
78
|
})
|
|
79
79
|
},
|
|
80
|
+
disconnectTest: {
|
|
81
|
+
method: 'GET',
|
|
82
|
+
path: '/disconnect-test',
|
|
83
|
+
description: 'Endpoint for testing client disconnection handling',
|
|
84
|
+
query: z.object({
|
|
85
|
+
delay: z.number().int().min(100).max(5000).optional().default(1000)
|
|
86
|
+
}),
|
|
87
|
+
responses: CreateResponses({
|
|
88
|
+
200: z.object({
|
|
89
|
+
message: z.string(),
|
|
90
|
+
disconnected: z.boolean()
|
|
91
|
+
}),
|
|
92
|
+
})
|
|
93
|
+
},
|
|
80
94
|
}
|
|
81
95
|
}
|
|
82
96
|
})
|
package/package.json
CHANGED
package/src/definition.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z, ZodTypeAny, ZodType } from 'zod';
|
|
2
|
+
import express from 'express';
|
|
2
3
|
|
|
3
4
|
// Marker class for raw TypeScript types
|
|
4
5
|
export class TsTypeMarker<T> {
|
|
@@ -30,6 +31,15 @@ const errorDetailSchema = z.object({
|
|
|
30
31
|
const unifiedErrorSchema = z.array(errorDetailSchema).nullable(); // Nullable if no errors
|
|
31
32
|
export type UnifiedError = z.infer<typeof unifiedErrorSchema>;
|
|
32
33
|
|
|
34
|
+
// Type for custom error handler function
|
|
35
|
+
export type ErrorHandler = (
|
|
36
|
+
error: unknown,
|
|
37
|
+
routeDefinition: RouteSchema,
|
|
38
|
+
method: string,
|
|
39
|
+
path: string,
|
|
40
|
+
expressRes: express.Response
|
|
41
|
+
) => boolean | void; // Return true if handled, void/null to use default
|
|
42
|
+
|
|
33
43
|
// Helper function to create the success-specific unified response schema
|
|
34
44
|
// This wraps the original data schema with a 'data' field and sets 'error' to null.
|
|
35
45
|
function createSuccessUnifiedResponseSchema<TData extends ZodTypeAny>(dataSchema: TData) {
|
package/src/handler.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { ApiDefinitionSchema, RouteSchema, UnifiedError, FileUploadConfig } from "./definition";
|
|
2
|
+
import { ApiDefinitionSchema, RouteSchema, UnifiedError, FileUploadConfig, ErrorHandler } from "./definition";
|
|
3
3
|
import { createRouteHandler, TypedRequest, TypedResponse } from "./router";
|
|
4
4
|
import { MiddlewareResponse } from "./object-handlers";
|
|
5
5
|
import express from "express";
|
|
@@ -350,7 +350,8 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
|
|
|
350
350
|
app: express.Express,
|
|
351
351
|
apiDefinition: TDef, // Pass the actual API definition object
|
|
352
352
|
routeHandlers: Array<SpecificRouteHandler<TDef>>, // Use the generic handler type
|
|
353
|
-
middlewares?: EndpointMiddleware<TDef>[]
|
|
353
|
+
middlewares?: EndpointMiddleware<TDef>[],
|
|
354
|
+
errorHandler?: ErrorHandler
|
|
354
355
|
) {
|
|
355
356
|
routeHandlers.forEach((specificHandlerIterationItem) => {
|
|
356
357
|
const { domain, routeKey, handler } = specificHandlerIterationItem as any; // Use 'as any' for simplicity in destructuring union
|
|
@@ -401,28 +402,39 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
|
|
|
401
402
|
: expressReq.body;
|
|
402
403
|
|
|
403
404
|
// Construct TypedRequest using TDef, currentDomain, currentRouteKey
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
405
|
+
// Create a new object that inherits from expressReq to preserve prototype methods like .on()
|
|
406
|
+
const finalTypedReq = Object.create(expressReq, {
|
|
407
|
+
// Core parsed/validated properties
|
|
408
|
+
params: { value: parsedParams, writable: true, enumerable: true, configurable: true },
|
|
409
|
+
query: { value: parsedQuery, writable: true, enumerable: true, configurable: true },
|
|
410
|
+
body: { value: parsedBody, writable: true, enumerable: true, configurable: true },
|
|
411
|
+
ctx: { value: (expressReq as any).ctx, writable: true, enumerable: true, configurable: true },
|
|
412
|
+
|
|
413
|
+
// Unified API for client disconnection
|
|
414
|
+
onClose: {
|
|
415
|
+
value: (callback: () => void) => expressReq.on('close', callback),
|
|
416
|
+
writable: false,
|
|
417
|
+
enumerable: true,
|
|
418
|
+
configurable: false
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
// Restore original Express request properties for full compatibility
|
|
422
|
+
headers: { value: expressReq.headers, writable: false, enumerable: true, configurable: false },
|
|
423
|
+
cookies: { value: expressReq.cookies, writable: false, enumerable: true, configurable: false },
|
|
424
|
+
ip: { value: expressReq.ip, writable: false, enumerable: true, configurable: false },
|
|
425
|
+
ips: { value: expressReq.ips, writable: false, enumerable: true, configurable: false },
|
|
426
|
+
hostname: { value: expressReq.hostname, writable: false, enumerable: true, configurable: false },
|
|
427
|
+
protocol: { value: expressReq.protocol, writable: false, enumerable: true, configurable: false },
|
|
428
|
+
secure: { value: expressReq.secure, writable: false, enumerable: true, configurable: false },
|
|
429
|
+
xhr: { value: expressReq.xhr, writable: false, enumerable: true, configurable: false },
|
|
430
|
+
fresh: { value: expressReq.fresh, writable: false, enumerable: true, configurable: false },
|
|
431
|
+
stale: { value: expressReq.stale, writable: false, enumerable: true, configurable: false },
|
|
432
|
+
subdomains: { value: expressReq.subdomains, writable: false, enumerable: true, configurable: false },
|
|
433
|
+
path: { value: expressReq.path, writable: false, enumerable: true, configurable: false },
|
|
434
|
+
originalUrl: { value: expressReq.originalUrl, writable: false, enumerable: true, configurable: false },
|
|
435
|
+
baseUrl: { value: expressReq.baseUrl, writable: false, enumerable: true, configurable: false },
|
|
436
|
+
url: { value: expressReq.url, writable: false, enumerable: true, configurable: false },
|
|
437
|
+
}) as TypedRequest<TDef, typeof currentDomain, typeof currentRouteKey>;
|
|
426
438
|
|
|
427
439
|
// Augment expressRes with the .respond and .setHeader methods, using TDef
|
|
428
440
|
const typedExpressRes = expressRes as TypedResponse<TDef, typeof currentDomain, typeof currentRouteKey>;
|
|
@@ -530,6 +542,15 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
|
|
|
530
542
|
await specificHandlerFn(finalTypedReq, typedExpressRes);
|
|
531
543
|
|
|
532
544
|
} catch (error) {
|
|
545
|
+
// Check if custom error handler is provided
|
|
546
|
+
if (errorHandler) {
|
|
547
|
+
const handled = errorHandler(error, routeDefinition, method, path, expressRes);
|
|
548
|
+
if (handled) {
|
|
549
|
+
return; // Error was handled by custom handler
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Default error handling
|
|
533
554
|
if (error instanceof z.ZodError) {
|
|
534
555
|
const mappedErrors: UnifiedError = error.issues.map(err => {
|
|
535
556
|
let errorType: 'param' | 'query' | 'body' | 'general' = 'general';
|
|
@@ -471,7 +471,9 @@ export function registerHonoRouteHandlers<
|
|
|
471
471
|
ip: c.req.header('CF-Connecting-IP') || '127.0.0.1',
|
|
472
472
|
method: c.req.method,
|
|
473
473
|
path: c.req.path,
|
|
474
|
-
originalUrl: c.req.url
|
|
474
|
+
originalUrl: c.req.url,
|
|
475
|
+
// Hono doesn't support long-lived connections, so onClose is undefined
|
|
476
|
+
onClose: undefined
|
|
475
477
|
} as TypedRequest<TDef, typeof currentDomain, typeof currentRouteKey>;
|
|
476
478
|
|
|
477
479
|
const fakeRes = {
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { ApiClient, FetchHttpClientAdapter } from './client';
|
|
2
2
|
export { generateOpenApiSpec } from './openapi'
|
|
3
3
|
export { generateOpenApiSpec as generateOpenApiSpec2 } from './openapi-self'
|
|
4
|
-
export { CreateApiDefinition, CreateResponses, ApiDefinitionSchema } from './definition';
|
|
4
|
+
export { CreateApiDefinition, CreateResponses, ApiDefinitionSchema, ErrorHandler } from './definition';
|
|
5
5
|
export { RegisterHandlers, EndpointMiddleware, UniversalEndpointMiddleware, SimpleMiddleware, EndpointInfo, MiddlewareResponse } from './object-handlers';
|
|
6
6
|
export { File as UploadedFile } from './router';
|
|
7
7
|
export { z as ZodSchema } from 'zod';
|
package/src/object-handlers.ts
CHANGED
|
@@ -125,7 +125,8 @@ export function RegisterHandlers<
|
|
|
125
125
|
app: express.Express,
|
|
126
126
|
apiDefinition: TDef,
|
|
127
127
|
objectHandlers: ObjectHandlers<TDef, Ctx>,
|
|
128
|
-
middlewares?: AnyMiddleware<TDef>[]
|
|
128
|
+
middlewares?: AnyMiddleware<TDef>[],
|
|
129
|
+
errorHandler?: import('./definition').ErrorHandler
|
|
129
130
|
): void {
|
|
130
131
|
const handlerArray = transformObjectHandlersToArray(objectHandlers);
|
|
131
132
|
|
|
@@ -143,7 +144,7 @@ export function RegisterHandlers<
|
|
|
143
144
|
}
|
|
144
145
|
}) || [];
|
|
145
146
|
|
|
146
|
-
registerRouteHandlers(app, apiDefinition, handlerArray, endpointMiddlewares);
|
|
147
|
+
registerRouteHandlers(app, apiDefinition, handlerArray, endpointMiddlewares, errorHandler);
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
// Factory function to create a typed handler registrar for a specific API definition
|
package/src/router.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type TypedRequest<
|
|
|
27
27
|
files?: File[] | { [fieldname: string]: File[] };
|
|
28
28
|
// Add typed context object for carrying data between middlewares and handlers
|
|
29
29
|
ctx?: Ctx;
|
|
30
|
+
// Unified client disconnection handler (Express only, undefined for Hono)
|
|
31
|
+
onClose?: (callback: () => void) => void;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
// --- Enhanced TypedResponse with res.respond, now generic over TDef ---
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { Server } from 'http';
|
|
4
|
+
import { RegisterHandlers, CreateApiDefinition, CreateResponses, ErrorHandler } from '../src';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
const ErrorHandlerTestApiDefinition = CreateApiDefinition({
|
|
8
|
+
prefix: '/api',
|
|
9
|
+
endpoints: {
|
|
10
|
+
test: {
|
|
11
|
+
success: {
|
|
12
|
+
method: 'GET',
|
|
13
|
+
path: '/success',
|
|
14
|
+
responses: CreateResponses({
|
|
15
|
+
200: z.object({ message: z.string() })
|
|
16
|
+
})
|
|
17
|
+
},
|
|
18
|
+
validationError: {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
path: '/validation-error',
|
|
21
|
+
body: z.object({
|
|
22
|
+
name: z.string().min(3, 'Name must be at least 3 characters'),
|
|
23
|
+
email: z.string().email('Invalid email format')
|
|
24
|
+
}),
|
|
25
|
+
responses: CreateResponses({
|
|
26
|
+
200: z.object({ message: z.string() })
|
|
27
|
+
})
|
|
28
|
+
},
|
|
29
|
+
customError: {
|
|
30
|
+
method: 'GET',
|
|
31
|
+
path: '/custom-error',
|
|
32
|
+
responses: CreateResponses({
|
|
33
|
+
200: z.object({ message: z.string() })
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const testHandlers = {
|
|
41
|
+
test: {
|
|
42
|
+
success: async (req: any, res: any) => {
|
|
43
|
+
res.respond(200, { message: 'success' });
|
|
44
|
+
},
|
|
45
|
+
validationError: async () => {
|
|
46
|
+
// This will cause a Zod validation error - validation happens before handler
|
|
47
|
+
throw new Error('Should not reach handler');
|
|
48
|
+
},
|
|
49
|
+
customError: async (req: any, res: any) => {
|
|
50
|
+
// Throw a custom error to test error handler
|
|
51
|
+
throw new Error('Custom application error');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe('Error Handler Tests', () => {
|
|
57
|
+
let server: Server;
|
|
58
|
+
let port: number;
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
port = 3009; // Use a different port
|
|
62
|
+
const app = express();
|
|
63
|
+
app.use(express.json());
|
|
64
|
+
|
|
65
|
+
// Custom error handler
|
|
66
|
+
const customErrorHandler: ErrorHandler = (error, routeDefinition, method, path, expressRes) => {
|
|
67
|
+
if (error instanceof z.ZodError) {
|
|
68
|
+
// Custom Zod error handling
|
|
69
|
+
const customErrors = error.issues.map(issue => ({
|
|
70
|
+
field: issue.path.join('.'),
|
|
71
|
+
message: `Custom: ${issue.message}`,
|
|
72
|
+
type: issue.path[0] === 'body' ? 'body' : issue.path[0] === 'query' ? 'query' : 'param'
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
expressRes.status(422).json({
|
|
76
|
+
data: null,
|
|
77
|
+
error: customErrors,
|
|
78
|
+
customHandled: true
|
|
79
|
+
});
|
|
80
|
+
return true; // Handled
|
|
81
|
+
} else if (error instanceof Error && error.message === 'Custom application error') {
|
|
82
|
+
// Custom application error handling
|
|
83
|
+
expressRes.status(400).json({
|
|
84
|
+
data: null,
|
|
85
|
+
error: [{ field: 'general', message: 'Custom error message', type: 'general' }],
|
|
86
|
+
customHandled: true
|
|
87
|
+
});
|
|
88
|
+
return true; // Handled
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false; // Not handled, use default
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
RegisterHandlers(app, ErrorHandlerTestApiDefinition, testHandlers, undefined, customErrorHandler);
|
|
95
|
+
|
|
96
|
+
server = app.listen(port);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterAll(async () => {
|
|
100
|
+
if (server) {
|
|
101
|
+
server.close();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('should handle success response normally', async () => {
|
|
106
|
+
const response = await fetch(`http://localhost:${port}/api/success`);
|
|
107
|
+
expect(response.status).toBe(200);
|
|
108
|
+
const data = await response.json();
|
|
109
|
+
expect(data).toEqual({
|
|
110
|
+
data: { message: 'success' }
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('should use custom error handler for Zod validation errors', async () => {
|
|
115
|
+
const response = await fetch(`http://localhost:${port}/api/validation-error`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/json' },
|
|
118
|
+
body: JSON.stringify({ name: 'A', email: 'invalid-email' })
|
|
119
|
+
});
|
|
120
|
+
expect(response.status).toBe(422);
|
|
121
|
+
const data = await response.json() as any;
|
|
122
|
+
expect(data.customHandled).toBe(true);
|
|
123
|
+
expect(data.error).toBeDefined();
|
|
124
|
+
expect(data.error.length).toBeGreaterThan(0);
|
|
125
|
+
expect(data.error[0].message).toContain('Custom:');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('should use custom error handler for application errors', async () => {
|
|
129
|
+
const response = await fetch(`http://localhost:${port}/api/custom-error`);
|
|
130
|
+
expect(response.status).toBe(400);
|
|
131
|
+
const data = await response.json() as any;
|
|
132
|
+
expect(data.customHandled).toBe(true);
|
|
133
|
+
expect(data.error).toEqual([{
|
|
134
|
+
field: 'general',
|
|
135
|
+
message: 'Custom error message',
|
|
136
|
+
type: 'general'
|
|
137
|
+
}]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('should fall back to default error handler for unhandled errors', async () => {
|
|
141
|
+
// Test with a route that doesn't exist to trigger a different error
|
|
142
|
+
const response = await fetch(`http://localhost:${port}/api/nonexistent`);
|
|
143
|
+
expect(response.status).toBe(404);
|
|
144
|
+
// This would be handled by Express default 404 handler, not our error handler
|
|
145
|
+
});
|
|
146
|
+
});
|
package/tests/setup.ts
CHANGED
|
@@ -50,6 +50,25 @@ const simplePublicHandlers = {
|
|
|
50
50
|
|
|
51
51
|
// Close the stream
|
|
52
52
|
res.endStream();
|
|
53
|
+
},
|
|
54
|
+
disconnectTest: async (req: any, res: any) => {
|
|
55
|
+
const delay = req.query?.delay || 1000;
|
|
56
|
+
let disconnected = false;
|
|
57
|
+
|
|
58
|
+
// Set up disconnection handler using unified API
|
|
59
|
+
req.onClose?.(() => {
|
|
60
|
+
console.log('Client disconnected during disconnect-test');
|
|
61
|
+
disconnected = true;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Simulate long-running operation
|
|
65
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
66
|
+
|
|
67
|
+
// Check if client disconnected during the delay
|
|
68
|
+
res.respond(200, {
|
|
69
|
+
message: disconnected ? 'Client disconnected' : 'Operation completed',
|
|
70
|
+
disconnected
|
|
71
|
+
});
|
|
53
72
|
}
|
|
54
73
|
},
|
|
55
74
|
status: {
|
package/tests/simple-api.test.ts
CHANGED
|
@@ -251,6 +251,62 @@ describe.each([
|
|
|
251
251
|
return data ? JSON.parse(data) : null;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
test('should handle client disconnection with req.onClose', async () => {
|
|
255
|
+
// Test that the endpoint works normally when client doesn't disconnect
|
|
256
|
+
const result = await client.callApi('common', 'disconnectTest', {
|
|
257
|
+
query: { delay: 100 }
|
|
258
|
+
}, {
|
|
259
|
+
200: ({ data }) => {
|
|
260
|
+
expect(data.message).toBe('Operation completed');
|
|
261
|
+
expect(data.disconnected).toBe(false);
|
|
262
|
+
return data;
|
|
263
|
+
},
|
|
264
|
+
422: ({ error }) => {
|
|
265
|
+
throw new Error(`Validation error: ${JSON.stringify(error)}`);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(result.disconnected).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('should detect client disconnection during request processing', async () => {
|
|
273
|
+
if (serverName === 'Hono') {
|
|
274
|
+
// Hono doesn't support disconnection detection, so onClose is undefined
|
|
275
|
+
// The endpoint completes successfully but doesn't detect disconnections
|
|
276
|
+
const response = await fetch(`${baseUrl}/api/v1/public/disconnect-test?delay=100`);
|
|
277
|
+
expect(response.status).toBe(200);
|
|
278
|
+
const data = await response.json();
|
|
279
|
+
expect(data.data.disconnected).toBe(false); // No disconnection detected
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Use fetch directly with AbortController to simulate client disconnection
|
|
284
|
+
const controller = new AbortController();
|
|
285
|
+
const signal = controller.signal;
|
|
286
|
+
|
|
287
|
+
// Start the request
|
|
288
|
+
const fetchPromise = fetch(`${baseUrl}/api/v1/public/disconnect-test?delay=500`, {
|
|
289
|
+
signal,
|
|
290
|
+
headers: { 'Accept': 'application/json' }
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Abort the request after a short delay (before the server finishes)
|
|
294
|
+
setTimeout(() => {
|
|
295
|
+
controller.abort();
|
|
296
|
+
}, 200);
|
|
297
|
+
|
|
298
|
+
// The request should be aborted (node-fetch uses different error messages)
|
|
299
|
+
await expect(fetchPromise).rejects.toThrow(/aborted/i);
|
|
300
|
+
|
|
301
|
+
// Give the server a moment to process the disconnection
|
|
302
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
303
|
+
|
|
304
|
+
// Note: In a real test environment, we would need a way to verify
|
|
305
|
+
// that the close handler was called on the server side.
|
|
306
|
+
// For this test, we're mainly verifying that the endpoint exists
|
|
307
|
+
// and that client disconnection doesn't crash the server.
|
|
308
|
+
});
|
|
309
|
+
|
|
254
310
|
test('generateUrl should return correct URL for ping', () => {
|
|
255
311
|
const url = client.generateUrl('common', 'ping');
|
|
256
312
|
expect(url).toBe(`${baseUrl}/api/v1/public/ping`);
|