@onebun/docs 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,12 +4,11 @@ Documentation generation package for OneBun framework with OpenAPI/Swagger suppo
4
4
 
5
5
  ## Features
6
6
 
7
- - 📖 **OpenAPI Generation** - Automatic OpenAPI 3.0 specification from decorators
8
- - 🎨 **Swagger UI** - Built-in Swagger UI for interactive API documentation
9
- - 🔄 **JSON Schema Converter** - Convert validation schemas to JSON Schema format
10
- - 🏷️ **Decorator-based** - Use decorators to document your API endpoints
11
- - 📝 **Type-safe** - Full TypeScript support with type inference
12
- - ⚡ **Effect.js Integration** - Seamless integration with Effect.js ecosystem
7
+ - OpenAPI Generation - Automatic OpenAPI 3.1 specification from decorators
8
+ - Swagger UI - Built-in Swagger UI HTML generation for interactive API documentation
9
+ - JSON Schema Converter - Convert ArkType validation schemas to JSON Schema format
10
+ - Decorator-based - Use decorators to add metadata for documentation
11
+ - Type-safe - Full TypeScript support with type inference
13
12
 
14
13
  ## Installation
15
14
 
@@ -21,178 +20,212 @@ bun add @onebun/docs
21
20
 
22
21
  ### Basic Usage
23
22
 
23
+ The `@onebun/docs` package provides decorators for adding documentation metadata to your controllers. Note that `@ApiResponse` for response validation is provided by `@onebun/core`.
24
+
25
+ **Important: Decorator Order Matters!**
26
+ - `@ApiTags` must be placed **above** `@Controller` (because `@Controller` wraps the class)
27
+ - `@ApiOperation` must be placed **above** route decorators (`@Get`, `@Post`, etc.)
28
+ - `@ApiResponse` must be placed **below** route decorators
29
+
24
30
  ```typescript
25
- import { OneBunApplication, Controller, Get, Post, Body } from '@onebun/core';
26
- import { ApiTag, ApiOperation, ApiResponse, ApiBody } from '@onebun/docs';
31
+ import { Controller, Get, Post, Body, BaseController, ApiResponse } from '@onebun/core';
32
+ import { ApiTags, ApiOperation } from '@onebun/docs';
33
+ import { type } from 'arktype';
34
+
35
+ const createUserSchema = type({
36
+ name: 'string',
37
+ email: 'string.email',
38
+ });
27
39
 
40
+ // @ApiTags ABOVE @Controller
41
+ @ApiTags('Users')
28
42
  @Controller('/users')
29
- @ApiTag('Users', 'User management endpoints')
30
- export class UserController {
31
- @Get()
43
+ export class UserController extends BaseController {
44
+ // @ApiOperation ABOVE @Get, @ApiResponse BELOW @Get
32
45
  @ApiOperation({ summary: 'Get all users', description: 'Returns a list of all users' })
33
- @ApiResponse({ status: 200, description: 'List of users returned successfully' })
34
- async getUsers() {
35
- return { users: [] };
46
+ @Get('/')
47
+ @ApiResponse(200, { description: 'List of users returned successfully' })
48
+ async getUsers(): Promise<Response> {
49
+ return this.success({ users: [] });
36
50
  }
37
51
 
38
- @Post()
39
52
  @ApiOperation({ summary: 'Create user', description: 'Creates a new user' })
40
- @ApiBody({ description: 'User data', required: true })
41
- @ApiResponse({ status: 201, description: 'User created successfully' })
42
- @ApiResponse({ status: 400, description: 'Invalid input data' })
43
- async createUser(@Body() userData: CreateUserDto) {
44
- return { id: '1', ...userData };
53
+ @Post('/')
54
+ @ApiResponse(201, { schema: createUserSchema, description: 'User created successfully' })
55
+ @ApiResponse(400, { description: 'Invalid input data' })
56
+ async createUser(@Body(createUserSchema) userData: typeof createUserSchema.infer): Promise<Response> {
57
+ return this.success({ id: '1', ...userData });
45
58
  }
46
59
  }
47
60
  ```
48
61
 
49
- ### Enable Swagger UI
62
+ ## Decorators
63
+
64
+ ### From @onebun/docs
65
+
66
+ #### @ApiTags(...tags)
67
+
68
+ Group endpoints under tags. Can be used on controller class or individual methods.
50
69
 
51
70
  ```typescript
52
- import { OneBunApplication } from '@onebun/core';
53
- import { AppModule } from './app.module';
54
-
55
- const app = new OneBunApplication(AppModule, {
56
- docs: {
57
- enabled: true,
58
- path: '/docs',
59
- title: 'My API',
60
- version: '1.0.0',
61
- description: 'API documentation for my service'
62
- }
63
- });
71
+ import { ApiTags } from '@onebun/docs';
64
72
 
65
- await app.start();
66
- // Swagger UI available at http://localhost:3000/docs
73
+ // @ApiTags must be ABOVE @Controller
74
+ @ApiTags('Users', 'User Management')
75
+ @Controller('/users')
76
+ export class UserController extends BaseController {
77
+ // All endpoints in this controller will be tagged with 'Users' and 'User Management'
78
+ }
67
79
  ```
68
80
 
69
- ## Decorators
81
+ #### @ApiOperation(options)
70
82
 
71
- ### Controller Level
83
+ Describe an API operation with summary, description, and additional tags.
72
84
 
