codehooks-js 1.3.24 → 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.
@@ -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
+ }