webspresso 0.0.58 → 0.0.61
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 +66 -1
- package/core/openapi/build-from-api-routes.js +172 -0
- package/core/openapi/orm-components.js +146 -0
- package/core/orm/index.js +2 -0
- package/core/orm/migrations/scaffold.js +13 -0
- package/core/orm/schema-helpers.js +54 -0
- package/core/orm/scopes.js +27 -0
- package/core/orm/seeder.js +4 -0
- package/core/orm/types.js +1 -1
- package/core/orm/utils/nanoid.js +30 -0
- package/index.js +3 -1
- package/package.json +11 -6
- package/plugins/admin-panel/components.js +1 -0
- package/plugins/admin-panel/field-renderers/index.js +1 -0
- 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/templates/skills/webspresso-usage/SKILL.md +2 -1
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
|
|
|
@@ -1237,6 +1240,7 @@ The `zdb` helpers wrap Zod schemas with database column metadata:
|
|
|
1237
1240
|
|--------|-------------|---------|
|
|
1238
1241
|
| `zdb.id()` | Primary key (bigint, auto-increment) | |
|
|
1239
1242
|
| `zdb.uuid()` | UUID primary key | |
|
|
1243
|
+
| `zdb.nanoid(opts)` | Nanoid primary key (URL-safe string, stored as VARCHAR) | `maxLength` (default `21`) |
|
|
1240
1244
|
| `zdb.string(opts)` | VARCHAR column | `maxLength`, `unique`, `index`, `nullable` |
|
|
1241
1245
|
| `zdb.text(opts)` | TEXT column | `nullable` |
|
|
1242
1246
|
| `zdb.integer(opts)` | INTEGER column | `nullable`, `default` |
|
|
@@ -1252,6 +1256,9 @@ The `zdb` helpers wrap Zod schemas with database column metadata:
|
|
|
1252
1256
|
| `zdb.enum(values, opts)` | ENUM column | `default`, `nullable` |
|
|
1253
1257
|
| `zdb.foreignKey(table, opts)` | Foreign key (bigint) | `referenceColumn`, `nullable` |
|
|
1254
1258
|
| `zdb.foreignUuid(table, opts)` | Foreign key (uuid) | `referenceColumn`, `nullable` |
|
|
1259
|
+
| `zdb.foreignNanoid(table, opts)` | Foreign key (nanoid string) | `referenceColumn`, `nullable`, `maxLength` (must match referenced PK) |
|
|
1260
|
+
|
|
1261
|
+
**Nanoid columns:** Migration scaffolding emits `table.string(column, maxLength)`. For a **nanoid primary key**, if you omit the primary key on `repository.create()`, Webspresso fills it with a cryptographically random ID using the same default alphabet as the [`nanoid`](https://github.com/ai/nanoid) package (implemented in-framework; no extra dependency). You can also call **`generateNanoid`** (exported from `webspresso`) anywhere you need the same generator.
|
|
1255
1262
|
|
|
1256
1263
|
### Model Definition
|
|
1257
1264
|
|
|
@@ -1776,6 +1783,64 @@ const user = plugin.api.getModel('User'); // Single model
|
|
|
1776
1783
|
const names = plugin.api.getModelNames(); // Model names
|
|
1777
1784
|
```
|
|
1778
1785
|
|
|
1786
|
+
### Health check plugin
|
|
1787
|
+
|
|
1788
|
+
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.
|
|
1789
|
+
|
|
1790
|
+
**Setup:**
|
|
1791
|
+
|
|
1792
|
+
```javascript
|
|
1793
|
+
const { createApp, healthCheckPlugin } = require('webspresso');
|
|
1794
|
+
|
|
1795
|
+
const app = createApp({
|
|
1796
|
+
plugins: [
|
|
1797
|
+
healthCheckPlugin({
|
|
1798
|
+
path: '/health', // default
|
|
1799
|
+
verbose: true, // timestamp, uptime, NODE_ENV, framework name/version
|
|
1800
|
+
authorize: (req) => true, // optional — restrict who can read the endpoint
|
|
1801
|
+
checks: async ({ db }) => {
|
|
1802
|
+
if (db) await db.knex.raw('select 1');
|
|
1803
|
+
return { database: 'ok' };
|
|
1804
|
+
},
|
|
1805
|
+
}),
|
|
1806
|
+
],
|
|
1807
|
+
});
|
|
1808
|
+
```
|
|
1809
|
+
|
|
1810
|
+
- **`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).
|
|
1811
|
+
- Use a **custom `path`** if your app already serves `GET /health` from `pages/`.
|
|
1812
|
+
|
|
1813
|
+
### Swagger / OpenAPI plugin
|
|
1814
|
+
|
|
1815
|
+
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).
|
|
1816
|
+
|
|
1817
|
+
**Setup:**
|
|
1818
|
+
|
|
1819
|
+
```javascript
|
|
1820
|
+
const { createApp, swaggerPlugin } = require('webspresso');
|
|
1821
|
+
|
|
1822
|
+
const app = createApp({
|
|
1823
|
+
plugins: [
|
|
1824
|
+
swaggerPlugin({
|
|
1825
|
+
path: '/_swagger', // UI: GET /_swagger, spec: GET /_swagger/openapi.json
|
|
1826
|
+
enabled: true, // default: true in development, false in production
|
|
1827
|
+
title: 'My API', // optional OpenAPI info.title
|
|
1828
|
+
serverUrl: 'https://api.example.com', // optional servers[0].url (else BASE_URL or localhost)
|
|
1829
|
+
includeOrmSchemas: false, // merge ORM model schemas into components.schemas
|
|
1830
|
+
ormExclude: ['Secret'], // when includeOrmSchemas is true
|
|
1831
|
+
authorize: (req) => true, // optional gate for both UI and JSON
|
|
1832
|
+
}),
|
|
1833
|
+
],
|
|
1834
|
+
});
|
|
1835
|
+
```
|
|
1836
|
+
|
|
1837
|
+
**Endpoints:**
|
|
1838
|
+
|
|
1839
|
+
- `GET /_swagger/openapi.json` — Full OpenAPI document (`paths` from API routes; request/response shapes from exported `schema({ z })` when present).
|
|
1840
|
+
- `GET /_swagger` — Swagger UI (loads the JSON above; requires network access for CDN assets).
|
|
1841
|
+
|
|
1842
|
+
In production, keep the plugin disabled or protect it with `authorize` / your own middleware.
|
|
1843
|
+
|
|
1779
1844
|
## Development
|
|
1780
1845
|
|
|
1781
1846
|
```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,146 @@
|
|
|
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 'nanoid':
|
|
105
|
+
result.type = 'string';
|
|
106
|
+
if (meta.maxLength) {
|
|
107
|
+
result.maxLength = meta.maxLength;
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
|
|
111
|
+
case 'json':
|
|
112
|
+
result.type = 'object';
|
|
113
|
+
break;
|
|
114
|
+
|
|
115
|
+
case 'enum':
|
|
116
|
+
result.type = 'string';
|
|
117
|
+
if (meta.enumValues) {
|
|
118
|
+
result.enum = meta.enumValues;
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
|
|
122
|
+
case 'text':
|
|
123
|
+
case 'string':
|
|
124
|
+
default:
|
|
125
|
+
result.type = 'string';
|
|
126
|
+
if (meta.maxLength) {
|
|
127
|
+
result.maxLength = meta.maxLength;
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (meta.nullable) {
|
|
133
|
+
result.nullable = true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (meta.default !== undefined) {
|
|
137
|
+
result.default = meta.default;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = {
|
|
144
|
+
generateOrmOpenApiSchemas,
|
|
145
|
+
columnToOpenApiType,
|
|
146
|
+
};
|
package/core/orm/index.js
CHANGED
|
@@ -14,6 +14,7 @@ const { createSeeder } = require('./seeder');
|
|
|
14
14
|
const { createScopeContext } = require('./scopes');
|
|
15
15
|
const { ModelEvents, Hooks, HookCancellationError, createEventContext } = require('./events');
|
|
16
16
|
const { omitHiddenColumns, sanitizeForOutput } = require('./utils');
|
|
17
|
+
const { generateNanoid } = require('./utils/nanoid');
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Create a database instance
|
|
@@ -273,6 +274,7 @@ module.exports = {
|
|
|
273
274
|
// Column utilities
|
|
274
275
|
extractColumnsFromSchema,
|
|
275
276
|
getColumnMeta,
|
|
277
|
+
generateNanoid,
|
|
276
278
|
// Output sanitization (exclude hidden columns from API/templates)
|
|
277
279
|
omitHiddenColumns,
|
|
278
280
|
sanitizeForOutput,
|
|
@@ -164,6 +164,19 @@ function generateColumnLine(columnName, meta) {
|
|
|
164
164
|
}
|
|
165
165
|
break;
|
|
166
166
|
|
|
167
|
+
case 'nanoid': {
|
|
168
|
+
const nanoLen = meta.maxLength || 21;
|
|
169
|
+
if (meta.primary) {
|
|
170
|
+
parts.push(`table.string('${columnName}', ${nanoLen})`);
|
|
171
|
+
} else if (meta.references) {
|
|
172
|
+
parts.push(`table.string('${columnName}', ${nanoLen})`);
|
|
173
|
+
fkLine = `table.foreign('${columnName}').references('${meta.referenceColumn || 'id'}').inTable('${meta.references}');`;
|
|
174
|
+
} else {
|
|
175
|
+
parts.push(`table.string('${columnName}', ${nanoLen})`);
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
167
180
|
default:
|
|
168
181
|
parts.push(`table.string('${columnName}')`);
|
|
169
182
|
}
|
|
@@ -359,6 +359,30 @@ function createSchemaHelpers(z) {
|
|
|
359
359
|
}, z);
|
|
360
360
|
},
|
|
361
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Nanoid primary key column (URL-safe string, default length 21)
|
|
364
|
+
* @param {Partial<import('./types').ColumnMeta>} [options={}]
|
|
365
|
+
* @returns {SchemaBuilder}
|
|
366
|
+
*/
|
|
367
|
+
nanoid(options = {}) {
|
|
368
|
+
const { maxLength = 21, ...rest } = options;
|
|
369
|
+
const schema = z
|
|
370
|
+
.string()
|
|
371
|
+
.length(maxLength)
|
|
372
|
+
.regex(/^[A-Za-z0-9_-]+$/)
|
|
373
|
+
.optional();
|
|
374
|
+
return createSchemaBuilder(
|
|
375
|
+
schema,
|
|
376
|
+
{
|
|
377
|
+
type: 'nanoid',
|
|
378
|
+
primary: true,
|
|
379
|
+
maxLength,
|
|
380
|
+
...rest,
|
|
381
|
+
},
|
|
382
|
+
z
|
|
383
|
+
);
|
|
384
|
+
},
|
|
385
|
+
|
|
362
386
|
/**
|
|
363
387
|
* String column (varchar)
|
|
364
388
|
* @param {Partial<import('./types').ColumnMeta>} [options={}]
|
|
@@ -667,6 +691,36 @@ function createSchemaHelpers(z) {
|
|
|
667
691
|
...rest,
|
|
668
692
|
}, z);
|
|
669
693
|
},
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Nanoid foreign key column
|
|
697
|
+
* @param {string} references - Referenced table name
|
|
698
|
+
* @param {Partial<import('./types').ColumnMeta>} [options={}]
|
|
699
|
+
* @returns {SchemaBuilder}
|
|
700
|
+
*/
|
|
701
|
+
foreignNanoid(references, options = {}) {
|
|
702
|
+
const {
|
|
703
|
+
referenceColumn = 'id',
|
|
704
|
+
nullable = false,
|
|
705
|
+
maxLength = 21,
|
|
706
|
+
...rest
|
|
707
|
+
} = options;
|
|
708
|
+
let schema = z
|
|
709
|
+
.string()
|
|
710
|
+
.length(maxLength)
|
|
711
|
+
.regex(/^[A-Za-z0-9_-]+$/);
|
|
712
|
+
if (nullable) {
|
|
713
|
+
schema = schema.nullable().optional();
|
|
714
|
+
}
|
|
715
|
+
return createSchemaBuilder(schema, {
|
|
716
|
+
type: 'nanoid',
|
|
717
|
+
references,
|
|
718
|
+
referenceColumn,
|
|
719
|
+
nullable,
|
|
720
|
+
maxLength,
|
|
721
|
+
...rest,
|
|
722
|
+
}, z);
|
|
723
|
+
},
|
|
670
724
|
};
|
|
671
725
|
|
|
672
726
|
return helpers;
|
package/core/orm/scopes.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* @module core/orm/scopes
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const { generateNanoid } = require('./utils/nanoid');
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Apply soft delete scope to a query builder
|
|
9
11
|
* @param {import('knex').Knex.QueryBuilder} qb - Knex query builder
|
|
@@ -115,6 +117,29 @@ function applyInsertTenant(data, context, model) {
|
|
|
115
117
|
};
|
|
116
118
|
}
|
|
117
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Generate nanoid primary key when missing on insert
|
|
122
|
+
* @param {Object} data - Data to insert
|
|
123
|
+
* @param {import('./types').ModelDefinition} model - Model definition
|
|
124
|
+
* @returns {Object}
|
|
125
|
+
*/
|
|
126
|
+
function applyInsertNanoidPrimary(data, model) {
|
|
127
|
+
const pk = model.primaryKey;
|
|
128
|
+
const meta = model.columns && model.columns.get(pk);
|
|
129
|
+
if (!meta || meta.type !== 'nanoid') {
|
|
130
|
+
return data;
|
|
131
|
+
}
|
|
132
|
+
const val = data[pk];
|
|
133
|
+
if (val !== undefined && val !== null && val !== '') {
|
|
134
|
+
return data;
|
|
135
|
+
}
|
|
136
|
+
const len = meta.maxLength || 21;
|
|
137
|
+
return {
|
|
138
|
+
...data,
|
|
139
|
+
[pk]: generateNanoid(len),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
118
143
|
/**
|
|
119
144
|
* Get soft delete data (for UPDATE instead of DELETE)
|
|
120
145
|
* @returns {Object} Soft delete update data
|
|
@@ -142,6 +167,7 @@ function applyInsertModifiers(data, context, model) {
|
|
|
142
167
|
let modified = { ...data };
|
|
143
168
|
modified = applyInsertTimestamps(modified, model);
|
|
144
169
|
modified = applyInsertTenant(modified, context, model);
|
|
170
|
+
modified = applyInsertNanoidPrimary(modified, model);
|
|
145
171
|
return modified;
|
|
146
172
|
}
|
|
147
173
|
|
|
@@ -174,6 +200,7 @@ module.exports = {
|
|
|
174
200
|
applyInsertTimestamps,
|
|
175
201
|
applyUpdateTimestamps,
|
|
176
202
|
applyInsertTenant,
|
|
203
|
+
applyInsertNanoidPrimary,
|
|
177
204
|
applyInsertModifiers,
|
|
178
205
|
applyUpdateModifiers,
|
|
179
206
|
getSoftDeleteData,
|
package/core/orm/seeder.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const { getModel, getAllModels } = require('./model');
|
|
8
|
+
const { generateNanoid } = require('./utils/nanoid');
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* @typedef {Object} SeederOptions
|
|
@@ -189,6 +190,9 @@ function createSeeder(faker, knex) {
|
|
|
189
190
|
case 'uuid':
|
|
190
191
|
return faker.string.uuid();
|
|
191
192
|
|
|
193
|
+
case 'nanoid':
|
|
194
|
+
return generateNanoid(meta.maxLength || 21);
|
|
195
|
+
|
|
192
196
|
case 'json':
|
|
193
197
|
return { key: faker.lorem.word(), value: faker.lorem.sentence() };
|
|
194
198
|
|
package/core/orm/types.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// ============================================================================
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* @typedef {'id'|'string'|'text'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'} ColumnType
|
|
12
|
+
* @typedef {'id'|'string'|'text'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'|'nanoid'} ColumnType
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-safe ID generation compatible with the default nanoid alphabet/algorithm.
|
|
3
|
+
* @module core/orm/utils/nanoid
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
/** Same 64-char alphabet as npm `nanoid` default (URL-safe). */
|
|
9
|
+
const URL_ALPHABET =
|
|
10
|
+
'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {number} [size=21] - Id length (default matches nanoid)
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
function generateNanoid(size = 21) {
|
|
17
|
+
const n = Math.max(1, Math.floor(size));
|
|
18
|
+
const bytes = new Uint8Array(n);
|
|
19
|
+
crypto.randomFillSync(bytes);
|
|
20
|
+
let id = '';
|
|
21
|
+
for (let i = 0; i < n; i++) {
|
|
22
|
+
id += URL_ALPHABET[bytes[i] & 63];
|
|
23
|
+
}
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
generateNanoid,
|
|
29
|
+
URL_ALPHABET,
|
|
30
|
+
};
|
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.61",
|
|
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
|
}
|
|
@@ -68,6 +68,7 @@ function initializeDefaultRenderers() {
|
|
|
68
68
|
registerFieldRenderer('timestamp', basicRenderers.DateTimeField);
|
|
69
69
|
registerFieldRenderer('enum', basicRenderers.SelectField);
|
|
70
70
|
registerFieldRenderer('uuid', basicRenderers.TextField);
|
|
71
|
+
registerFieldRenderer('nanoid', basicRenderers.TextField);
|
|
71
72
|
registerFieldRenderer('id', basicRenderers.NumberField);
|
|
72
73
|
|
|
73
74
|
// Complex types
|
|
@@ -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;
|
|
@@ -143,13 +143,14 @@ Analytics plugin adds `fsy.analyticsHead`, `fsy.verificationTags`, etc., when co
|
|
|
143
143
|
|
|
144
144
|
## 9. ORM overview
|
|
145
145
|
|
|
146
|
-
**Define schema** with **`zdb`** (`zdb.id()`, `zdb.string({...})`, `zdb.foreignKey`, `zdb.timestamp`, `zdb.json`, …).
|
|
146
|
+
**Define schema** with **`zdb`** (`zdb.id()`, `zdb.uuid()`, `zdb.nanoid()`, `zdb.string({...})`, `zdb.foreignKey`, `zdb.foreignUuid`, `zdb.foreignNanoid`, `zdb.timestamp`, `zdb.json`, …).
|
|
147
147
|
|
|
148
148
|
**Define model** with **`defineModel({ name, table, schema, relations, scopes, hidden, admin })`**.
|
|
149
149
|
|
|
150
150
|
- **Relations:** `belongsTo`, `hasMany`, `hasOne` with `model: () => OtherModel`.
|
|
151
151
|
- **Scopes:** `softDelete`, `timestamps`, optional `tenant` column.
|
|
152
152
|
- **`hidden`:** columns never exposed in admin/API (e.g. `password_hash`).
|
|
153
|
+
- **Nanoid PK:** `zdb.nanoid()` / `zdb.nanoid({ maxLength: 12 })` — string primary key; migrations use `string(length)`. On **`create()`**, omitting the PK auto-fills a URL-safe id (built-in generator, same alphabet as `nanoid`). Use **`zdb.foreignNanoid('table', { maxLength })`** when the parent uses nanoid PKs; **`generateNanoid`** is exported from `webspresso` for manual ids.
|
|
153
154
|
|
|
154
155
|
**Database:** `createDatabase({ client, connection, models: './models' })` — auto-loads `models/*.js` (ignore `_prefix`).
|
|
155
156
|
|