73
- - `@ApiTag(name, description?)` - Group endpoints under a tag
85
+ ```typescript
86
+ import { ApiOperation } from '@onebun/docs';
87
+
88
+ // @ApiOperation must be ABOVE route decorator
89
+ @ApiOperation({
90
+ summary: 'Get user by ID',
91
+ description: 'Returns a single user by their unique identifier',
92
+ tags: ['Users'], // Additional tags for this specific endpoint
93
+ })
94
+ @Get('/:id')
95
+ async getUser(@Param('id') id: string): Promise<Response> {
96
+ // ...
97
+ }
98
+ ```
74
99
 
75
- ### Method Level
100
+ ### From @onebun/core
76
101
 
77
- - `@ApiOperation(options)` - Describe the operation
78
- - `@ApiResponse(options)` - Document response types
79
- - `@ApiBody(options)` - Document request body
80
- - `@ApiParam(options)` - Document path parameters
81
- - `@ApiQuery(options)` - Document query parameters
82
- - `@ApiHeader(options)` - Document header parameters
102
+ #### @ApiResponse(statusCode, options?)
83
103
 
84
- ## Configuration Options
104
+ Document and validate response types. This decorator is provided by `@onebun/core`.
85
105
 
86
106
  ```typescript
87
- interface DocsOptions {
88
- // Enable/disable documentation (default: true)
89
- enabled?: boolean;
90
-
91
- // Path for Swagger UI (default: '/docs')
92
- path?: string;
93
-
94
- // API title
95
- title?: string;
96
-
97
- // API version
98
- version?: string;
99
-
100
- // API description
101
- description?: string;
102
-
103
- // Contact information
104
- contact?: {
105
- name?: string;
106
- email?: string;
107
- url?: string;
108
- };
109
-
110
- // License information
111
- license?: {
112
- name: string;
113
- url?: string;
114
- };
115
-
116
- // External documentation
117
- externalDocs?: {
118
- description?: string;
119
- url: string;
120
- };
121
-
122
- // Server URLs
123
- servers?: Array<{
124
- url: string;
125
- description?: string;
126
- }>;
107
+ import { ApiResponse } from '@onebun/core';
108
+ import { type } from 'arktype';
109
+
110
+ const userSchema = type({
111
+ id: 'string',
112
+ name: 'string',
113
+ email: 'string.email',
114
+ });
115
+
116
+ // @ApiResponse must be BELOW route decorator
117
+ @Get('/:id')
118
+ @ApiResponse(200, { schema: userSchema, description: 'User found' })
119
+ @ApiResponse(404, { description: 'User not found' })
120
+ async getUser(@Param('id') id: string): Promise<Response> {
121
+ // Response will be validated against userSchema for 200 responses
127
122
  }
128
123
  ```
129
124
 
130
125
  ## OpenAPI Generation
131
126
 
132
- ### Programmatic Access
127
+ ### Generate OpenAPI Spec from Controllers
133
128
 
134
129
  ```typescript
135
130
  import { generateOpenApiSpec } from '@onebun/docs';
131
+ import { UserController } from './user.controller';
132
+ import { OrderController } from './order.controller';
136
133
 
137
- const spec = generateOpenApiSpec(AppModule, {
134
+ const spec = generateOpenApiSpec([UserController, OrderController], {
138
135
  title: 'My API',
139
- version: '1.0.0'
136
+ version: '1.0.0',
137
+ description: 'API documentation for my service',
140
138
  });
141
139
 
142
140
  // Save to file
143
141
  await Bun.write('openapi.json', JSON.stringify(spec, null, 2));
144
142
  ```
145
143
 
146
- ### JSON Schema Conversion
144
+ ### Generate Swagger UI HTML
147
145
 
148
146
  ```typescript
149
- import { toJsonSchema } from '@onebun/docs';
150
- import { S } from '@onebun/core';
147
+ import { generateSwaggerUiHtml } from '@onebun/docs';
151
148
 
152
- const userSchema = S.object({
153
- id: S.string(),
154
- email: S.string().email(),
155
- age: S.number().min(0).max(150)
156
- });
149
+ // Generate HTML that loads OpenAPI spec from a URL
150
+ const html = generateSwaggerUiHtml('/openapi.json');
157
151
 
158
- const jsonSchema = toJsonSchema(userSchema);
159
- // Returns JSON Schema compatible object
152
+ // Serve as an endpoint
153
+ @Get('/docs')
154
+ async getDocs(): Promise<Response> {
155
+ return new Response(html, {
156
+ headers: { 'Content-Type': 'text/html' },
157
+ });
158
+ }
160
159
  ```
161
160
 
162
- ## Integration with Validation
161
+ ### JSON Schema Conversion
163
162
 
164
- The docs package works seamlessly with `@onebun/core` validation schemas:
163
+ Convert ArkType schemas to JSON Schema format for OpenAPI documentation:
165
164
 
