@openaddresses/batch-schema 10.26.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 +11 -0
- package/dist/lib/schema.d.ts +35 -12
- package/dist/lib/schema.js +134 -217
- package/dist/lib/schema.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/lib/schema.ts +170 -226
- package/package.json +1 -1
- package/test/response.srv.test.ts +114 -0
package/lib/schema.ts
CHANGED
|
@@ -4,15 +4,15 @@ 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
16
|
import { normalizeBody, matchContentType } from './types.js';
|
|
17
17
|
import type { OpenAPIDocumentInput } from './openapi.js'
|
|
18
18
|
import SchemaAPI from './api.js';
|
|
@@ -30,6 +30,82 @@ addFormatsModule.default(ajv);
|
|
|
30
30
|
|
|
31
31
|
export type ErrorListItem = { type: 'Body' | 'Query' | 'Params'; errors: ErrorObject[] };
|
|
32
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
|
+
|
|
33
109
|
/**
|
|
34
110
|
* @class
|
|
35
111
|
*
|
|
@@ -56,38 +132,45 @@ export default class Schemas {
|
|
|
56
132
|
|
|
57
133
|
/**
|
|
58
134
|
* Compile body validators keyed by content-type. Returns null when no
|
|
59
|
-
* body validation is configured. Each entry is either `true`
|
|
60
|
-
* accepted for this content-type) or a compiled ajv validator
|
|
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.
|
|
61
139
|
*/
|
|
62
140
|
compileBodyValidators(
|
|
63
141
|
body: RequestValidation<TSchema, TSchema, TSchema, TSchema>['body']
|
|
64
|
-
):
|
|
142
|
+
): CompiledBodyValidation | null {
|
|
65
143
|
const normalized = normalizeBody(body);
|
|
66
144
|
if (!normalized) return null;
|
|
67
145
|
|
|
68
|
-
const
|
|
146
|
+
const validators = new Map<string, true | ValidateFunction>();
|
|
69
147
|
for (const ct of Object.keys(normalized)) {
|
|
70
148
|
const schema = normalized[ct].schema;
|
|
71
149
|
if (schema === true || schema === undefined) {
|
|
72
|
-
|
|
150
|
+
validators.set(ct, true);
|
|
73
151
|
} else {
|
|
74
|
-
|
|
152
|
+
validators.set(ct, ajv.compile(schema));
|
|
75
153
|
}
|
|
76
154
|
}
|
|
77
|
-
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
validators,
|
|
158
|
+
matcher: normalized,
|
|
159
|
+
supported: Object.keys(normalized).join(', ')
|
|
160
|
+
};
|
|
78
161
|
}
|
|
79
162
|
|
|
80
163
|
/**
|
|
81
|
-
* Validate the incoming request body against the compiled
|
|
82
|
-
*
|
|
164
|
+
* Validate the incoming request body against the compiled validation
|
|
165
|
+
* state for the route. Pushes a Body error onto `errors` if validation
|
|
83
166
|
* fails; returns an Err to respond with for an unsupported content-type.
|
|
84
167
|
*
|
|
85
|
-
* If `
|
|
168
|
+
* If `opts.required` is false and the request has no body / no
|
|
86
169
|
* content-type, validation is skipped.
|
|
87
170
|
*/
|
|
88
171
|
validateBody(
|
|
89
172
|
req: Request,
|
|
90
|
-
|
|
173
|
+
compiled: CompiledBodyValidation,
|
|
91
174
|
errors: Array<ErrorListItem>,
|
|
92
175
|
opts: { required?: boolean } = {}
|
|
93
176
|
): Err | null {
|
|
@@ -96,14 +179,11 @@ export default class Schemas {
|
|
|
96
179
|
|
|
97
180
|
if (!raw && !required) return null;
|
|
98
181
|
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
const matched = matchContentType(raw, normalizedMap);
|
|
102
|
-
const validator = matched ? validators.get(matched) : undefined;
|
|
182
|
+
const matched = matchContentType(raw, compiled.matcher);
|
|
183
|
+
const validator = matched ? compiled.validators.get(matched) : undefined;
|
|
103
184
|
|
|
104
185
|
if (!validator) {
|
|
105
|
-
|
|
106
|
-
return new Err(400, null, `Content-Type ${raw || '(none)'} not supported. Supported: ${supported}`);
|
|
186
|
+
return new Err(400, null, `Content-Type ${raw || '(none)'} not supported. Supported: ${compiled.supported}`);
|
|
107
187
|
}
|
|
108
188
|
|
|
109
189
|
if (validator !== true) {
|
|
@@ -220,21 +300,48 @@ export default class Schemas {
|
|
|
220
300
|
await bp_fn(this, config);
|
|
221
301
|
}
|
|
222
302
|
|
|
223
|
-
|
|
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,
|
|
224
322
|
path: string,
|
|
225
|
-
opts: RequestValidation<TParams, TQuery, TBody, TResponse
|
|
226
|
-
handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery
|
|
227
|
-
|
|
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
|
+
|
|
228
329
|
try {
|
|
229
330
|
const validation = this.normalizeValidation(opts);
|
|
230
331
|
|
|
231
|
-
this.docs.push({ method
|
|
232
|
-
this.schemas.set(
|
|
332
|
+
this.docs.push({ method, path }, validation);
|
|
333
|
+
this.schemas.set(`${methodUpper} ${path}`, validation);
|
|
233
334
|
|
|
234
|
-
const resValidation =
|
|
335
|
+
const resValidation = this.compileResponseValidation(validation.res);
|
|
235
336
|
const paramsValidation = validation.params && ajv.compile(validation.params);
|
|
236
337
|
const queryValidation = validation.query && ajv.compile(validation.query);
|
|
237
|
-
|
|
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
|
+
}
|
|
238
345
|
|
|
239
346
|
const _handler: RequestHandler = (req, res, next) => {
|
|
240
347
|
if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
|
|
@@ -247,77 +354,52 @@ export default class Schemas {
|
|
|
247
354
|
const errors: Array<ErrorListItem> = [];
|
|
248
355
|
if (paramsValidation && !paramsValidation(req.params)) errors.push({ type: 'Params', errors: paramsValidation.errors as ErrorObject[] });
|
|
249
356
|
if (queryValidation && !queryValidation(req.query)) errors.push({ type: 'Query', errors: queryValidation.errors as ErrorObject[] });
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
}
|
|
260
375
|
return json.call(this, obj);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
376
|
+
};
|
|
377
|
+
}
|
|
263
378
|
|
|
264
379
|
return handler(req, res, next);
|
|
265
380
|
};
|
|
266
381
|
|
|
267
|
-
this.router
|
|
382
|
+
this.router[method as 'get' | 'delete' | 'post' | 'patch' | 'put'](path, _handler);
|
|
268
383
|
} catch (err) {
|
|
269
|
-
|
|
384
|
+
const label = methodUpper.charAt(0) + methodUpper.slice(1).toLowerCase();
|
|
385
|
+
throw new Error(`${label}: ${path}: ` + String(err), { cause: err })
|
|
270
386
|
}
|
|
271
387
|
}
|
|
272
388
|
|
|
273
|
-
async
|
|
389
|
+
async get<TParams extends TSchema, TQuery extends TSchema, TBody extends TSchema, TResponse extends TSchema>(
|
|
274
390
|
path: string,
|
|
275
391
|
opts: RequestValidation<TParams, TQuery, TBody, TResponse> = {},
|
|
276
392
|
handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>
|
|
277
393
|
) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
this.docs.push({ method: Doc.HttpMethods.DELETE, path: path }, validation);
|
|
282
|
-
this.schemas.set(`DELETE ${path}`, validation);
|
|
283
|
-
|
|
284
|
-
const resValidation = validation.res && !(validation.res instanceof Type.Any) && !(validation.res instanceof Type.Unknown) && ajv.compile(validation.res);
|
|
285
|
-
const paramsValidation = validation.params && ajv.compile(validation.params);
|
|
286
|
-
const queryValidation = validation.query && ajv.compile(validation.query);
|
|
287
|
-
if (validation.body) throw new Error(`Body not allowed`);
|
|
288
|
-
|
|
289
|
-
const _handler: RequestHandler = (req, res, next) => {
|
|
290
|
-
if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
|
|
291
|
-
Object.defineProperty(req, 'query', {
|
|
292
|
-
writable: true,
|
|
293
|
-
value: { ...req.query },
|
|
294
|
-
})
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const errors: Array<ErrorListItem> = [];
|
|
298
|
-
if (paramsValidation && !paramsValidation(req.params)) errors.push({ type: 'Params', errors: paramsValidation.errors as ErrorObject[] });
|
|
299
|
-
if (queryValidation && !queryValidation(req.query)) errors.push({ type: 'Query', errors: queryValidation.errors as ErrorObject[] });
|
|
300
|
-
if (errors.length) return Err.respond(this.validationError(Doc.HttpMethods.DELETE, path), res, errors);
|
|
301
|
-
|
|
302
|
-
const json = res.json;
|
|
303
|
-
res.json = function(obj) {
|
|
304
|
-
obj = JSON.parse(JSON.stringify(obj)) // Here as Date => String needs to happen
|
|
305
|
-
if ((res.statusCode === null || res.statusCode === 200) && resValidation && !resValidation(obj)) {
|
|
306
|
-
res.status(400);
|
|
307
|
-
console.error(`Response Validation Error: ${JSON.stringify(obj)}`);
|
|
308
|
-
return json.call(this, { type: 'Response', errors: resValidation.errors as ErrorObject[] });
|
|
309
|
-
} else {
|
|
310
|
-
return json.call(this, obj);
|
|
311
|
-
}
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
return handler(req, res, next);
|
|
315
|
-
};
|
|
394
|
+
this.route(Doc.HttpMethods.GET, path, opts, handler, false);
|
|
395
|
+
}
|
|
316
396
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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);
|
|
321
403
|
}
|
|
322
404
|
|
|
323
405
|
async post<TParams extends TSchema, TQuery extends TSchema, TBody extends TSchema, TResponse extends TSchema>(
|
|
@@ -325,53 +407,7 @@ export default class Schemas {
|
|
|
325
407
|
opts: RequestValidation<TParams, TQuery, TBody, TResponse> = {},
|
|
326
408
|
handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>
|
|
327
409
|
) {
|
|
328
|
-
|
|
329
|
-
const validation = this.normalizeValidation(opts);
|
|
330
|
-
|
|
331
|
-
this.docs.push({ method: Doc.HttpMethods.POST, path: path }, validation);
|
|
332
|
-
this.schemas.set(`POST ${path}`, validation);
|
|
333
|
-
|
|
334
|
-
const resValidation = validation.res && !(validation.res instanceof Type.Any) && !(validation.res instanceof Type.Unknown) && ajv.compile(validation.res);
|
|
335
|
-
const paramsValidation = validation.params && ajv.compile(validation.params);
|
|
336
|
-
const queryValidation = validation.query && ajv.compile(validation.query);
|
|
337
|
-
const bodyValidators = this.compileBodyValidators(validation.body);
|
|
338
|
-
|
|
339
|
-
const _handler: RequestHandler = (req, res, next) => {
|
|
340
|
-
if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
|
|
341
|
-
Object.defineProperty(req, 'query', {
|
|
342
|
-
writable: true,
|
|
343
|
-
value: { ...req.query },
|
|
344
|
-
})
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const errors: Array<ErrorListItem> = [];
|
|
348
|
-
if (paramsValidation && !paramsValidation(req.params)) errors.push({ type: 'Params', errors: paramsValidation.errors as ErrorObject[] });
|
|
349
|
-
if (queryValidation && !queryValidation(req.query)) errors.push({ type: 'Query', errors: queryValidation.errors as ErrorObject[] });
|
|
350
|
-
if (bodyValidators) {
|
|
351
|
-
const ctErr = this.validateBody(req, bodyValidators, errors, { required: validation.bodyRequired });
|
|
352
|
-
if (ctErr) return Err.respond(ctErr, res);
|
|
353
|
-
}
|
|
354
|
-
if (errors.length) return Err.respond(this.validationError(Doc.HttpMethods.POST, path), res, errors);
|
|
355
|
-
|
|
356
|
-
const json = res.json;
|
|
357
|
-
res.json = function(obj) {
|
|
358
|
-
obj = JSON.parse(JSON.stringify(obj)) // Here as Date => String needs to happen
|
|
359
|
-
if ((res.statusCode === null || res.statusCode === 200) && resValidation && !resValidation(obj)) {
|
|
360
|
-
res.status(400);
|
|
361
|
-
console.error(`Response Validation Error: ${JSON.stringify(obj)}`);
|
|
362
|
-
return json.call(this, { type: 'Response', errors: resValidation.errors as ErrorObject[] });
|
|
363
|
-
} else {
|
|
364
|
-
return json.call(this, obj);
|
|
365
|
-
}
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
return handler(req, res, next);
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
this.router.post(path, _handler);
|
|
372
|
-
} catch (err) {
|
|
373
|
-
throw new Error(`Post: ${path}: ` + String(err), { cause: err })
|
|
374
|
-
}
|
|
410
|
+
this.route(Doc.HttpMethods.POST, path, opts, handler, true);
|
|
375
411
|
}
|
|
376
412
|
|
|
377
413
|
async patch<TParams extends TSchema, TQuery extends TSchema, TBody extends TSchema, TResponse extends TSchema>(
|
|
@@ -379,53 +415,7 @@ export default class Schemas {
|
|
|
379
415
|
opts: RequestValidation<TParams, TQuery, TBody, TResponse> = {},
|
|
380
416
|
handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>
|
|
381
417
|
) {
|
|
382
|
-
|
|
383
|
-
const validation = this.normalizeValidation(opts);
|
|
384
|
-
|
|
385
|
-
this.docs.push({ method: Doc.HttpMethods.PATCH, path: path }, validation);
|
|
386
|
-
this.schemas.set(`PATCH ${path}`, validation);
|
|
387
|
-
|
|
388
|
-
const resValidation = validation.res && !(validation.res instanceof Type.Any) && !(validation.res instanceof Type.Unknown) && ajv.compile(validation.res);
|
|
389
|
-
const paramsValidation = validation.params && ajv.compile(validation.params);
|
|
390
|
-
const queryValidation = validation.query && ajv.compile(validation.query);
|
|
391
|
-
const bodyValidators = this.compileBodyValidators(validation.body);
|
|
392
|
-
|
|
393
|
-
const _handler: RequestHandler = (req, res, next) => {
|
|
394
|
-
if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
|
|
395
|
-
Object.defineProperty(req, 'query', {
|
|
396
|
-
writable: true,
|
|
397
|
-
value: { ...req.query },
|
|
398
|
-
})
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const errors: Array<ErrorListItem> = [];
|
|
402
|
-
if (paramsValidation && !paramsValidation(req.params)) errors.push({ type: 'Params', errors: paramsValidation.errors as ErrorObject[] });
|
|
403
|
-
if (queryValidation && !queryValidation(req.query)) errors.push({ type: 'Query', errors: queryValidation.errors as ErrorObject[] });
|
|
404
|
-
if (bodyValidators) {
|
|
405
|
-
const ctErr = this.validateBody(req, bodyValidators, errors, { required: validation.bodyRequired });
|
|
406
|
-
if (ctErr) return Err.respond(ctErr, res);
|
|
407
|
-
}
|
|
408
|
-
if (errors.length) return Err.respond(this.validationError(Doc.HttpMethods.PATCH, path), res, errors);
|
|
409
|
-
|
|
410
|
-
const json = res.json;
|
|
411
|
-
res.json = function(obj) {
|
|
412
|
-
obj = JSON.parse(JSON.stringify(obj)) // Here as Date => String needs to happen
|
|
413
|
-
if ((res.statusCode === null || res.statusCode === 200) && resValidation && !resValidation(obj)) {
|
|
414
|
-
res.status(400);
|
|
415
|
-
console.error(`Response Validation Error: ${JSON.stringify(obj)}`);
|
|
416
|
-
return json.call(this, { type: 'Response', errors: resValidation.errors as ErrorObject[] });
|
|
417
|
-
} else {
|
|
418
|
-
return json.call(this, obj);
|
|
419
|
-
}
|
|
420
|
-
};
|
|
421
|
-
|
|
422
|
-
return handler(req, res, next);
|
|
423
|
-
};
|
|
424
|
-
|
|
425
|
-
this.router.patch(path, _handler);
|
|
426
|
-
} catch (err) {
|
|
427
|
-
throw new Error(`Patch: ${path}: ` + String(err), { cause: err })
|
|
428
|
-
}
|
|
418
|
+
this.route(Doc.HttpMethods.PATCH, path, opts, handler, true);
|
|
429
419
|
}
|
|
430
420
|
|
|
431
421
|
async put<TParams extends TSchema, TQuery extends TSchema, TBody extends TSchema, TResponse extends TSchema>(
|
|
@@ -433,53 +423,7 @@ export default class Schemas {
|
|
|
433
423
|
opts: RequestValidation<TParams, TQuery, TBody, TResponse> = {},
|
|
434
424
|
handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>
|
|
435
425
|
) {
|
|
436
|
-
|
|
437
|
-
const validation = this.normalizeValidation(opts);
|
|
438
|
-
|
|
439
|
-
this.docs.push({ method: Doc.HttpMethods.PUT, path: path }, validation);
|
|
440
|
-
this.schemas.set(`PUT ${path}`, validation);
|
|
441
|
-
|
|
442
|
-
const resValidation = validation.res && !(validation.res instanceof Type.Any) && !(validation.res instanceof Type.Unknown) && ajv.compile(validation.res);
|
|
443
|
-
const paramsValidation = validation.params && ajv.compile(validation.params);
|
|
444
|
-
const queryValidation = validation.query && ajv.compile(validation.query);
|
|
445
|
-
const bodyValidators = this.compileBodyValidators(validation.body);
|
|
446
|
-
|
|
447
|
-
const _handler: RequestHandler = (req, res, next) => {
|
|
448
|
-
if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
|
|
449
|
-
Object.defineProperty(req, 'query', {
|
|
450
|
-
writable: true,
|
|
451
|
-
value: { ...req.query },
|
|
452
|
-
})
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
const errors: Array<ErrorListItem> = [];
|
|
456
|
-
if (paramsValidation && !paramsValidation(req.params)) errors.push({ type: 'Params', errors: paramsValidation.errors as ErrorObject[] });
|
|
457
|
-
if (queryValidation && !queryValidation(req.query)) errors.push({ type: 'Query', errors: queryValidation.errors as ErrorObject[] });
|
|
458
|
-
if (bodyValidators) {
|
|
459
|
-
const ctErr = this.validateBody(req, bodyValidators, errors, { required: validation.bodyRequired });
|
|
460
|
-
if (ctErr) return Err.respond(ctErr, res);
|
|
461
|
-
}
|
|
462
|
-
if (errors.length) return Err.respond(this.validationError(Doc.HttpMethods.PUT, path), res, errors);
|
|
463
|
-
|
|
464
|
-
const json = res.json;
|
|
465
|
-
res.json = function(obj) {
|
|
466
|
-
obj = JSON.parse(JSON.stringify(obj)) // Here as Date => String needs to happen
|
|
467
|
-
if ((res.statusCode === null || res.statusCode === 200) && resValidation && !resValidation(obj)) {
|
|
468
|
-
res.status(400);
|
|
469
|
-
console.error(`Response Validation Error: ${JSON.stringify(obj)}`);
|
|
470
|
-
return json.call(this, { type: 'Response', errors: resValidation.errors as ErrorObject[] });
|
|
471
|
-
} else {
|
|
472
|
-
return json.call(this, obj);
|
|
473
|
-
}
|
|
474
|
-
};
|
|
475
|
-
|
|
476
|
-
return handler(req, res, next);
|
|
477
|
-
};
|
|
478
|
-
|
|
479
|
-
this.router.put(path, _handler);
|
|
480
|
-
} catch (err) {
|
|
481
|
-
throw new Error(`Put: ${path}: ` + String(err), { cause: err })
|
|
482
|
-
}
|
|
426
|
+
this.route(Doc.HttpMethods.PUT, path, opts, handler, true);
|
|
483
427
|
}
|
|
484
428
|
|
|
485
429
|
not_found() {
|
package/package.json
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { Type } from '@sinclair/typebox';
|
|
5
|
+
import Schema from '../index.js';
|
|
6
|
+
|
|
7
|
+
const app = express();
|
|
8
|
+
const schema = new Schema(express.Router(), { logging: false });
|
|
9
|
+
|
|
10
|
+
app.use('/api', schema.router);
|
|
11
|
+
|
|
12
|
+
let server: ReturnType<typeof app.listen>;
|
|
13
|
+
|
|
14
|
+
const stripped = { ok: true, secret: 'do-not-send' };
|
|
15
|
+
|
|
16
|
+
describe('Response Validation', () => {
|
|
17
|
+
before((_, done) => {
|
|
18
|
+
server = app.listen(2001, async () => {
|
|
19
|
+
await schema.get('/date-res', {
|
|
20
|
+
res: Type.Object({
|
|
21
|
+
when: Type.String()
|
|
22
|
+
})
|
|
23
|
+
}, async (_req, res) => {
|
|
24
|
+
res.json({ when: new Date(0) as unknown as string });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
await schema.get('/strip-res', {
|
|
28
|
+
res: Type.Object({
|
|
29
|
+
ok: Type.Boolean()
|
|
30
|
+
})
|
|
31
|
+
}, async (_req, res) => {
|
|
32
|
+
res.json(stripped);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await schema.get('/bad-res', {
|
|
36
|
+
res: Type.Object({
|
|
37
|
+
ok: Type.Boolean()
|
|
38
|
+
})
|
|
39
|
+
}, async (_req, res) => {
|
|
40
|
+
res.json({ wrong: 1 } as unknown as { ok: boolean });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await schema.get('/created-res', {
|
|
44
|
+
res: Type.Object({
|
|
45
|
+
ok: Type.Boolean()
|
|
46
|
+
})
|
|
47
|
+
}, async (_req, res) => {
|
|
48
|
+
res.status(201).json({ anything: 'goes', when: new Date(0) } as unknown as { ok: boolean });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await schema.get('/any-res', {
|
|
52
|
+
res: Type.Any()
|
|
53
|
+
}, async (_req, res) => {
|
|
54
|
+
res.json({ when: new Date(0), extra: 'kept' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
done();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
after(() => {
|
|
62
|
+
server.close();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('GET: /date-res serializes Dates before validation', async () => {
|
|
66
|
+
const res = await fetch('http://localhost:2001/api/date-res');
|
|
67
|
+
assert.strictEqual(res.status, 200);
|
|
68
|
+
assert.deepStrictEqual(await res.json(), { when: '1970-01-01T00:00:00.000Z' });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('GET: /strip-res removes additional properties without mutating the handler object', async () => {
|
|
72
|
+
const res = await fetch('http://localhost:2001/api/strip-res');
|
|
73
|
+
assert.strictEqual(res.status, 200);
|
|
74
|
+
assert.deepStrictEqual(await res.json(), { ok: true });
|
|
75
|
+
assert.deepStrictEqual(stripped, { ok: true, secret: 'do-not-send' });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('GET: /bad-res returns a Response validation error', async () => {
|
|
79
|
+
const res = await fetch('http://localhost:2001/api/bad-res');
|
|
80
|
+
assert.strictEqual(res.status, 400);
|
|
81
|
+
const body = await res.json();
|
|
82
|
+
assert.strictEqual(body.type, 'Response');
|
|
83
|
+
assert.ok(Array.isArray(body.errors));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('GET: /created-res skips response validation on non-200 status', async () => {
|
|
87
|
+
const res = await fetch('http://localhost:2001/api/created-res');
|
|
88
|
+
assert.strictEqual(res.status, 201);
|
|
89
|
+
assert.deepStrictEqual(await res.json(), {
|
|
90
|
+
anything: 'goes',
|
|
91
|
+
when: '1970-01-01T00:00:00.000Z'
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('GET: /any-res passes Type.Any responses through untouched', async () => {
|
|
96
|
+
const res = await fetch('http://localhost:2001/api/any-res');
|
|
97
|
+
assert.strictEqual(res.status, 200);
|
|
98
|
+
assert.deepStrictEqual(await res.json(), {
|
|
99
|
+
when: '1970-01-01T00:00:00.000Z',
|
|
100
|
+
extra: 'kept'
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('GET: rejects body validation at registration', async () => {
|
|
105
|
+
await assert.rejects(
|
|
106
|
+
schema.get('/no-body-allowed', {
|
|
107
|
+
body: Type.Object({ nope: Type.Boolean() })
|
|
108
|
+
}, async (_req, res) => {
|
|
109
|
+
res.json({});
|
|
110
|
+
}),
|
|
111
|
+
/Get: \/no-body-allowed: Error: Body not allowed/
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|