@openaddresses/batch-schema 10.25.0 → 10.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/test.yml +19 -19
- package/CHANGELOG.md +15 -0
- package/README.md +78 -0
- package/dist/lib/openapi.js +16 -6
- package/dist/lib/openapi.js.map +1 -1
- package/dist/lib/schema.d.ts +45 -2
- package/dist/lib/schema.js +177 -192
- package/dist/lib/schema.js.map +1 -1
- package/dist/lib/types.d.ts +48 -1
- package/dist/lib/types.js +48 -1
- package/dist/lib/types.js.map +1 -1
- package/dist/test/response.srv.test.d.ts +1 -0
- package/dist/test/response.srv.test.js +95 -0
- package/dist/test/response.srv.test.js.map +1 -0
- package/dist/test/schema.srv.test.js +216 -0
- package/dist/test/schema.srv.test.js.map +1 -1
- package/lib/openapi.ts +14 -6
- package/lib/schema.ts +224 -201
- package/lib/types.ts +86 -1
- package/package.json +1 -1
- package/test/response.srv.test.ts +114 -0
- package/test/schema.srv.test.ts +244 -0
package/lib/schema.ts
CHANGED
|
@@ -4,15 +4,16 @@ import path from 'node:path';
|
|
|
4
4
|
import morgan from 'morgan';
|
|
5
5
|
import bodyparser from 'body-parser';
|
|
6
6
|
import Err from '@openaddresses/batch-error';
|
|
7
|
-
import {
|
|
7
|
+
import { Kind } from '@sinclair/typebox';
|
|
8
8
|
import type { Static, TSchema } from '@sinclair/typebox';
|
|
9
9
|
import { OpenAPIV3 as Doc } from 'openapi-types'
|
|
10
10
|
import { Router } from 'express'
|
|
11
11
|
import type { RequestHandler } from 'express'
|
|
12
12
|
import { Ajv } from 'ajv'
|
|
13
|
-
import type { ErrorObject } from 'ajv'
|
|
13
|
+
import type { ErrorObject, ValidateFunction } from 'ajv'
|
|
14
14
|
import addFormatsModule from 'ajv-formats'
|
|
15
|
-
import type { RequestValidation } from './types.js';
|
|
15
|
+
import type { RequestValidation, NormalizedBody } from './types.js';
|
|
16
|
+
import { normalizeBody, matchContentType } from './types.js';
|
|
16
17
|
import type { OpenAPIDocumentInput } from './openapi.js'
|
|
17
18
|
import SchemaAPI from './api.js';
|
|
18
19
|
import Docs from './openapi.js';
|
|
@@ -29,6 +30,82 @@ addFormatsModule.default(ajv);
|
|
|
29
30
|
|
|
30
31
|
export type ErrorListItem = { type: 'Body' | 'Query' | 'Params'; errors: ErrorObject[] };
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Per-route body validation state, compiled once at route registration:
|
|
35
|
+
* ajv validators keyed by content-type, the normalized map used for
|
|
36
|
+
* content-type matching, and the precomputed supported-types string
|
|
37
|
+
* used in error messages.
|
|
38
|
+
*/
|
|
39
|
+
export type CompiledBodyValidation = {
|
|
40
|
+
validators: Map<string, true | ValidateFunction>;
|
|
41
|
+
matcher: NormalizedBody;
|
|
42
|
+
supported: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type RouteMethod =
|
|
46
|
+
| Doc.HttpMethods.GET
|
|
47
|
+
| Doc.HttpMethods.DELETE
|
|
48
|
+
| Doc.HttpMethods.POST
|
|
49
|
+
| Doc.HttpMethods.PATCH
|
|
50
|
+
| Doc.HttpMethods.PUT;
|
|
51
|
+
|
|
52
|
+
const MAX_LOGGED_RESPONSE = 4096;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Deep-clone a value following JSON.stringify semantics: toJSON() support
|
|
56
|
+
* (Date => ISO string), undefined/function/symbol omission, NaN/Infinity
|
|
57
|
+
* => null, and circular reference detection. Response validation needs the
|
|
58
|
+
* serialized shape of the payload (Dates must already be strings), and ajv's
|
|
59
|
+
* removeAdditional/useDefaults mutate the object they validate, so the
|
|
60
|
+
* caller's object must be protected. A single-pass clone does both without
|
|
61
|
+
* the cost of JSON.parse(JSON.stringify()) building an intermediate string.
|
|
62
|
+
*/
|
|
63
|
+
function jsonClone(value: unknown, seen?: Set<object>): unknown {
|
|
64
|
+
if (value !== null && typeof value === 'object' && typeof (value as { toJSON?: unknown }).toJSON === 'function') {
|
|
65
|
+
value = (value as { toJSON: () => unknown }).toJSON();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (value === null) return null;
|
|
69
|
+
|
|
70
|
+
switch (typeof value) {
|
|
71
|
+
case 'string':
|
|
72
|
+
case 'boolean':
|
|
73
|
+
return value;
|
|
74
|
+
case 'number':
|
|
75
|
+
return Number.isFinite(value) ? value : null;
|
|
76
|
+
case 'bigint':
|
|
77
|
+
throw new TypeError('Do not know how to serialize a BigInt');
|
|
78
|
+
case 'object':
|
|
79
|
+
break;
|
|
80
|
+
default:
|
|
81
|
+
return undefined; // undefined | function | symbol are omitted, as in JSON.stringify
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!seen) seen = new Set();
|
|
85
|
+
if (seen.has(value as object)) throw new TypeError('Converting circular structure to JSON');
|
|
86
|
+
seen.add(value as object);
|
|
87
|
+
|
|
88
|
+
let out: unknown;
|
|
89
|
+
if (Array.isArray(value)) {
|
|
90
|
+
const arr = new Array(value.length);
|
|
91
|
+
for (let i = 0; i < value.length; i++) {
|
|
92
|
+
const v = jsonClone(value[i], seen);
|
|
93
|
+
arr[i] = v === undefined ? null : v;
|
|
94
|
+
}
|
|
95
|
+
out = arr;
|
|
96
|
+
} else {
|
|
97
|
+
const obj: Record<string, unknown> = {};
|
|
98
|
+
for (const key of Object.keys(value)) {
|
|
99
|
+
const v = jsonClone((value as Record<string, unknown>)[key], seen);
|
|
100
|
+
if (v !== undefined) obj[key] = v;
|
|
101
|
+
}
|
|
102
|
+
out = obj;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
seen.delete(value as object);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
32
109
|
/**
|
|
33
110
|
* @class
|
|
34
111
|
*
|
|
@@ -53,6 +130,71 @@ export default class Schemas {
|
|
|
53
130
|
return new Err(400, null, `Validation Error ${String(method).toUpperCase()} ${path}`);
|
|
54
131
|
}
|
|
55
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Compile body validators keyed by content-type. Returns null when no
|
|
135
|
+
* body validation is configured. Each validator entry is either `true`
|
|
136
|
+
* (any body accepted for this content-type) or a compiled ajv validator.
|
|
137
|
+
* The content-type matcher and supported-types error string are
|
|
138
|
+
* precomputed here so no per-request allocation is needed.
|
|
139
|
+
*/
|
|
140
|
+
compileBodyValidators(
|
|
141
|
+
body: RequestValidation<TSchema, TSchema, TSchema, TSchema>['body']
|
|
142
|
+
): CompiledBodyValidation | null {
|
|
143
|
+
const normalized = normalizeBody(body);
|
|
144
|
+
if (!normalized) return null;
|
|
145
|
+
|
|
146
|
+
const validators = new Map<string, true | ValidateFunction>();
|
|
147
|
+
for (const ct of Object.keys(normalized)) {
|
|
148
|
+
const schema = normalized[ct].schema;
|
|
149
|
+
if (schema === true || schema === undefined) {
|
|
150
|
+
validators.set(ct, true);
|
|
151
|
+
} else {
|
|
152
|
+
validators.set(ct, ajv.compile(schema));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
validators,
|
|
158
|
+
matcher: normalized,
|
|
159
|
+
supported: Object.keys(normalized).join(', ')
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Validate the incoming request body against the compiled validation
|
|
165
|
+
* state for the route. Pushes a Body error onto `errors` if validation
|
|
166
|
+
* fails; returns an Err to respond with for an unsupported content-type.
|
|
167
|
+
*
|
|
168
|
+
* If `opts.required` is false and the request has no body / no
|
|
169
|
+
* content-type, validation is skipped.
|
|
170
|
+
*/
|
|
171
|
+
validateBody(
|
|
172
|
+
req: Request,
|
|
173
|
+
compiled: CompiledBodyValidation,
|
|
174
|
+
errors: Array<ErrorListItem>,
|
|
175
|
+
opts: { required?: boolean } = {}
|
|
176
|
+
): Err | null {
|
|
177
|
+
const raw = (req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
|
178
|
+
const required = opts.required !== false;
|
|
179
|
+
|
|
180
|
+
if (!raw && !required) return null;
|
|
181
|
+
|
|
182
|
+
const matched = matchContentType(raw, compiled.matcher);
|
|
183
|
+
const validator = matched ? compiled.validators.get(matched) : undefined;
|
|
184
|
+
|
|
185
|
+
if (!validator) {
|
|
186
|
+
return new Err(400, null, `Content-Type ${raw || '(none)'} not supported. Supported: ${compiled.supported}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (validator !== true) {
|
|
190
|
+
if (!validator(req.body)) {
|
|
191
|
+
errors.push({ type: 'Body', errors: validator.errors as ErrorObject[] });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
56
198
|
constructor(
|
|
57
199
|
router: Router,
|
|
58
200
|
opts: {
|
|
@@ -74,8 +216,16 @@ export default class Schemas {
|
|
|
74
216
|
this.router.use(morgan('combined', morganOptions));
|
|
75
217
|
}
|
|
76
218
|
|
|
77
|
-
this.router.use(bodyparser.urlencoded({ extended: true }));
|
|
219
|
+
this.router.use(bodyparser.urlencoded({ extended: true, limit: `${opts.limit || 50}mb` }));
|
|
78
220
|
this.router.use(bodyparser.json({ limit: `${opts.limit || 50}mb` }));
|
|
221
|
+
this.router.use(bodyparser.text({
|
|
222
|
+
limit: `${opts.limit || 50}mb`,
|
|
223
|
+
type: ['text/*', 'application/xml', 'application/*+xml']
|
|
224
|
+
}));
|
|
225
|
+
this.router.use(bodyparser.raw({
|
|
226
|
+
limit: `${opts.limit || 50}mb`,
|
|
227
|
+
type: ['application/octet-stream']
|
|
228
|
+
}));
|
|
79
229
|
|
|
80
230
|
if (opts.error) {
|
|
81
231
|
this.error = opts.error;
|
|
@@ -150,21 +300,48 @@ export default class Schemas {
|
|
|
150
300
|
await bp_fn(this, config);
|
|
151
301
|
}
|
|
152
302
|
|
|
153
|
-
|
|
303
|
+
/**
|
|
304
|
+
* Compile the response validator for a route. Type.Any and Type.Unknown
|
|
305
|
+
* impose no constraints, so they are skipped entirely — routes with an
|
|
306
|
+
* open response schema (or none) bypass response validation and its
|
|
307
|
+
* clone at request time.
|
|
308
|
+
*/
|
|
309
|
+
private compileResponseValidation(res?: TSchema): ValidateFunction | null {
|
|
310
|
+
if (!res) return null;
|
|
311
|
+
if (res[Kind] === 'Any' || res[Kind] === 'Unknown') return null;
|
|
312
|
+
return ajv.compile(res);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Shared route registration: compiles all validators once, then installs
|
|
317
|
+
* a request handler that validates params/query/body and — only when a
|
|
318
|
+
* response schema requires it — wraps res.json with response validation.
|
|
319
|
+
*/
|
|
320
|
+
private route<TParams extends TSchema, TQuery extends TSchema, TBody extends TSchema, TResponse extends TSchema>(
|
|
321
|
+
method: RouteMethod,
|
|
154
322
|
path: string,
|
|
155
|
-
opts: RequestValidation<TParams, TQuery, TBody, TResponse
|
|
156
|
-
handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery
|
|
157
|
-
|
|
323
|
+
opts: RequestValidation<TParams, TQuery, TBody, TResponse>,
|
|
324
|
+
handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>,
|
|
325
|
+
allowBody: boolean
|
|
326
|
+
): void {
|
|
327
|
+
const methodUpper = String(method).toUpperCase();
|
|
328
|
+
|
|
158
329
|
try {
|
|
159
330
|
const validation = this.normalizeValidation(opts);
|
|
160
331
|
|
|
161
|
-
this.docs.push({ method
|
|
162
|
-
this.schemas.set(
|
|
332
|
+
this.docs.push({ method, path }, validation);
|
|
333
|
+
this.schemas.set(`${methodUpper} ${path}`, validation);
|
|
163
334
|
|
|
164
|
-
const resValidation =
|
|
335
|
+
const resValidation = this.compileResponseValidation(validation.res);
|
|
165
336
|
const paramsValidation = validation.params && ajv.compile(validation.params);
|
|
166
337
|
const queryValidation = validation.query && ajv.compile(validation.query);
|
|
167
|
-
|
|
338
|
+
|
|
339
|
+
let bodyValidation: CompiledBodyValidation | null = null;
|
|
340
|
+
if (allowBody) {
|
|
341
|
+
bodyValidation = this.compileBodyValidators(validation.body);
|
|
342
|
+
} else if (validation.body) {
|
|
343
|
+
throw new Error(`Body not allowed`);
|
|
344
|
+
}
|
|
168
345
|
|
|
169
346
|
const _handler: RequestHandler = (req, res, next) => {
|
|
170
347
|
if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
|
|
@@ -177,77 +354,52 @@ export default class Schemas {
|
|
|
177
354
|
const errors: Array<ErrorListItem> = [];
|
|
178
355
|
if (paramsValidation && !paramsValidation(req.params)) errors.push({ type: 'Params', errors: paramsValidation.errors as ErrorObject[] });
|
|
179
356
|
if (queryValidation && !queryValidation(req.query)) errors.push({ type: 'Query', errors: queryValidation.errors as ErrorObject[] });
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
357
|
+
if (bodyValidation) {
|
|
358
|
+
const ctErr = this.validateBody(req, bodyValidation, errors, { required: validation.bodyRequired });
|
|
359
|
+
if (ctErr) return Err.respond(ctErr, res);
|
|
360
|
+
}
|
|
361
|
+
if (errors.length) return Err.respond(this.validationError(method, path), res, errors);
|
|
362
|
+
|
|
363
|
+
if (resValidation) {
|
|
364
|
+
const json = res.json;
|
|
365
|
+
res.json = function(obj) {
|
|
366
|
+
if (res.statusCode === null || res.statusCode === 200) {
|
|
367
|
+
obj = jsonClone(obj); // Date => String needs to happen before validation
|
|
368
|
+
if (!resValidation(obj)) {
|
|
369
|
+
res.status(400);
|
|
370
|
+
const logged = JSON.stringify(obj);
|
|
371
|
+
console.error(`Response Validation Error: ${methodUpper} ${path}: ${logged && logged.length > MAX_LOGGED_RESPONSE ? logged.slice(0, MAX_LOGGED_RESPONSE) + '... (truncated)' : logged}`);
|
|
372
|
+
return json.call(this, { type: 'Response', errors: resValidation.errors as ErrorObject[] });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
190
375
|
return json.call(this, obj);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
376
|
+
};
|
|
377
|
+
}
|
|
193
378
|
|
|
194
379
|
return handler(req, res, next);
|
|
195
380
|
};
|
|
196
381
|
|
|
197
|
-
this.router
|
|
382
|
+
this.router[method as 'get' | 'delete' | 'post' | 'patch' | 'put'](path, _handler);
|
|
198
383
|
} catch (err) {
|
|
199
|
-
|
|
384
|
+
const label = methodUpper.charAt(0) + methodUpper.slice(1).toLowerCase();
|
|
385
|
+
throw new Error(`${label}: ${path}: ` + String(err), { cause: err })
|
|
200
386
|
}
|
|
201
387
|
}
|
|
202
388
|
|
|
203
|
-
async
|
|
389
|
+
async get<TParams extends TSchema, TQuery extends TSchema, TBody extends TSchema, TResponse extends TSchema>(
|
|
204
390
|
path: string,
|
|
205
391
|
opts: RequestValidation<TParams, TQuery, TBody, TResponse> = {},
|
|
206
392
|
handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>
|
|
207
393
|
) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
this.docs.push({ method: Doc.HttpMethods.DELETE, path: path }, validation);
|
|
212
|
-
this.schemas.set(`DELETE ${path}`, validation);
|
|
213
|
-
|
|
214
|
-
const resValidation = validation.res && !(validation.res instanceof Type.Any) && !(validation.res instanceof Type.Unknown) && ajv.compile(validation.res);
|
|
215
|
-
const paramsValidation = validation.params && ajv.compile(validation.params);
|
|
216
|
-
const queryValidation = validation.query && ajv.compile(validation.query);
|
|
217
|
-
if (validation.body) throw new Error(`Body not allowed`);
|
|
218
|
-
|
|
219
|
-
const _handler: RequestHandler = (req, res, next) => {
|
|
220
|
-
if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
|
|
221
|
-
Object.defineProperty(req, 'query', {
|
|
222
|
-
writable: true,
|
|
223
|
-
value: { ...req.query },
|
|
224
|
-
})
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const errors: Array<ErrorListItem> = [];
|
|
228
|
-
if (paramsValidation && !paramsValidation(req.params)) errors.push({ type: 'Params', errors: paramsValidation.errors as ErrorObject[] });
|
|
229
|
-
if (queryValidation && !queryValidation(req.query)) errors.push({ type: 'Query', errors: queryValidation.errors as ErrorObject[] });
|
|
230
|
-
if (errors.length) return Err.respond(this.validationError(Doc.HttpMethods.DELETE, path), res, errors);
|
|
231
|
-
|
|
232
|
-
const json = res.json;
|
|
233
|
-
res.json = function(obj) {
|
|
234
|
-
obj = JSON.parse(JSON.stringify(obj)) // Here as Date => String needs to happen
|
|
235
|
-
if ((res.statusCode === null || res.statusCode === 200) && resValidation && !resValidation(obj)) {
|
|
236
|
-
res.status(400);
|
|
237
|
-
console.error(`Response Validation Error: ${JSON.stringify(obj)}`);
|
|
238
|
-
return json.call(this, { type: 'Response', errors: resValidation.errors as ErrorObject[] });
|
|
239
|
-
} else {
|
|
240
|
-
return json.call(this, obj);
|
|
241
|
-
}
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
return handler(req, res, next);
|
|
245
|
-
};
|
|
394
|
+
this.route(Doc.HttpMethods.GET, path, opts, handler, false);
|
|
395
|
+
}
|
|
246
396
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
397
|
+
async delete<TParams extends TSchema, TQuery extends TSchema, TBody extends TSchema, TResponse extends TSchema>(
|
|
398
|
+
path: string,
|
|
399
|
+
opts: RequestValidation<TParams, TQuery, TBody, TResponse> = {},
|
|
400
|
+
handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>
|
|
401
|
+
) {
|
|
402
|
+
this.route(Doc.HttpMethods.DELETE, path, opts, handler, false);
|
|
251
403
|
}
|
|
252
404
|
|
|
253
405
|
async post<TParams extends TSchema, TQuery extends TSchema, TBody extends TSchema, TResponse extends TSchema>(
|
|
@@ -255,50 +407,7 @@ export default class Schemas {
|
|
|
255
407
|
opts: RequestValidation<TParams, TQuery, TBody, TResponse> = {},
|
|
256
408
|
handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>
|
|
257
409
|
) {
|
|
258
|
-
|
|
259
|
-
const validation = this.normalizeValidation(opts);
|
|
260
|
-
|
|
261
|
-
this.docs.push({ method: Doc.HttpMethods.POST, path: path }, validation);
|
|
262
|
-
this.schemas.set(`POST ${path}`, validation);
|
|
263
|
-
|
|
264
|
-
const resValidation = validation.res && !(validation.res instanceof Type.Any) && !(validation.res instanceof Type.Unknown) && ajv.compile(validation.res);
|
|
265
|
-
const paramsValidation = validation.params && ajv.compile(validation.params);
|
|
266
|
-
const queryValidation = validation.query && ajv.compile(validation.query);
|
|
267
|
-
const bodyValidation = validation.body && ajv.compile(validation.body);
|
|
268
|
-
|
|
269
|
-
const _handler: RequestHandler = (req, res, next) => {
|
|
270
|
-
if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
|
|
271
|
-
Object.defineProperty(req, 'query', {
|
|
272
|
-
writable: true,
|
|
273
|
-
value: { ...req.query },
|
|
274
|
-
})
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const errors: Array<ErrorListItem> = [];
|
|
278
|
-
if (paramsValidation && !paramsValidation(req.params)) errors.push({ type: 'Params', errors: paramsValidation.errors as ErrorObject[] });
|
|
279
|
-
if (queryValidation && !queryValidation(req.query)) errors.push({ type: 'Query', errors: queryValidation.errors as ErrorObject[] });
|
|
280
|
-
if (bodyValidation && !bodyValidation(req.body)) errors.push({ type: 'Body', errors: bodyValidation.errors as ErrorObject[] });
|
|
281
|
-
if (errors.length) return Err.respond(this.validationError(Doc.HttpMethods.POST, path), res, errors);
|
|
282
|
-
|
|
283
|
-
const json = res.json;
|
|
284
|
-
res.json = function(obj) {
|
|
285
|
-
obj = JSON.parse(JSON.stringify(obj)) // Here as Date => String needs to happen
|
|
286
|
-
if ((res.statusCode === null || res.statusCode === 200) && resValidation && !resValidation(obj)) {
|
|
287
|
-
res.status(400);
|
|
288
|
-
console.error(`Response Validation Error: ${JSON.stringify(obj)}`);
|
|
289
|
-
return json.call(this, { type: 'Response', errors: resValidation.errors as ErrorObject[] });
|
|
290
|
-
} else {
|
|
291
|
-
return json.call(this, obj);
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
|
-
|
|
295
|
-
return handler(req, res, next);
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
this.router.post(path, _handler);
|
|
299
|
-
} catch (err) {
|
|
300
|
-
throw new Error(`Post: ${path}: ` + String(err), { cause: err })
|
|
301
|
-
}
|
|
410
|
+
this.route(Doc.HttpMethods.POST, path, opts, handler, true);
|
|
302
411
|
}
|
|
303
412
|
|
|
304
413
|
async patch<TParams extends TSchema, TQuery extends TSchema, TBody extends TSchema, TResponse extends TSchema>(
|
|
@@ -306,50 +415,7 @@ export default class Schemas {
|
|
|
306
415
|
opts: RequestValidation<TParams, TQuery, TBody, TResponse> = {},
|
|
307
416
|
handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>
|
|
308
417
|
) {
|
|
309
|
-
|
|
310
|
-
const validation = this.normalizeValidation(opts);
|
|
311
|
-
|
|
312
|
-
this.docs.push({ method: Doc.HttpMethods.PATCH, path: path }, validation);
|
|
313
|
-
this.schemas.set(`PATCH ${path}`, validation);
|
|
314
|
-
|
|
315
|
-
const resValidation = validation.res && !(validation.res instanceof Type.Any) && !(validation.res instanceof Type.Unknown) && ajv.compile(validation.res);
|
|
316
|
-
const paramsValidation = validation.params && ajv.compile(validation.params);
|
|
317
|
-
const queryValidation = validation.query && ajv.compile(validation.query);
|
|
318
|
-
const bodyValidation = validation.body && ajv.compile(validation.body);
|
|
319
|
-
|
|
320
|
-
const _handler: RequestHandler = (req, res, next) => {
|
|
321
|
-
if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
|
|
322
|
-
Object.defineProperty(req, 'query', {
|
|
323
|
-
writable: true,
|
|
324
|
-
value: { ...req.query },
|
|
325
|
-
})
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const errors: Array<ErrorListItem> = [];
|
|
329
|
-
if (paramsValidation && !paramsValidation(req.params)) errors.push({ type: 'Params', errors: paramsValidation.errors as ErrorObject[] });
|
|
330
|
-
if (queryValidation && !queryValidation(req.query)) errors.push({ type: 'Query', errors: queryValidation.errors as ErrorObject[] });
|
|
331
|
-
if (bodyValidation && !bodyValidation(req.body)) errors.push({ type: 'Body', errors: bodyValidation.errors as ErrorObject[] });
|
|
332
|
-
if (errors.length) return Err.respond(this.validationError(Doc.HttpMethods.PATCH, path), res, errors);
|
|
333
|
-
|
|
334
|
-
const json = res.json;
|
|
335
|
-
res.json = function(obj) {
|
|
336
|
-
obj = JSON.parse(JSON.stringify(obj)) // Here as Date => String needs to happen
|
|
337
|
-
if ((res.statusCode === null || res.statusCode === 200) && resValidation && !resValidation(obj)) {
|
|
338
|
-
res.status(400);
|
|
339
|
-
console.error(`Response Validation Error: ${JSON.stringify(obj)}`);
|
|
340
|
-
return json.call(this, { type: 'Response', errors: resValidation.errors as ErrorObject[] });
|
|
341
|
-
} else {
|
|
342
|
-
return json.call(this, obj);
|
|
343
|
-
}
|
|
344
|
-
};
|
|
345
|
-
|
|
346
|
-
return handler(req, res, next);
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
this.router.patch(path, _handler);
|
|
350
|
-
} catch (err) {
|
|
351
|
-
throw new Error(`Patch: ${path}: ` + String(err), { cause: err })
|
|
352
|
-
}
|
|
418
|
+
this.route(Doc.HttpMethods.PATCH, path, opts, handler, true);
|
|
353
419
|
}
|
|
354
420
|
|
|
355
421
|
async put<TParams extends TSchema, TQuery extends TSchema, TBody extends TSchema, TResponse extends TSchema>(
|
|
@@ -357,50 +423,7 @@ export default class Schemas {
|
|
|
357
423
|
opts: RequestValidation<TParams, TQuery, TBody, TResponse> = {},
|
|
358
424
|
handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>
|
|
359
425
|
) {
|
|
360
|
-
|
|
361
|
-
const validation = this.normalizeValidation(opts);
|
|
362
|
-
|
|
363
|
-
this.docs.push({ method: Doc.HttpMethods.PUT, path: path }, validation);
|
|
364
|
-
this.schemas.set(`PUT ${path}`, validation);
|
|
365
|
-
|
|
366
|
-
const resValidation = validation.res && !(validation.res instanceof Type.Any) && !(validation.res instanceof Type.Unknown) && ajv.compile(validation.res);
|
|
367
|
-
const paramsValidation = validation.params && ajv.compile(validation.params);
|
|
368
|
-
const queryValidation = validation.query && ajv.compile(validation.query);
|
|
369
|
-
const bodyValidation = validation.body && ajv.compile(validation.body);
|
|
370
|
-
|
|
371
|
-
const _handler: RequestHandler = (req, res, next) => {
|
|
372
|
-
if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
|
|
373
|
-
Object.defineProperty(req, 'query', {
|
|
374
|
-
writable: true,
|
|
375
|
-
value: { ...req.query },
|
|
376
|
-
})
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const errors: Array<ErrorListItem> = [];
|
|
380
|
-
if (paramsValidation && !paramsValidation(req.params)) errors.push({ type: 'Params', errors: paramsValidation.errors as ErrorObject[] });
|
|
381
|
-
if (queryValidation && !queryValidation(req.query)) errors.push({ type: 'Query', errors: queryValidation.errors as ErrorObject[] });
|
|
382
|
-
if (bodyValidation && !bodyValidation(req.body)) errors.push({ type: 'Body', errors: bodyValidation.errors as ErrorObject[] });
|
|
383
|
-
if (errors.length) return Err.respond(this.validationError(Doc.HttpMethods.PUT, path), res, errors);
|
|
384
|
-
|
|
385
|
-
const json = res.json;
|
|
386
|
-
res.json = function(obj) {
|
|
387
|
-
obj = JSON.parse(JSON.stringify(obj)) // Here as Date => String needs to happen
|
|
388
|
-
if ((res.statusCode === null || res.statusCode === 200) && resValidation && !resValidation(obj)) {
|
|
389
|
-
res.status(400);
|
|
390
|
-
console.error(`Response Validation Error: ${JSON.stringify(obj)}`);
|
|
391
|
-
return json.call(this, { type: 'Response', errors: resValidation.errors as ErrorObject[] });
|
|
392
|
-
} else {
|
|
393
|
-
return json.call(this, obj);
|
|
394
|
-
}
|
|
395
|
-
};
|
|
396
|
-
|
|
397
|
-
return handler(req, res, next);
|
|
398
|
-
};
|
|
399
|
-
|
|
400
|
-
this.router.put(path, _handler);
|
|
401
|
-
} catch (err) {
|
|
402
|
-
throw new Error(`Put: ${path}: ` + String(err), { cause: err })
|
|
403
|
-
}
|
|
426
|
+
this.route(Doc.HttpMethods.PUT, path, opts, handler, true);
|
|
404
427
|
}
|
|
405
428
|
|
|
406
429
|
not_found() {
|
package/lib/types.ts
CHANGED
|
@@ -1,5 +1,88 @@
|
|
|
1
|
+
import { Kind } from '@sinclair/typebox';
|
|
1
2
|
import type { TSchema } from '@sinclair/typebox';
|
|
2
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Per-content-type body entry. May be:
|
|
6
|
+
* - a TypeBox schema (validated, no example)
|
|
7
|
+
* - `true` (any body accepted, no validation)
|
|
8
|
+
* - an object with optional `schema` (TSchema | true), `example`, `examples`
|
|
9
|
+
* (forwarded to the OpenAPI media type object)
|
|
10
|
+
*/
|
|
11
|
+
export type BodyEntryConfig = {
|
|
12
|
+
schema?: TSchema | true;
|
|
13
|
+
example?: unknown;
|
|
14
|
+
examples?: Record<string, { summary?: string; description?: string; value: unknown }>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type BodyEntry = TSchema | true | BodyEntryConfig;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Body validation can either be:
|
|
21
|
+
* - a TypeBox schema (treated as `application/json` for backwards compatibility)
|
|
22
|
+
* - a record mapping content-type strings to a `BodyEntry`. Content-type
|
|
23
|
+
* keys may include wildcards: `text/*`, `application/*`, or `* / *`.
|
|
24
|
+
*/
|
|
25
|
+
export type BodyValidation<TBody extends TSchema> =
|
|
26
|
+
| TBody
|
|
27
|
+
| Record<string, BodyEntry>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalized internal representation: every entry has the expanded
|
|
31
|
+
* `BodyEntryConfig` shape with `schema` always set (defaulting to `true`).
|
|
32
|
+
*/
|
|
33
|
+
export type NormalizedBody = Record<string, Required<Pick<BodyEntryConfig, 'schema'>> & BodyEntryConfig>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Detect whether a given value is a TypeBox schema. TypeBox attaches a
|
|
37
|
+
* `[Kind]` symbol to every schema instance which is what we use to
|
|
38
|
+
* distinguish a schema from a content-type-keyed body record.
|
|
39
|
+
*/
|
|
40
|
+
export function isTSchema(value: unknown): value is TSchema {
|
|
41
|
+
return !!value && typeof value === 'object' && Kind in (value as object);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeEntry(entry: BodyEntry): NormalizedBody[string] {
|
|
45
|
+
if (entry === true) return { schema: true };
|
|
46
|
+
if (isTSchema(entry)) return { schema: entry };
|
|
47
|
+
return { schema: true, ...entry };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Normalize the body option into a `NormalizedBody` map. A bare TypeBox
|
|
52
|
+
* schema is mapped to `application/json` for backwards compatibility.
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeBody(
|
|
55
|
+
body: TSchema | Record<string, BodyEntry> | undefined
|
|
56
|
+
): NormalizedBody | undefined {
|
|
57
|
+
if (!body) return undefined;
|
|
58
|
+
if (isTSchema(body)) return { 'application/json': { schema: body } };
|
|
59
|
+
|
|
60
|
+
const out: NormalizedBody = {};
|
|
61
|
+
for (const ct of Object.keys(body)) {
|
|
62
|
+
out[ct.toLowerCase()] = normalizeEntry(body[ct]);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Match an incoming content-type against a normalized body map, honoring
|
|
69
|
+
* wildcard entries like `text/*` or `*\/*`. Exact matches always win.
|
|
70
|
+
*/
|
|
71
|
+
export function matchContentType(
|
|
72
|
+
incoming: string,
|
|
73
|
+
normalized: NormalizedBody
|
|
74
|
+
): string | undefined {
|
|
75
|
+
if (!incoming) {
|
|
76
|
+
return normalized['*/*'] ? '*/*' : undefined;
|
|
77
|
+
}
|
|
78
|
+
if (normalized[incoming]) return incoming;
|
|
79
|
+
|
|
80
|
+
const [type] = incoming.split('/');
|
|
81
|
+
if (type && normalized[`${type}/*`]) return `${type}/*`;
|
|
82
|
+
if (normalized['*/*']) return '*/*';
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
3
86
|
export type RequestValidation<
|
|
4
87
|
TParams extends TSchema,
|
|
5
88
|
TQuery extends TSchema,
|
|
@@ -13,6 +96,8 @@ export type RequestValidation<
|
|
|
13
96
|
description?: string;
|
|
14
97
|
params?: TParams;
|
|
15
98
|
query?: TQuery;
|
|
16
|
-
body?: TBody
|
|
99
|
+
body?: BodyValidation<TBody>;
|
|
100
|
+
/** Whether the request body is required. Defaults to true when `body` is set. */
|
|
101
|
+
bodyRequired?: boolean;
|
|
17
102
|
res?: TResponse
|
|
18
103
|
};
|