166
165
  ```typescript
167
- import { Controller, Post, Body, S } from '@onebun/core';
168
- import { ApiOperation, ApiBody, ApiResponse } from '@onebun/docs';
166
+ import { arktypeToJsonSchema } from '@onebun/docs';
167
+ import { type } from 'arktype';
169
168
 
170
- const CreateUserSchema = S.object({
171
- name: S.string().min(1).max(100),
172
- email: S.string().email(),
173
- age: S.number().optional()
169
+ const userSchema = type({
170
+ id: 'string',
171
+ email: 'string.email',
172
+ age: 'number > 0',
174
173
  });
175
174
 
176
- @Controller('/users')
177
- export class UserController {
178
- @Post()
179
- @ApiOperation({ summary: 'Create user' })
180
- @ApiBody({ schema: CreateUserSchema })
181
- @ApiResponse({ status: 201, schema: CreateUserSchema })
182
- async createUser(@Body(CreateUserSchema) userData: typeof CreateUserSchema.infer) {
183
- return userData;
184
- }
185
- }
175
+ const jsonSchema = arktypeToJsonSchema(userSchema);
176
+ // Returns JSON Schema compatible object
186
177
  ```
187
178
 
179
+ ## Integration with @onebun/core
180
+
181
+ The docs package reads metadata from `@onebun/core` decorators automatically:
182
+
183
+ - Route information from `@Get`, `@Post`, `@Put`, `@Delete`, `@Patch`
184
+ - Path parameters from `@Param`
185
+ - Query parameters from `@Query`
186
+ - Request body from `@Body`
187
+ - Response schemas from `@ApiResponse`
188
+
189
+ This means your existing controller decorators contribute to the generated OpenAPI spec.
190
+
188
191
  ## Best Practices
189
192
 
190
- 1. **Document all endpoints** - Add at least `@ApiOperation` and `@ApiResponse` to every endpoint
191
- 2. **Use meaningful descriptions** - Help consumers understand your API
192
- 3. **Group related endpoints** - Use `@ApiTag` for logical grouping
193
- 4. **Document error responses** - Include common error status codes
193
+ 1. **Use @ApiOperation for all endpoints** - Add summary and description
194
+ 2. **Use @ApiTags for grouping** - Group related endpoints logically
195
+ 3. **Document response types** - Use `@ApiResponse` with schemas from `@onebun/core`
196
+ 4. **Use meaningful descriptions** - Help API consumers understand your endpoints
194
197
  5. **Keep documentation updated** - Decorators ensure docs stay in sync with code
195
198
 
199
+ ## API Reference
200
+
201
+ ### generateOpenApiSpec(controllers, options)
202
+
203
+ Generate OpenAPI 3.1 specification from controller classes.
204
+
205
+ **Parameters:**
206
+ - `controllers: Function[]` - Array of controller classes
207
+ - `options: { title?: string; version?: string; description?: string }` - OpenAPI info options
208
+
209
+ **Returns:** `OpenApiSpec` - OpenAPI 3.1 compliant specification object
210
+
211
+ ### generateSwaggerUiHtml(specUrl)
212
+
213
+ Generate HTML page with Swagger UI.
214
+
215
+ **Parameters:**
216
+ - `specUrl: string` - URL to the OpenAPI JSON specification
217
+
218
+ **Returns:** `string` - HTML string
219
+
220
+ ### arktypeToJsonSchema(schema)
221
+
222
+ Convert ArkType schema to JSON Schema.
223
+
224
+ **Parameters:**
225
+ - `schema: Type<unknown>` - ArkType schema
226
+
227
+ **Returns:** `Record<string, unknown>` - JSON Schema object
228
+
196
229
  ## License
197
230
 
198
231
  [LGPL-3.0](../../LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/docs",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Documentation generation for OneBun framework - OpenAPI, Swagger UI",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -41,6 +41,7 @@
41
41
  "swagger-ui-dist": "^5.17.14"
42
42
  },
43
43
  "devDependencies": {
44
+ "arktype": "^2.0.0",
44
45
  "bun-types": "1.2.2"
45
46
  },
