webspresso 0.0.94 → 0.0.96
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/.agents/ADMIN.md +225 -0
- package/.agents/AGENTS.md +72 -0
- package/.agents/ARCHITECTURE.md +119 -0
- package/.agents/AUTH.md +103 -0
- package/.agents/CLI.md +115 -0
- package/.agents/COMPRESSION.md +59 -0
- package/.agents/CONVENTIONS.md +46 -0
- package/.agents/EMAIL.md +141 -0
- package/.agents/ERRORS.md +201 -0
- package/.agents/MCP.md +103 -0
- package/.agents/ORM.md +230 -0
- package/.agents/PLUGINS.md +419 -0
- package/.agents/QUEUE.md +113 -0
- package/.agents/REALTIME.md +213 -0
- package/.agents/ROUTING.md +292 -0
- package/.agents/SERVICES.md +341 -0
- package/.agents/SHUTDOWN.md +67 -0
- package/.agents/STREAMING.md +73 -0
- package/.agents/WORKFLOW.md +27 -0
- package/.agents/internal/DOCUMENTATION_REVIEW.md +485 -0
- package/.agents/internal/TECHNICAL_REVIEW.md +428 -0
- package/.cursorrules +14 -0
- package/AGENTS.md +175 -0
- package/CLAUDE.md +54 -0
- package/README.md +18 -14
- package/bin/commands/agents-init.js +169 -0
- package/bin/commands/api.js +114 -48
- package/bin/commands/doctor.js +35 -0
- package/bin/commands/new.js +13 -2
- package/bin/commands/page.js +75 -89
- package/bin/commands/service.js +77 -0
- package/bin/webspresso.js +2 -2
- package/core/compileSchema.js +1 -1
- package/core/errors/middleware.js +22 -2
- package/core/errors/normalize.js +29 -2
- package/{utils/schemaCache.js → core/schema-cache.js} +2 -3
- package/index.d.ts +306 -0
- package/index.js +35 -1
- package/package.json +19 -6
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +22 -1
- package/plugins/csrf/index.js +3 -2
- package/plugins/csv/engine.js +447 -0
- package/plugins/csv/index.js +94 -0
- package/plugins/csv/middleware.js +80 -0
- package/plugins/csv/services.js +138 -0
- package/plugins/file-manager/admin-component.js +613 -0
- package/plugins/file-manager/api-handlers.js +261 -0
- package/plugins/file-manager/core.js +601 -0
- package/plugins/file-manager/index.js +139 -0
- package/plugins/file-manager/security.js +207 -0
- package/plugins/index.js +8 -0
- package/plugins/polar/CHANGELOG.md +18 -0
- package/plugins/polar/README.md +30 -7
- package/plugins/polar/index.js +6 -1
- package/plugins/polar/src/billing-hooks.js +51 -0
- package/plugins/polar/src/config.js +2 -0
- package/plugins/polar/src/rate-limit.js +96 -28
- package/plugins/polar/src/routes.js +3 -2
- package/plugins/polar/src/urls.js +26 -2
- package/plugins/polar/src/user-resolver.js +5 -1
- package/plugins/rate-limit/index.js +5 -1
- package/plugins/xlsx/engine.js +397 -0
- package/plugins/xlsx/index.js +91 -0
- package/plugins/xlsx/middleware.js +87 -0
- package/plugins/xlsx/services.js +118 -0
- package/src/api/api-loader.js +233 -0
- package/src/api/define-api.js +35 -0
- package/src/discovery/file-route-parser.js +201 -0
- package/src/discovery/scan-routes.js +265 -0
- package/src/file-router.js +6 -0
- package/src/modules/define-module.js +99 -0
- package/src/modules/module-discovery.js +117 -0
- package/src/pages/define-page.js +36 -0
- package/src/pages/page-loader.js +214 -0
- package/src/routing/mount-discovered-routes.js +78 -0
- package/src/routing/route-table.js +191 -0
- package/src/server.js +112 -15
- package/src/services/builtins/file-manager.js +178 -0
- package/src/services/index.js +2 -0
- package/bin/commands/skill.js +0 -187
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +0 -289
- package/templates/skills/webspresso-usage/REFERENCE-kernel.md +0 -93
- package/templates/skills/webspresso-usage/SKILL.md +0 -48
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso XLSX Plugin
|
|
5
|
+
* Excel spreadsheet generation, parsing, ORM export, streaming, and res.xlsx() integration
|
|
6
|
+
* @module plugins/xlsx
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const engine = require('./engine');
|
|
10
|
+
const { createXlsxMiddleware, XLSX_MIME_TYPE } = require('./middleware');
|
|
11
|
+
const { createXlsxServices } = require('./services');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Standalone XLSX Toolkit Singleton
|
|
15
|
+
*/
|
|
16
|
+
const xlsx = {
|
|
17
|
+
...engine,
|
|
18
|
+
MIME_TYPE: XLSX_MIME_TYPE,
|
|
19
|
+
createMiddleware: createXlsxMiddleware,
|
|
20
|
+
createServices: createXlsxServices,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* XLSX Plugin for Webspresso
|
|
25
|
+
* @param {Object} [options]
|
|
26
|
+
* @param {boolean} [options.enabled=true]
|
|
27
|
+
* @param {boolean} [options.sanitizeFormulas=true] - Prevent Formula Injection attacks
|
|
28
|
+
* @param {Object} [options.headerStyle] - Default header styling (fill, font, border)
|
|
29
|
+
* @param {Object} [options.db] - Optional database instance for model export services
|
|
30
|
+
* @returns {import('../../index').Plugin}
|
|
31
|
+
*/
|
|
32
|
+
function xlsxPlugin(options = {}) {
|
|
33
|
+
const {
|
|
34
|
+
enabled = true,
|
|
35
|
+
sanitizeFormulas = true,
|
|
36
|
+
headerStyle = { fill: '4F46E5', color: 'FFFFFF', bold: true },
|
|
37
|
+
db: dbOption,
|
|
38
|
+
} = options;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
name: 'xlsx',
|
|
42
|
+
version: '1.0.0',
|
|
43
|
+
description: 'Excel XLSX spreadsheet engine, res.xlsx() response decorator, and ORM export tools',
|
|
44
|
+
enabled,
|
|
45
|
+
|
|
46
|
+
register(ctx) {
|
|
47
|
+
if (!enabled) return;
|
|
48
|
+
|
|
49
|
+
// 1. Expose on PluginContext & global app
|
|
50
|
+
ctx.xlsx = xlsx;
|
|
51
|
+
if (ctx.app) {
|
|
52
|
+
ctx.app.xlsx = xlsx;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. Register built-in services (xlsx.generate, xlsx.parse, xlsx.exportModel)
|
|
56
|
+
if (ctx.app && ctx.app.serviceRegistry) {
|
|
57
|
+
const db = dbOption ?? ctx.db ?? ctx.app?.get?.('db') ?? null;
|
|
58
|
+
const services = createXlsxServices({ db, ...options });
|
|
59
|
+
for (const [name, def] of Object.entries(services)) {
|
|
60
|
+
if (!ctx.app.serviceRegistry.has(name)) {
|
|
61
|
+
ctx.app.serviceRegistry.register(name, def);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3. Register named middleware & global decorator
|
|
67
|
+
const middleware = createXlsxMiddleware({
|
|
68
|
+
sanitizeFormulas,
|
|
69
|
+
headerStyle,
|
|
70
|
+
...options,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (ctx.middlewares) {
|
|
74
|
+
ctx.middlewares.xlsx = middleware;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (ctx.app && typeof ctx.app.use === 'function') {
|
|
78
|
+
ctx.app.use(middleware);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
xlsxPlugin,
|
|
86
|
+
xlsx,
|
|
87
|
+
createXlsxMiddleware,
|
|
88
|
+
createXlsxServices,
|
|
89
|
+
XLSX_MIME_TYPE,
|
|
90
|
+
...engine,
|
|
91
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* XLSX Express Middleware
|
|
5
|
+
* Decorates res.xlsx() and req.parseXlsx() helpers
|
|
6
|
+
* @module plugins/xlsx/middleware
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { generate, parse, fromModel } = require('./engine');
|
|
10
|
+
|
|
11
|
+
const XLSX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates the XLSX middleware
|
|
15
|
+
* @param {Object} [globalOptions]
|
|
16
|
+
* @returns {Function}
|
|
17
|
+
*/
|
|
18
|
+
function createXlsxMiddleware(globalOptions = {}) {
|
|
19
|
+
return (req, res, next) => {
|
|
20
|
+
// 1. Response Helper: res.xlsx(data, filename, options)
|
|
21
|
+
res.xlsx = async function (data, filename = 'export.xlsx', options = {}) {
|
|
22
|
+
try {
|
|
23
|
+
let buffer;
|
|
24
|
+
|
|
25
|
+
if (Buffer.isBuffer(data)) {
|
|
26
|
+
buffer = data;
|
|
27
|
+
} else if (data && typeof data.find === 'function' && data.model) {
|
|
28
|
+
// Repository instance
|
|
29
|
+
buffer = await fromModel(data, options.filter || options.query || {}, {
|
|
30
|
+
...globalOptions,
|
|
31
|
+
...options,
|
|
32
|
+
});
|
|
33
|
+
} else if (Array.isArray(data)) {
|
|
34
|
+
buffer = await generate({
|
|
35
|
+
...globalOptions,
|
|
36
|
+
...options,
|
|
37
|
+
sheets: [
|
|
38
|
+
{
|
|
39
|
+
name: options.sheetName || 'Data',
|
|
40
|
+
columns: options.columns,
|
|
41
|
+
rows: data,
|
|
42
|
+
headerStyle: options.headerStyle,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
} else if (data && typeof data === 'object') {
|
|
47
|
+
buffer = await generate({
|
|
48
|
+
...globalOptions,
|
|
49
|
+
...options,
|
|
50
|
+
...data,
|
|
51
|
+
});
|
|
52
|
+
} else {
|
|
53
|
+
throw new Error('res.xlsx() expects an Array of rows, a sheets object, a Model Repository, or a Buffer');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const safeFilename = filename.endsWith('.xlsx') ? filename : `${filename}.xlsx`;
|
|
57
|
+
|
|
58
|
+
res.setHeader('Content-Type', XLSX_MIME_TYPE);
|
|
59
|
+
res.setHeader(
|
|
60
|
+
'Content-Disposition',
|
|
61
|
+
`attachment; filename="${encodeURIComponent(safeFilename)}"; filename*=UTF-8''${encodeURIComponent(safeFilename)}`
|
|
62
|
+
);
|
|
63
|
+
res.setHeader('Content-Length', buffer.length);
|
|
64
|
+
|
|
65
|
+
return res.end(buffer);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return next(err);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// 2. Request Helper: req.parseXlsx(fileOrBuffer, options)
|
|
72
|
+
req.parseXlsx = async function (fileOrBuffer, options = {}) {
|
|
73
|
+
const target = fileOrBuffer || req.file?.buffer || req.rawBody || req.body;
|
|
74
|
+
if (!target) {
|
|
75
|
+
throw new Error('req.parseXlsx requires a file Buffer or multipart req.file.buffer');
|
|
76
|
+
}
|
|
77
|
+
return parse(target, options);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
next();
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
createXlsxMiddleware,
|
|
86
|
+
XLSX_MIME_TYPE,
|
|
87
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in XLSX Services
|
|
5
|
+
* Exposed via ctx.service('xlsx.*') and req.service('xlsx.*')
|
|
6
|
+
* @module plugins/xlsx/services
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { z } = require('zod');
|
|
10
|
+
const { generate, parse, fromModel } = require('./engine');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates built-in services for the XLSX plugin
|
|
14
|
+
* @param {Object} [options]
|
|
15
|
+
* @param {Object} [options.db]
|
|
16
|
+
* @returns {Object.<string, Object>}
|
|
17
|
+
*/
|
|
18
|
+
function createXlsxServices(options = {}) {
|
|
19
|
+
return {
|
|
20
|
+
'xlsx.generate': {
|
|
21
|
+
schema: z.object({
|
|
22
|
+
sheets: z.array(
|
|
23
|
+
z.object({
|
|
24
|
+
name: z.string().optional(),
|
|
25
|
+
columns: z.array(z.object({ header: z.string(), key: z.string(), width: z.number().optional() })).optional(),
|
|
26
|
+
rows: z.array(z.record(z.any())),
|
|
27
|
+
})
|
|
28
|
+
).or(z.object({
|
|
29
|
+
name: z.string().optional(),
|
|
30
|
+
columns: z.array(z.object({ header: z.string(), key: z.string(), width: z.number().optional() })).optional(),
|
|
31
|
+
rows: z.array(z.record(z.any())),
|
|
32
|
+
})),
|
|
33
|
+
creator: z.string().optional(),
|
|
34
|
+
title: z.string().optional(),
|
|
35
|
+
}),
|
|
36
|
+
handler: async (input) => {
|
|
37
|
+
const buffer = await generate(input);
|
|
38
|
+
return {
|
|
39
|
+
base64: buffer.toString('base64'),
|
|
40
|
+
size: buffer.length,
|
|
41
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
'xlsx.parse': {
|
|
47
|
+
schema: z.object({
|
|
48
|
+
base64: z.string().optional(),
|
|
49
|
+
sheet: z.union([z.string(), z.number()]).optional(),
|
|
50
|
+
headerRow: z.number().optional(),
|
|
51
|
+
}),
|
|
52
|
+
handler: async (input) => {
|
|
53
|
+
if (!input.base64 && !input.buffer) {
|
|
54
|
+
throw new Error('xlsx.parse requires base64 string or buffer');
|
|
55
|
+
}
|
|
56
|
+
const buffer = input.buffer || Buffer.from(input.base64, 'base64');
|
|
57
|
+
const data = await parse(buffer, {
|
|
58
|
+
sheet: input.sheet,
|
|
59
|
+
headerRow: input.headerRow,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const rowCount = Array.isArray(data)
|
|
63
|
+
? data.length
|
|
64
|
+
: Object.values(data).reduce((acc, sheetRows) => acc + sheetRows.length, 0);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
data,
|
|
68
|
+
rowCount,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
'xlsx.exportModel': {
|
|
74
|
+
schema: z.object({
|
|
75
|
+
model: z.string().min(1),
|
|
76
|
+
filter: z.record(z.any()).optional().default({}),
|
|
77
|
+
columns: z.array(z.string()).optional(),
|
|
78
|
+
filename: z.string().optional(),
|
|
79
|
+
}),
|
|
80
|
+
handler: async (input, ctx) => {
|
|
81
|
+
const db = ctx.db || options.db;
|
|
82
|
+
if (!db || typeof db.getRepository !== 'function') {
|
|
83
|
+
throw new Error('xlsx.exportModel requires an active database connection');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const repo = db.getRepository(input.model);
|
|
87
|
+
if (!repo) {
|
|
88
|
+
throw new Error(`Model repository "${input.model}" not found`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let customColumns;
|
|
92
|
+
if (input.columns) {
|
|
93
|
+
customColumns = input.columns.map((c) => ({
|
|
94
|
+
header: c.charAt(0).toUpperCase() + c.slice(1).replace(/_/g, ' '),
|
|
95
|
+
key: c,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const buffer = await fromModel(repo, input.filter, {
|
|
100
|
+
columns: customColumns,
|
|
101
|
+
title: `${input.model} Export`,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const filename = input.filename || `${input.model.toLowerCase()}_export.xlsx`;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
filename: filename.endsWith('.xlsx') ? filename : `${filename}.xlsx`,
|
|
108
|
+
base64: buffer.toString('base64'),
|
|
109
|
+
size: buffer.length,
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
createXlsxServices,
|
|
118
|
+
};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso API Loader
|
|
5
|
+
* Handles validation and execution of defineApi() endpoints
|
|
6
|
+
* @module src/api/api-loader
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { ZodError } = require('zod');
|
|
10
|
+
const { z: zForApi } = require('../../core/validation');
|
|
11
|
+
const { resolveMiddlewares } = require('../file-router');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compiles a schema definition from an API module
|
|
15
|
+
* @param {Object|Function} schemaDef
|
|
16
|
+
* @returns {Object|null}
|
|
17
|
+
*/
|
|
18
|
+
function compileApiSchema(schemaDef) {
|
|
19
|
+
if (!schemaDef) return null;
|
|
20
|
+
if (typeof schemaDef === 'function') {
|
|
21
|
+
try {
|
|
22
|
+
return schemaDef({ z: zForApi });
|
|
23
|
+
} catch (err) {
|
|
24
|
+
throw new Error(`Failed to compile API schema: ${err.message}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (typeof schemaDef === 'object') {
|
|
28
|
+
return schemaDef;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validates request input against compiled schema
|
|
35
|
+
* @param {import('express').Request} req
|
|
36
|
+
* @param {Object} compiledSchema
|
|
37
|
+
*/
|
|
38
|
+
function validateRequestInput(req, compiledSchema) {
|
|
39
|
+
req.input = {
|
|
40
|
+
body: undefined,
|
|
41
|
+
params: undefined,
|
|
42
|
+
query: undefined,
|
|
43
|
+
headers: undefined,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (!compiledSchema) return;
|
|
47
|
+
|
|
48
|
+
if (compiledSchema.body && typeof compiledSchema.body.parse === 'function') {
|
|
49
|
+
req.input.body = compiledSchema.body.parse(req.body);
|
|
50
|
+
} else {
|
|
51
|
+
req.input.body = req.body;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (compiledSchema.params && typeof compiledSchema.params.parse === 'function') {
|
|
55
|
+
req.input.params = compiledSchema.params.parse(req.params);
|
|
56
|
+
} else {
|
|
57
|
+
req.input.params = req.params;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (compiledSchema.query && typeof compiledSchema.query.parse === 'function') {
|
|
61
|
+
req.input.query = compiledSchema.query.parse(req.query);
|
|
62
|
+
} else {
|
|
63
|
+
req.input.query = req.query;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (compiledSchema.headers && typeof compiledSchema.headers.parse === 'function') {
|
|
67
|
+
req.input.headers = compiledSchema.headers.parse(req.headers);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Creates an Express route handler for a discovered API descriptor
|
|
73
|
+
* @param {Object} descriptor - Route descriptor
|
|
74
|
+
* @param {Object} context - Server context
|
|
75
|
+
* @returns {Function}
|
|
76
|
+
*/
|
|
77
|
+
function createApiHandler(descriptor, context) {
|
|
78
|
+
const {
|
|
79
|
+
middlewares = {},
|
|
80
|
+
serviceRegistry = null,
|
|
81
|
+
db = null,
|
|
82
|
+
options: appOptions = {},
|
|
83
|
+
} = context;
|
|
84
|
+
|
|
85
|
+
const isDev = process.env.NODE_ENV === 'development';
|
|
86
|
+
let cachedMiddlewares = null;
|
|
87
|
+
let cachedApiDef = null;
|
|
88
|
+
let cachedHandlerFn = null;
|
|
89
|
+
let cachedCompiledSchema = null;
|
|
90
|
+
|
|
91
|
+
return async (req, res, next) => {
|
|
92
|
+
try {
|
|
93
|
+
// 1. Reload module in development
|
|
94
|
+
if (isDev) {
|
|
95
|
+
try {
|
|
96
|
+
const resolvedPath = require.resolve(descriptor.file);
|
|
97
|
+
if (require.cache[resolvedPath]) {
|
|
98
|
+
delete require.cache[resolvedPath];
|
|
99
|
+
cachedMiddlewares = null;
|
|
100
|
+
cachedApiDef = null;
|
|
101
|
+
cachedHandlerFn = null;
|
|
102
|
+
cachedCompiledSchema = null;
|
|
103
|
+
}
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let apiDef = cachedApiDef;
|
|
108
|
+
let handlerFn = cachedHandlerFn;
|
|
109
|
+
let compiledSchema = cachedCompiledSchema;
|
|
110
|
+
|
|
111
|
+
if (!apiDef) {
|
|
112
|
+
let apiModule = {};
|
|
113
|
+
try {
|
|
114
|
+
apiModule = require(descriptor.file);
|
|
115
|
+
} catch (loadErr) {
|
|
116
|
+
return next(loadErr);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
apiDef = typeof apiModule === 'function' ? { handler: apiModule } : apiModule.default || apiModule;
|
|
120
|
+
handlerFn = apiDef.handler || (typeof apiModule === 'function' ? apiModule : null);
|
|
121
|
+
|
|
122
|
+
if (typeof handlerFn !== 'function') {
|
|
123
|
+
throw new Error(`API file "${descriptor.source}" does not export a valid handler function`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
compiledSchema = compileApiSchema(apiDef.schema);
|
|
127
|
+
|
|
128
|
+
if (!isDev) {
|
|
129
|
+
cachedApiDef = apiDef;
|
|
130
|
+
cachedHandlerFn = handlerFn;
|
|
131
|
+
cachedCompiledSchema = compiledSchema;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 2. Injections
|
|
136
|
+
if (db) req.db = db;
|
|
137
|
+
if (serviceRegistry) {
|
|
138
|
+
req.service = (name, input, opts) =>
|
|
139
|
+
serviceRegistry.call(name, input, { req, res, db }, opts);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 3. Schema validation
|
|
143
|
+
if (compiledSchema) {
|
|
144
|
+
try {
|
|
145
|
+
validateRequestInput(req, compiledSchema);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (err instanceof ZodError || err.name === 'ZodError' || Array.isArray(err?.issues)) {
|
|
148
|
+
return res.status(400).json({
|
|
149
|
+
error: 'Validation Error',
|
|
150
|
+
issues: err.issues,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
req.input = {
|
|
157
|
+
body: req.body,
|
|
158
|
+
params: req.params,
|
|
159
|
+
query: req.query,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 4. Resolve and execute middlewares (module-local first, cached across requests)
|
|
164
|
+
if (!cachedMiddlewares) {
|
|
165
|
+
const moduleMiddlewares = descriptor.moduleConfig?.middlewares || {};
|
|
166
|
+
const combinedRegistry = Object.assign({}, middlewares, moduleMiddlewares);
|
|
167
|
+
cachedMiddlewares = resolveMiddlewares(apiDef.middleware, combinedRegistry);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const mw of cachedMiddlewares) {
|
|
171
|
+
if (typeof mw !== 'function') continue;
|
|
172
|
+
await new Promise((resolve, reject) => {
|
|
173
|
+
try {
|
|
174
|
+
mw(req, res, (err) => {
|
|
175
|
+
if (err) reject(err);
|
|
176
|
+
else resolve();
|
|
177
|
+
});
|
|
178
|
+
} catch (syncErr) {
|
|
179
|
+
reject(syncErr);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
if (res.headersSent) return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 5. Execute handler
|
|
186
|
+
const apiCtx = {
|
|
187
|
+
req,
|
|
188
|
+
res,
|
|
189
|
+
params: req.params || {},
|
|
190
|
+
query: req.query || {},
|
|
191
|
+
body: req.body || {},
|
|
192
|
+
input: req.input,
|
|
193
|
+
service: (name, input, opts) =>
|
|
194
|
+
serviceRegistry ? serviceRegistry.call(name, input, { req, res, db }, opts) : null,
|
|
195
|
+
module: descriptor.module || null,
|
|
196
|
+
config: appOptions,
|
|
197
|
+
logger: console,
|
|
198
|
+
db,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
req.ctx = apiCtx;
|
|
202
|
+
res.service = apiCtx.service;
|
|
203
|
+
res.input = apiCtx.input;
|
|
204
|
+
res.module = apiCtx.module;
|
|
205
|
+
|
|
206
|
+
const result = await handlerFn(req, res, apiCtx);
|
|
207
|
+
|
|
208
|
+
// 6. Auto-respond if handler returned a value and response not yet sent
|
|
209
|
+
if (!res.headersSent) {
|
|
210
|
+
if (result !== undefined) {
|
|
211
|
+
return res.json(result);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (err && typeof err === 'object') {
|
|
216
|
+
if (!err.route) err.route = descriptor.path;
|
|
217
|
+
if (!err.method) err.method = descriptor.method ? descriptor.method.toUpperCase() : (req.method || 'GET');
|
|
218
|
+
if (!err.source) err.source = descriptor.source || descriptor.file;
|
|
219
|
+
if (!err.file) err.file = descriptor.file;
|
|
220
|
+
if (!err.module) err.module = descriptor.module || null;
|
|
221
|
+
if (!err.phase) err.phase = 'handler';
|
|
222
|
+
if (!err.requestId) err.requestId = req.id || (req.headers && req.headers['x-request-id']) || null;
|
|
223
|
+
}
|
|
224
|
+
return next(err);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
createApiHandler,
|
|
231
|
+
compileApiSchema,
|
|
232
|
+
validateRequestInput,
|
|
233
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso API Definition Helper
|
|
5
|
+
* @module src/api/define-api
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Defines an API route with schema validation, response metadata, and handler
|
|
10
|
+
* @param {Object} config
|
|
11
|
+
* @param {string} [config.description] - Endpoint description for OpenAPI
|
|
12
|
+
* @param {Array<string>} [config.tags] - Endpoint tags for OpenAPI
|
|
13
|
+
* @param {Array<string|Function>} [config.middleware] - Named or functional middlewares
|
|
14
|
+
* @param {Object|Function} [config.schema] - Request schemas (params, query, body, headers)
|
|
15
|
+
* @param {Object} [config.response] - Expected response schemas by status code
|
|
16
|
+
* @param {Function} config.handler - Request handler: async (req, ctx) => data
|
|
17
|
+
* @returns {Object}
|
|
18
|
+
*/
|
|
19
|
+
function defineApi(config = {}) {
|
|
20
|
+
if (typeof config === 'function') {
|
|
21
|
+
return {
|
|
22
|
+
handler: config,
|
|
23
|
+
__isWebspressoApi: true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
...config,
|
|
29
|
+
__isWebspressoApi: true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
defineApi,
|
|
35
|
+
};
|