webspresso 0.0.58 → 0.0.60
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 +62 -1
- package/core/openapi/build-from-api-routes.js +172 -0
- package/core/openapi/orm-components.js +139 -0
- package/index.js +3 -1
- package/package.json +11 -6
- package/plugins/health-check.js +84 -0
- package/plugins/index.js +4 -0
- package/plugins/schema-explorer.js +2 -133
- package/plugins/swagger.js +126 -0
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Webspresso
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/webspresso)
|
|
4
|
+
[](https://npmx.dev/package/webspresso)
|
|
5
|
+
|
|
3
6
|
A minimal, file-based SSR framework for Node.js with Nunjucks templating.
|
|
4
7
|
|
|
5
8
|
## Features
|
|
@@ -12,7 +15,7 @@ A minimal, file-based SSR framework for Node.js with Nunjucks templating.
|
|
|
12
15
|
- **Lifecycle Hooks**: Global and route-level hooks for request processing
|
|
13
16
|
- **Template Helpers**: Laravel-inspired helper functions available in templates
|
|
14
17
|
- **Plugin System**: Extensible architecture with version control and inter-plugin communication
|
|
15
|
-
- **Built-in Plugins**: Development dashboard, sitemap generator, SEO checker, analytics integration (Google, Yandex, Bing), self-hosted site analytics
|
|
18
|
+
- **Built-in Plugins**: Development dashboard, sitemap generator, SEO checker, analytics integration (Google, Yandex, Bing), self-hosted site analytics, optional Swagger UI for HTTP APIs, configurable HTTP health probe endpoint
|
|
16
19
|
|
|
17
20
|
## Installation
|
|
18
21
|
|
|
@@ -1776,6 +1779,64 @@ const user = plugin.api.getModel('User'); // Single model
|
|
|
1776
1779
|
const names = plugin.api.getModelNames(); // Model names
|
|
1777
1780
|
```
|
|
1778
1781
|
|
|
1782
|
+
### Health check plugin
|
|
1783
|
+
|
|
1784
|
+
Exposes a lightweight **GET** endpoint for load balancers and orchestrators (Kubernetes, Docker healthcheck, etc.). **Enabled by default** in all environments; set `enabled: false` to turn it off.
|
|
1785
|
+
|
|
1786
|
+
**Setup:**
|
|
1787
|
+
|
|
1788
|
+
```javascript
|
|
1789
|
+
const { createApp, healthCheckPlugin } = require('webspresso');
|
|
1790
|
+
|
|
1791
|
+
const app = createApp({
|
|
1792
|
+
plugins: [
|
|
1793
|
+
healthCheckPlugin({
|
|
1794
|
+
path: '/health', // default
|
|
1795
|
+
verbose: true, // timestamp, uptime, NODE_ENV, framework name/version
|
|
1796
|
+
authorize: (req) => true, // optional — restrict who can read the endpoint
|
|
1797
|
+
checks: async ({ db }) => {
|
|
1798
|
+
if (db) await db.knex.raw('select 1');
|
|
1799
|
+
return { database: 'ok' };
|
|
1800
|
+
},
|
|
1801
|
+
}),
|
|
1802
|
+
],
|
|
1803
|
+
});
|
|
1804
|
+
```
|
|
1805
|
+
|
|
1806
|
+
- **`checks`**: If this function throws, the handler responds with **503** and `{ status: 'unhealthy', error, ... }`. Return a plain object to merge into `checks` on success (e.g. dependency status).
|
|
1807
|
+
- Use a **custom `path`** if your app already serves `GET /health` from `pages/`.
|
|
1808
|
+
|
|
1809
|
+
### Swagger / OpenAPI plugin
|
|
1810
|
+
|
|
1811
|
+
Serves **OpenAPI 3** for file-based `pages/api` routes and optional [Zod](https://zod.dev) `schema` exports, plus a **Swagger UI** page. Defaults to **development only** (same idea as the schema explorer).
|
|
1812
|
+
|
|
1813
|
+
**Setup:**
|
|
1814
|
+
|
|
1815
|
+
```javascript
|
|
1816
|
+
const { createApp, swaggerPlugin } = require('webspresso');
|
|
1817
|
+
|
|
1818
|
+
const app = createApp({
|
|
1819
|
+
plugins: [
|
|
1820
|
+
swaggerPlugin({
|
|
1821
|
+
path: '/_swagger', // UI: GET /_swagger, spec: GET /_swagger/openapi.json
|
|
1822
|
+
enabled: true, // default: true in development, false in production
|
|
1823
|
+
title: 'My API', // optional OpenAPI info.title
|
|
1824
|
+
serverUrl: 'https://api.example.com', // optional servers[0].url (else BASE_URL or localhost)
|
|
1825
|
+
includeOrmSchemas: false, // merge ORM model schemas into components.schemas
|
|
1826
|
+
ormExclude: ['Secret'], // when includeOrmSchemas is true
|
|
1827
|
+
authorize: (req) => true, // optional gate for both UI and JSON
|
|
1828
|
+
}),
|
|
1829
|
+
],
|
|
1830
|
+
});
|
|
1831
|
+
```
|
|
1832
|
+
|
|
1833
|
+
**Endpoints:**
|
|
1834
|
+
|
|
1835
|
+
- `GET /_swagger/openapi.json` — Full OpenAPI document (`paths` from API routes; request/response shapes from exported `schema({ z })` when present).
|
|
1836
|
+
- `GET /_swagger` — Swagger UI (loads the JSON above; requires network access for CDN assets).
|
|
1837
|
+
|
|
1838
|
+
In production, keep the plugin disabled or protect it with `authorize` / your own middleware.
|
|
1839
|
+
|
|
1779
1840
|
## Development
|
|
1780
1841
|
|
|
1781
1842
|
```bash
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build OpenAPI 3 document from file-router API route metadata + Zod schemas
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { zodToJsonSchema } = require('zod-to-json-schema');
|
|
8
|
+
const { compileSchema } = require('../compileSchema');
|
|
9
|
+
const { generateOrmOpenApiSchemas } = require('./orm-components');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Express route pattern → OpenAPI path (e.g. /users/:id → /users/{id})
|
|
13
|
+
* @param {string} expressPath
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
function expressPathToOpenApi(expressPath) {
|
|
17
|
+
return String(expressPath)
|
|
18
|
+
.replace(/:([A-Za-z0-9_]+)/g, '{$1}')
|
|
19
|
+
.replace(/\*/g, '{wildcard}');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {import('zod').ZodObject<any>} zodObj
|
|
24
|
+
* @param {'path' | 'query'} where
|
|
25
|
+
* @returns {object[]}
|
|
26
|
+
*/
|
|
27
|
+
function expandZodObjectToParameters(zodObj, where) {
|
|
28
|
+
if (!zodObj || zodObj._def?.typeName !== 'ZodObject') return [];
|
|
29
|
+
const shape = zodObj.shape;
|
|
30
|
+
return Object.entries(shape).map(([name, zType]) => ({
|
|
31
|
+
name,
|
|
32
|
+
in: where,
|
|
33
|
+
required: where === 'path' ? true : !zType.isOptional(),
|
|
34
|
+
schema: zodToJsonSchema(zType, { target: 'openApi3', $refStrategy: 'none' }),
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} route - { type, method, pattern, file }
|
|
40
|
+
* @param {object|null} compiled - compileSchema result
|
|
41
|
+
* @returns {object} OpenAPI Operation Object
|
|
42
|
+
*/
|
|
43
|
+
function buildOperation(route, compiled) {
|
|
44
|
+
const filePath = route.file;
|
|
45
|
+
const method = route.method.toLowerCase();
|
|
46
|
+
const summary = `${method.toUpperCase()} ${filePath}`;
|
|
47
|
+
|
|
48
|
+
const op = {
|
|
49
|
+
tags: ['api'],
|
|
50
|
+
summary,
|
|
51
|
+
operationId: String(filePath)
|
|
52
|
+
.replace(/[^a-zA-Z0-9]+/g, '_')
|
|
53
|
+
.replace(/^_|_$/g, ''),
|
|
54
|
+
responses: {
|
|
55
|
+
200: {
|
|
56
|
+
description: 'OK',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (compiled?.response) {
|
|
62
|
+
op.responses['200'] = {
|
|
63
|
+
description: 'OK',
|
|
64
|
+
content: {
|
|
65
|
+
'application/json': {
|
|
66
|
+
schema: zodToJsonSchema(compiled.response, { target: 'openApi3', $refStrategy: 'none' }),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parameters = [
|
|
73
|
+
...expandZodObjectToParameters(compiled?.params, 'path'),
|
|
74
|
+
...expandZodObjectToParameters(compiled?.query, 'query'),
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
if (parameters.length) {
|
|
78
|
+
op.parameters = parameters;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (compiled?.body) {
|
|
82
|
+
op.requestBody = {
|
|
83
|
+
content: {
|
|
84
|
+
'application/json': {
|
|
85
|
+
schema: zodToJsonSchema(compiled.body, { target: 'openApi3', $refStrategy: 'none' }),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return op;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {object} opts
|
|
96
|
+
* @param {object[]} opts.routes - route metadata from mountPages
|
|
97
|
+
* @param {string} opts.pagesDir
|
|
98
|
+
* @param {boolean} [opts.includeOrmSchemas]
|
|
99
|
+
* @param {string[]} [opts.ormExclude]
|
|
100
|
+
* @param {object} [opts.info]
|
|
101
|
+
* @param {object[]} [opts.servers]
|
|
102
|
+
* @returns {object} OpenAPI 3.0.x document
|
|
103
|
+
*/
|
|
104
|
+
function buildOpenApiDocument(opts) {
|
|
105
|
+
const {
|
|
106
|
+
routes = [],
|
|
107
|
+
pagesDir,
|
|
108
|
+
includeOrmSchemas = false,
|
|
109
|
+
ormExclude = [],
|
|
110
|
+
info = {},
|
|
111
|
+
servers = [{ url: '/' }],
|
|
112
|
+
} = opts;
|
|
113
|
+
|
|
114
|
+
if (!pagesDir) {
|
|
115
|
+
throw new Error('buildOpenApiDocument: pagesDir is required');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const paths = {};
|
|
119
|
+
const apiRoutes = routes.filter((r) => r.type === 'api');
|
|
120
|
+
const absPages = path.resolve(pagesDir);
|
|
121
|
+
|
|
122
|
+
for (const route of apiRoutes) {
|
|
123
|
+
const fullPath = path.join(absPages, route.file);
|
|
124
|
+
if (!fs.existsSync(fullPath)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let mod;
|
|
129
|
+
try {
|
|
130
|
+
mod = require(fullPath);
|
|
131
|
+
} catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let compiled = null;
|
|
136
|
+
try {
|
|
137
|
+
compiled = compileSchema(fullPath, mod);
|
|
138
|
+
} catch {
|
|
139
|
+
compiled = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const openApiPath = expressPathToOpenApi(route.pattern);
|
|
143
|
+
const method = route.method.toLowerCase();
|
|
144
|
+
|
|
145
|
+
if (!paths[openApiPath]) {
|
|
146
|
+
paths[openApiPath] = {};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
paths[openApiPath][method] = buildOperation(route, compiled);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const doc = {
|
|
153
|
+
openapi: '3.0.3',
|
|
154
|
+
info: {
|
|
155
|
+
title: info.title || 'API',
|
|
156
|
+
version: info.version || '1.0.0',
|
|
157
|
+
...(info.description ? { description: info.description } : {}),
|
|
158
|
+
},
|
|
159
|
+
servers,
|
|
160
|
+
paths,
|
|
161
|
+
components: {
|
|
162
|
+
schemas: includeOrmSchemas ? generateOrmOpenApiSchemas({ exclude: ormExclude }) : {},
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return doc;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = {
|
|
170
|
+
expressPathToOpenApi,
|
|
171
|
+
buildOpenApiDocument,
|
|
172
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORM column → OpenAPI schema fragments (shared by schema-explorer and swagger plugin)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { getAllModels } = require('../orm/model');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate OpenAPI 3 components.schemas from registered ORM models
|
|
9
|
+
* @param {{ exclude?: string[] }} options
|
|
10
|
+
* @returns {Record<string, object>}
|
|
11
|
+
*/
|
|
12
|
+
function generateOrmOpenApiSchemas(options = {}) {
|
|
13
|
+
const { exclude = [] } = options;
|
|
14
|
+
const models = getAllModels();
|
|
15
|
+
const schemas = {};
|
|
16
|
+
|
|
17
|
+
for (const [name, model] of models) {
|
|
18
|
+
if (exclude.includes(name)) continue;
|
|
19
|
+
|
|
20
|
+
const properties = {};
|
|
21
|
+
const required = [];
|
|
22
|
+
|
|
23
|
+
if (model.columns) {
|
|
24
|
+
for (const [colName, meta] of model.columns) {
|
|
25
|
+
properties[colName] = columnToOpenApiType(meta);
|
|
26
|
+
|
|
27
|
+
if (!meta.nullable && !meta.autoIncrement && meta.default === undefined) {
|
|
28
|
+
required.push(colName);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
schemas[name] = {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties,
|
|
36
|
+
required: required.length > 0 ? required : undefined,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const inputProperties = {};
|
|
40
|
+
const inputRequired = [];
|
|
41
|
+
|
|
42
|
+
if (model.columns) {
|
|
43
|
+
for (const [colName, meta] of model.columns) {
|
|
44
|
+
if (meta.autoIncrement || meta.auto) continue;
|
|
45
|
+
|
|
46
|
+
inputProperties[colName] = columnToOpenApiType(meta);
|
|
47
|
+
|
|
48
|
+
if (!meta.nullable && meta.default === undefined) {
|
|
49
|
+
inputRequired.push(colName);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
schemas[`${name}Input`] = {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: inputProperties,
|
|
57
|
+
required: inputRequired.length > 0 ? inputRequired : undefined,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return schemas;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {Object} meta - Column metadata
|
|
66
|
+
* @returns {Object}
|
|
67
|
+
*/
|
|
68
|
+
function columnToOpenApiType(meta) {
|
|
69
|
+
const result = {};
|
|
70
|
+
|
|
71
|
+
switch (meta.type) {
|
|
72
|
+
case 'bigint':
|
|
73
|
+
case 'integer':
|
|
74
|
+
result.type = 'integer';
|
|
75
|
+
if (meta.type === 'bigint') result.format = 'int64';
|
|
76
|
+
break;
|
|
77
|
+
|
|
78
|
+
case 'float':
|
|
79
|
+
case 'decimal':
|
|
80
|
+
result.type = 'number';
|
|
81
|
+
if (meta.type === 'decimal') result.format = 'double';
|
|
82
|
+
break;
|
|
83
|
+
|
|
84
|
+
case 'boolean':
|
|
85
|
+
result.type = 'boolean';
|
|
86
|
+
break;
|
|
87
|
+
|
|
88
|
+
case 'date':
|
|
89
|
+
result.type = 'string';
|
|
90
|
+
result.format = 'date';
|
|
91
|
+
break;
|
|
92
|
+
|
|
93
|
+
case 'datetime':
|
|
94
|
+
case 'timestamp':
|
|
95
|
+
result.type = 'string';
|
|
96
|
+
result.format = 'date-time';
|
|
97
|
+
break;
|
|
98
|
+
|
|
99
|
+
case 'uuid':
|
|
100
|
+
result.type = 'string';
|
|
101
|
+
result.format = 'uuid';
|
|
102
|
+
break;
|
|
103
|
+
|
|
104
|
+
case 'json':
|
|
105
|
+
result.type = 'object';
|
|
106
|
+
break;
|
|
107
|
+
|
|
108
|
+
case 'enum':
|
|
109
|
+
result.type = 'string';
|
|
110
|
+
if (meta.enumValues) {
|
|
111
|
+
result.enum = meta.enumValues;
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
|
|
115
|
+
case 'text':
|
|
116
|
+
case 'string':
|
|
117
|
+
default:
|
|
118
|
+
result.type = 'string';
|
|
119
|
+
if (meta.maxLength) {
|
|
120
|
+
result.maxLength = meta.maxLength;
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (meta.nullable) {
|
|
126
|
+
result.nullable = true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (meta.default !== undefined) {
|
|
130
|
+
result.default = meta.default;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
generateOrmOpenApiSchemas,
|
|
138
|
+
columnToOpenApiType,
|
|
139
|
+
};
|
package/index.js
CHANGED
|
@@ -30,7 +30,7 @@ const {
|
|
|
30
30
|
const orm = require('./core/orm');
|
|
31
31
|
|
|
32
32
|
// Built-in plugins
|
|
33
|
-
const { schemaExplorerPlugin, adminPanelPlugin, siteAnalyticsPlugin, auditLogPlugin, recaptchaPlugin } = require('./plugins');
|
|
33
|
+
const { schemaExplorerPlugin, adminPanelPlugin, siteAnalyticsPlugin, auditLogPlugin, recaptchaPlugin, swaggerPlugin, healthCheckPlugin } = require('./plugins');
|
|
34
34
|
|
|
35
35
|
module.exports = {
|
|
36
36
|
// Main API
|
|
@@ -72,4 +72,6 @@ module.exports = {
|
|
|
72
72
|
siteAnalyticsPlugin,
|
|
73
73
|
auditLogPlugin,
|
|
74
74
|
recaptchaPlugin,
|
|
75
|
+
swaggerPlugin,
|
|
76
|
+
healthCheckPlugin,
|
|
75
77
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.60",
|
|
4
4
|
"description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"license": "MIT",
|
|
31
31
|
"repository": {
|
|
32
32
|
"type": "git",
|
|
33
|
-
"url": "https://github.com/
|
|
33
|
+
"url": "https://github.com/litepacks/webspresso.git"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"index.js",
|
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
"knex": "^3.1.0",
|
|
55
55
|
"nunjucks": "^3.2.4",
|
|
56
56
|
"sharp": "^0.33.5",
|
|
57
|
-
"zod": "^3.23.0"
|
|
57
|
+
"zod": "^3.23.0",
|
|
58
|
+
"zod-to-json-schema": "^3.25.2"
|
|
58
59
|
},
|
|
59
60
|
"peerDependencies": {
|
|
60
61
|
"@faker-js/faker": ">=8.0.0",
|
|
@@ -83,15 +84,19 @@
|
|
|
83
84
|
"devDependencies": {
|
|
84
85
|
"@faker-js/faker": "^9.9.0",
|
|
85
86
|
"@playwright/test": "^1.48.0",
|
|
86
|
-
"@vitest/coverage-v8": "^
|
|
87
|
+
"@vitest/coverage-v8": "^3.0.0",
|
|
87
88
|
"better-sqlite3": "^11.10.0",
|
|
88
89
|
"chokidar": "^3.5.3",
|
|
89
90
|
"dotenv": "^16.3.1",
|
|
90
|
-
"release-it": "^
|
|
91
|
+
"release-it": "^19.0.0",
|
|
91
92
|
"supertest": "^6.3.4",
|
|
92
|
-
"vitest": "^
|
|
93
|
+
"vitest": "^3.0.0"
|
|
93
94
|
},
|
|
94
95
|
"engines": {
|
|
95
96
|
"node": ">=18.0.0"
|
|
97
|
+
},
|
|
98
|
+
"overrides": {
|
|
99
|
+
"tar": "^7.5.11",
|
|
100
|
+
"undici": "^6.24.0"
|
|
96
101
|
}
|
|
97
102
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health check plugin — liveness/readiness style HTTP endpoint for load balancers and monitoring
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const pathMod = require('path');
|
|
6
|
+
const PKG = require(pathMod.join(__dirname, '..', 'package.json'));
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {Object} [options]
|
|
10
|
+
* @param {string} [options.path='/health'] — GET endpoint path
|
|
11
|
+
* @param {boolean} [options.enabled=true] — Disable entirely when false
|
|
12
|
+
* @param {function} [options.authorize] — (req) => boolean; optional gate (e.g. internal network only)
|
|
13
|
+
* @param {boolean} [options.verbose=true] — Include timestamp, uptime, env, framework version
|
|
14
|
+
* @param {function} [options.checks] — async ({ req, db, options }) => Record<string, string> — custom checks; thrown error → 503
|
|
15
|
+
*/
|
|
16
|
+
function healthCheckPlugin(options = {}) {
|
|
17
|
+
const {
|
|
18
|
+
path: routePath = '/health',
|
|
19
|
+
enabled,
|
|
20
|
+
authorize,
|
|
21
|
+
verbose = true,
|
|
22
|
+
checks,
|
|
23
|
+
} = options;
|
|
24
|
+
|
|
25
|
+
const isEnabled = enabled !== undefined ? enabled : true;
|
|
26
|
+
const normalizedPath = routePath.startsWith('/') ? routePath : `/${routePath}`;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
name: 'health-check',
|
|
30
|
+
version: '1.0.0',
|
|
31
|
+
description: 'HTTP health endpoint for probes and monitoring',
|
|
32
|
+
|
|
33
|
+
onRoutesReady(ctx) {
|
|
34
|
+
if (!isEnabled) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
ctx.addRoute('get', normalizedPath, async (req, res) => {
|
|
39
|
+
if (authorize && !authorize(req)) {
|
|
40
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const body = {
|
|
44
|
+
status: 'ok',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
if (verbose) {
|
|
48
|
+
body.timestamp = new Date().toISOString();
|
|
49
|
+
body.uptime = process.uptime();
|
|
50
|
+
body.env = process.env.NODE_ENV || 'development';
|
|
51
|
+
body.framework = {
|
|
52
|
+
name: PKG.name,
|
|
53
|
+
version: PKG.version,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (typeof checks === 'function') {
|
|
58
|
+
try {
|
|
59
|
+
const result = await checks({
|
|
60
|
+
req,
|
|
61
|
+
db: ctx.db,
|
|
62
|
+
options: ctx.options,
|
|
63
|
+
});
|
|
64
|
+
if (result && typeof result === 'object') {
|
|
65
|
+
body.checks = result;
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
body.status = 'unhealthy';
|
|
69
|
+
body.error = err.message || String(err);
|
|
70
|
+
return res.status(503).json(body);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
res.json(body);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
78
|
+
console.log(` Health check: GET ${normalizedPath}`);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = healthCheckPlugin;
|
package/plugins/index.js
CHANGED
|
@@ -12,6 +12,8 @@ const seoCheckerPlugin = require('./seo-checker');
|
|
|
12
12
|
const siteAnalyticsPlugin = require('./site-analytics');
|
|
13
13
|
const auditLogPlugin = require('./audit-log');
|
|
14
14
|
const recaptchaPlugin = require('./recaptcha');
|
|
15
|
+
const swaggerPlugin = require('./swagger');
|
|
16
|
+
const healthCheckPlugin = require('./health-check');
|
|
15
17
|
|
|
16
18
|
module.exports = {
|
|
17
19
|
sitemapPlugin,
|
|
@@ -23,5 +25,7 @@ module.exports = {
|
|
|
23
25
|
siteAnalyticsPlugin,
|
|
24
26
|
auditLogPlugin,
|
|
25
27
|
recaptchaPlugin,
|
|
28
|
+
swaggerPlugin,
|
|
29
|
+
healthCheckPlugin,
|
|
26
30
|
};
|
|
27
31
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
const { getAllModels, getModel } = require('../core/orm/model');
|
|
8
8
|
const { getColumnMeta } = require('../core/orm/schema-helpers');
|
|
9
|
+
const { generateOrmOpenApiSchemas } = require('../core/openapi/orm-components');
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Create Schema Explorer plugin
|
|
@@ -99,7 +100,7 @@ function schemaExplorerPlugin(options = {}) {
|
|
|
99
100
|
return res.status(403).json({ error: 'Forbidden' });
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
const openApiSchemas =
|
|
103
|
+
const openApiSchemas = generateOrmOpenApiSchemas({ exclude });
|
|
103
104
|
|
|
104
105
|
res.json({
|
|
105
106
|
openapi: '3.0.0',
|
|
@@ -262,137 +263,5 @@ function serializeRelations(relations) {
|
|
|
262
263
|
return result;
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
/**
|
|
266
|
-
* Generate OpenAPI schemas from models
|
|
267
|
-
* @param {Object} options
|
|
268
|
-
* @returns {Object}
|
|
269
|
-
*/
|
|
270
|
-
function generateOpenApiSchemas(options) {
|
|
271
|
-
const { exclude } = options;
|
|
272
|
-
const models = getAllModels();
|
|
273
|
-
const schemas = {};
|
|
274
|
-
|
|
275
|
-
for (const [name, model] of models) {
|
|
276
|
-
if (exclude.includes(name)) continue;
|
|
277
|
-
|
|
278
|
-
const properties = {};
|
|
279
|
-
const required = [];
|
|
280
|
-
|
|
281
|
-
if (model.columns) {
|
|
282
|
-
for (const [colName, meta] of model.columns) {
|
|
283
|
-
properties[colName] = columnToOpenApiType(meta);
|
|
284
|
-
|
|
285
|
-
if (!meta.nullable && !meta.autoIncrement && meta.default === undefined) {
|
|
286
|
-
required.push(colName);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
schemas[name] = {
|
|
292
|
-
type: 'object',
|
|
293
|
-
properties,
|
|
294
|
-
required: required.length > 0 ? required : undefined,
|
|
295
|
-
};
|
|
296
|
-
|
|
297
|
-
// Add input schema (without auto fields)
|
|
298
|
-
const inputProperties = {};
|
|
299
|
-
const inputRequired = [];
|
|
300
|
-
|
|
301
|
-
if (model.columns) {
|
|
302
|
-
for (const [colName, meta] of model.columns) {
|
|
303
|
-
// Skip auto-generated fields for input
|
|
304
|
-
if (meta.autoIncrement || meta.auto) continue;
|
|
305
|
-
|
|
306
|
-
inputProperties[colName] = columnToOpenApiType(meta);
|
|
307
|
-
|
|
308
|
-
if (!meta.nullable && meta.default === undefined) {
|
|
309
|
-
inputRequired.push(colName);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
schemas[`${name}Input`] = {
|
|
315
|
-
type: 'object',
|
|
316
|
-
properties: inputProperties,
|
|
317
|
-
required: inputRequired.length > 0 ? inputRequired : undefined,
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
return schemas;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Convert column metadata to OpenAPI type
|
|
326
|
-
* @param {Object} meta - Column metadata
|
|
327
|
-
* @returns {Object}
|
|
328
|
-
*/
|
|
329
|
-
function columnToOpenApiType(meta) {
|
|
330
|
-
const result = {};
|
|
331
|
-
|
|
332
|
-
switch (meta.type) {
|
|
333
|
-
case 'bigint':
|
|
334
|
-
case 'integer':
|
|
335
|
-
result.type = 'integer';
|
|
336
|
-
if (meta.type === 'bigint') result.format = 'int64';
|
|
337
|
-
break;
|
|
338
|
-
|
|
339
|
-
case 'float':
|
|
340
|
-
case 'decimal':
|
|
341
|
-
result.type = 'number';
|
|
342
|
-
if (meta.type === 'decimal') result.format = 'double';
|
|
343
|
-
break;
|
|
344
|
-
|
|
345
|
-
case 'boolean':
|
|
346
|
-
result.type = 'boolean';
|
|
347
|
-
break;
|
|
348
|
-
|
|
349
|
-
case 'date':
|
|
350
|
-
result.type = 'string';
|
|
351
|
-
result.format = 'date';
|
|
352
|
-
break;
|
|
353
|
-
|
|
354
|
-
case 'datetime':
|
|
355
|
-
case 'timestamp':
|
|
356
|
-
result.type = 'string';
|
|
357
|
-
result.format = 'date-time';
|
|
358
|
-
break;
|
|
359
|
-
|
|
360
|
-
case 'uuid':
|
|
361
|
-
result.type = 'string';
|
|
362
|
-
result.format = 'uuid';
|
|
363
|
-
break;
|
|
364
|
-
|
|
365
|
-
case 'json':
|
|
366
|
-
result.type = 'object';
|
|
367
|
-
break;
|
|
368
|
-
|
|
369
|
-
case 'enum':
|
|
370
|
-
result.type = 'string';
|
|
371
|
-
if (meta.enumValues) {
|
|
372
|
-
result.enum = meta.enumValues;
|
|
373
|
-
}
|
|
374
|
-
break;
|
|
375
|
-
|
|
376
|
-
case 'text':
|
|
377
|
-
case 'string':
|
|
378
|
-
default:
|
|
379
|
-
result.type = 'string';
|
|
380
|
-
if (meta.maxLength) {
|
|
381
|
-
result.maxLength = meta.maxLength;
|
|
382
|
-
}
|
|
383
|
-
break;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
if (meta.nullable) {
|
|
387
|
-
result.nullable = true;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (meta.default !== undefined) {
|
|
391
|
-
result.default = meta.default;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return result;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
266
|
module.exports = schemaExplorerPlugin;
|
|
398
267
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swagger / OpenAPI plugin — HTTP API docs from file-based routes + Zod, optional ORM schemas
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { buildOpenApiDocument } = require('../core/openapi/build-from-api-routes');
|
|
7
|
+
|
|
8
|
+
const PKG = require(path.join(__dirname, '..', 'package.json'));
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} specUrl - Absolute path on same origin (e.g. /_swagger/openapi.json)
|
|
12
|
+
*/
|
|
13
|
+
function buildSwaggerUiHtml(specUrl) {
|
|
14
|
+
const urlJson = JSON.stringify(specUrl);
|
|
15
|
+
return `<!DOCTYPE html>
|
|
16
|
+
<html lang="en">
|
|
17
|
+
<head>
|
|
18
|
+
<meta charset="UTF-8" />
|
|
19
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
20
|
+
<title>API documentation</title>
|
|
21
|
+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" crossorigin />
|
|
22
|
+
</head>
|
|
23
|
+
<body>
|
|
24
|
+
<div id="swagger-ui"></div>
|
|
25
|
+
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" crossorigin></script>
|
|
26
|
+
<script>
|
|
27
|
+
window.onload = function () {
|
|
28
|
+
SwaggerUIBundle({
|
|
29
|
+
url: ${urlJson},
|
|
30
|
+
dom_id: '#swagger-ui',
|
|
31
|
+
deepLinking: true
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
</script>
|
|
35
|
+
</body>
|
|
36
|
+
</html>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {Object} [options]
|
|
41
|
+
* @param {string} [options.path='/_swagger'] - Base path; JSON at `{path}/openapi.json`, UI at `{path}`
|
|
42
|
+
* @param {boolean} [options.enabled] - Default: development only
|
|
43
|
+
* @param {Function} [options.authorize] - (req) => boolean
|
|
44
|
+
* @param {boolean} [options.includeOrmSchemas] - Merge ORM components.schemas
|
|
45
|
+
* @param {string[]} [options.ormExclude] - Model names to exclude when includeOrmSchemas
|
|
46
|
+
* @param {string} [options.title] - OpenAPI info.title
|
|
47
|
+
* @param {string} [options.version] - OpenAPI info.version
|
|
48
|
+
* @param {string} [options.description] - OpenAPI info.description
|
|
49
|
+
* @param {string} [options.serverUrl] - Override servers[0].url (default: BASE_URL or http://localhost:3000)
|
|
50
|
+
*/
|
|
51
|
+
function swaggerPlugin(options = {}) {
|
|
52
|
+
const {
|
|
53
|
+
path: basePath = '/_swagger',
|
|
54
|
+
enabled,
|
|
55
|
+
authorize,
|
|
56
|
+
includeOrmSchemas = false,
|
|
57
|
+
ormExclude = [],
|
|
58
|
+
title,
|
|
59
|
+
version,
|
|
60
|
+
description,
|
|
61
|
+
serverUrl,
|
|
62
|
+
} = options;
|
|
63
|
+
|
|
64
|
+
const normalizedBase = `/${String(basePath).replace(/^\/+|\/+$/g, '')}`;
|
|
65
|
+
const jsonPath = `${normalizedBase}/openapi.json`;
|
|
66
|
+
const uiPath = normalizedBase;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
name: 'swagger',
|
|
70
|
+
version: '1.0.0',
|
|
71
|
+
|
|
72
|
+
onRoutesReady(ctx) {
|
|
73
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
74
|
+
const isEnabled = enabled !== undefined ? enabled : isDev;
|
|
75
|
+
|
|
76
|
+
if (!isEnabled) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const forbid = (req, res) => {
|
|
81
|
+
if (authorize && !authorize(req)) {
|
|
82
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const server = serverUrl || process.env.BASE_URL || 'http://localhost:3000';
|
|
89
|
+
|
|
90
|
+
ctx.addRoute('get', jsonPath, (req, res) => {
|
|
91
|
+
if (forbid(req, res)) return;
|
|
92
|
+
try {
|
|
93
|
+
const doc = buildOpenApiDocument({
|
|
94
|
+
routes: ctx.routes || [],
|
|
95
|
+
pagesDir: ctx.options.pagesDir,
|
|
96
|
+
includeOrmSchemas,
|
|
97
|
+
ormExclude,
|
|
98
|
+
info: {
|
|
99
|
+
title: title || PKG.name || 'API',
|
|
100
|
+
version: version || PKG.version || '1.0.0',
|
|
101
|
+
description:
|
|
102
|
+
description ||
|
|
103
|
+
'Generated from pages/api routes and optional Zod schema exports.',
|
|
104
|
+
},
|
|
105
|
+
servers: [{ url: server.replace(/\/$/, '') }],
|
|
106
|
+
});
|
|
107
|
+
res.json(doc);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.warn('[swagger] OpenAPI generation failed:', err.message);
|
|
110
|
+
res.status(500).json({ error: 'OpenAPI generation failed', message: err.message });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
ctx.addRoute('get', uiPath, (req, res) => {
|
|
115
|
+
if (forbid(req, res)) return;
|
|
116
|
+
res.type('html').send(buildSwaggerUiHtml(jsonPath));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (isDev) {
|
|
120
|
+
console.log(` Swagger UI: ${uiPath} (OpenAPI: ${jsonPath})`);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = swaggerPlugin;
|