46
47
  "engines": {
package/src/decorators.ts CHANGED
@@ -72,24 +72,58 @@ export function ApiTags(...tags: string[]): ClassDecorator & MethodDecorator {
72
72
 
73
73
  /**
74
74
  * Get API operation metadata for a method
75
+ * Walks up the prototype chain to find metadata (needed because @Controller wraps classes)
75
76
  */
76
77
  export function getApiOperationMetadata(
77
78
  target: object,
78
79
  propertyKey: string | symbol,
79
80
  ): { summary?: string; description?: string; tags?: string[] } | undefined {
80
- return getMetadata(API_OPERATION_METADATA, target, propertyKey);
81
+ // Try to get metadata from target directly
82
+ let result = getMetadata(API_OPERATION_METADATA, target, propertyKey);
83
+ if (result) {
84
+ return result;
85
+ }
86
+
87
+ // Walk up prototype chain (needed for wrapped classes)
88
+ let proto = Object.getPrototypeOf(target);
89
+ while (proto && proto !== Object.prototype) {
90
+ result = getMetadata(API_OPERATION_METADATA, proto, propertyKey);
91
+ if (result) {
92
+ return result;
93
+ }
94
+ proto = Object.getPrototypeOf(proto);
95
+ }
96
+
97
+ return undefined;
81
98
  }
82
99
 
83
100
  /**
84
101
  * Get API tags for a class or method
102
+ * Walks up the prototype chain to find metadata (needed because @Controller wraps classes)
85
103
  */
86
104
  export function getApiTagsMetadata(
87
105
  target: object | Function,
88
106
  propertyKey?: string | symbol,
89
107
  ): string[] | undefined {
90
108
  if (propertyKey !== undefined) {
91
- return getMetadata(API_TAGS_METADATA, target, propertyKey);
109
+ // Method-level tags - walk prototype chain
110
+ let result = getMetadata(API_TAGS_METADATA, target, propertyKey);
111
+ if (result) {
112
+ return result;
113
+ }
114
+
115
+ let proto = Object.getPrototypeOf(target);
116
+ while (proto && proto !== Object.prototype) {
117
+ result = getMetadata(API_TAGS_METADATA, proto, propertyKey);
118
+ if (result) {
119
+ return result;
120
+ }
121
+ proto = Object.getPrototypeOf(proto);
122
+ }
123
+
124
+ return undefined;
92
125
  }
93
126
 
127
+ // Class-level tags
94
128
  return getMetadata(API_TAGS_METADATA, target);
95
129
  }
@@ -4,160 +4,186 @@
4
4
  * This file tests code examples from:
5
5
  * - packages/docs/README.md
6
6
  *
7
- * Note: The README describes more decorators than are currently implemented.
8
- * This test file tests the actually available decorators.
7
+ * The \@onebun/docs package provides:
8
+ * - \@ApiTags - for grouping endpoints
9
+ * - \@ApiOperation - for describing operations
10
+ * - generateOpenApiSpec - for generating OpenAPI specs
11
+ * - generateSwaggerUiHtml - for generating Swagger UI
12
+ * - arktypeToJsonSchema - for converting schemas
13
+ *
14
+ * Note: \@ApiResponse is provided by \@onebun/core for response validation
9
15
  */
10
16
 
17
+ import { type } from 'arktype';
11
18
  import {
12
19
  describe,
13
- it,
14
20
  expect,
21
+ it,
15
22
  } from 'bun:test';
16
23
 
17
- import { ApiOperation, ApiTags } from './decorators';
18
-
19
- /* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-empty-function */
20
- // Mock decorators for the ones that aren't implemented yet
21
- // These are described in README but not yet implemented
22
- const ApiResponse = (_options: { status: number; description: string }) =>
23
- (_target: object, _propertyKey?: string | symbol) => {};
24
- const ApiBody = (_options: { description?: string; required?: boolean }) =>
25
- (_target: object, _propertyKey?: string | symbol) => {};
26
- const ApiParam = (_options: { name: string; description?: string }) =>
27
- (_target: object, _propertyKey?: string | symbol) => {};
28
- const ApiQuery = (_options: { name: string; description?: string }) =>
29
- (_target: object, _propertyKey?: string | symbol) => {};
30
- const ApiHeader = (_options: { name: string; description?: string }) =>
31
- (_target: object, _propertyKey?: string | symbol) => {};
32
- const ApiTag = ApiTags; // Alias for the actual implementation
33
- /* eslint-enable @typescript-eslint/naming-convention, @typescript-eslint/no-empty-function */
34
-
35
- describe('Docs README Examples', () => {
36
- describe('Controller Level Decorators (README)', () => {
37
- it('should have @ApiTag decorator available', () => {
38
- // From README: Controller Level - @ApiTag
39
- expect(ApiTag).toBeDefined();
40
- expect(typeof ApiTag).toBe('function');
41
- });
24
+ import {
25
+ ApiResponse,
26
+ BaseController,
27
+ Body,
28
+ Controller,
29
+ Get,
30
+ Param,
31
+ Post,
32
+ } from '@onebun/core';
42
33
 
43
- it('should use @ApiTag decorator on controller', () => {
44
- // From README: Basic Usage example
45
- @ApiTag('Users', 'User management endpoints')
46
- class UserController {}
34
+ import {
35
+ ApiOperation,
36
+ ApiTags,
37
+ getApiOperationMetadata,
38
+ getApiTagsMetadata,
39
+ } from './decorators';
40
+ import { generateOpenApiSpec } from './openapi/generator';
41
+ import { arktypeToJsonSchema } from './openapi/json-schema-converter';
42
+ import { generateSwaggerUiHtml } from './openapi/swagger-ui';
47
43
 
48
- expect(UserController).toBeDefined();
44
+ describe('Docs README Examples', () => {
45
+ describe('Decorators from @onebun/docs', () => {
46
+ it('should have @ApiTags decorator available', () => {
47
+ expect(ApiTags).toBeDefined();
48
+ expect(typeof ApiTags).toBe('function');
49
49
  });
50
- });
51
50
 
52
- describe('Method Level Decorators (README)', () => {
53
51
  it('should have @ApiOperation decorator available', () => {
54
- // From README: Method Level - @ApiOperation
55
52
  expect(ApiOperation).toBeDefined();
56
53
  expect(typeof ApiOperation).toBe('function');
57
54
  });
58
55
 
59
- it('should have @ApiResponse decorator available', () => {
60
- // From README: Method Level - @ApiResponse
61
- expect(ApiResponse).toBeDefined();
62
- expect(typeof ApiResponse).toBe('function');
63
- });
56
+ it('should use @ApiTags on controller class', () => {
57
+ @ApiTags('Users')
58
+ class UserController {}
64
59
 
65
- it('should have @ApiBody decorator available', () => {
66
- // From README: Method Level - @ApiBody
67
- expect(ApiBody).toBeDefined();
68
- expect(typeof ApiBody).toBe('function');
60
+ const tags = getApiTagsMetadata(UserController);
61
+ expect(tags).toBeDefined();
62
+ expect(tags!.length).toBeGreaterThan(0);
63
+ expect(tags).toContain('Users');
69
64
  });
70
65
 
71
- it('should have @ApiParam decorator available', () => {
72
- // From README: Method Level - @ApiParam
73
- expect(ApiParam).toBeDefined();
74
- expect(typeof ApiParam).toBe('function');
66
+ it('should use @ApiTags with multiple tags', () => {
67
+ @ApiTags('Users', 'User Management')
68
+ class UserController {}
69
+
70
+ const tags = getApiTagsMetadata(UserController);
71
+ expect(tags).toBeDefined();
72
+ expect(tags).toContain('Users');
73
+ expect(tags).toContain('User Management');
75
74
  });
76
75
 
77
- it('should have @ApiQuery decorator available', () => {
78
- // From README: Method Level - @ApiQuery
79
- expect(ApiQuery).toBeDefined();
80
- expect(typeof ApiQuery).toBe('function');
76
+ it('should use @ApiOperation on method', () => {
77
+ class TestController {
78
+ @ApiOperation({
79
+ summary: 'Get all users',
80
+ description: 'Returns a list of all users',
81
+ })
82
+ async getUsers() {
83
+ return [];
84
+ }
85
+ }
86
+
87
+ const metadata = getApiOperationMetadata(
88
+ TestController.prototype,
89
+ 'getUsers',
90
+ );
91
+ expect(metadata?.summary).toBe('Get all users');
92
+ expect(metadata?.description).toBe('Returns a list of all users');
81
93
  });
82
94
 
83
- it('should have @ApiHeader decorator available', () => {
84
- // From README: Method Level - @ApiHeader
85
- expect(ApiHeader).toBeDefined();
86
- expect(typeof ApiHeader).toBe('function');
95
+ it('should use @ApiOperation with tags', () => {
96
+ class TestController {
97
+ @ApiOperation({
98
+ summary: 'Get user',
99
+ tags: ['Users', 'Admin'],
100
+ })
101
+ async getUser() {
102
+ return {};
103
+ }
104
+ }
105
+
106
+ const metadata = getApiOperationMetadata(
107
+ TestController.prototype,
108
+ 'getUser',
109
+ );
110
+ expect(metadata?.tags).toContain('Users');
111
+ expect(metadata?.tags).toContain('Admin');
87
112
  });
88
113
  });
89
114
 
90
115
  describe('Basic Usage Example (README)', () => {
91
- it('should use decorators on controller methods', () => {
92
- // From README: Basic Usage example
93
- interface CreateUserDto {
94
- name: string;
95
- email: string;
96
- }
97
-
98
- @ApiTag('Users', 'User management endpoints')
99
- class UserController {
116
+ it('should use decorators from both packages', () => {
117
+ const createUserSchema = type({
118
+ name: 'string',
119
+ email: 'string.email',
120
+ });
121
+
122
+ // Note: @ApiTags must be ABOVE @Controller because @Controller wraps the class
123
+ // Decorators are applied bottom-to-top, so @ApiTags runs last and gets the wrapped class
124
+ @ApiTags('Users')
125
+ @Controller('/users')
126
+ class UserController extends BaseController {
127
+ // Note: @ApiOperation must be ABOVE @Get for the same reason
100
128
  @ApiOperation({
101
129
  summary: 'Get all users',
102
130
  description: 'Returns a list of all users',
103
131
  })
104
- @ApiResponse({ status: 200, description: 'List of users returned successfully' })
105
- async getUsers() {
106
- return { users: [] };
132
+ @ApiResponse(200, { description: 'List of users returned successfully' })
133
+ @Get('/')
134
+ async getUsers(): Promise<Response> {
135
+ return this.success({ users: [] });
107
136
  }
108
137
 
109
- @ApiOperation({ summary: 'Create user', description: 'Creates a new user' })
110
- @ApiBody({ description: 'User data', required: true })
111
- @ApiResponse({ status: 201, description: 'User created successfully' })
112
- @ApiResponse({ status: 400, description: 'Invalid input data' })
113
- async createUser(userData: CreateUserDto) {
114
- return { id: '1', ...userData };
138
+ @ApiOperation({
139
+ summary: 'Create user',
140
+ description: 'Creates a new user',
141
+ })
142
+ @ApiResponse(201, {
143
+ schema: createUserSchema,
144
+ description: 'User created successfully',
145
+ })
146
+ @ApiResponse(400, { description: 'Invalid input data' })
147
+ @Post('/')
148
+ async createUser(
149
+ @Body(createUserSchema) userData: typeof createUserSchema.infer,
150
+ ): Promise<Response> {
151
+ return this.success({ id: '1', ...userData });
115
152
  }
116
153
  }
117
154
 
118
155
  expect(UserController).toBeDefined();
156
+
157
+ // Verify tags are set on controller
158
+ const tags = getApiTagsMetadata(UserController);
159
+ expect(tags).toBeDefined();
160
+ expect(tags!.length).toBeGreaterThan(0);
119
161
  });
120
162
  });
121
163
 
122
164
  describe('Configuration Options (README)', () => {
123
165
  it('should define valid DocsOptions interface', () => {
124
- // From README: Configuration Options
166
+ // This matches the DocsApplicationOptions interface in core
125
167
  const docsOptions = {
126
- // Enable/disable documentation (default: true)
127
168
  enabled: true,
128
-
129
- // Path for Swagger UI (default: '/docs')
130
169
  path: '/docs',
131
-
132
- // API title
170
+ jsonPath: '/openapi.json',
133
171
  title: 'My API',
134
-
135
- // API version
136
172
  version: '1.0.0',
137
-
138
- // API description
139
173
  description: 'API documentation for my service',
140
-
141
- // Contact information
142
174
  contact: {
143
175
  name: 'API Support',
144
176
  email: 'support@example.com',
145
177
  url: 'https://example.com/support',
146
178
  },
147
-
148
- // License information
149
179
  license: {
150
180
  name: 'MIT',
151
181
  url: 'https://opensource.org/licenses/MIT',
152
182
  },
153
-
154
- // External documentation
155
183
  externalDocs: {
156
184
  description: 'More information',
157
185
  url: 'https://example.com/docs',
158
186
  },
159
-
160
- // Server URLs
161
187
  servers: [
162
188
  {
163
189
  url: 'https://api.example.com',
@@ -172,196 +198,278 @@ describe('Docs README Examples', () => {
172
198
 
173
199
  expect(docsOptions.enabled).toBe(true);
174
200
  expect(docsOptions.path).toBe('/docs');
201
+ expect(docsOptions.jsonPath).toBe('/openapi.json');
175
202
  expect(docsOptions.title).toBe('My API');
176
203
  expect(docsOptions.version).toBe('1.0.0');
177
204
  expect(docsOptions.servers).toHaveLength(2);
178
205
  });
179
206
  });
207
+ });
180
208
 
181
- describe('Best Practices (README)', () => {
182
- it('should document all endpoints', () => {
183
- // From README: Best Practices
184
- // 1. Document all endpoints - Add at least @ApiOperation and @ApiResponse
185
- class BestPracticeController {
186
- @ApiOperation({ summary: 'Get resource' })
187
- @ApiResponse({ status: 200, description: 'Resource found' })
188
- @ApiResponse({ status: 404, description: 'Resource not found' })
189
- async getResource() {
190
- return {};
209
+ describe('OpenAPI Generation', () => {
210
+ describe('generateOpenApiSpec', () => {
211
+ it('should generate spec from controllers', () => {
212
+ // Note: @ApiTags must be ABOVE @Controller
213
+ @ApiTags('Users')
214
+ @Controller('/users')
215
+ class UserController extends BaseController {
216
+ @ApiOperation({ summary: 'Get all users' })
217
+ @Get('/')
218
+ async getUsers(): Promise<Response> {
219
+ return this.success([]);
220
+ }
221
+
222
+ @ApiOperation({ summary: 'Get user by ID' })
223
+ @Get('/:id')
224
+ async getUser(@Param('id') id: string): Promise<Response> {
225
+ return this.success({ id });
191
226
  }
192
227
  }
193
228
 
194
- expect(BestPracticeController).toBeDefined();
229
+ const spec = generateOpenApiSpec([UserController], {
230
+ title: 'Test API',
231
+ version: '1.0.0',
232
+ description: 'Test API description',
233
+ });
234
+
235
+ expect(spec.openapi).toBe('3.1.0');
236
+ expect(spec.info.title).toBe('Test API');
237
+ expect(spec.info.version).toBe('1.0.0');
238
+ expect(spec.info.description).toBe('Test API description');
239
+ expect(spec.paths).toBeDefined();
240
+ // Controller path + route path = /users + / = /users/
241
+ expect(spec.paths['/users/']).toBeDefined();
242
+ expect(spec.paths['/users/:id']).toBeDefined();
195
243
  });
196
244
 
197
- it('should use meaningful descriptions', () => {
198
- // From README: Best Practices
199
- // 2. Use meaningful descriptions - Help consumers understand your API
200
- class DescriptiveController {
201
- @ApiOperation({
202
- summary: 'Create a new user account',
203
- description:
204
- 'Creates a new user account with the provided email and password. ' +
205
- 'The email must be unique and a valid email format. ' +
206
- 'Password must be at least 8 characters.',
207
- })
208
- @ApiResponse({
209
- status: 201,
210
- description: 'User account created successfully. Returns the new user object.',
211
- })
212
- @ApiResponse({
213
- status: 400,
214
- description: 'Invalid input - email format incorrect or password too short.',
215
- })
216
- @ApiResponse({
217
- status: 409,
218
- description: 'Conflict - a user with this email already exists.',
219
- })
220
- async createUser() {
221
- return {};
245
+ it('should include tags from @ApiTags', () => {
246
+ @ApiTags('Orders')
247
+ @Controller('/orders')
248
+ class OrderController extends BaseController {
249
+ @ApiOperation({ summary: 'Get orders' })
250
+ @Get('/')
251
+ async getOrders(): Promise<Response> {
252
+ return this.success([]);
222
253
  }
223
254
  }
224
255
 
225
- expect(DescriptiveController).toBeDefined();
256
+ const spec = generateOpenApiSpec([OrderController], {
257
+ title: 'Test API',
258
+ version: '1.0.0',
259
+ });
260
+
261
+ // Path is /orders/ (controller path + route path)
262
+ const getOperation = spec.paths['/orders/']?.get;
263
+ expect(getOperation).toBeDefined();
264
+ expect(getOperation?.tags).toBeDefined();
265
+ expect(getOperation?.tags).toContain('Orders');
226
266
  });
227
267
 
228
- it('should group related endpoints', () => {
229
- // From README: Best Practices
230
- // 3. Group related endpoints - Use @ApiTag for logical grouping
231
- @ApiTag('Users', 'Operations related to user management')
232
- class UsersController {
233
- async getUsers() {
234
- return [];
235
- }
236
- async createUser() {
237
- return {};
238
- }
239
- async updateUser() {
240
- return {};
241
- }
242
- async deleteUser() {
243
- return {};
268
+ it('should include operation metadata from @ApiOperation', () => {
269
+ @Controller('/products')
270
+ class ProductController extends BaseController {
271
+ @ApiOperation({
272
+ summary: 'List products',
273
+ description: 'Returns paginated list of products',
274
+ tags: ['Products', 'Catalog'],
275
+ })
276
+ @Get('/')
277
+ async listProducts(): Promise<Response> {
278
+ return this.success([]);
244
279
  }
245
280
  }
246
281
 
247
- @ApiTag('Orders', 'Operations related to order management')
248
- class OrdersController {
249
- async getOrders() {
250
- return [];
251
- }
252
- async createOrder() {
253
- return {};
282
+ const spec = generateOpenApiSpec([ProductController], {
283
+ title: 'Test API',
284
+ version: '1.0.0',
285
+ });
286
+
287
+ const getOperation = spec.paths['/products/']?.get;
288
+ expect(getOperation).toBeDefined();
289
+ expect(getOperation?.summary).toBe('List products');
290
+ expect(getOperation?.description).toBe(
291
+ 'Returns paginated list of products',
292
+ );
293
+ expect(getOperation?.tags).toContain('Products');
294
+ expect(getOperation?.tags).toContain('Catalog');
295
+ });
296
+
297
+ it('should include response schemas from @ApiResponse', () => {
298
+ const productSchema = type({
299
+ id: 'string',
300
+ name: 'string',
301
+ price: 'number',
302
+ });
303
+
304
+ @Controller('/products')
305
+ class ProductController extends BaseController {
306
+ // Note: @Get must be ABOVE @ApiResponse because @Get reads response schemas when it runs
307
+ // and decorators apply bottom-to-top
308
+ @Get('/:id')
309
+ @ApiResponse(200, {
310
+ schema: productSchema,
311
+ description: 'Product found',
312
+ })
313
+ @ApiResponse(404, { description: 'Product not found' })
314
+ async getProduct(@Param('id') id: string): Promise<Response> {
315
+ return this.success({ id, name: 'Test', price: 10 });
254
316
  }
255
317
  }
256
318
 
257
- expect(UsersController).toBeDefined();
258
- expect(OrdersController).toBeDefined();
319
+ const spec = generateOpenApiSpec([ProductController], {
320
+ title: 'Test API',
321
+ version: '1.0.0',
322
+ });
323
+
324
+ const responses = spec.paths['/products/:id']?.get?.responses;
325
+ expect(responses?.['200']).toBeDefined();
326
+ expect(responses?.['200']?.description).toBe('Product found');
327
+ expect(responses?.['404']).toBeDefined();
328
+ expect(responses?.['404']?.description).toBe('Product not found');
259
329
  });
260
330
 
261
- it('should document error responses', () => {
262
- // From README: Best Practices
263
- // 4. Document error responses - Include common error status codes
264
- class ErrorDocumentedController {
265
- @ApiOperation({ summary: 'Update user' })
266
- @ApiResponse({ status: 200, description: 'User updated successfully' })
267
- @ApiResponse({ status: 400, description: 'Invalid request body' })
268
- @ApiResponse({ status: 401, description: 'Authentication required' })
269
- @ApiResponse({ status: 403, description: 'Permission denied' })
270
- @ApiResponse({ status: 404, description: 'User not found' })
271
- @ApiResponse({ status: 422, description: 'Validation error' })
272
- @ApiResponse({ status: 500, description: 'Internal server error' })
273
- async updateUser() {
274
- return {};
331
+ it('should use default options when not provided', () => {
332
+ @Controller('/api')
333
+ class ApiController extends BaseController {
334
+ @Get('/health')
335
+ async health(): Promise<Response> {
336
+ return this.success({ status: 'ok' });
275
337
  }
276
338
  }
277
339
 
278
- expect(ErrorDocumentedController).toBeDefined();
340
+ const spec = generateOpenApiSpec([ApiController]);
341
+
342
+ expect(spec.info.title).toBe('OneBun API');
343
+ expect(spec.info.version).toBe('1.0.0');
279
344
  });
280
345
  });
281
- });
282
346
 
283
- describe('Decorator Options', () => {
284
- describe('@ApiOperation options', () => {
285
- it('should accept summary and description', () => {
286
- class TestController {
287
- @ApiOperation({
288
- summary: 'Short summary',
289
- description: 'Longer description with more details',
290
- })
291
- async testMethod() {}
292
- }
347
+ describe('generateSwaggerUiHtml', () => {
348
+ it('should generate valid HTML', () => {
349
+ const html = generateSwaggerUiHtml('/openapi.json');
293
350
 
294
- expect(TestController).toBeDefined();
351
+ expect(html).toContain('<!DOCTYPE html>');
352
+ expect(html).toContain('swagger-ui');
353
+ expect(html).toContain('/openapi.json');
295
354
  });
296
- });
297
355
 
298
- describe('@ApiResponse options', () => {
299
- it('should accept status and description', () => {
300
- class TestController {
301
- @ApiResponse({
302
- status: 200,
303
- description: 'Success response',
304
- })
305
- async testMethod() {}
306
- }
356
+ it('should include spec URL in SwaggerUI config', () => {
357
+ const html = generateSwaggerUiHtml('/api/v1/openapi.json');
307
358
 
308
- expect(TestController).toBeDefined();
359
+ expect(html).toContain('url: "/api/v1/openapi.json"');
309
360
  });
310
361
  });
311
362
 
312
- describe('@ApiBody options', () => {
313
- it('should accept description and required', () => {
314
- class TestController {
315
- @ApiBody({
316
- description: 'Request body',
317
- required: true,
318
- })
319
- async testMethod() {}
320
- }
363
+ describe('arktypeToJsonSchema', () => {
364
+ it('should convert simple object schema', () => {
365
+ const userSchema = type({
366
+ name: 'string',
367
+ age: 'number',
368
+ });
369
+
370
+ const jsonSchema = arktypeToJsonSchema(userSchema);
321
371
 
322
- expect(TestController).toBeDefined();
372
+ expect(jsonSchema.type).toBe('object');
373
+ expect(jsonSchema.properties).toBeDefined();
323
374
  });
324
- });
325
375
 
326
- describe('@ApiParam options', () => {
327
- it('should accept name and description', () => {
328
- class TestController {
329
- @ApiParam({
330
- name: 'id',
331
- description: 'Resource ID',
332
- })
333
- async testMethod() {}
334
- }
376
+ it('should convert schema with string constraints', () => {
377
+ const emailSchema = type('string.email');
378
+
379
+ const jsonSchema = arktypeToJsonSchema(emailSchema);
380
+
381
+ expect(jsonSchema).toBeDefined();
382
+ });
383
+
384
+ it('should convert nested object schema', () => {
385
+ const addressSchema = type({
386
+ street: 'string',
387
+ city: 'string',
388
+ });
389
+
390
+ const userSchema = type({
391
+ name: 'string',
392
+ address: addressSchema,
393
+ });
394
+
395
+ const jsonSchema = arktypeToJsonSchema(userSchema);
335
396
 
336
- expect(TestController).toBeDefined();
397
+ expect(jsonSchema.type).toBe('object');
398
+ expect(jsonSchema.properties).toBeDefined();
337
399
  });
338
400
  });
401
+ });
339
402
 
340
- describe('@ApiQuery options', () => {
341
- it('should accept name and description', () => {
342
- class TestController {
343
- @ApiQuery({
344
- name: 'page',
345
- description: 'Page number',
346
- })
347
- async testMethod() {}
403
+ describe('Best Practices (README)', () => {
404
+ it('should use @ApiOperation for all endpoints', () => {
405
+ @Controller('/resources')
406
+ class ResourceController extends BaseController {
407
+ @ApiOperation({ summary: 'List resources' })
408
+ @ApiResponse(200, { description: 'Resources listed' })
409
+ @Get('/')
410
+ async list(): Promise<Response> {
411
+ return this.success([]);
348
412
  }
349
413
 
350
- expect(TestController).toBeDefined();
351
- });
414
+ @ApiOperation({ summary: 'Get resource by ID' })
415
+ @ApiResponse(200, { description: 'Resource found' })
416
+ @ApiResponse(404, { description: 'Resource not found' })
417
+ @Get('/:id')
418
+ async get(@Param('id') id: string): Promise<Response> {
419
+ return this.success({ id });
420
+ }
421
+ }
422
+
423
+ expect(ResourceController).toBeDefined();
352
424
  });
353
425
 
354
- describe('@ApiHeader options', () => {
355
- it('should accept name and description', () => {
356
- class TestController {
357
- @ApiHeader({
358
- name: 'X-Request-ID',
359
- description: 'Request ID for tracing',
360
- })
361
- async testMethod() {}
426
+ it('should use @ApiTags for grouping', () => {
427
+ // Note: @ApiTags MUST be placed ABOVE @Controller
428
+ // because @Controller wraps the class and decorators apply bottom-to-top
429
+ @ApiTags('Users')
430
+ @Controller('/users')
431
+ class UsersController extends BaseController {
432
+ @Get('/')
433
+ async list(): Promise<Response> {
434
+ return this.success([]);
435
+ }
436
+ }
437
+
438
+ @ApiTags('Orders')
439
+ @Controller('/orders')
440
+ class OrdersController extends BaseController {
441
+ @Get('/')
442
+ async list(): Promise<Response> {
443
+ return this.success([]);
362
444
  }
445
+ }
446
+
447
+ const usersTags = getApiTagsMetadata(UsersController);
448
+ const ordersTags = getApiTagsMetadata(OrdersController);
449
+
450
+ expect(usersTags).toBeDefined();
451
+ expect(usersTags).toContain('Users');
452
+ expect(ordersTags).toBeDefined();
453
+ expect(ordersTags).toContain('Orders');
454
+ });
363
455
 
364
- expect(TestController).toBeDefined();
456
+ it('should document response types with @ApiResponse from core', () => {
457
+ const userSchema = type({
458
+ id: 'string',
459
+ name: 'string',
460
+ email: 'string.email',
365
461
  });
462
+
463
+ @Controller('/users')
464
+ class UserController extends BaseController {
465
+ @ApiResponse(200, { schema: userSchema, description: 'User found' })
466
+ @ApiResponse(404, { description: 'User not found' })
467
+ @Get('/:id')
468
+ async getUser(@Param('id') id: string): Promise<Response> {
469
+ return this.success({ id, name: 'Test', email: 'test@test.com' });
470
+ }
471
+ }
472
+
473
+ expect(UserController).toBeDefined();
366
474
  });
367
475
  });