codehooks-js 1.3.25 → 1.4.0
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 +28 -0
- package/crudlify/index.mjs +4 -0
- package/crudlify/lib/schema/json-schema/index.mjs +35 -19
- package/index.js +76 -7
- package/openapi/crudlify-docs.mjs +823 -0
- package/openapi/generator.mjs +417 -0
- package/openapi/index.mjs +221 -0
- package/openapi/schema-converter.mjs +668 -0
- package/openapi/swagger-ui.mjs +92 -0
- package/package.json +12 -3
- package/types/index.d.ts +256 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI specification generator
|
|
3
|
+
* Generates OpenAPI 3.0.3 spec from Codehooks routes and metadata
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { generateCrudlifyPaths } from './crudlify-docs.mjs';
|
|
7
|
+
import { convertToJsonSchema, detectSchemaType } from './schema-converter.mjs';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_SPEC = {
|
|
10
|
+
openapi: '3.0.3',
|
|
11
|
+
info: {
|
|
12
|
+
title: 'Codehooks API',
|
|
13
|
+
version: '1.0.0'
|
|
14
|
+
},
|
|
15
|
+
servers: [],
|
|
16
|
+
paths: {},
|
|
17
|
+
components: {
|
|
18
|
+
schemas: {},
|
|
19
|
+
securitySchemes: {
|
|
20
|
+
apiKey: {
|
|
21
|
+
type: 'apiKey',
|
|
22
|
+
in: 'header',
|
|
23
|
+
name: 'x-apikey',
|
|
24
|
+
description: 'Codehooks API key for authentication'
|
|
25
|
+
},
|
|
26
|
+
bearerAuth: {
|
|
27
|
+
type: 'http',
|
|
28
|
+
scheme: 'bearer',
|
|
29
|
+
bearerFormat: 'JWT',
|
|
30
|
+
description: 'JWT Bearer token authentication'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
tags: []
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generate complete OpenAPI specification
|
|
39
|
+
* @param {object} app - Codehooks app instance
|
|
40
|
+
* @param {object} config - OpenAPI configuration
|
|
41
|
+
* @returns {Promise<object>} OpenAPI specification
|
|
42
|
+
*/
|
|
43
|
+
export async function generateOpenApiSpec(app, config = {}) {
|
|
44
|
+
const spec = {
|
|
45
|
+
openapi: '3.0.3',
|
|
46
|
+
info: {
|
|
47
|
+
...DEFAULT_SPEC.info,
|
|
48
|
+
...config.info
|
|
49
|
+
},
|
|
50
|
+
servers: config.servers || [],
|
|
51
|
+
paths: {},
|
|
52
|
+
components: {
|
|
53
|
+
schemas: {},
|
|
54
|
+
securitySchemes: {
|
|
55
|
+
...DEFAULT_SPEC.components.securitySchemes
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
// Default security - require API key for all endpoints
|
|
59
|
+
security: [{ apiKey: [] }]
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Add tags if provided
|
|
63
|
+
if (config.tags && config.tags.length > 0) {
|
|
64
|
+
spec.tags = config.tags;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 2. Get crudlify config (needed to filter generic routes)
|
|
68
|
+
const crudlifySchema = app.settings['_crudlify_schema'];
|
|
69
|
+
const crudlifyOptions = app.settings['_crudlify_options'] || {};
|
|
70
|
+
const crudlifyEnabled = crudlifySchema !== undefined;
|
|
71
|
+
|
|
72
|
+
// 1. Generate paths from custom routes with openapi() metadata
|
|
73
|
+
// Pass crudlify info to filter out generic /:collection routes
|
|
74
|
+
const { paths: customPaths, schemas: customSchemas } = await generateCustomRoutePaths(
|
|
75
|
+
app.routes,
|
|
76
|
+
app.openApiMeta || {},
|
|
77
|
+
crudlifyEnabled ? crudlifyOptions.prefix || '' : null
|
|
78
|
+
);
|
|
79
|
+
let crudlifyPaths = {};
|
|
80
|
+
let crudlifyComponents = { schemas: {} };
|
|
81
|
+
|
|
82
|
+
// Generate crudlify docs if enabled (even with no schemas - will show generic docs)
|
|
83
|
+
if (crudlifyEnabled) {
|
|
84
|
+
try {
|
|
85
|
+
const crudDocs = await generateCrudlifyPaths(crudlifySchema || {}, crudlifyOptions);
|
|
86
|
+
crudlifyPaths = crudDocs.paths;
|
|
87
|
+
crudlifyComponents = crudDocs.components;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.warn('Failed to generate crudlify docs:', error.message);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. Merge paths (custom routes take precedence over crudlify)
|
|
94
|
+
let mergedPaths = mergePaths(crudlifyPaths, customPaths);
|
|
95
|
+
|
|
96
|
+
// 4. Apply filter if provided
|
|
97
|
+
if (typeof config.filter === 'function') {
|
|
98
|
+
mergedPaths = filterPaths(mergedPaths, config.filter);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
spec.paths = mergedPaths;
|
|
102
|
+
|
|
103
|
+
// 5. Merge component schemas (custom routes + crudlify + user config)
|
|
104
|
+
spec.components.schemas = {
|
|
105
|
+
...crudlifyComponents.schemas,
|
|
106
|
+
...customSchemas,
|
|
107
|
+
...(config.components?.schemas || {})
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// 6. Merge security schemes (user config + defaults)
|
|
111
|
+
if (config.components?.securitySchemes) {
|
|
112
|
+
spec.components.securitySchemes = {
|
|
113
|
+
...spec.components.securitySchemes,
|
|
114
|
+
...config.components.securitySchemes
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 7. Add global security if configured
|
|
119
|
+
if (config.security) {
|
|
120
|
+
spec.security = config.security;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 8. Add external docs if configured
|
|
124
|
+
if (config.externalDocs) {
|
|
125
|
+
spec.externalDocs = config.externalDocs;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return spec;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Generate OpenAPI paths from custom routes
|
|
133
|
+
* @param {object} routes - App routes object
|
|
134
|
+
* @param {object} openApiMeta - Route metadata from openapi() wrappers
|
|
135
|
+
* @param {string|null} crudlifyPrefix - Crudlify prefix if enabled, null if disabled
|
|
136
|
+
* @returns {Promise<{paths: object, schemas: object}>} OpenAPI paths and schemas
|
|
137
|
+
*/
|
|
138
|
+
async function generateCustomRoutePaths(routes, openApiMeta, crudlifyPrefix) {
|
|
139
|
+
const paths = {};
|
|
140
|
+
const schemas = {};
|
|
141
|
+
|
|
142
|
+
for (const [routeKey, handlers] of Object.entries(routes)) {
|
|
143
|
+
// Parse "METHOD /path" format
|
|
144
|
+
const spaceIndex = routeKey.indexOf(' ');
|
|
145
|
+
if (spaceIndex === -1) continue;
|
|
146
|
+
|
|
147
|
+
const method = routeKey.substring(0, spaceIndex);
|
|
148
|
+
const path = routeKey.substring(spaceIndex + 1);
|
|
149
|
+
|
|
150
|
+
// Skip regex routes (too complex to document automatically)
|
|
151
|
+
if (path.startsWith('{')) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Skip internal routes (OpenAPI spec and docs routes)
|
|
156
|
+
if (path.includes('openapi.json') || path.includes('/docs')) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Skip crudlify generic routes (they use :collection parameter)
|
|
161
|
+
// These are documented via crudlify-docs.mjs with specific collection names
|
|
162
|
+
if (crudlifyPrefix !== null && isCrudlifyGenericRoute(path, crudlifyPrefix)) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Convert Express-style params to OpenAPI style: :id -> {id}
|
|
167
|
+
const openApiPath = convertPathParams(path);
|
|
168
|
+
|
|
169
|
+
if (!paths[openApiPath]) {
|
|
170
|
+
paths[openApiPath] = {};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Get metadata from openapi() wrapper or generate default
|
|
174
|
+
const meta = openApiMeta[routeKey] || {};
|
|
175
|
+
const httpMethod = method === '*' ? 'get' : method.toLowerCase();
|
|
176
|
+
|
|
177
|
+
// Don't overwrite if method already exists (from crudlify)
|
|
178
|
+
if (paths[openApiPath][httpMethod]) {
|
|
179
|
+
// Merge metadata if there's custom metadata
|
|
180
|
+
if (Object.keys(meta).length > 0) {
|
|
181
|
+
paths[openApiPath][httpMethod] = {
|
|
182
|
+
...paths[openApiPath][httpMethod],
|
|
183
|
+
...meta
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Build operation object
|
|
190
|
+
const operation = {
|
|
191
|
+
summary: meta.summary || `${method} ${path}`,
|
|
192
|
+
tags: meta.tags || ['default'],
|
|
193
|
+
parameters: meta.parameters || extractPathParams(path),
|
|
194
|
+
responses: meta.responses || { 200: { description: 'Success' } }
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Add optional fields
|
|
198
|
+
if (meta.description) {
|
|
199
|
+
operation.description = meta.description;
|
|
200
|
+
}
|
|
201
|
+
if (meta.operationId) {
|
|
202
|
+
operation.operationId = meta.operationId;
|
|
203
|
+
}
|
|
204
|
+
if (meta.requestBody) {
|
|
205
|
+
// Process request body and extract schema for components
|
|
206
|
+
const { processedBody, schemaName, schema } = await processRequestBodyWithSchema(
|
|
207
|
+
meta.requestBody,
|
|
208
|
+
meta.schemaName || meta.operationId || deriveSchemaName(path, method)
|
|
209
|
+
);
|
|
210
|
+
operation.requestBody = processedBody;
|
|
211
|
+
|
|
212
|
+
// Register schema in components if we extracted one
|
|
213
|
+
if (schemaName && schema) {
|
|
214
|
+
schemas[schemaName] = schema;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (meta.security) {
|
|
218
|
+
operation.security = meta.security;
|
|
219
|
+
}
|
|
220
|
+
if (meta.deprecated) {
|
|
221
|
+
operation.deprecated = true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
paths[openApiPath][httpMethod] = operation;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { paths, schemas };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Derive a schema name from path and method
|
|
232
|
+
* @param {string} path - Route path
|
|
233
|
+
* @param {string} method - HTTP method
|
|
234
|
+
* @returns {string} Schema name
|
|
235
|
+
*/
|
|
236
|
+
function deriveSchemaName(path, method) {
|
|
237
|
+
// Convert /orders/:id to OrdersId, POST /orders to CreateOrder
|
|
238
|
+
const parts = path.split('/').filter(p => p && !p.startsWith(':'));
|
|
239
|
+
const baseName = parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
|
|
240
|
+
|
|
241
|
+
const prefixes = {
|
|
242
|
+
'POST': 'Create',
|
|
243
|
+
'PUT': 'Update',
|
|
244
|
+
'PATCH': 'Patch',
|
|
245
|
+
'GET': '',
|
|
246
|
+
'DELETE': 'Delete'
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const prefix = prefixes[method.toUpperCase()] || '';
|
|
250
|
+
return `${prefix}${baseName}Request`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Process request body, converting schemas and extracting for components
|
|
255
|
+
* @param {object} requestBody - Request body spec
|
|
256
|
+
* @param {string} schemaName - Name to use for the schema in components
|
|
257
|
+
* @returns {Promise<{processedBody: object, schemaName: string|null, schema: object|null}>}
|
|
258
|
+
*/
|
|
259
|
+
async function processRequestBodyWithSchema(requestBody, schemaName) {
|
|
260
|
+
const result = {
|
|
261
|
+
...requestBody,
|
|
262
|
+
content: {}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
let extractedSchema = null;
|
|
266
|
+
let extractedSchemaName = null;
|
|
267
|
+
|
|
268
|
+
if (requestBody.content) {
|
|
269
|
+
for (const [contentType, contentSpec] of Object.entries(requestBody.content)) {
|
|
270
|
+
result.content[contentType] = { ...contentSpec };
|
|
271
|
+
|
|
272
|
+
const schema = contentSpec.schema;
|
|
273
|
+
if (schema) {
|
|
274
|
+
const schemaType = detectSchemaType(schema);
|
|
275
|
+
// If it's a Zod or Yup schema, convert it
|
|
276
|
+
if (schemaType === 'zod' || schemaType === 'yup') {
|
|
277
|
+
try {
|
|
278
|
+
const convertedSchema = await convertToJsonSchema(schema);
|
|
279
|
+
// Store schema in components and use $ref
|
|
280
|
+
extractedSchema = convertedSchema;
|
|
281
|
+
extractedSchemaName = schemaName;
|
|
282
|
+
result.content[contentType].schema = { $ref: `#/components/schemas/${schemaName}` };
|
|
283
|
+
} catch (error) {
|
|
284
|
+
// Fall back to generic object
|
|
285
|
+
result.content[contentType].schema = { type: 'object' };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Otherwise keep the schema as-is (already JSON Schema or $ref)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
processedBody: result,
|
|
295
|
+
schemaName: extractedSchemaName,
|
|
296
|
+
schema: extractedSchema
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Convert Express-style path params to OpenAPI style
|
|
302
|
+
* /users/:id -> /users/{id}
|
|
303
|
+
* @param {string} path - Express-style path
|
|
304
|
+
* @returns {string} OpenAPI-style path
|
|
305
|
+
*/
|
|
306
|
+
function convertPathParams(path) {
|
|
307
|
+
return path.replace(/:(\w+)/g, '{$1}');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Extract path parameters from Express-style path
|
|
312
|
+
* @param {string} path - Express-style path
|
|
313
|
+
* @returns {Array} OpenAPI parameters array
|
|
314
|
+
*/
|
|
315
|
+
function extractPathParams(path) {
|
|
316
|
+
const params = [];
|
|
317
|
+
const regex = /:(\w+)/g;
|
|
318
|
+
let match;
|
|
319
|
+
|
|
320
|
+
while ((match = regex.exec(path)) !== null) {
|
|
321
|
+
params.push({
|
|
322
|
+
name: match[1],
|
|
323
|
+
in: 'path',
|
|
324
|
+
required: true,
|
|
325
|
+
schema: { type: 'string' }
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return params;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Merge paths objects, with later objects taking precedence
|
|
334
|
+
* @param {...object} pathsObjects - Multiple paths objects
|
|
335
|
+
* @returns {object} Merged paths
|
|
336
|
+
*/
|
|
337
|
+
function mergePaths(...pathsObjects) {
|
|
338
|
+
const merged = {};
|
|
339
|
+
|
|
340
|
+
for (const paths of pathsObjects) {
|
|
341
|
+
for (const [path, methods] of Object.entries(paths)) {
|
|
342
|
+
if (!merged[path]) {
|
|
343
|
+
merged[path] = {};
|
|
344
|
+
}
|
|
345
|
+
for (const [method, operation] of Object.entries(methods)) {
|
|
346
|
+
merged[path][method] = operation;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Sort paths alphabetically for consistent output
|
|
352
|
+
const sorted = {};
|
|
353
|
+
const sortedKeys = Object.keys(merged).sort();
|
|
354
|
+
for (const key of sortedKeys) {
|
|
355
|
+
sorted[key] = merged[key];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return sorted;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Filter paths using a user-provided filter function
|
|
363
|
+
* @param {object} paths - OpenAPI paths object
|
|
364
|
+
* @param {function} filterFn - Filter function: (op) => boolean
|
|
365
|
+
* op: { method: string, path: string, operation: object }
|
|
366
|
+
* Returns true to include, false to exclude
|
|
367
|
+
* @returns {object} Filtered paths
|
|
368
|
+
*/
|
|
369
|
+
function filterPaths(paths, filterFn) {
|
|
370
|
+
const filtered = {};
|
|
371
|
+
|
|
372
|
+
for (const [path, methods] of Object.entries(paths)) {
|
|
373
|
+
const filteredMethods = {};
|
|
374
|
+
|
|
375
|
+
for (const [method, operation] of Object.entries(methods)) {
|
|
376
|
+
const op = {
|
|
377
|
+
method,
|
|
378
|
+
path,
|
|
379
|
+
operationId: operation.operationId,
|
|
380
|
+
tags: operation.tags || [],
|
|
381
|
+
summary: operation.summary
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
if (filterFn(op)) {
|
|
385
|
+
filteredMethods[method] = operation;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Only include path if it has at least one method
|
|
390
|
+
if (Object.keys(filteredMethods).length > 0) {
|
|
391
|
+
filtered[path] = filteredMethods;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return filtered;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Check if a path is a crudlify generic route
|
|
400
|
+
* Crudlify registers routes like /:collection, /:collection/:ID, /:collection/_byquery
|
|
401
|
+
* @param {string} path - Route path
|
|
402
|
+
* @param {string} prefix - Crudlify prefix
|
|
403
|
+
* @returns {boolean} True if this is a crudlify generic route
|
|
404
|
+
*/
|
|
405
|
+
function isCrudlifyGenericRoute(path, prefix) {
|
|
406
|
+
// Normalize prefix
|
|
407
|
+
const normalizedPrefix = prefix || '';
|
|
408
|
+
|
|
409
|
+
// Crudlify route patterns (with optional prefix)
|
|
410
|
+
const crudlifyPatterns = [
|
|
411
|
+
`${normalizedPrefix}/:collection`,
|
|
412
|
+
`${normalizedPrefix}/:collection/:ID`,
|
|
413
|
+
`${normalizedPrefix}/:collection/_byquery`
|
|
414
|
+
];
|
|
415
|
+
|
|
416
|
+
return crudlifyPatterns.includes(path);
|
|
417
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI documentation module for Codehooks
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* import { app, openapi } from 'codehooks-js';
|
|
6
|
+
*
|
|
7
|
+
* // Enable OpenAPI docs
|
|
8
|
+
* app.openapi({ info: { title: 'My API', version: '1.0.0' } }, '/docs');
|
|
9
|
+
*
|
|
10
|
+
* // Document a custom route
|
|
11
|
+
* app.get('/health',
|
|
12
|
+
* openapi({
|
|
13
|
+
* summary: 'Health check',
|
|
14
|
+
* tags: ['System'],
|
|
15
|
+
* responses: { 200: { description: 'OK' } }
|
|
16
|
+
* }),
|
|
17
|
+
* (req, res) => res.json({ status: 'ok' })
|
|
18
|
+
* );
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export { generateOpenApiSpec } from './generator.mjs';
|
|
22
|
+
export { generateSwaggerHtml } from './swagger-ui.mjs';
|
|
23
|
+
export { convertToJsonSchema, detectSchemaType } from './schema-converter.mjs';
|
|
24
|
+
export { generateCrudlifyPaths } from './crudlify-docs.mjs';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create an OpenAPI documentation wrapper middleware
|
|
28
|
+
*
|
|
29
|
+
* This middleware attaches OpenAPI metadata to the route, which is then
|
|
30
|
+
* extracted during route registration and used for spec generation.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} spec - OpenAPI operation specification
|
|
33
|
+
* @param {string} [spec.summary] - Short summary of the operation
|
|
34
|
+
* @param {string} [spec.description] - Detailed description
|
|
35
|
+
* @param {string[]} [spec.tags] - Tags for grouping in docs
|
|
36
|
+
* @param {string} [spec.operationId] - Unique operation identifier
|
|
37
|
+
* @param {Array} [spec.parameters] - Path, query, header, cookie parameters
|
|
38
|
+
* @param {object} [spec.requestBody] - Request body specification
|
|
39
|
+
* @param {object} [spec.responses] - Response specifications by status code
|
|
40
|
+
* @param {Array} [spec.security] - Security requirements
|
|
41
|
+
* @param {boolean} [spec.deprecated] - Whether operation is deprecated
|
|
42
|
+
* @returns {Function} Middleware function with attached metadata
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* app.post('/users',
|
|
46
|
+
* openapi({
|
|
47
|
+
* summary: 'Create a new user',
|
|
48
|
+
* tags: ['Users'],
|
|
49
|
+
* requestBody: {
|
|
50
|
+
* required: true,
|
|
51
|
+
* content: {
|
|
52
|
+
* 'application/json': {
|
|
53
|
+
* schema: {
|
|
54
|
+
* type: 'object',
|
|
55
|
+
* required: ['name', 'email'],
|
|
56
|
+
* properties: {
|
|
57
|
+
* name: { type: 'string' },
|
|
58
|
+
* email: { type: 'string', format: 'email' }
|
|
59
|
+
* }
|
|
60
|
+
* }
|
|
61
|
+
* }
|
|
62
|
+
* }
|
|
63
|
+
* },
|
|
64
|
+
* responses: {
|
|
65
|
+
* 201: { description: 'User created' },
|
|
66
|
+
* 400: { description: 'Validation error' }
|
|
67
|
+
* }
|
|
68
|
+
* }),
|
|
69
|
+
* async (req, res) => {
|
|
70
|
+
* // Handle user creation
|
|
71
|
+
* }
|
|
72
|
+
* );
|
|
73
|
+
*/
|
|
74
|
+
export function openapi(spec) {
|
|
75
|
+
// Create a pass-through middleware
|
|
76
|
+
const wrapper = function openapiMiddleware(req, res, next) {
|
|
77
|
+
// Simply continue to the next handler
|
|
78
|
+
if (typeof next === 'function') {
|
|
79
|
+
next();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Attach the OpenAPI spec to the function for later extraction
|
|
84
|
+
wrapper._openApiSpec = spec;
|
|
85
|
+
|
|
86
|
+
// Mark as openapi middleware for easy detection
|
|
87
|
+
wrapper._isOpenApiMiddleware = true;
|
|
88
|
+
|
|
89
|
+
return wrapper;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Helper to create response specification
|
|
94
|
+
* @param {string} description - Response description
|
|
95
|
+
* @param {object} [schema] - Response schema (JSON Schema or Zod/Yup)
|
|
96
|
+
* @param {string} [contentType='application/json'] - Content type
|
|
97
|
+
* @returns {object} OpenAPI response object
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* openapi({
|
|
101
|
+
* responses: {
|
|
102
|
+
* 200: response('Success', UserSchema),
|
|
103
|
+
* 404: response('Not found')
|
|
104
|
+
* }
|
|
105
|
+
* })
|
|
106
|
+
*/
|
|
107
|
+
export function response(description, schema, contentType = 'application/json') {
|
|
108
|
+
if (!schema) {
|
|
109
|
+
return { description };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
description,
|
|
114
|
+
content: {
|
|
115
|
+
[contentType]: { schema }
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Helper to create request body specification
|
|
122
|
+
* @param {object} schema - Request body schema (JSON Schema or Zod/Yup)
|
|
123
|
+
* @param {object} [options] - Additional options
|
|
124
|
+
* @param {boolean} [options.required=true] - Whether body is required
|
|
125
|
+
* @param {string} [options.description] - Body description
|
|
126
|
+
* @param {string} [options.contentType='application/json'] - Content type
|
|
127
|
+
* @returns {object} OpenAPI request body object
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* openapi({
|
|
131
|
+
* requestBody: body(CreateUserSchema, { description: 'User data' })
|
|
132
|
+
* })
|
|
133
|
+
*/
|
|
134
|
+
export function body(schema, options = {}) {
|
|
135
|
+
const {
|
|
136
|
+
required = true,
|
|
137
|
+
description,
|
|
138
|
+
contentType = 'application/json'
|
|
139
|
+
} = options;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
required,
|
|
143
|
+
...(description && { description }),
|
|
144
|
+
content: {
|
|
145
|
+
[contentType]: { schema }
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Helper to create path parameter specification
|
|
152
|
+
* @param {string} name - Parameter name
|
|
153
|
+
* @param {object} [options] - Parameter options
|
|
154
|
+
* @param {string} [options.description] - Parameter description
|
|
155
|
+
* @param {object} [options.schema] - Parameter schema
|
|
156
|
+
* @param {*} [options.example] - Example value
|
|
157
|
+
* @returns {object} OpenAPI parameter object
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* openapi({
|
|
161
|
+
* parameters: [
|
|
162
|
+
* param('id', { description: 'User ID', schema: { type: 'string', format: 'uuid' } })
|
|
163
|
+
* ]
|
|
164
|
+
* })
|
|
165
|
+
*/
|
|
166
|
+
export function param(name, options = {}) {
|
|
167
|
+
return {
|
|
168
|
+
name,
|
|
169
|
+
in: 'path',
|
|
170
|
+
required: true,
|
|
171
|
+
schema: options.schema || { type: 'string' },
|
|
172
|
+
...(options.description && { description: options.description }),
|
|
173
|
+
...(options.example !== undefined && { example: options.example })
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Helper to create query parameter specification
|
|
179
|
+
* @param {string} name - Parameter name
|
|
180
|
+
* @param {object} [options] - Parameter options
|
|
181
|
+
* @param {string} [options.description] - Parameter description
|
|
182
|
+
* @param {boolean} [options.required=false] - Whether required
|
|
183
|
+
* @param {object} [options.schema] - Parameter schema
|
|
184
|
+
* @param {*} [options.example] - Example value
|
|
185
|
+
* @returns {object} OpenAPI parameter object
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* openapi({
|
|
189
|
+
* parameters: [
|
|
190
|
+
* query('limit', { description: 'Max items', schema: { type: 'integer', default: 10 } }),
|
|
191
|
+
* query('search', { description: 'Search term' })
|
|
192
|
+
* ]
|
|
193
|
+
* })
|
|
194
|
+
*/
|
|
195
|
+
export function query(name, options = {}) {
|
|
196
|
+
return {
|
|
197
|
+
name,
|
|
198
|
+
in: 'query',
|
|
199
|
+
required: options.required || false,
|
|
200
|
+
schema: options.schema || { type: 'string' },
|
|
201
|
+
...(options.description && { description: options.description }),
|
|
202
|
+
...(options.example !== undefined && { example: options.example })
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Helper to create header parameter specification
|
|
208
|
+
* @param {string} name - Header name
|
|
209
|
+
* @param {object} [options] - Parameter options
|
|
210
|
+
* @returns {object} OpenAPI parameter object
|
|
211
|
+
*/
|
|
212
|
+
export function header(name, options = {}) {
|
|
213
|
+
return {
|
|
214
|
+
name,
|
|
215
|
+
in: 'header',
|
|
216
|
+
required: options.required || false,
|
|
217
|
+
schema: options.schema || { type: 'string' },
|
|
218
|
+
...(options.description && { description: options.description }),
|
|
219
|
+
...(options.example !== undefined && { example: options.example })
|
|
220
|
+
};
|
|
221
|
+
}
|