ts-typed-api 0.2.17 → 0.2.19

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.
@@ -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
- export type ApiDefinitionSchema = {
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
- endpoints: Record<string, Record<string, RouteSchema>>;
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
@@ -56,8 +56,19 @@ function preprocessQueryParams(query, querySchema) {
56
56
  return processedQuery;
57
57
  }
58
58
  // Helper function to create respond method for middleware compatibility
59
- function createRespondFunction(routeDefinition, responseSetter) {
59
+ function createRespondFunction(routeDefinition, responseSetter, middlewareRes) {
60
60
  return (status, data) => {
61
+ // Call any registered response callbacks
62
+ if (middlewareRes && middlewareRes._responseCallbacks) {
63
+ middlewareRes._responseCallbacks.forEach((callback) => {
64
+ try {
65
+ callback(status, data);
66
+ }
67
+ catch (error) {
68
+ console.error('Error in response callback:', error);
69
+ }
70
+ });
71
+ }
61
72
  const responseSchema = routeDefinition.responses[status];
62
73
  if (!responseSchema) {
63
74
  console.error(`No response schema defined for status ${status}`);
@@ -305,6 +316,17 @@ middlewares) {
305
316
  // Augment expressRes with the .respond and .setHeader methods, using TDef
306
317
  const typedExpressRes = expressRes;
307
318
  typedExpressRes.respond = (status, dataForResponse) => {
319
+ // Call any registered response callbacks from middleware
320
+ if (expressRes._responseCallbacks) {
321
+ expressRes._responseCallbacks.forEach((callback) => {
322
+ try {
323
+ callback(status, dataForResponse);
324
+ }
325
+ catch (error) {
326
+ console.error('Error in response callback:', error);
327
+ }
328
+ });
329
+ }
308
330
  // Use the passed apiDefinition object
309
331
  const routeSchemaForHandler = apiDefinition.endpoints[currentDomain][currentRouteKey];
310
332
  const responseSchemaForStatus = routeSchemaForHandler.responses[status];
@@ -422,11 +444,18 @@ middlewares) {
422
444
  middlewares.forEach(middleware => {
423
445
  const wrappedMiddleware = async (req, res, next) => {
424
446
  try {
425
- // Add respond method to res for middleware compatibility
447
+ // Add respond and onFinish methods to res for middleware compatibility
426
448
  const middlewareRes = res;
427
449
  middlewareRes.respond = createRespondFunction(routeDefinition, (status, data) => {
428
450
  res.status(status).json(data);
429
- });
451
+ }, middlewareRes);
452
+ middlewareRes.onResponse = (callback) => {
453
+ // Store callback on the underlying express response so it's accessible from TypedResponse
454
+ if (!res._responseCallbacks) {
455
+ res._responseCallbacks = [];
456
+ }
457
+ res._responseCallbacks.push(callback);
458
+ };
430
459
  await middleware(req, middlewareRes, next, { domain: currentDomain, routeKey: currentRouteKey });
431
460
  }
432
461
  catch (error) {
@@ -273,6 +273,17 @@ function registerHonoRouteHandlers(app, apiDefinition, routeHandlers, middleware
273
273
  c.ctx = c.get('ctx') || {};
274
274
  // Add respond method to context
275
275
  c.respond = (status, data) => {
276
+ // Call any registered response callbacks from middleware
277
+ if (c._responseCallbacks) {
278
+ c._responseCallbacks.forEach((callback) => {
279
+ try {
280
+ callback(status, data);
281
+ }
282
+ catch (error) {
283
+ console.error('Error in response callback:', error);
284
+ }
285
+ });
286
+ }
276
287
  const responseSchema = routeDefinition.responses[status];
277
288
  if (!responseSchema) {
278
289
  console.error(`No response schema defined for status ${status} in route ${String(currentDomain)}/${String(currentRouteKey)}`);
@@ -426,9 +437,20 @@ function registerHonoRouteHandlers(app, apiDefinition, routeHandlers, middleware
426
437
  path: c.req.path,
427
438
  originalUrl: c.req.url
428
439
  };
429
- // Create minimal res object with respond method for middleware compatibility
440
+ // Create minimal res object with respond and onResponse methods for middleware compatibility
430
441
  const fakeRes = {
431
442
  respond: (status, data) => {
443
+ // Call any registered response callbacks
444
+ if (c._responseCallbacks) {
445
+ c._responseCallbacks.forEach((callback) => {
446
+ try {
447
+ callback(status, data);
448
+ }
449
+ catch (error) {
450
+ console.error('Error in response callback:', error);
451
+ }
452
+ });
453
+ }
432
454
  const responseSchema = routeDefinition.responses[status];
433
455
  if (!responseSchema) {
434
456
  console.error(`No response schema defined for status ${status}`);
@@ -482,6 +504,13 @@ function registerHonoRouteHandlers(app, apiDefinition, routeHandlers, middleware
482
504
  },
483
505
  end: () => {
484
506
  // Perhaps do nothing or set response
507
+ },
508
+ onResponse: (callback) => {
509
+ // Store callback to be called when respond() is invoked
510
+ if (!c._responseCallbacks) {
511
+ c._responseCallbacks = [];
512
+ }
513
+ c._responseCallbacks.push(callback);
485
514
  }
486
515
  };
487
516
  // Call Express-style middleware
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { ApiClient, FetchHttpClientAdapter } from './client';
2
2
  export { generateOpenApiSpec } from './openapi';
3
3
  export { generateOpenApiSpec as generateOpenApiSpec2 } from './openapi-self';
4
4
  export { CreateApiDefinition, CreateResponses, ApiDefinitionSchema } from './definition';
5
- export { RegisterHandlers, EndpointMiddleware, UniversalEndpointMiddleware, SimpleMiddleware, EndpointInfo } from './object-handlers';
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';
8
8
  export { RegisterHonoHandlers, registerHonoRouteHandlers, HonoFile, HonoFileType, honoFileSchema, HonoTypedContext, CreateTypedHonoHandlerWithContext } from './hono-cloudflare-workers';
@@ -19,6 +19,7 @@ export interface MiddlewareResponse {
19
19
  json(data: any): void;
20
20
  setHeader(name: string, value: string): void;
21
21
  end(): void;
22
+ onResponse(callback: (status: number, data: any) => void): void;
22
23
  }
23
24
  export type EndpointMiddlewareCtx<Ctx extends Record<string, any> = Record<string, any>, TDef extends ApiDefinitionSchema = ApiDefinitionSchema> = ((req: express.Request & {
24
25
  ctx?: Ctx;
@@ -14,6 +14,10 @@ export interface OpenAPISpec {
14
14
  components?: {
15
15
  schemas?: Record<string, SchemaObject>;
16
16
  };
17
+ tags?: Array<{
18
+ name: string;
19
+ description?: string;
20
+ }>;
17
21
  }
18
22
  export interface PathItem {
19
23
  get?: Operation;
@@ -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 { ApiDefinitionSchema } from './definition';
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
  }
@@ -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,6 +47,7 @@ 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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-typed-api",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "A lightweight, type-safe RPC library for TypeScript with Zod validation",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
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
- endpoints: Record<string, Record<string, RouteSchema>>;
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
@@ -83,9 +83,21 @@ function preprocessQueryParams(query: any, querySchema?: z.ZodTypeAny): any {
83
83
  // Helper function to create respond method for middleware compatibility
84
84
  function createRespondFunction(
85
85
  routeDefinition: RouteSchema,
86
- responseSetter: (status: number, data: any) => void
86
+ responseSetter: (status: number, data: any) => void,
87
+ middlewareRes?: any
87
88
  ) {
88
89
  return (status: number, data: any) => {
90
+ // Call any registered response callbacks
91
+ if (middlewareRes && middlewareRes._responseCallbacks) {
92
+ middlewareRes._responseCallbacks.forEach((callback: (status: number, data: any) => void) => {
93
+ try {
94
+ callback(status, data);
95
+ } catch (error) {
96
+ console.error('Error in response callback:', error);
97
+ }
98
+ });
99
+ }
100
+
89
101
  const responseSchema = routeDefinition.responses[status];
90
102
 
91
103
  if (!responseSchema) {
@@ -359,6 +371,17 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
359
371
  const typedExpressRes = expressRes as TypedResponse<TDef, typeof currentDomain, typeof currentRouteKey>;
360
372
 
361
373
  typedExpressRes.respond = (status, dataForResponse) => {
374
+ // Call any registered response callbacks from middleware
375
+ if ((expressRes as any)._responseCallbacks) {
376
+ (expressRes as any)._responseCallbacks.forEach((callback: (status: number, data: any) => void) => {
377
+ try {
378
+ callback(status, dataForResponse);
379
+ } catch (error) {
380
+ console.error('Error in response callback:', error);
381
+ }
382
+ });
383
+ }
384
+
362
385
  // Use the passed apiDefinition object
363
386
  const routeSchemaForHandler = apiDefinition.endpoints[currentDomain][currentRouteKey] as RouteSchema;
364
387
  const responseSchemaForStatus = routeSchemaForHandler.responses[status as number];
@@ -487,11 +510,18 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
487
510
  middlewares.forEach(middleware => {
488
511
  const wrappedMiddleware: express.RequestHandler = async (req, res, next) => {
489
512
  try {
490
- // Add respond method to res for middleware compatibility
513
+ // Add respond and onFinish methods to res for middleware compatibility
491
514
  const middlewareRes = res as any;
492
515
  middlewareRes.respond = createRespondFunction(routeDefinition, (status, data) => {
493
516
  res.status(status).json(data);
494
- });
517
+ }, middlewareRes);
518
+ middlewareRes.onResponse = (callback: (status: number, data: any) => void) => {
519
+ // Store callback on the underlying express response so it's accessible from TypedResponse
520
+ if (!(res as any)._responseCallbacks) {
521
+ (res as any)._responseCallbacks = [];
522
+ }
523
+ (res as any)._responseCallbacks.push(callback);
524
+ };
495
525
  await middleware(req, middlewareRes as MiddlewareResponse, next, { domain: currentDomain, routeKey: currentRouteKey } as any);
496
526
  } catch (error) {
497
527
  next(error);
@@ -341,6 +341,17 @@ export function registerHonoRouteHandlers<
341
341
 
342
342
  // Add respond method to context
343
343
  (c as any).respond = (status: number, data: any) => {
344
+ // Call any registered response callbacks from middleware
345
+ if ((c as any)._responseCallbacks) {
346
+ (c as any)._responseCallbacks.forEach((callback: (status: number, data: any) => void) => {
347
+ try {
348
+ callback(status, data);
349
+ } catch (error) {
350
+ console.error('Error in response callback:', error);
351
+ }
352
+ });
353
+ }
354
+
344
355
  const responseSchema = routeDefinition.responses[status];
345
356
 
346
357
  if (!responseSchema) {
@@ -508,9 +519,20 @@ export function registerHonoRouteHandlers<
508
519
  originalUrl: c.req.url
509
520
  };
510
521
 
511
- // Create minimal res object with respond method for middleware compatibility
522
+ // Create minimal res object with respond and onResponse methods for middleware compatibility
512
523
  const fakeRes = {
513
524
  respond: (status: number, data: any) => {
525
+ // Call any registered response callbacks
526
+ if ((c as any)._responseCallbacks) {
527
+ (c as any)._responseCallbacks.forEach((callback: (status: number, data: any) => void) => {
528
+ try {
529
+ callback(status, data);
530
+ } catch (error) {
531
+ console.error('Error in response callback:', error);
532
+ }
533
+ });
534
+ }
535
+
514
536
  const responseSchema = routeDefinition.responses[status];
515
537
 
516
538
  if (!responseSchema) {
@@ -571,6 +593,13 @@ export function registerHonoRouteHandlers<
571
593
  },
572
594
  end: () => {
573
595
  // Perhaps do nothing or set response
596
+ },
597
+ onResponse: (callback: (status: number, data: any) => void) => {
598
+ // Store callback to be called when respond() is invoked
599
+ if (!(c as any)._responseCallbacks) {
600
+ (c as any)._responseCallbacks = [];
601
+ }
602
+ (c as any)._responseCallbacks.push(callback);
574
603
  }
575
604
  };
576
605
 
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ export { ApiClient, FetchHttpClientAdapter } from './client';
2
2
  export { generateOpenApiSpec } from './openapi'
3
3
  export { generateOpenApiSpec as generateOpenApiSpec2 } from './openapi-self'
4
4
  export { CreateApiDefinition, CreateResponses, ApiDefinitionSchema } from './definition';
5
- export { RegisterHandlers, EndpointMiddleware, UniversalEndpointMiddleware, SimpleMiddleware, EndpointInfo } from './object-handlers';
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';
8
8
 
@@ -43,6 +43,7 @@ export interface MiddlewareResponse {
43
43
  json(data: any): void;
44
44
  setHeader(name: string, value: string): void;
45
45
  end(): void;
46
+ onResponse(callback: (status: number, data: any) => void): void;
46
47
  }
47
48
 
48
49
  // Unified middleware type that works for both Express and Hono with context typing
@@ -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 { ApiDefinitionSchema, RouteSchema } from './definition';
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;
@@ -105,4 +105,81 @@ describe.each([
105
105
  ).rejects.toThrow('Forbidden as expected');
106
106
  });
107
107
  });
108
+
109
+ describe('Response Logging Middleware', () => {
110
+ let consoleSpy: jest.SpyInstance;
111
+
112
+ beforeEach(() => {
113
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
114
+ });
115
+
116
+ afterEach(() => {
117
+ consoleSpy.mockRestore();
118
+ });
119
+
120
+ test('should log response status without breaking functionality', async () => {
121
+ // The response logging middleware should not interfere with normal operation
122
+ const result = await client.callApi('public', 'ping', {}, {
123
+ 200: ({ data }) => {
124
+ expect(data.message).toBe('pong');
125
+ return data;
126
+ },
127
+ 422: ({ error }) => {
128
+ throw new Error(`Validation error: ${JSON.stringify(error)}`);
129
+ }
130
+ });
131
+
132
+ expect(result.message).toBe('pong');
133
+
134
+ // Assert that console.log was called with the expected message
135
+ expect(consoleSpy).toHaveBeenCalledWith('[TIMING] public.ping responded with 200');
136
+ expect(consoleSpy).toHaveBeenCalledWith('[Test] GET /api/v1/ping - Domain: public, Route: ping');
137
+ });
138
+
139
+ test('should log response status for protected routes', async () => {
140
+ const result = await client.callApi('public', 'protected', { headers: { Authorization: 'Bearer valid-token' } }, {
141
+ 200: ({ data }) => {
142
+ expect(data.message).toBe('protected content');
143
+ expect(data.user).toBe('testuser');
144
+ return data;
145
+ },
146
+ 401: ({ data }) => {
147
+ throw new Error(`Authentication failed: ${data.error}`);
148
+ },
149
+ 403: ({ data }) => {
150
+ throw new Error(`Forbidden: ${data.error}`);
151
+ },
152
+ 422: ({ error }) => {
153
+ throw new Error(`Validation error: ${JSON.stringify(error)}`);
154
+ }
155
+ });
156
+
157
+ expect(result.user).toBe('testuser');
158
+
159
+ // Assert that console.log was called with the expected messages
160
+ expect(consoleSpy).toHaveBeenCalledWith('[TIMING] public.protected responded with 200');
161
+ expect(consoleSpy).toHaveBeenCalledWith('[Test] GET /api/v1/protected - Domain: public, Route: protected');
162
+ });
163
+
164
+ test('should log error status codes for auth failures', async () => {
165
+ await expect(
166
+ client.callApi('public', 'protected', {}, {
167
+ 200: ({ data }) => data,
168
+ 401: ({ data }) => {
169
+ expect(data.error).toBe('No authorization header');
170
+ throw new Error('Authentication failed as expected');
171
+ },
172
+ 403: ({ data }) => {
173
+ throw new Error(`Unexpected forbidden: ${data.error}`);
174
+ },
175
+ 422: ({ error }) => {
176
+ throw new Error(`Validation error: ${JSON.stringify(error)}`);
177
+ }
178
+ })
179
+ ).rejects.toThrow('Authentication failed as expected');
180
+
181
+ // Assert that console.log was called with the error status
182
+ expect(consoleSpy).toHaveBeenCalledWith('[TIMING] public.protected responded with 401');
183
+ });
184
+ });
108
185
  });
@@ -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
@@ -481,6 +481,14 @@ const middlewareTestHandlers = {
481
481
  // Generic middleware setup function
482
482
  function setupMiddlewareApp(app: any, isHono: boolean) {
483
483
  // Define middleware functions
484
+ const timingMiddleware: EndpointMiddlewareCtx<Ctx> = async (req, res, next, endpointInfo) => {
485
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
486
+ res.onResponse((status, _data) => {
487
+ console.log(`[TIMING] ${endpointInfo.domain}.${endpointInfo.routeKey} responded with ${status}`);
488
+ });
489
+ await next();
490
+ };
491
+
484
492
  const loggingMiddleware: EndpointMiddlewareCtx<Ctx> = async (req, res, next, endpointInfo) => {
485
493
  console.log(`[Test] ${req.method} ${req.path} - Domain: ${endpointInfo.domain}, Route: ${endpointInfo.routeKey}`);
486
494
  await next();
@@ -509,6 +517,7 @@ function setupMiddlewareApp(app: any, isHono: boolean) {
509
517
  }
510
518
 
511
519
  const middlewares = [
520
+ timingMiddleware,
512
521
  loggingMiddleware,
513
522
  contextMiddleware,
514
523
  authMiddleware