ts-typed-api 0.2.18 → 0.2.20
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 +6 -3
- package/dist/handler.js +74 -2
- package/dist/hono-cloudflare-workers.js +86 -2
- package/dist/openapi-self.d.ts +4 -0
- package/dist/openapi-self.js +20 -1
- package/dist/openapi.d.ts +2 -2
- package/dist/openapi.js +11 -1
- package/dist/router.d.ts +3 -0
- package/examples/advanced/definitions.ts +7 -0
- package/examples/simple/definitions.ts +31 -0
- package/examples/simple/server.ts +27 -0
- package/package.json +1 -1
- package/src/definition.ts +6 -3
- package/src/handler.ts +80 -2
- package/src/hono-cloudflare-workers.ts +90 -2
- package/src/openapi-self.ts +27 -1
- package/src/openapi.ts +17 -4
- package/src/router.ts +4 -0
- package/tests/openapi-spec.test.ts +101 -0
- package/tests/setup.ts +27 -0
- package/tests/simple-api.test.ts +130 -0
package/dist/definition.d.ts
CHANGED
|
@@ -91,10 +91,13 @@ type RouteWithBody = {
|
|
|
91
91
|
fileUpload?: FileUploadConfig;
|
|
92
92
|
responses: Record<number, ZodTypeAny>;
|
|
93
93
|
};
|
|
94
|
-
export type RouteSchema = RouteWithoutBody | RouteWithBody
|
|
95
|
-
|
|
94
|
+
export type RouteSchema = (RouteWithoutBody | RouteWithBody) & {
|
|
95
|
+
description?: string;
|
|
96
|
+
};
|
|
97
|
+
export type ApiDefinitionSchema<TEndpoints extends Record<string, Record<string, RouteSchema>> = Record<string, Record<string, RouteSchema>>> = {
|
|
96
98
|
prefix?: string;
|
|
97
|
-
|
|
99
|
+
sectionDescriptions?: Partial<Record<keyof TEndpoints, string>>;
|
|
100
|
+
endpoints: TEndpoints;
|
|
98
101
|
};
|
|
99
102
|
export declare function CreateApiDefinition<T extends ApiDefinitionSchema>(definition: T): T;
|
|
100
103
|
export type ApiRouteKey<TDef extends ApiDefinitionSchema, TDomain extends keyof TDef['endpoints']> = keyof TDef['endpoints'][TDomain];
|
package/dist/handler.js
CHANGED
|
@@ -6,6 +6,55 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.registerRouteHandlers = registerRouteHandlers;
|
|
7
7
|
const zod_1 = require("zod");
|
|
8
8
|
const multer_1 = __importDefault(require("multer"));
|
|
9
|
+
// Helper function to preprocess parameters for type coercion
|
|
10
|
+
function preprocessParams(params, paramsSchema) {
|
|
11
|
+
if (!paramsSchema || !params)
|
|
12
|
+
return params;
|
|
13
|
+
// Create a copy to avoid mutating the original
|
|
14
|
+
const processedParams = { ...params };
|
|
15
|
+
// Get the shape of the schema if it's a ZodObject
|
|
16
|
+
if (paramsSchema instanceof zod_1.z.ZodObject) {
|
|
17
|
+
const shape = paramsSchema.shape;
|
|
18
|
+
for (const [key, value] of Object.entries(processedParams)) {
|
|
19
|
+
if (typeof value === 'string' && shape[key]) {
|
|
20
|
+
const fieldSchema = shape[key];
|
|
21
|
+
// Handle ZodOptional and ZodDefault wrappers
|
|
22
|
+
let innerSchema = fieldSchema;
|
|
23
|
+
if (fieldSchema instanceof zod_1.z.ZodOptional) {
|
|
24
|
+
innerSchema = fieldSchema._def.innerType;
|
|
25
|
+
}
|
|
26
|
+
if (fieldSchema instanceof zod_1.z.ZodDefault) {
|
|
27
|
+
innerSchema = fieldSchema._def.innerType;
|
|
28
|
+
}
|
|
29
|
+
// Handle nested ZodOptional/ZodDefault combinations
|
|
30
|
+
while (innerSchema instanceof zod_1.z.ZodOptional || innerSchema instanceof zod_1.z.ZodDefault) {
|
|
31
|
+
if (innerSchema instanceof zod_1.z.ZodOptional) {
|
|
32
|
+
innerSchema = innerSchema._def.innerType;
|
|
33
|
+
}
|
|
34
|
+
else if (innerSchema instanceof zod_1.z.ZodDefault) {
|
|
35
|
+
innerSchema = innerSchema._def.innerType;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Convert based on the inner schema type
|
|
39
|
+
if (innerSchema instanceof zod_1.z.ZodNumber) {
|
|
40
|
+
const numValue = Number(value);
|
|
41
|
+
if (!isNaN(numValue)) {
|
|
42
|
+
processedParams[key] = numValue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else if (innerSchema instanceof zod_1.z.ZodBoolean) {
|
|
46
|
+
if (value === 'true') {
|
|
47
|
+
processedParams[key] = true;
|
|
48
|
+
}
|
|
49
|
+
else if (value === 'false') {
|
|
50
|
+
processedParams[key] = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return processedParams;
|
|
57
|
+
}
|
|
9
58
|
// Helper function to preprocess query parameters for type coercion
|
|
10
59
|
function preprocessQueryParams(query, querySchema) {
|
|
11
60
|
if (!querySchema || !query)
|
|
@@ -277,9 +326,12 @@ middlewares) {
|
|
|
277
326
|
try {
|
|
278
327
|
// Ensure TDef is correctly used for type inference if this section needs it.
|
|
279
328
|
// Currently, parsedParams,Query,Body are based on runtime routeDefinition.
|
|
280
|
-
const
|
|
281
|
-
?
|
|
329
|
+
const preprocessedParams = ('params' in routeDefinition && routeDefinition.params)
|
|
330
|
+
? preprocessParams(expressReq.params, routeDefinition.params)
|
|
282
331
|
: expressReq.params;
|
|
332
|
+
const parsedParams = ('params' in routeDefinition && routeDefinition.params)
|
|
333
|
+
? routeDefinition.params.parse(preprocessedParams)
|
|
334
|
+
: preprocessedParams;
|
|
283
335
|
// Preprocess query parameters to handle type coercion from strings
|
|
284
336
|
const preprocessedQuery = ('query' in routeDefinition && routeDefinition.query)
|
|
285
337
|
? preprocessQueryParams(expressReq.query, routeDefinition.query)
|
|
@@ -380,6 +432,26 @@ middlewares) {
|
|
|
380
432
|
Object.getPrototypeOf(expressRes).setHeader.call(expressRes, name, value);
|
|
381
433
|
return typedExpressRes;
|
|
382
434
|
};
|
|
435
|
+
// SSE streaming methods
|
|
436
|
+
typedExpressRes.startSSE = () => {
|
|
437
|
+
typedExpressRes.setHeader('Content-Type', 'text/event-stream');
|
|
438
|
+
typedExpressRes.setHeader('Cache-Control', 'no-cache');
|
|
439
|
+
typedExpressRes.setHeader('Connection', 'keep-alive');
|
|
440
|
+
typedExpressRes.setHeader('Access-Control-Allow-Origin', '*');
|
|
441
|
+
typedExpressRes.setHeader('Access-Control-Allow-Headers', 'Cache-Control');
|
|
442
|
+
};
|
|
443
|
+
typedExpressRes.streamSSE = (eventName, data, id) => {
|
|
444
|
+
let event = '';
|
|
445
|
+
if (eventName)
|
|
446
|
+
event += `event: ${eventName}\n`;
|
|
447
|
+
if (id)
|
|
448
|
+
event += `id: ${id}\n`;
|
|
449
|
+
event += `data: ${JSON.stringify(data)}\n\n`;
|
|
450
|
+
expressRes.write(event);
|
|
451
|
+
};
|
|
452
|
+
typedExpressRes.endStream = () => {
|
|
453
|
+
expressRes.end();
|
|
454
|
+
};
|
|
383
455
|
const specificHandlerFn = handler;
|
|
384
456
|
await specificHandlerFn(finalTypedReq, typedExpressRes);
|
|
385
457
|
}
|
|
@@ -19,6 +19,55 @@ exports.honoFileSchema = zod_1.z.object({
|
|
|
19
19
|
path: zod_1.z.string().optional(),
|
|
20
20
|
stream: zod_1.z.any().optional(),
|
|
21
21
|
});
|
|
22
|
+
// Helper function to preprocess parameters for type coercion
|
|
23
|
+
function preprocessParams(params, paramsSchema) {
|
|
24
|
+
if (!paramsSchema || !params)
|
|
25
|
+
return params;
|
|
26
|
+
// Create a copy to avoid mutating the original
|
|
27
|
+
const processedParams = { ...params };
|
|
28
|
+
// Get the shape of the schema if it's a ZodObject
|
|
29
|
+
if (paramsSchema instanceof zod_1.z.ZodObject) {
|
|
30
|
+
const shape = paramsSchema.shape;
|
|
31
|
+
for (const [key, value] of Object.entries(processedParams)) {
|
|
32
|
+
if (typeof value === 'string' && shape[key]) {
|
|
33
|
+
const fieldSchema = shape[key];
|
|
34
|
+
// Handle ZodOptional and ZodDefault wrappers
|
|
35
|
+
let innerSchema = fieldSchema;
|
|
36
|
+
if (fieldSchema instanceof zod_1.z.ZodOptional) {
|
|
37
|
+
innerSchema = fieldSchema._def.innerType;
|
|
38
|
+
}
|
|
39
|
+
if (fieldSchema instanceof zod_1.z.ZodDefault) {
|
|
40
|
+
innerSchema = fieldSchema._def.innerType;
|
|
41
|
+
}
|
|
42
|
+
// Handle nested ZodOptional/ZodDefault combinations
|
|
43
|
+
while (innerSchema instanceof zod_1.z.ZodOptional || innerSchema instanceof zod_1.z.ZodDefault) {
|
|
44
|
+
if (innerSchema instanceof zod_1.z.ZodOptional) {
|
|
45
|
+
innerSchema = innerSchema._def.innerType;
|
|
46
|
+
}
|
|
47
|
+
else if (innerSchema instanceof zod_1.z.ZodDefault) {
|
|
48
|
+
innerSchema = innerSchema._def.innerType;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Convert based on the inner schema type
|
|
52
|
+
if (innerSchema instanceof zod_1.z.ZodNumber) {
|
|
53
|
+
const numValue = Number(value);
|
|
54
|
+
if (!isNaN(numValue)) {
|
|
55
|
+
processedParams[key] = numValue;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (innerSchema instanceof zod_1.z.ZodBoolean) {
|
|
59
|
+
if (value === 'true') {
|
|
60
|
+
processedParams[key] = true;
|
|
61
|
+
}
|
|
62
|
+
else if (value === 'false') {
|
|
63
|
+
processedParams[key] = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return processedParams;
|
|
70
|
+
}
|
|
22
71
|
// Helper function to preprocess query parameters for type coercion
|
|
23
72
|
function preprocessQueryParams(query, querySchema) {
|
|
24
73
|
if (!querySchema || !query)
|
|
@@ -240,9 +289,12 @@ function registerHonoRouteHandlers(app, apiDefinition, routeHandlers, middleware
|
|
|
240
289
|
const honoMiddleware = async (c) => {
|
|
241
290
|
try {
|
|
242
291
|
// Parse and validate request
|
|
243
|
-
const
|
|
244
|
-
?
|
|
292
|
+
const preprocessedParams = ('params' in routeDefinition && routeDefinition.params)
|
|
293
|
+
? preprocessParams(c.req.param(), routeDefinition.params)
|
|
245
294
|
: c.req.param();
|
|
295
|
+
const parsedParams = ('params' in routeDefinition && routeDefinition.params)
|
|
296
|
+
? routeDefinition.params.parse(preprocessedParams)
|
|
297
|
+
: preprocessedParams;
|
|
246
298
|
const preprocessedQuery = ('query' in routeDefinition && routeDefinition.query)
|
|
247
299
|
? preprocessQueryParams(c.req.query(), routeDefinition.query)
|
|
248
300
|
: c.req.query();
|
|
@@ -351,6 +403,38 @@ function registerHonoRouteHandlers(app, apiDefinition, routeHandlers, middleware
|
|
|
351
403
|
setHeader: (name, value) => {
|
|
352
404
|
c.header(name, value);
|
|
353
405
|
return fakeRes;
|
|
406
|
+
},
|
|
407
|
+
// SSE streaming methods for Hono
|
|
408
|
+
startSSE: () => {
|
|
409
|
+
c.header('Content-Type', 'text/event-stream');
|
|
410
|
+
c.header('Cache-Control', 'no-cache');
|
|
411
|
+
c.header('Connection', 'keep-alive');
|
|
412
|
+
c.header('Access-Control-Allow-Origin', '*');
|
|
413
|
+
c.header('Access-Control-Allow-Headers', 'Cache-Control');
|
|
414
|
+
},
|
|
415
|
+
streamSSE: (eventName, data, id) => {
|
|
416
|
+
let event = '';
|
|
417
|
+
if (eventName)
|
|
418
|
+
event += `event: ${eventName}\n`;
|
|
419
|
+
if (id)
|
|
420
|
+
event += `id: ${id}\n`;
|
|
421
|
+
event += `data: ${JSON.stringify(data)}\n\n`;
|
|
422
|
+
// For Hono, we need to accumulate the response
|
|
423
|
+
if (!c.__sseBuffer) {
|
|
424
|
+
c.__sseBuffer = '';
|
|
425
|
+
}
|
|
426
|
+
c.__sseBuffer += event;
|
|
427
|
+
},
|
|
428
|
+
endStream: () => {
|
|
429
|
+
// Send the accumulated SSE data
|
|
430
|
+
const sseData = c.__sseBuffer || '';
|
|
431
|
+
c.__response = new Response(sseData, {
|
|
432
|
+
headers: {
|
|
433
|
+
'Content-Type': 'text/event-stream',
|
|
434
|
+
'Cache-Control': 'no-cache',
|
|
435
|
+
'Connection': 'keep-alive'
|
|
436
|
+
}
|
|
437
|
+
});
|
|
354
438
|
}
|
|
355
439
|
};
|
|
356
440
|
const specificHandlerFn = handler;
|
package/dist/openapi-self.d.ts
CHANGED
package/dist/openapi-self.js
CHANGED
|
@@ -417,10 +417,12 @@ function processRoute(route, fullPath, registry, domain, anonymousTypes = false)
|
|
|
417
417
|
const responses = createResponses(route.responses, registry, anonymousTypes);
|
|
418
418
|
const operation = {
|
|
419
419
|
summary: `${route.method} ${fullPath}`,
|
|
420
|
-
description: `${route.method} operation for ${fullPath}`,
|
|
421
420
|
responses,
|
|
422
421
|
tags: [domain]
|
|
423
422
|
};
|
|
423
|
+
if (route.description) {
|
|
424
|
+
operation.description = route.description;
|
|
425
|
+
}
|
|
424
426
|
if (parameters.length > 0) {
|
|
425
427
|
operation.parameters = parameters;
|
|
426
428
|
}
|
|
@@ -450,6 +452,7 @@ function generateOpenApiSpec(definitions, options = {}) {
|
|
|
450
452
|
const definitionsArray = Array.isArray(definitions) ? definitions : [definitions];
|
|
451
453
|
const anonymousTypes = options.anonymousTypes || false;
|
|
452
454
|
const allPaths = {};
|
|
455
|
+
const allTags = [];
|
|
453
456
|
// Process each definition
|
|
454
457
|
for (const definition of definitionsArray) {
|
|
455
458
|
const paths = processApiDefinition(definition, registry, anonymousTypes);
|
|
@@ -463,6 +466,18 @@ function generateOpenApiSpec(definitions, options = {}) {
|
|
|
463
466
|
allPaths[path] = pathItem;
|
|
464
467
|
}
|
|
465
468
|
}
|
|
469
|
+
// Collect tags from sectionDescriptions
|
|
470
|
+
if (definition.sectionDescriptions) {
|
|
471
|
+
for (const [sectionName, description] of Object.entries(definition.sectionDescriptions)) {
|
|
472
|
+
// Avoid duplicates
|
|
473
|
+
if (!allTags.find(tag => tag.name === sectionName)) {
|
|
474
|
+
allTags.push({
|
|
475
|
+
name: sectionName,
|
|
476
|
+
description: description
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
466
481
|
}
|
|
467
482
|
const spec = {
|
|
468
483
|
openapi: '3.0.0',
|
|
@@ -477,6 +492,10 @@ function generateOpenApiSpec(definitions, options = {}) {
|
|
|
477
492
|
if (options.servers && options.servers.length > 0) {
|
|
478
493
|
spec.servers = options.servers;
|
|
479
494
|
}
|
|
495
|
+
// Add tags if any were found
|
|
496
|
+
if (allTags.length > 0) {
|
|
497
|
+
spec.tags = allTags;
|
|
498
|
+
}
|
|
480
499
|
// Add components with schemas if any were registered and not using anonymous types
|
|
481
500
|
if (!anonymousTypes) {
|
|
482
501
|
const schemas = registry.getSchemas();
|
package/dist/openapi.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export declare function generateOpenApiSpec(definitions: ApiDefinitionSchema | ApiDefinitionSchema[], options?: {
|
|
1
|
+
import { RouteSchema } from './definition';
|
|
2
|
+
export declare function generateOpenApiSpec<TEndpoints extends Record<string, Record<string, RouteSchema>>>(definitions: import('./definition').ApiDefinitionSchema<TEndpoints> | import('./definition').ApiDefinitionSchema<TEndpoints>[], options?: {
|
|
3
3
|
info?: {
|
|
4
4
|
title?: string;
|
|
5
5
|
version?: string;
|
package/dist/openapi.js
CHANGED
|
@@ -47,11 +47,20 @@ function generateOpenApiSpec(definitions, options = {}) {
|
|
|
47
47
|
},
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
// Collect all tags with descriptions for the OpenAPI spec
|
|
51
|
+
const allTags = [];
|
|
50
52
|
// Iterate over multiple API definitions to register routes
|
|
51
53
|
definitionArray.forEach((definition) => {
|
|
52
54
|
Object.keys(definition.endpoints).forEach(domainNameKey => {
|
|
53
55
|
// domainNameKey is a string, representing the domain like 'users', 'products'
|
|
54
56
|
const domain = definition.endpoints[domainNameKey];
|
|
57
|
+
// Add tag if not already present
|
|
58
|
+
if (!allTags.find(tag => tag.name === domainNameKey)) {
|
|
59
|
+
allTags.push({
|
|
60
|
+
name: domainNameKey,
|
|
61
|
+
description: definition.sectionDescriptions?.[domainNameKey]
|
|
62
|
+
});
|
|
63
|
+
}
|
|
55
64
|
Object.keys(domain).forEach(routeNameKey => {
|
|
56
65
|
// routeNameKey is a string, representing the route name like 'getUser', 'createProduct'
|
|
57
66
|
const route = domain[routeNameKey];
|
|
@@ -91,6 +100,7 @@ function generateOpenApiSpec(definitions, options = {}) {
|
|
|
91
100
|
}
|
|
92
101
|
const operation = {
|
|
93
102
|
summary: `${domainNameKey} - ${routeNameKey}`, // Use keys directly for summary
|
|
103
|
+
description: route.description, // Use route description if provided
|
|
94
104
|
tags: [domainNameKey], // Use domainNameKey for tags
|
|
95
105
|
parameters: parameters.length > 0 ? parameters : undefined,
|
|
96
106
|
requestBody: requestBody,
|
|
@@ -103,7 +113,6 @@ function generateOpenApiSpec(definitions, options = {}) {
|
|
|
103
113
|
method: route.method.toLowerCase(), // Ensure method is lowercase
|
|
104
114
|
path: openApiPath,
|
|
105
115
|
...operation,
|
|
106
|
-
// Add description or other OpenAPI fields if available in RouteSchema
|
|
107
116
|
});
|
|
108
117
|
});
|
|
109
118
|
});
|
|
@@ -118,6 +127,7 @@ function generateOpenApiSpec(definitions, options = {}) {
|
|
|
118
127
|
description: options.info?.description ?? 'Automatically generated OpenAPI specification',
|
|
119
128
|
},
|
|
120
129
|
servers: options.servers ?? [{ url: '/api' }], // Adjust as needed
|
|
130
|
+
tags: allTags.filter(tag => tag.description), // Only include tags that have descriptions
|
|
121
131
|
});
|
|
122
132
|
return openApiDocument;
|
|
123
133
|
}
|
package/dist/router.d.ts
CHANGED
|
@@ -17,6 +17,9 @@ export interface TypedResponse<TDef extends ApiDefinitionSchema, TDomain extends
|
|
|
17
17
|
respondContentType: (status: number, data: any, contentType: string) => void;
|
|
18
18
|
setHeader: (name: string, value: string) => this;
|
|
19
19
|
json: <B = any>(body: B) => this;
|
|
20
|
+
startSSE: () => void;
|
|
21
|
+
streamSSE: (eventName?: string, data?: any, id?: string) => void;
|
|
22
|
+
endStream: () => void;
|
|
20
23
|
}
|
|
21
24
|
export declare function createRouteHandler<TDef extends ApiDefinitionSchema, TDomain extends keyof TDef['endpoints'], TRouteKey extends keyof TDef['endpoints'][TDomain], // Using direct keyof for simplicity
|
|
22
25
|
Ctx extends Record<string, any> = Record<string, any>>(domain: TDomain, routeKey: TRouteKey, handler: (req: TypedRequest<TDef, TDomain, TRouteKey, ApiParams<TDef, TDomain, TRouteKey>, ApiBody<TDef, TDomain, TRouteKey>, ApiQuery<TDef, TDomain, TRouteKey>, Record<string, any>, Ctx>, res: TypedResponse<TDef, TDomain, TRouteKey>) => Promise<void> | void): {
|
|
@@ -20,11 +20,16 @@ const ProductSchema = z.object({
|
|
|
20
20
|
|
|
21
21
|
export const PublicApiDefinition = CreateApiDefinition({
|
|
22
22
|
prefix: '/api/v1/public',
|
|
23
|
+
sectionDescriptions: {
|
|
24
|
+
auth: 'Authentication endpoints for user login and logout',
|
|
25
|
+
products: 'Product management and listing endpoints'
|
|
26
|
+
},
|
|
23
27
|
endpoints: {
|
|
24
28
|
auth: {
|
|
25
29
|
login: {
|
|
26
30
|
method: 'POST',
|
|
27
31
|
path: '/login',
|
|
32
|
+
description: 'Authenticate a user with username and password, returns JWT token',
|
|
28
33
|
body: z.object({
|
|
29
34
|
username: z.string(),
|
|
30
35
|
password: z.string()
|
|
@@ -42,6 +47,7 @@ export const PublicApiDefinition = CreateApiDefinition({
|
|
|
42
47
|
logout: {
|
|
43
48
|
method: 'POST',
|
|
44
49
|
path: '/logout',
|
|
50
|
+
description: 'Log out the current user and invalidate their session',
|
|
45
51
|
responses: CreateResponses({
|
|
46
52
|
200: z.object({
|
|
47
53
|
message: z.string()
|
|
@@ -53,6 +59,7 @@ export const PublicApiDefinition = CreateApiDefinition({
|
|
|
53
59
|
list: {
|
|
54
60
|
method: 'GET',
|
|
55
61
|
path: '/products',
|
|
62
|
+
description: 'Retrieve a paginated list of products with optional filtering',
|
|
56
63
|
query: z.object({
|
|
57
64
|
page: z.number().int().min(1).optional().default(1),
|
|
58
65
|
limit: z.number().int().min(1).max(100).optional().default(10),
|
|
@@ -2,11 +2,16 @@ import { ZodSchema as z, CreateApiDefinition, CreateResponses } from '../../src'
|
|
|
2
2
|
|
|
3
3
|
export const PublicApiDefinition = CreateApiDefinition({
|
|
4
4
|
prefix: '/api/v1/public',
|
|
5
|
+
sectionDescriptions: {
|
|
6
|
+
status: 'Health check and status endpoints',
|
|
7
|
+
common: 'Common utility endpoints'
|
|
8
|
+
},
|
|
5
9
|
endpoints: {
|
|
6
10
|
status: {
|
|
7
11
|
probe1: {
|
|
8
12
|
method: 'GET',
|
|
9
13
|
path: '/status/probe1',
|
|
14
|
+
description: 'Advanced health check with query parameters',
|
|
10
15
|
query: z.object({
|
|
11
16
|
match: z.boolean()
|
|
12
17
|
}),
|
|
@@ -21,6 +26,7 @@ export const PublicApiDefinition = CreateApiDefinition({
|
|
|
21
26
|
probe2: {
|
|
22
27
|
method: 'GET',
|
|
23
28
|
path: '/status/probe2',
|
|
29
|
+
description: 'Simple health check endpoint',
|
|
24
30
|
responses: CreateResponses({
|
|
25
31
|
200: z.enum(["pong"]),
|
|
26
32
|
})
|
|
@@ -30,6 +36,7 @@ export const PublicApiDefinition = CreateApiDefinition({
|
|
|
30
36
|
ping: {
|
|
31
37
|
method: 'GET',
|
|
32
38
|
path: '/ping',
|
|
39
|
+
description: 'Basic ping endpoint to check if the service is alive',
|
|
33
40
|
query: z.object({
|
|
34
41
|
format: z.enum(["json", "html"]).optional()
|
|
35
42
|
}),
|
|
@@ -40,12 +47,36 @@ export const PublicApiDefinition = CreateApiDefinition({
|
|
|
40
47
|
customHeaders: {
|
|
41
48
|
method: 'GET',
|
|
42
49
|
path: '/custom-headers',
|
|
50
|
+
description: 'Returns information about custom headers',
|
|
43
51
|
responses: CreateResponses({
|
|
44
52
|
200: z.object({
|
|
45
53
|
message: z.string()
|
|
46
54
|
}),
|
|
47
55
|
})
|
|
48
56
|
},
|
|
57
|
+
longpoll: {
|
|
58
|
+
method: 'GET',
|
|
59
|
+
path: '/longpoll/:sequence',
|
|
60
|
+
description: 'Long polling endpoint that simulates delayed response',
|
|
61
|
+
params: z.object({
|
|
62
|
+
sequence: z.number().int().min(1)
|
|
63
|
+
}),
|
|
64
|
+
responses: CreateResponses({
|
|
65
|
+
200: z.object({
|
|
66
|
+
sequence: z.number(),
|
|
67
|
+
data: z.string(),
|
|
68
|
+
timestamp: z.number()
|
|
69
|
+
}),
|
|
70
|
+
})
|
|
71
|
+
},
|
|
72
|
+
stream: {
|
|
73
|
+
method: 'GET',
|
|
74
|
+
path: '/stream',
|
|
75
|
+
description: 'Server-Sent Events streaming endpoint',
|
|
76
|
+
responses: CreateResponses({
|
|
77
|
+
200: z.string() // Raw SSE data
|
|
78
|
+
})
|
|
79
|
+
},
|
|
49
80
|
}
|
|
50
81
|
}
|
|
51
82
|
})
|
|
@@ -49,6 +49,33 @@ RegisterHandlers(app, PublicApiDefinition, {
|
|
|
49
49
|
res.setHeader('x-custom-test', 'test-value');
|
|
50
50
|
res.setHeader('x-another-header', 'another-value');
|
|
51
51
|
res.respond(200, { message: "headers set" });
|
|
52
|
+
},
|
|
53
|
+
longpoll: async (req, res) => {
|
|
54
|
+
const sequence = req.params.sequence;
|
|
55
|
+
// Simulate long polling delay based on sequence
|
|
56
|
+
const delay = sequence * 100; // 100ms per sequence number
|
|
57
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
58
|
+
res.respond(200, {
|
|
59
|
+
sequence,
|
|
60
|
+
data: `object ${sequence}`,
|
|
61
|
+
timestamp: Date.now()
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
stream: async (req, res) => {
|
|
65
|
+
// Initialize SSE with proper headers
|
|
66
|
+
res.startSSE();
|
|
67
|
+
|
|
68
|
+
// Send SSE events with JSON data at intervals
|
|
69
|
+
await res.streamSSE('update', { sequence: 1, data: 'object 1' });
|
|
70
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
71
|
+
|
|
72
|
+
await res.streamSSE('update', { sequence: 2, data: 'object 2' });
|
|
73
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
74
|
+
|
|
75
|
+
await res.streamSSE('update', { sequence: 3, data: 'object 3' });
|
|
76
|
+
|
|
77
|
+
// Close the stream
|
|
78
|
+
res.endStream();
|
|
52
79
|
}
|
|
53
80
|
},
|
|
54
81
|
status: {
|
package/package.json
CHANGED
package/src/definition.ts
CHANGED
|
@@ -184,13 +184,16 @@ type RouteWithBody = {
|
|
|
184
184
|
};
|
|
185
185
|
|
|
186
186
|
// Union type for all route schemas
|
|
187
|
-
export type RouteSchema = RouteWithoutBody | RouteWithBody
|
|
187
|
+
export type RouteSchema = (RouteWithoutBody | RouteWithBody) & {
|
|
188
|
+
description?: string;
|
|
189
|
+
};
|
|
188
190
|
|
|
189
191
|
// Define the structure for the entire API definition object
|
|
190
192
|
// Now includes an optional prefix and endpoints record
|
|
191
|
-
export type ApiDefinitionSchema = {
|
|
193
|
+
export type ApiDefinitionSchema<TEndpoints extends Record<string, Record<string, RouteSchema>> = Record<string, Record<string, RouteSchema>>> = {
|
|
192
194
|
prefix?: string;
|
|
193
|
-
|
|
195
|
+
sectionDescriptions?: Partial<Record<keyof TEndpoints, string>>;
|
|
196
|
+
endpoints: TEndpoints;
|
|
194
197
|
};
|
|
195
198
|
|
|
196
199
|
// Helper function to ensure the definition conforms to ApiDefinitionSchema
|
package/src/handler.ts
CHANGED
|
@@ -27,6 +27,59 @@ export type EndpointMiddleware<TDef extends ApiDefinitionSchema = ApiDefinitionS
|
|
|
27
27
|
}[keyof TDef['endpoints']]
|
|
28
28
|
) => void | Promise<void>;
|
|
29
29
|
|
|
30
|
+
// Helper function to preprocess parameters for type coercion
|
|
31
|
+
function preprocessParams(params: any, paramsSchema?: z.ZodTypeAny): any {
|
|
32
|
+
if (!paramsSchema || !params) return params;
|
|
33
|
+
|
|
34
|
+
// Create a copy to avoid mutating the original
|
|
35
|
+
const processedParams = { ...params };
|
|
36
|
+
|
|
37
|
+
// Get the shape of the schema if it's a ZodObject
|
|
38
|
+
if (paramsSchema instanceof z.ZodObject) {
|
|
39
|
+
const shape = paramsSchema.shape;
|
|
40
|
+
|
|
41
|
+
for (const [key, value] of Object.entries(processedParams)) {
|
|
42
|
+
if (typeof value === 'string' && shape[key]) {
|
|
43
|
+
const fieldSchema = shape[key];
|
|
44
|
+
|
|
45
|
+
// Handle ZodOptional and ZodDefault wrappers
|
|
46
|
+
let innerSchema = fieldSchema;
|
|
47
|
+
if (fieldSchema instanceof z.ZodOptional) {
|
|
48
|
+
innerSchema = fieldSchema._def.innerType;
|
|
49
|
+
}
|
|
50
|
+
if (fieldSchema instanceof z.ZodDefault) {
|
|
51
|
+
innerSchema = fieldSchema._def.innerType;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Handle nested ZodOptional/ZodDefault combinations
|
|
55
|
+
while (innerSchema instanceof z.ZodOptional || innerSchema instanceof z.ZodDefault) {
|
|
56
|
+
if (innerSchema instanceof z.ZodOptional) {
|
|
57
|
+
innerSchema = innerSchema._def.innerType;
|
|
58
|
+
} else if (innerSchema instanceof z.ZodDefault) {
|
|
59
|
+
innerSchema = innerSchema._def.innerType;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Convert based on the inner schema type
|
|
64
|
+
if (innerSchema instanceof z.ZodNumber) {
|
|
65
|
+
const numValue = Number(value);
|
|
66
|
+
if (!isNaN(numValue)) {
|
|
67
|
+
processedParams[key] = numValue;
|
|
68
|
+
}
|
|
69
|
+
} else if (innerSchema instanceof z.ZodBoolean) {
|
|
70
|
+
if (value === 'true') {
|
|
71
|
+
processedParams[key] = true;
|
|
72
|
+
} else if (value === 'false') {
|
|
73
|
+
processedParams[key] = false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return processedParams;
|
|
81
|
+
}
|
|
82
|
+
|
|
30
83
|
// Helper function to preprocess query parameters for type coercion
|
|
31
84
|
function preprocessQueryParams(query: any, querySchema?: z.ZodTypeAny): any {
|
|
32
85
|
if (!querySchema || !query) return query;
|
|
@@ -326,10 +379,14 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
|
|
|
326
379
|
try {
|
|
327
380
|
// Ensure TDef is correctly used for type inference if this section needs it.
|
|
328
381
|
// Currently, parsedParams,Query,Body are based on runtime routeDefinition.
|
|
329
|
-
const
|
|
330
|
-
? (routeDefinition.params as z.ZodTypeAny)
|
|
382
|
+
const preprocessedParams = ('params' in routeDefinition && routeDefinition.params)
|
|
383
|
+
? preprocessParams(expressReq.params, routeDefinition.params as z.ZodTypeAny)
|
|
331
384
|
: expressReq.params;
|
|
332
385
|
|
|
386
|
+
const parsedParams = ('params' in routeDefinition && routeDefinition.params)
|
|
387
|
+
? (routeDefinition.params as z.ZodTypeAny).parse(preprocessedParams)
|
|
388
|
+
: preprocessedParams;
|
|
389
|
+
|
|
333
390
|
// Preprocess query parameters to handle type coercion from strings
|
|
334
391
|
const preprocessedQuery = ('query' in routeDefinition && routeDefinition.query)
|
|
335
392
|
? preprocessQueryParams(expressReq.query, routeDefinition.query as z.ZodTypeAny)
|
|
@@ -444,6 +501,27 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
|
|
|
444
501
|
return typedExpressRes;
|
|
445
502
|
};
|
|
446
503
|
|
|
504
|
+
// SSE streaming methods
|
|
505
|
+
typedExpressRes.startSSE = () => {
|
|
506
|
+
typedExpressRes.setHeader('Content-Type', 'text/event-stream');
|
|
507
|
+
typedExpressRes.setHeader('Cache-Control', 'no-cache');
|
|
508
|
+
typedExpressRes.setHeader('Connection', 'keep-alive');
|
|
509
|
+
typedExpressRes.setHeader('Access-Control-Allow-Origin', '*');
|
|
510
|
+
typedExpressRes.setHeader('Access-Control-Allow-Headers', 'Cache-Control');
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
typedExpressRes.streamSSE = (eventName?: string, data?: any, id?: string) => {
|
|
514
|
+
let event = '';
|
|
515
|
+
if (eventName) event += `event: ${eventName}\n`;
|
|
516
|
+
if (id) event += `id: ${id}\n`;
|
|
517
|
+
event += `data: ${JSON.stringify(data)}\n\n`;
|
|
518
|
+
expressRes.write(event);
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
typedExpressRes.endStream = () => {
|
|
522
|
+
expressRes.end();
|
|
523
|
+
};
|
|
524
|
+
|
|
447
525
|
const specificHandlerFn = handler as (
|
|
448
526
|
req: TypedRequest<TDef, typeof currentDomain, typeof currentRouteKey>,
|
|
449
527
|
res: TypedResponse<TDef, typeof currentDomain, typeof currentRouteKey>
|
|
@@ -45,6 +45,59 @@ export type HonoTypedContext<
|
|
|
45
45
|
respond: TypedResponse<TDef, TDomain, TRouteKey>['respond'];
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
// Helper function to preprocess parameters for type coercion
|
|
49
|
+
function preprocessParams(params: any, paramsSchema?: z.ZodTypeAny): any {
|
|
50
|
+
if (!paramsSchema || !params) return params;
|
|
51
|
+
|
|
52
|
+
// Create a copy to avoid mutating the original
|
|
53
|
+
const processedParams = { ...params };
|
|
54
|
+
|
|
55
|
+
// Get the shape of the schema if it's a ZodObject
|
|
56
|
+
if (paramsSchema instanceof z.ZodObject) {
|
|
57
|
+
const shape = paramsSchema.shape;
|
|
58
|
+
|
|
59
|
+
for (const [key, value] of Object.entries(processedParams)) {
|
|
60
|
+
if (typeof value === 'string' && shape[key]) {
|
|
61
|
+
const fieldSchema = shape[key];
|
|
62
|
+
|
|
63
|
+
// Handle ZodOptional and ZodDefault wrappers
|
|
64
|
+
let innerSchema = fieldSchema;
|
|
65
|
+
if (fieldSchema instanceof z.ZodOptional) {
|
|
66
|
+
innerSchema = fieldSchema._def.innerType;
|
|
67
|
+
}
|
|
68
|
+
if (fieldSchema instanceof z.ZodDefault) {
|
|
69
|
+
innerSchema = fieldSchema._def.innerType;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Handle nested ZodOptional/ZodDefault combinations
|
|
73
|
+
while (innerSchema instanceof z.ZodOptional || innerSchema instanceof z.ZodDefault) {
|
|
74
|
+
if (innerSchema instanceof z.ZodOptional) {
|
|
75
|
+
innerSchema = innerSchema._def.innerType;
|
|
76
|
+
} else if (innerSchema instanceof z.ZodDefault) {
|
|
77
|
+
innerSchema = innerSchema._def.innerType;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Convert based on the inner schema type
|
|
82
|
+
if (innerSchema instanceof z.ZodNumber) {
|
|
83
|
+
const numValue = Number(value);
|
|
84
|
+
if (!isNaN(numValue)) {
|
|
85
|
+
processedParams[key] = numValue;
|
|
86
|
+
}
|
|
87
|
+
} else if (innerSchema instanceof z.ZodBoolean) {
|
|
88
|
+
if (value === 'true') {
|
|
89
|
+
processedParams[key] = true;
|
|
90
|
+
} else if (value === 'false') {
|
|
91
|
+
processedParams[key] = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return processedParams;
|
|
99
|
+
}
|
|
100
|
+
|
|
48
101
|
// Helper function to preprocess query parameters for type coercion
|
|
49
102
|
function preprocessQueryParams(query: any, querySchema?: z.ZodTypeAny): any {
|
|
50
103
|
if (!querySchema || !query) return query;
|
|
@@ -304,10 +357,14 @@ export function registerHonoRouteHandlers<
|
|
|
304
357
|
) => {
|
|
305
358
|
try {
|
|
306
359
|
// Parse and validate request
|
|
307
|
-
const
|
|
308
|
-
? (routeDefinition.params as z.ZodTypeAny)
|
|
360
|
+
const preprocessedParams = ('params' in routeDefinition && routeDefinition.params)
|
|
361
|
+
? preprocessParams(c.req.param(), routeDefinition.params as z.ZodTypeAny)
|
|
309
362
|
: c.req.param();
|
|
310
363
|
|
|
364
|
+
const parsedParams = ('params' in routeDefinition && routeDefinition.params)
|
|
365
|
+
? (routeDefinition.params as z.ZodTypeAny).parse(preprocessedParams)
|
|
366
|
+
: preprocessedParams;
|
|
367
|
+
|
|
311
368
|
const preprocessedQuery = ('query' in routeDefinition && routeDefinition.query)
|
|
312
369
|
? preprocessQueryParams(c.req.query(), routeDefinition.query as z.ZodTypeAny)
|
|
313
370
|
: c.req.query();
|
|
@@ -429,6 +486,37 @@ export function registerHonoRouteHandlers<
|
|
|
429
486
|
setHeader: (name: string, value: string) => {
|
|
430
487
|
c.header(name, value);
|
|
431
488
|
return fakeRes;
|
|
489
|
+
},
|
|
490
|
+
// SSE streaming methods for Hono
|
|
491
|
+
startSSE: () => {
|
|
492
|
+
c.header('Content-Type', 'text/event-stream');
|
|
493
|
+
c.header('Cache-Control', 'no-cache');
|
|
494
|
+
c.header('Connection', 'keep-alive');
|
|
495
|
+
c.header('Access-Control-Allow-Origin', '*');
|
|
496
|
+
c.header('Access-Control-Allow-Headers', 'Cache-Control');
|
|
497
|
+
},
|
|
498
|
+
streamSSE: (eventName?: string, data?: any, id?: string) => {
|
|
499
|
+
let event = '';
|
|
500
|
+
if (eventName) event += `event: ${eventName}\n`;
|
|
501
|
+
if (id) event += `id: ${id}\n`;
|
|
502
|
+
event += `data: ${JSON.stringify(data)}\n\n`;
|
|
503
|
+
|
|
504
|
+
// For Hono, we need to accumulate the response
|
|
505
|
+
if (!(c as any).__sseBuffer) {
|
|
506
|
+
(c as any).__sseBuffer = '';
|
|
507
|
+
}
|
|
508
|
+
(c as any).__sseBuffer += event;
|
|
509
|
+
},
|
|
510
|
+
endStream: () => {
|
|
511
|
+
// Send the accumulated SSE data
|
|
512
|
+
const sseData = (c as any).__sseBuffer || '';
|
|
513
|
+
(c as any).__response = new Response(sseData, {
|
|
514
|
+
headers: {
|
|
515
|
+
'Content-Type': 'text/event-stream',
|
|
516
|
+
'Cache-Control': 'no-cache',
|
|
517
|
+
'Connection': 'keep-alive'
|
|
518
|
+
}
|
|
519
|
+
});
|
|
432
520
|
}
|
|
433
521
|
} as TypedResponse<TDef, typeof currentDomain, typeof currentRouteKey>;
|
|
434
522
|
|
package/src/openapi-self.ts
CHANGED
|
@@ -17,6 +17,10 @@ export interface OpenAPISpec {
|
|
|
17
17
|
components?: {
|
|
18
18
|
schemas?: Record<string, SchemaObject>;
|
|
19
19
|
};
|
|
20
|
+
tags?: Array<{
|
|
21
|
+
name: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
}>;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export interface PathItem {
|
|
@@ -558,11 +562,14 @@ function processRoute(
|
|
|
558
562
|
|
|
559
563
|
const operation: Operation = {
|
|
560
564
|
summary: `${route.method} ${fullPath}`,
|
|
561
|
-
description: `${route.method} operation for ${fullPath}`,
|
|
562
565
|
responses,
|
|
563
566
|
tags: [domain]
|
|
564
567
|
};
|
|
565
568
|
|
|
569
|
+
if (route.description) {
|
|
570
|
+
operation.description = route.description;
|
|
571
|
+
}
|
|
572
|
+
|
|
566
573
|
if (parameters.length > 0) {
|
|
567
574
|
operation.parameters = parameters;
|
|
568
575
|
}
|
|
@@ -609,6 +616,7 @@ export function generateOpenApiSpec(
|
|
|
609
616
|
const anonymousTypes = options.anonymousTypes || false;
|
|
610
617
|
|
|
611
618
|
const allPaths: Record<string, PathItem> = {};
|
|
619
|
+
const allTags: Array<{ name: string; description?: string }> = [];
|
|
612
620
|
|
|
613
621
|
// Process each definition
|
|
614
622
|
for (const definition of definitionsArray) {
|
|
@@ -623,6 +631,19 @@ export function generateOpenApiSpec(
|
|
|
623
631
|
allPaths[path] = pathItem;
|
|
624
632
|
}
|
|
625
633
|
}
|
|
634
|
+
|
|
635
|
+
// Collect tags from sectionDescriptions
|
|
636
|
+
if (definition.sectionDescriptions) {
|
|
637
|
+
for (const [sectionName, description] of Object.entries(definition.sectionDescriptions)) {
|
|
638
|
+
// Avoid duplicates
|
|
639
|
+
if (!allTags.find(tag => tag.name === sectionName)) {
|
|
640
|
+
allTags.push({
|
|
641
|
+
name: sectionName,
|
|
642
|
+
description: description
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
626
647
|
}
|
|
627
648
|
|
|
628
649
|
const spec: OpenAPISpec = {
|
|
@@ -640,6 +661,11 @@ export function generateOpenApiSpec(
|
|
|
640
661
|
spec.servers = options.servers;
|
|
641
662
|
}
|
|
642
663
|
|
|
664
|
+
// Add tags if any were found
|
|
665
|
+
if (allTags.length > 0) {
|
|
666
|
+
spec.tags = allTags;
|
|
667
|
+
}
|
|
668
|
+
|
|
643
669
|
// Add components with schemas if any were registered and not using anonymous types
|
|
644
670
|
if (!anonymousTypes) {
|
|
645
671
|
const schemas = registry.getSchemas();
|
package/src/openapi.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { RouteSchema } from './definition';
|
|
2
2
|
import { OpenAPIRegistry, OpenApiGeneratorV31, extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
|
|
3
3
|
import { z, ZodTypeAny } from 'zod';
|
|
4
4
|
|
|
5
5
|
// Extend Zod with OpenAPI capabilities
|
|
6
6
|
extendZodWithOpenApi(z);
|
|
7
7
|
|
|
8
|
-
export function generateOpenApiSpec(
|
|
9
|
-
definitions: ApiDefinitionSchema | ApiDefinitionSchema[],
|
|
8
|
+
export function generateOpenApiSpec<TEndpoints extends Record<string, Record<string, RouteSchema>>>(
|
|
9
|
+
definitions: import('./definition').ApiDefinitionSchema<TEndpoints> | import('./definition').ApiDefinitionSchema<TEndpoints>[],
|
|
10
10
|
options: {
|
|
11
11
|
info?: {
|
|
12
12
|
title?: string;
|
|
@@ -59,11 +59,23 @@ export function generateOpenApiSpec(
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// Collect all tags with descriptions for the OpenAPI spec
|
|
63
|
+
const allTags: Array<{ name: string; description?: string }> = [];
|
|
64
|
+
|
|
62
65
|
// Iterate over multiple API definitions to register routes
|
|
63
66
|
definitionArray.forEach((definition) => {
|
|
64
67
|
Object.keys(definition.endpoints).forEach(domainNameKey => {
|
|
65
68
|
// domainNameKey is a string, representing the domain like 'users', 'products'
|
|
66
69
|
const domain = definition.endpoints[domainNameKey];
|
|
70
|
+
|
|
71
|
+
// Add tag if not already present
|
|
72
|
+
if (!allTags.find(tag => tag.name === domainNameKey)) {
|
|
73
|
+
allTags.push({
|
|
74
|
+
name: domainNameKey,
|
|
75
|
+
description: definition.sectionDescriptions?.[domainNameKey]
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
67
79
|
Object.keys(domain).forEach(routeNameKey => {
|
|
68
80
|
// routeNameKey is a string, representing the route name like 'getUser', 'createProduct'
|
|
69
81
|
const route: RouteSchema = domain[routeNameKey];
|
|
@@ -108,6 +120,7 @@ export function generateOpenApiSpec(
|
|
|
108
120
|
|
|
109
121
|
const operation = {
|
|
110
122
|
summary: `${domainNameKey} - ${routeNameKey}`, // Use keys directly for summary
|
|
123
|
+
description: route.description, // Use route description if provided
|
|
111
124
|
tags: [domainNameKey], // Use domainNameKey for tags
|
|
112
125
|
parameters: parameters.length > 0 ? parameters : undefined,
|
|
113
126
|
requestBody: requestBody,
|
|
@@ -122,7 +135,6 @@ export function generateOpenApiSpec(
|
|
|
122
135
|
method: route.method.toLowerCase() as any, // Ensure method is lowercase
|
|
123
136
|
path: openApiPath,
|
|
124
137
|
...operation,
|
|
125
|
-
// Add description or other OpenAPI fields if available in RouteSchema
|
|
126
138
|
});
|
|
127
139
|
});
|
|
128
140
|
});
|
|
@@ -138,6 +150,7 @@ export function generateOpenApiSpec(
|
|
|
138
150
|
description: options.info?.description ?? 'Automatically generated OpenAPI specification',
|
|
139
151
|
},
|
|
140
152
|
servers: options.servers ?? [{ url: '/api' }], // Adjust as needed
|
|
153
|
+
tags: allTags.filter(tag => tag.description), // Only include tags that have descriptions
|
|
141
154
|
});
|
|
142
155
|
|
|
143
156
|
return openApiDocument;
|
package/src/router.ts
CHANGED
|
@@ -62,6 +62,10 @@ export interface TypedResponse<
|
|
|
62
62
|
respondContentType: (status: number, data: any, contentType: string) => void;
|
|
63
63
|
setHeader: (name: string, value: string) => this;
|
|
64
64
|
json: <B = any>(body: B) => this; // Keep original json
|
|
65
|
+
// SSE streaming methods
|
|
66
|
+
startSSE: () => void;
|
|
67
|
+
streamSSE: (eventName?: string, data?: any, id?: string) => void;
|
|
68
|
+
endStream: () => void;
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
// Type-safe route handler creation function, now generic over TDef and Ctx
|
|
@@ -316,4 +316,105 @@ describe('OpenAPI Specification Generation', () => {
|
|
|
316
316
|
}
|
|
317
317
|
}
|
|
318
318
|
});
|
|
319
|
+
|
|
320
|
+
test('should include section descriptions in OpenAPI tags', () => {
|
|
321
|
+
const spec = generateOpenApiSpec(PublicApiDefinition);
|
|
322
|
+
|
|
323
|
+
expect(spec).toBeDefined();
|
|
324
|
+
expect(spec.tags).toBeDefined();
|
|
325
|
+
expect(Array.isArray(spec.tags)).toBe(true);
|
|
326
|
+
|
|
327
|
+
// Check that tags with descriptions are included
|
|
328
|
+
const statusTag = spec.tags?.find((tag: any) => tag.name === 'status');
|
|
329
|
+
const commonTag = spec.tags?.find((tag: any) => tag.name === 'common');
|
|
330
|
+
|
|
331
|
+
expect(statusTag).toBeDefined();
|
|
332
|
+
expect(statusTag?.description).toBe('Health check and status endpoints');
|
|
333
|
+
|
|
334
|
+
expect(commonTag).toBeDefined();
|
|
335
|
+
expect(commonTag?.description).toBe('Common utility endpoints');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('should include route descriptions in operation descriptions', () => {
|
|
339
|
+
const spec = generateOpenApiSpec(PublicApiDefinition);
|
|
340
|
+
|
|
341
|
+
expect(spec).toBeDefined();
|
|
342
|
+
expect(spec.paths).toBeDefined();
|
|
343
|
+
|
|
344
|
+
// Check specific endpoints for descriptions
|
|
345
|
+
const probe1Path = spec.paths?.['/api/v1/public/status/probe1'];
|
|
346
|
+
const pingPath = spec.paths?.['/api/v1/public/ping'];
|
|
347
|
+
|
|
348
|
+
expect(probe1Path?.get?.description).toBe('Advanced health check with query parameters');
|
|
349
|
+
expect(pingPath?.get?.description).toBe('Basic ping endpoint to check if the service is alive');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('should handle API definitions without descriptions', () => {
|
|
353
|
+
// Create a definition without descriptions
|
|
354
|
+
const NoDescriptionDefinition = CreateApiDefinition({
|
|
355
|
+
endpoints: {
|
|
356
|
+
test: {
|
|
357
|
+
simpleEndpoint: {
|
|
358
|
+
method: 'GET' as const,
|
|
359
|
+
path: '/test',
|
|
360
|
+
responses: {
|
|
361
|
+
200: z.object({ message: z.string() })
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const spec = generateOpenApiSpec(NoDescriptionDefinition);
|
|
369
|
+
|
|
370
|
+
expect(spec).toBeDefined();
|
|
371
|
+
expect(spec.tags).toBeUndefined(); // No tags should be generated when no descriptions
|
|
372
|
+
|
|
373
|
+
const testPath = spec.paths?.['/test'];
|
|
374
|
+
expect(testPath?.get?.description).toBeUndefined(); // No description on operation
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('should handle partial section descriptions', () => {
|
|
378
|
+
// Create a definition with only some sections having descriptions
|
|
379
|
+
const PartialDescriptionDefinition = CreateApiDefinition({
|
|
380
|
+
sectionDescriptions: {
|
|
381
|
+
section1: 'Description for section 1'
|
|
382
|
+
// section2 intentionally omitted
|
|
383
|
+
},
|
|
384
|
+
endpoints: {
|
|
385
|
+
section1: {
|
|
386
|
+
endpoint1: {
|
|
387
|
+
method: 'GET' as const,
|
|
388
|
+
path: '/section1/endpoint1',
|
|
389
|
+
responses: {
|
|
390
|
+
200: z.object({ data: z.string() })
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
section2: {
|
|
395
|
+
endpoint2: {
|
|
396
|
+
method: 'GET' as const,
|
|
397
|
+
path: '/section2/endpoint2',
|
|
398
|
+
responses: {
|
|
399
|
+
200: z.object({ data: z.string() })
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const spec = generateOpenApiSpec(PartialDescriptionDefinition);
|
|
407
|
+
|
|
408
|
+
expect(spec).toBeDefined();
|
|
409
|
+
expect(spec.tags).toBeDefined();
|
|
410
|
+
expect(spec.tags?.length).toBe(1); // Only one tag should be generated
|
|
411
|
+
|
|
412
|
+
const section1Tag = spec.tags?.find((tag: any) => tag.name === 'section1');
|
|
413
|
+
expect(section1Tag).toBeDefined();
|
|
414
|
+
expect(section1Tag?.description).toBe('Description for section 1');
|
|
415
|
+
|
|
416
|
+
// section2 should not have a tag since it has no description
|
|
417
|
+
const section2Tag = spec.tags?.find((tag: any) => tag.name === 'section2');
|
|
418
|
+
expect(section2Tag).toBeUndefined();
|
|
419
|
+
});
|
|
319
420
|
});
|
package/tests/setup.ts
CHANGED
|
@@ -23,6 +23,33 @@ const simplePublicHandlers = {
|
|
|
23
23
|
res.setHeader('X-Custom-Test', 'test-value');
|
|
24
24
|
res.setHeader('X-Another-Header', 'another-value');
|
|
25
25
|
res.respond(200, { message: "headers set" });
|
|
26
|
+
},
|
|
27
|
+
longpoll: async (req: any, res: any) => {
|
|
28
|
+
const sequence = req.params.sequence;
|
|
29
|
+
// Simulate long polling delay based on sequence
|
|
30
|
+
const delay = sequence * 100; // 100ms per sequence number
|
|
31
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
32
|
+
res.respond(200, {
|
|
33
|
+
sequence,
|
|
34
|
+
data: `object ${sequence}`,
|
|
35
|
+
timestamp: Date.now()
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
stream: async (req: any, res: any) => {
|
|
39
|
+
// Initialize SSE with proper headers
|
|
40
|
+
res.startSSE();
|
|
41
|
+
|
|
42
|
+
// Send SSE events with JSON data at intervals
|
|
43
|
+
await res.streamSSE('update', { sequence: 1, data: 'object 1' });
|
|
44
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
45
|
+
|
|
46
|
+
await res.streamSSE('update', { sequence: 2, data: 'object 2' });
|
|
47
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
48
|
+
|
|
49
|
+
await res.streamSSE('update', { sequence: 3, data: 'object 3' });
|
|
50
|
+
|
|
51
|
+
// Close the stream
|
|
52
|
+
res.endStream();
|
|
26
53
|
}
|
|
27
54
|
},
|
|
28
55
|
status: {
|
package/tests/simple-api.test.ts
CHANGED
|
@@ -121,6 +121,136 @@ describe.each([
|
|
|
121
121
|
expect(anotherHeader).toBe('another-value');
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
test('should handle long polling with delayed responses', async () => {
|
|
125
|
+
const startTime = Date.now();
|
|
126
|
+
|
|
127
|
+
// Test multiple sequences to simulate intervals
|
|
128
|
+
for (let seq = 1; seq <= 3; seq++) {
|
|
129
|
+
const result = await client.callApi('common', 'longpoll', {
|
|
130
|
+
params: { sequence: seq }
|
|
131
|
+
}, {
|
|
132
|
+
200: ({ data }) => {
|
|
133
|
+
expect(data.sequence).toBe(seq);
|
|
134
|
+
expect(data.data).toBe(`object ${seq}`);
|
|
135
|
+
expect(typeof data.timestamp).toBe('number');
|
|
136
|
+
expect(data.timestamp).toBeGreaterThan(startTime);
|
|
137
|
+
return data;
|
|
138
|
+
},
|
|
139
|
+
422: ({ error }) => {
|
|
140
|
+
throw new Error(`Validation error: ${JSON.stringify(error)}`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(result.sequence).toBe(seq);
|
|
145
|
+
expect(result.data).toBe(`object ${seq}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Verify that total time is at least the sum of delays (100ms * 1 + 100ms * 2 + 100ms * 3 = 600ms)
|
|
149
|
+
const elapsed = Date.now() - startTime;
|
|
150
|
+
expect(elapsed).toBeGreaterThanOrEqual(600); // Allow some tolerance for test execution
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('should handle SSE streaming with multiple JSON objects', async () => {
|
|
154
|
+
// Test SSE streaming by making a fetch request and parsing the response
|
|
155
|
+
const response = await fetch(`${baseUrl}/api/v1/public/stream`);
|
|
156
|
+
expect(response.status).toBe(200);
|
|
157
|
+
expect(response.headers.get('content-type')).toBe('text/event-stream');
|
|
158
|
+
|
|
159
|
+
const responseText = await response.text();
|
|
160
|
+
|
|
161
|
+
// Parse SSE events from the response
|
|
162
|
+
const events = responseText.trim().split('\n\n');
|
|
163
|
+
expect(events).toHaveLength(3);
|
|
164
|
+
|
|
165
|
+
// Verify each event
|
|
166
|
+
for (let i = 0; i < events.length; i++) {
|
|
167
|
+
const event = events[i];
|
|
168
|
+
const lines = event.split('\n');
|
|
169
|
+
expect(lines[0]).toBe('event: update');
|
|
170
|
+
|
|
171
|
+
const dataLine = lines.find(line => line.startsWith('data: '));
|
|
172
|
+
expect(dataLine).toBeDefined();
|
|
173
|
+
|
|
174
|
+
const data = JSON.parse(dataLine!.substring(6)); // Remove 'data: ' prefix
|
|
175
|
+
expect(data.sequence).toBe(i + 1);
|
|
176
|
+
expect(data.data).toBe(`object ${i + 1}`);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('should handle SSE streaming incrementally', async () => {
|
|
181
|
+
const response = await fetch(`${baseUrl}/api/v1/public/stream`);
|
|
182
|
+
expect(response.status).toBe(200);
|
|
183
|
+
expect(response.headers.get('content-type')).toBe('text/event-stream');
|
|
184
|
+
|
|
185
|
+
return new Promise<void>((resolve, reject) => {
|
|
186
|
+
// node-fetch returns a Node.js stream, cast properly
|
|
187
|
+
const stream = response.body as any; // Node.js Readable stream
|
|
188
|
+
let buffer = '';
|
|
189
|
+
const events: any[] = [];
|
|
190
|
+
|
|
191
|
+
stream.on('data', (chunk: Buffer) => {
|
|
192
|
+
buffer += chunk.toString();
|
|
193
|
+
|
|
194
|
+
// Parse complete SSE events from buffer
|
|
195
|
+
const lines = buffer.split('\n');
|
|
196
|
+
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
197
|
+
|
|
198
|
+
let currentEvent = '';
|
|
199
|
+
for (const line of lines) {
|
|
200
|
+
if (line === '') {
|
|
201
|
+
// Empty line = end of event
|
|
202
|
+
if (currentEvent.trim()) {
|
|
203
|
+
const eventData = parseSSEEvent(currentEvent);
|
|
204
|
+
if (eventData) events.push(eventData);
|
|
205
|
+
}
|
|
206
|
+
currentEvent = '';
|
|
207
|
+
} else {
|
|
208
|
+
currentEvent += line + '\n';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check if we have all expected events
|
|
213
|
+
if (events.length >= 3) {
|
|
214
|
+
// For Node.js streams, just remove listeners to "close"
|
|
215
|
+
stream.removeAllListeners('data');
|
|
216
|
+
stream.removeAllListeners('error');
|
|
217
|
+
stream.removeAllListeners('end');
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
expect(events).toHaveLength(3);
|
|
221
|
+
expect(events[0]).toEqual({ sequence: 1, data: 'object 1' });
|
|
222
|
+
expect(events[1]).toEqual({ sequence: 2, data: 'object 2' });
|
|
223
|
+
expect(events[2]).toEqual({ sequence: 3, data: 'object 3' });
|
|
224
|
+
resolve();
|
|
225
|
+
} catch (error) {
|
|
226
|
+
reject(error);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
stream.on('error', reject);
|
|
232
|
+
stream.on('end', () => {
|
|
233
|
+
// If we get here without 3 events, test failed
|
|
234
|
+
if (events.length < 3) {
|
|
235
|
+
reject(new Error(`Expected 3 events, got ${events.length}`));
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
function parseSSEEvent(eventText: string): any | null {
|
|
242
|
+
const lines = eventText.split('\n');
|
|
243
|
+
let data = '';
|
|
244
|
+
|
|
245
|
+
for (const line of lines) {
|
|
246
|
+
if (line.startsWith('data: ')) {
|
|
247
|
+
data = line.substring(6);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return data ? JSON.parse(data) : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
124
254
|
test('generateUrl should return correct URL for ping', () => {
|
|
125
255
|
const url = client.generateUrl('common', 'ping');
|
|
126
256
|
expect(url).toBe(`${baseUrl}/api/v1/public/ping`);
|