zero-http 0.2.4 → 0.3.1
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 +1250 -283
- package/documentation/config/db.js +25 -0
- package/documentation/config/middleware.js +44 -0
- package/documentation/config/tls.js +12 -0
- package/documentation/controllers/cookies.js +34 -0
- package/documentation/controllers/tasks.js +108 -0
- package/documentation/full-server.js +28 -177
- package/documentation/models/Task.js +21 -0
- package/documentation/public/data/api.json +404 -24
- package/documentation/public/data/docs.json +1139 -0
- package/documentation/public/data/examples.json +80 -2
- package/documentation/public/data/options.json +23 -8
- package/documentation/public/index.html +138 -99
- package/documentation/public/scripts/app.js +1 -3
- package/documentation/public/scripts/custom-select.js +189 -0
- package/documentation/public/scripts/data-sections.js +233 -250
- package/documentation/public/scripts/playground.js +270 -0
- package/documentation/public/scripts/ui.js +4 -3
- package/documentation/public/styles.css +56 -5
- package/documentation/public/vendor/icons/compress.svg +17 -17
- package/documentation/public/vendor/icons/database.svg +21 -0
- package/documentation/public/vendor/icons/env.svg +21 -0
- package/documentation/public/vendor/icons/fetch.svg +11 -14
- package/documentation/public/vendor/icons/security.svg +15 -0
- package/documentation/public/vendor/icons/sse.svg +12 -13
- package/documentation/public/vendor/icons/static.svg +12 -26
- package/documentation/public/vendor/icons/stream.svg +7 -13
- package/documentation/public/vendor/icons/validate.svg +17 -0
- package/documentation/routes/api.js +41 -0
- package/documentation/routes/core.js +20 -0
- package/documentation/routes/playground.js +29 -0
- package/documentation/routes/realtime.js +49 -0
- package/documentation/routes/uploads.js +71 -0
- package/index.js +62 -1
- package/lib/app.js +200 -8
- package/lib/body/json.js +28 -5
- package/lib/body/multipart.js +29 -1
- package/lib/body/raw.js +1 -1
- package/lib/body/sendError.js +1 -0
- package/lib/body/text.js +1 -1
- package/lib/body/typeMatch.js +6 -2
- package/lib/body/urlencoded.js +5 -2
- package/lib/debug.js +345 -0
- package/lib/env/index.js +440 -0
- package/lib/errors.js +231 -0
- package/lib/http/request.js +219 -1
- package/lib/http/response.js +410 -6
- package/lib/middleware/compress.js +39 -6
- package/lib/middleware/cookieParser.js +237 -0
- package/lib/middleware/cors.js +13 -2
- package/lib/middleware/csrf.js +135 -0
- package/lib/middleware/errorHandler.js +90 -0
- package/lib/middleware/helmet.js +176 -0
- package/lib/middleware/index.js +7 -2
- package/lib/middleware/rateLimit.js +12 -1
- package/lib/middleware/requestId.js +54 -0
- package/lib/middleware/static.js +95 -11
- package/lib/middleware/timeout.js +72 -0
- package/lib/middleware/validator.js +257 -0
- package/lib/orm/adapters/json.js +215 -0
- package/lib/orm/adapters/memory.js +383 -0
- package/lib/orm/adapters/mongo.js +444 -0
- package/lib/orm/adapters/mysql.js +272 -0
- package/lib/orm/adapters/postgres.js +394 -0
- package/lib/orm/adapters/sql-base.js +142 -0
- package/lib/orm/adapters/sqlite.js +311 -0
- package/lib/orm/index.js +276 -0
- package/lib/orm/model.js +895 -0
- package/lib/orm/query.js +807 -0
- package/lib/orm/schema.js +172 -0
- package/lib/router/index.js +136 -47
- package/lib/sse/stream.js +15 -3
- package/lib/ws/connection.js +19 -3
- package/lib/ws/handshake.js +3 -0
- package/lib/ws/index.js +3 -1
- package/lib/ws/room.js +222 -0
- package/package.json +15 -5
- package/types/app.d.ts +120 -0
- package/types/env.d.ts +80 -0
- package/types/errors.d.ts +147 -0
- package/types/fetch.d.ts +43 -0
- package/types/index.d.ts +135 -0
- package/types/middleware.d.ts +292 -0
- package/types/orm.d.ts +610 -0
- package/types/request.d.ts +99 -0
- package/types/response.d.ts +142 -0
- package/types/router.d.ts +78 -0
- package/types/sse.d.ts +78 -0
- package/types/websocket.d.ts +119 -0
package/lib/middleware/index.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module middleware
|
|
3
3
|
* @description Built-in middleware for zero-http.
|
|
4
|
-
* Re-exports
|
|
4
|
+
* Re-exports all middleware.
|
|
5
5
|
*/
|
|
6
6
|
const cors = require('./cors');
|
|
7
7
|
const logger = require('./logger');
|
|
8
8
|
const rateLimit = require('./rateLimit');
|
|
9
9
|
const compress = require('./compress');
|
|
10
10
|
const serveStatic = require('./static');
|
|
11
|
+
const helmet = require('./helmet');
|
|
12
|
+
const timeout = require('./timeout');
|
|
13
|
+
const requestId = require('./requestId');
|
|
14
|
+
const cookieParser = require('./cookieParser');
|
|
15
|
+
const errorHandler = require('./errorHandler');
|
|
11
16
|
|
|
12
|
-
module.exports = { cors, logger, rateLimit, compress, static: serveStatic };
|
|
17
|
+
module.exports = { cors, logger, rateLimit, compress, static: serveStatic, helmet, timeout, requestId, cookieParser, errorHandler };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* In-memory rate-limiting middleware.
|
|
3
|
-
* Limits requests per IP address within a
|
|
3
|
+
* Limits requests per IP address within a fixed time window.
|
|
4
4
|
*
|
|
5
5
|
* @param {object} [opts]
|
|
6
6
|
* @param {number} [opts.windowMs=60000] - Time window in milliseconds.
|
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
* @param {string} [opts.message] - Custom error message.
|
|
9
9
|
* @param {number} [opts.statusCode=429] - HTTP status for rate-limited responses.
|
|
10
10
|
* @param {function} [opts.keyGenerator] - (req) => string; custom key extraction (default: req.ip).
|
|
11
|
+
* @param {function} [opts.skip] - (req) => boolean; return true to skip rate limiting.
|
|
12
|
+
* @param {function} [opts.handler] - (req, res) => void; custom handler for rate-limited requests.
|
|
11
13
|
* @returns {function} Middleware function.
|
|
12
14
|
*/
|
|
15
|
+
const log = require('../debug')('zero:rateLimit');
|
|
16
|
+
|
|
13
17
|
function rateLimit(opts = {})
|
|
14
18
|
{
|
|
15
19
|
const windowMs = opts.windowMs || 60_000;
|
|
@@ -17,6 +21,8 @@ function rateLimit(opts = {})
|
|
|
17
21
|
const statusCode = opts.statusCode || 429;
|
|
18
22
|
const message = opts.message || 'Too many requests, please try again later.';
|
|
19
23
|
const keyGenerator = typeof opts.keyGenerator === 'function' ? opts.keyGenerator : (req) => req.ip || 'unknown';
|
|
24
|
+
const skipFn = typeof opts.skip === 'function' ? opts.skip : null;
|
|
25
|
+
const handlerFn = typeof opts.handler === 'function' ? opts.handler : null;
|
|
20
26
|
|
|
21
27
|
const hits = new Map(); // key → { count, resetTime }
|
|
22
28
|
|
|
@@ -33,6 +39,9 @@ function rateLimit(opts = {})
|
|
|
33
39
|
|
|
34
40
|
return (req, res, next) =>
|
|
35
41
|
{
|
|
42
|
+
// Allow skipping rate limit for certain requests
|
|
43
|
+
if (skipFn && skipFn(req)) return next();
|
|
44
|
+
|
|
36
45
|
const key = keyGenerator(req);
|
|
37
46
|
const now = Date.now();
|
|
38
47
|
let entry = hits.get(key);
|
|
@@ -53,7 +62,9 @@ function rateLimit(opts = {})
|
|
|
53
62
|
|
|
54
63
|
if (entry.count > max)
|
|
55
64
|
{
|
|
65
|
+
log.warn('rate limit exceeded for %s', key);
|
|
56
66
|
res.set('Retry-After', String(Math.ceil(windowMs / 1000)));
|
|
67
|
+
if (handlerFn) return handlerFn(req, res);
|
|
57
68
|
return res.status(statusCode).json({ error: message });
|
|
58
69
|
}
|
|
59
70
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module requestId
|
|
3
|
+
* @description Request ID middleware.
|
|
4
|
+
* Assigns a unique identifier to each incoming request for
|
|
5
|
+
* tracing and debugging. Sets the ID on both the request
|
|
6
|
+
* object and as a response header.
|
|
7
|
+
*/
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a request ID middleware.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} [opts]
|
|
14
|
+
* @param {string} [opts.header='X-Request-Id'] - Response header name.
|
|
15
|
+
* @param {Function} [opts.generator] - Custom ID generator `() => string`.
|
|
16
|
+
* @param {boolean} [opts.trustProxy=false] - Trust incoming X-Request-Id header from proxy.
|
|
17
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* app.use(requestId());
|
|
21
|
+
* app.get('/', (req, res) => {
|
|
22
|
+
* console.log(req.id); // e.g. '7f3a2b1c-...'
|
|
23
|
+
* });
|
|
24
|
+
*/
|
|
25
|
+
function requestId(opts = {})
|
|
26
|
+
{
|
|
27
|
+
const headerName = opts.header || 'X-Request-Id';
|
|
28
|
+
const trustProxy = !!opts.trustProxy;
|
|
29
|
+
const generator = typeof opts.generator === 'function'
|
|
30
|
+
? opts.generator
|
|
31
|
+
: () => crypto.randomUUID();
|
|
32
|
+
|
|
33
|
+
return (req, res, next) =>
|
|
34
|
+
{
|
|
35
|
+
let id;
|
|
36
|
+
|
|
37
|
+
if (trustProxy)
|
|
38
|
+
{
|
|
39
|
+
const existing = req.headers[headerName.toLowerCase()];
|
|
40
|
+
if (existing && typeof existing === 'string' && existing.length <= 128)
|
|
41
|
+
{
|
|
42
|
+
id = existing;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!id) id = generator();
|
|
47
|
+
|
|
48
|
+
req.id = id;
|
|
49
|
+
res.set(headerName, id);
|
|
50
|
+
next();
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = requestId;
|
package/lib/middleware/static.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const path = require('path');
|
|
9
|
+
const log = require('../debug')('zero:static');
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Extension → MIME-type lookup table.
|
|
@@ -78,14 +79,25 @@ const MIME = {
|
|
|
78
79
|
};
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
82
|
+
* Generate a weak ETag from file stats (mtime + size).
|
|
83
|
+
* @param {import('fs').Stats} stat
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function generateETag(stat)
|
|
87
|
+
{
|
|
88
|
+
return 'W/"' + stat.size.toString(16) + '-' + stat.mtimeMs.toString(16) + '"';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Stream a file to the raw Node response, setting Content-Type,
|
|
93
|
+
* Content-Length, ETag, and Last-Modified headers.
|
|
83
94
|
*
|
|
84
95
|
* @param {import('./response')} res - Wrapped response object.
|
|
85
96
|
* @param {string} filePath - Absolute path to the file.
|
|
86
97
|
* @param {import('fs').Stats} [stat] - Pre-fetched `fs.Stats` (for Content-Length).
|
|
98
|
+
* @param {import('./request')} [req] - Wrapped request (for conditional checks).
|
|
87
99
|
*/
|
|
88
|
-
function sendFile(res, filePath, stat)
|
|
100
|
+
function sendFile(res, filePath, stat, req)
|
|
89
101
|
{
|
|
90
102
|
const ext = path.extname(filePath).toLowerCase();
|
|
91
103
|
const ct = MIME[ext] || 'application/octet-stream';
|
|
@@ -93,11 +105,77 @@ function sendFile(res, filePath, stat)
|
|
|
93
105
|
try
|
|
94
106
|
{
|
|
95
107
|
raw.setHeader('Content-Type', ct);
|
|
96
|
-
if (stat
|
|
108
|
+
if (stat)
|
|
109
|
+
{
|
|
110
|
+
if (stat.size) raw.setHeader('Content-Length', stat.size);
|
|
111
|
+
// ETag and Last-Modified for caching
|
|
112
|
+
const etag = generateETag(stat);
|
|
113
|
+
raw.setHeader('ETag', etag);
|
|
114
|
+
raw.setHeader('Last-Modified', stat.mtime.toUTCString());
|
|
115
|
+
raw.setHeader('Accept-Ranges', 'bytes');
|
|
116
|
+
|
|
117
|
+
// Conditional request handling (304 Not Modified)
|
|
118
|
+
if (req)
|
|
119
|
+
{
|
|
120
|
+
const ifNoneMatch = req.headers['if-none-match'];
|
|
121
|
+
const ifModifiedSince = req.headers['if-modified-since'];
|
|
122
|
+
if (ifNoneMatch && ifNoneMatch === etag)
|
|
123
|
+
{
|
|
124
|
+
raw.statusCode = 304;
|
|
125
|
+
raw.end();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (ifModifiedSince && !ifNoneMatch)
|
|
129
|
+
{
|
|
130
|
+
const since = Date.parse(ifModifiedSince);
|
|
131
|
+
if (!isNaN(since) && stat.mtimeMs <= since)
|
|
132
|
+
{
|
|
133
|
+
raw.statusCode = 304;
|
|
134
|
+
raw.end();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Range request support (HTTP 206)
|
|
140
|
+
const rangeHeader = req.headers['range'];
|
|
141
|
+
if (rangeHeader && stat.size > 0)
|
|
142
|
+
{
|
|
143
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
|
|
144
|
+
if (match)
|
|
145
|
+
{
|
|
146
|
+
let start = match[1] ? parseInt(match[1], 10) : 0;
|
|
147
|
+
let end = match[2] ? parseInt(match[2], 10) : stat.size - 1;
|
|
148
|
+
if (!match[1] && match[2])
|
|
149
|
+
{
|
|
150
|
+
// suffix range: bytes=-500 means last 500 bytes
|
|
151
|
+
start = Math.max(0, stat.size - parseInt(match[2], 10));
|
|
152
|
+
end = stat.size - 1;
|
|
153
|
+
}
|
|
154
|
+
if (start > end || start >= stat.size || end >= stat.size)
|
|
155
|
+
{
|
|
156
|
+
raw.statusCode = 416;
|
|
157
|
+
raw.setHeader('Content-Range', 'bytes */' + stat.size);
|
|
158
|
+
raw.setHeader('Content-Length', 0);
|
|
159
|
+
raw.end();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
raw.statusCode = 206;
|
|
163
|
+
raw.setHeader('Content-Range', 'bytes ' + start + '-' + end + '/' + stat.size);
|
|
164
|
+
raw.setHeader('Content-Length', end - start + 1);
|
|
165
|
+
const stream = fs.createReadStream(filePath, { start, end });
|
|
166
|
+
stream.on('error', (err) => { log.warn('file read error %s: %s', filePath, err.message); try { raw.statusCode = 404; raw.end(); } catch (e) { } });
|
|
167
|
+
log.debug('serving %s (range %d-%d)', filePath, start, end);
|
|
168
|
+
stream.pipe(raw);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
97
174
|
}
|
|
98
175
|
catch (e) { /* best-effort */ }
|
|
99
176
|
const stream = fs.createReadStream(filePath);
|
|
100
|
-
stream.on('error', () => { try { raw.statusCode = 404; raw.end(); } catch (e) { } });
|
|
177
|
+
stream.on('error', (err) => { log.warn('file read error %s: %s', filePath, err.message); try { raw.statusCode = 404; raw.end(); } catch (e) { } });
|
|
178
|
+
log.debug('serving %s', filePath);
|
|
101
179
|
stream.pipe(raw);
|
|
102
180
|
}
|
|
103
181
|
|
|
@@ -136,9 +214,15 @@ function serveStatic(root, options = {})
|
|
|
136
214
|
return (req, res, next) =>
|
|
137
215
|
{
|
|
138
216
|
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
217
|
+
let urlPath;
|
|
218
|
+
try { urlPath = decodeURIComponent(req.url.split('?')[0]); } catch (e) { return res.status(400).json({ error: 'Bad Request' }); }
|
|
219
|
+
|
|
220
|
+
// Block null bytes (poison byte attack)
|
|
221
|
+
if (urlPath.indexOf('\0') !== -1) return res.status(400).json({ error: 'Bad Request' });
|
|
222
|
+
|
|
223
|
+
let file = path.resolve(root, '.' + path.sep + urlPath);
|
|
224
|
+
// Normalize and verify the resolved path is within root (prevents path traversal)
|
|
225
|
+
if (!file.startsWith(root + path.sep) && file !== root) return res.status(403).json({ error: 'Forbidden' });
|
|
142
226
|
|
|
143
227
|
if (isDotfile(file) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
144
228
|
|
|
@@ -160,7 +244,7 @@ function serveStatic(root, options = {})
|
|
|
160
244
|
{
|
|
161
245
|
if (isDotfile(f) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
162
246
|
applyHeaders(res, f);
|
|
163
|
-
return sendFile(res, f, st2);
|
|
247
|
+
return sendFile(res, f, st2, req);
|
|
164
248
|
}
|
|
165
249
|
tryExt(i + 1);
|
|
166
250
|
});
|
|
@@ -179,7 +263,7 @@ function serveStatic(root, options = {})
|
|
|
179
263
|
if (err2) return next();
|
|
180
264
|
if (isDotfile(idxFile) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
181
265
|
applyHeaders(res, idxFile);
|
|
182
|
-
sendFile(res, idxFile, st2);
|
|
266
|
+
sendFile(res, idxFile, st2, req);
|
|
183
267
|
});
|
|
184
268
|
}
|
|
185
269
|
else
|
|
@@ -187,7 +271,7 @@ function serveStatic(root, options = {})
|
|
|
187
271
|
if (isDotfile(file) && dotfiles === 'ignore') return next();
|
|
188
272
|
if (isDotfile(file) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
189
273
|
applyHeaders(res, file);
|
|
190
|
-
sendFile(res, file, st);
|
|
274
|
+
sendFile(res, file, st, req);
|
|
191
275
|
}
|
|
192
276
|
});
|
|
193
277
|
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module timeout
|
|
3
|
+
* @description Request timeout middleware.
|
|
4
|
+
* Automatically sends a 408 response if the handler doesn't
|
|
5
|
+
* respond within the configured time limit.
|
|
6
|
+
* Helps prevent Slowloris-style attacks and hung requests.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a request timeout middleware.
|
|
11
|
+
*
|
|
12
|
+
* @param {number} [ms=30000] - Timeout in milliseconds (default 30s).
|
|
13
|
+
* @param {object} [opts]
|
|
14
|
+
* @param {number} [opts.status=408] - HTTP status code for timeout responses.
|
|
15
|
+
* @param {string} [opts.message='Request Timeout'] - Error message body.
|
|
16
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* app.use(timeout(5000)); // 5 second timeout
|
|
20
|
+
* app.use(timeout(10000, { message: 'Too slow' }));
|
|
21
|
+
*/
|
|
22
|
+
const log = require('../debug')('zero:timeout');
|
|
23
|
+
|
|
24
|
+
function timeout(ms = 30000, opts = {})
|
|
25
|
+
{
|
|
26
|
+
if (typeof ms === 'object') { opts = ms; ms = 30000; }
|
|
27
|
+
|
|
28
|
+
const statusCode = opts.status || 408;
|
|
29
|
+
const message = opts.message || 'Request Timeout';
|
|
30
|
+
|
|
31
|
+
return (req, res, next) =>
|
|
32
|
+
{
|
|
33
|
+
let timedOut = false;
|
|
34
|
+
|
|
35
|
+
const timer = setTimeout(() =>
|
|
36
|
+
{
|
|
37
|
+
timedOut = true;
|
|
38
|
+
req._timedOut = true;
|
|
39
|
+
log.warn('request timed out after %dms: %s %s', ms, req.method, req.url);
|
|
40
|
+
|
|
41
|
+
// Only send response if headers haven't been sent yet
|
|
42
|
+
if (!res.headersSent && !res._sent)
|
|
43
|
+
{
|
|
44
|
+
res.status(statusCode).json({ error: message });
|
|
45
|
+
}
|
|
46
|
+
}, ms);
|
|
47
|
+
|
|
48
|
+
// Unref so the timer doesn't keep the process alive
|
|
49
|
+
if (timer.unref) timer.unref();
|
|
50
|
+
|
|
51
|
+
// Clear timeout when response finishes
|
|
52
|
+
const raw = res.raw;
|
|
53
|
+
const onFinish = () =>
|
|
54
|
+
{
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
raw.removeListener('finish', onFinish);
|
|
57
|
+
raw.removeListener('close', onFinish);
|
|
58
|
+
};
|
|
59
|
+
raw.on('finish', onFinish);
|
|
60
|
+
raw.on('close', onFinish);
|
|
61
|
+
|
|
62
|
+
// Expose timedOut check on request
|
|
63
|
+
Object.defineProperty(req, 'timedOut', {
|
|
64
|
+
get() { return timedOut; },
|
|
65
|
+
configurable: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
next();
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = timeout;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module middleware/validator
|
|
3
|
+
* @description Request validation middleware.
|
|
4
|
+
* Validates `req.body`, `req.query`, and `req.params` against a
|
|
5
|
+
* schema object. Returns 422 with detailed errors on failure.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const { createApp, validate } = require('zero-http');
|
|
9
|
+
* const app = createApp();
|
|
10
|
+
*
|
|
11
|
+
* app.post('/users', validate({
|
|
12
|
+
* body: {
|
|
13
|
+
* name: { type: 'string', required: true, minLength: 1, maxLength: 100 },
|
|
14
|
+
* email: { type: 'string', required: true, match: /^[^@]+@[^@]+\.[^@]+$/ },
|
|
15
|
+
* age: { type: 'integer', min: 0, max: 150 },
|
|
16
|
+
* },
|
|
17
|
+
* query: {
|
|
18
|
+
* format: { type: 'string', enum: ['json', 'xml'], default: 'json' },
|
|
19
|
+
* },
|
|
20
|
+
* }), (req, res) => {
|
|
21
|
+
* // req.body / req.query are now validated and sanitised
|
|
22
|
+
* });
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Supported shorthand types for validation rules.
|
|
27
|
+
* @private
|
|
28
|
+
*/
|
|
29
|
+
const COERCE = {
|
|
30
|
+
string(v) { return v == null ? v : String(v); },
|
|
31
|
+
integer(v) { const n = parseInt(v, 10); return Number.isNaN(n) ? v : n; },
|
|
32
|
+
number(v) { const n = Number(v); return Number.isNaN(n) ? v : n; },
|
|
33
|
+
float(v) { const n = parseFloat(v); return Number.isNaN(n) ? v : n; },
|
|
34
|
+
boolean(v)
|
|
35
|
+
{
|
|
36
|
+
if (typeof v === 'boolean') return v;
|
|
37
|
+
if (typeof v === 'string')
|
|
38
|
+
{
|
|
39
|
+
const l = v.toLowerCase();
|
|
40
|
+
if (l === 'true' || l === '1' || l === 'yes' || l === 'on') return true;
|
|
41
|
+
if (l === 'false' || l === '0' || l === 'no' || l === 'off') return false;
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
},
|
|
45
|
+
array(v)
|
|
46
|
+
{
|
|
47
|
+
if (Array.isArray(v)) return v;
|
|
48
|
+
if (typeof v === 'string')
|
|
49
|
+
{
|
|
50
|
+
try { const p = JSON.parse(v); if (Array.isArray(p)) return p; } catch {}
|
|
51
|
+
return v.split(',').map(s => s.trim());
|
|
52
|
+
}
|
|
53
|
+
return v;
|
|
54
|
+
},
|
|
55
|
+
json(v)
|
|
56
|
+
{
|
|
57
|
+
if (typeof v === 'string') { try { return JSON.parse(v); } catch {} }
|
|
58
|
+
return v;
|
|
59
|
+
},
|
|
60
|
+
date(v)
|
|
61
|
+
{
|
|
62
|
+
if (v instanceof Date) return v;
|
|
63
|
+
const d = new Date(v);
|
|
64
|
+
return Number.isNaN(d.getTime()) ? v : d;
|
|
65
|
+
},
|
|
66
|
+
uuid(v) { return v == null ? v : String(v); },
|
|
67
|
+
email(v) { return v == null ? v : String(v).trim().toLowerCase(); },
|
|
68
|
+
url(v) { return v == null ? v : String(v).trim(); },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate a single value against a rule definition.
|
|
73
|
+
*
|
|
74
|
+
* @param {*} value - Raw input value.
|
|
75
|
+
* @param {object} rule - Rule definition.
|
|
76
|
+
* @param {string} field - Field name (for error messages).
|
|
77
|
+
* @returns {{ value: *, error: string|null }}
|
|
78
|
+
* @private
|
|
79
|
+
*/
|
|
80
|
+
function validateField(value, rule, field)
|
|
81
|
+
{
|
|
82
|
+
// Apply default
|
|
83
|
+
if ((value === undefined || value === null || value === '') && rule.default !== undefined)
|
|
84
|
+
{
|
|
85
|
+
value = typeof rule.default === 'function' ? rule.default() : rule.default;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Required check
|
|
89
|
+
if (rule.required && (value === undefined || value === null || value === ''))
|
|
90
|
+
{
|
|
91
|
+
return { value, error: `${field} is required` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// If not required and absent, skip further checks
|
|
95
|
+
if (value === undefined || value === null) return { value, error: null };
|
|
96
|
+
|
|
97
|
+
// Type coercion
|
|
98
|
+
if (rule.type && COERCE[rule.type]) value = COERCE[rule.type](value);
|
|
99
|
+
|
|
100
|
+
// Type validation
|
|
101
|
+
if (rule.type)
|
|
102
|
+
{
|
|
103
|
+
switch (rule.type)
|
|
104
|
+
{
|
|
105
|
+
case 'string':
|
|
106
|
+
if (typeof value !== 'string') return { value, error: `${field} must be a string` };
|
|
107
|
+
break;
|
|
108
|
+
case 'integer':
|
|
109
|
+
if (!Number.isInteger(value)) return { value, error: `${field} must be an integer` };
|
|
110
|
+
break;
|
|
111
|
+
case 'number':
|
|
112
|
+
case 'float':
|
|
113
|
+
if (typeof value !== 'number' || Number.isNaN(value)) return { value, error: `${field} must be a number` };
|
|
114
|
+
break;
|
|
115
|
+
case 'boolean':
|
|
116
|
+
if (typeof value !== 'boolean') return { value, error: `${field} must be a boolean` };
|
|
117
|
+
break;
|
|
118
|
+
case 'array':
|
|
119
|
+
if (!Array.isArray(value)) return { value, error: `${field} must be an array` };
|
|
120
|
+
break;
|
|
121
|
+
case 'date':
|
|
122
|
+
if (!(value instanceof Date)) return { value, error: `${field} must be a valid date` };
|
|
123
|
+
break;
|
|
124
|
+
case 'email':
|
|
125
|
+
if (typeof value !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
126
|
+
return { value, error: `${field} must be a valid email` };
|
|
127
|
+
break;
|
|
128
|
+
case 'url':
|
|
129
|
+
try { new URL(value); }
|
|
130
|
+
catch { return { value, error: `${field} must be a valid URL` }; }
|
|
131
|
+
break;
|
|
132
|
+
case 'uuid':
|
|
133
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
|
|
134
|
+
return { value, error: `${field} must be a valid UUID` };
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Constraints
|
|
140
|
+
if (rule.minLength !== undefined && typeof value === 'string' && value.length < rule.minLength)
|
|
141
|
+
return { value, error: `${field} must be at least ${rule.minLength} characters` };
|
|
142
|
+
if (rule.maxLength !== undefined && typeof value === 'string' && value.length > rule.maxLength)
|
|
143
|
+
return { value, error: `${field} must be at most ${rule.maxLength} characters` };
|
|
144
|
+
if (rule.min !== undefined && typeof value === 'number' && value < rule.min)
|
|
145
|
+
return { value, error: `${field} must be >= ${rule.min}` };
|
|
146
|
+
if (rule.max !== undefined && typeof value === 'number' && value > rule.max)
|
|
147
|
+
return { value, error: `${field} must be <= ${rule.max}` };
|
|
148
|
+
if (rule.match && typeof value === 'string' && !rule.match.test(value))
|
|
149
|
+
return { value, error: `${field} format is invalid` };
|
|
150
|
+
if (rule.enum && !rule.enum.includes(value))
|
|
151
|
+
return { value, error: `${field} must be one of: ${rule.enum.join(', ')}` };
|
|
152
|
+
if (rule.minItems !== undefined && Array.isArray(value) && value.length < rule.minItems)
|
|
153
|
+
return { value, error: `${field} must have at least ${rule.minItems} items` };
|
|
154
|
+
if (rule.maxItems !== undefined && Array.isArray(value) && value.length > rule.maxItems)
|
|
155
|
+
return { value, error: `${field} must have at most ${rule.maxItems} items` };
|
|
156
|
+
|
|
157
|
+
// Custom validator function
|
|
158
|
+
if (typeof rule.validate === 'function')
|
|
159
|
+
{
|
|
160
|
+
const msg = rule.validate(value);
|
|
161
|
+
if (typeof msg === 'string') return { value, error: msg };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { value, error: null };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Validate an object against a schema.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} data - Input data.
|
|
171
|
+
* @param {object} schema - { fieldName: ruleObject }
|
|
172
|
+
* @param {object} [opts]
|
|
173
|
+
* @param {boolean} [opts.stripUnknown=true] - Remove fields not in schema.
|
|
174
|
+
* @returns {{ sanitized: object, errors: string[] }}
|
|
175
|
+
* @private
|
|
176
|
+
*/
|
|
177
|
+
function validateObject(data, schema, opts = {})
|
|
178
|
+
{
|
|
179
|
+
const errors = [];
|
|
180
|
+
const sanitized = {};
|
|
181
|
+
const stripUnknown = opts.stripUnknown !== false;
|
|
182
|
+
const source = data || {};
|
|
183
|
+
|
|
184
|
+
for (const [field, rule] of Object.entries(schema))
|
|
185
|
+
{
|
|
186
|
+
const { value, error } = validateField(source[field], rule, field);
|
|
187
|
+
if (error) errors.push(error);
|
|
188
|
+
else if (value !== undefined) sanitized[field] = value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Preserve unknown fields if not stripping
|
|
192
|
+
if (!stripUnknown)
|
|
193
|
+
{
|
|
194
|
+
for (const key of Object.keys(source))
|
|
195
|
+
{
|
|
196
|
+
if (!(key in schema)) sanitized[key] = source[key];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { sanitized, errors };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Create a validation middleware.
|
|
205
|
+
*
|
|
206
|
+
* @param {object} schema
|
|
207
|
+
* @param {object} [schema.body] - Rules for req.body fields.
|
|
208
|
+
* @param {object} [schema.query] - Rules for req.query fields.
|
|
209
|
+
* @param {object} [schema.params] - Rules for req.params fields.
|
|
210
|
+
* @param {object} [options]
|
|
211
|
+
* @param {boolean} [options.stripUnknown=true] - Remove fields not in schema.
|
|
212
|
+
* @param {Function} [options.onError] - Custom error handler `(errors, req, res) => {}`.
|
|
213
|
+
* @returns {Function} Middleware function.
|
|
214
|
+
*/
|
|
215
|
+
function validate(schema, options = {})
|
|
216
|
+
{
|
|
217
|
+
return function validatorMiddleware(req, res, next)
|
|
218
|
+
{
|
|
219
|
+
const allErrors = [];
|
|
220
|
+
|
|
221
|
+
if (schema.body)
|
|
222
|
+
{
|
|
223
|
+
const { sanitized, errors } = validateObject(req.body, schema.body, options);
|
|
224
|
+
if (errors.length) allErrors.push(...errors.map(e => `body.${e}`));
|
|
225
|
+
else req.body = sanitized;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (schema.query)
|
|
229
|
+
{
|
|
230
|
+
const { sanitized, errors } = validateObject(req.query, schema.query, options);
|
|
231
|
+
if (errors.length) allErrors.push(...errors.map(e => `query.${e}`));
|
|
232
|
+
else req.query = sanitized;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (schema.params)
|
|
236
|
+
{
|
|
237
|
+
const { sanitized, errors } = validateObject(req.params, schema.params, options);
|
|
238
|
+
if (errors.length) allErrors.push(...errors.map(e => `params.${e}`));
|
|
239
|
+
else req.params = sanitized;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (allErrors.length > 0)
|
|
243
|
+
{
|
|
244
|
+
if (options.onError) return options.onError(allErrors, req, res);
|
|
245
|
+
res.status(422).json({ errors: allErrors });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
next();
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Also export helpers for standalone use
|
|
254
|
+
validate.field = validateField;
|
|
255
|
+
validate.object = validateObject;
|
|
256
|
+
|
|
257
|
+
module.exports = validate;
|