@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/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 { Type } from '@sinclair/typebox';
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` (any body
60
- * accepted for this content-type) or a compiled ajv validator function.
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
- ): Map<string, true | ReturnType<typeof ajv.compile>> | null {
142
+ ): CompiledBodyValidation | null {
65
143
  const normalized = normalizeBody(body);
66
144
  if (!normalized) return null;
67
145
 
68
- const map = new Map<string, true | ReturnType<typeof ajv.compile>>();
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
- map.set(ct, true);
150
+ validators.set(ct, true);
73
151
  } else {
74
- map.set(ct, ajv.compile(schema));
152
+ validators.set(ct, ajv.compile(schema));
75
153
  }
76
154
  }
77
- return map;
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 validator
82
- * map for the route. Pushes a Body error onto `errors` if validation
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 `bodyRequired` is false and the request has no body / no
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
- validators: Map<string, true | ReturnType<typeof ajv.compile>>,
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 normalizedMap: Record<string, { schema: true }> = {};
100
- for (const key of validators.keys()) normalizedMap[key] = { schema: true };
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
- const supported = Array.from(validators.keys()).join(', ');
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
- async get<TParams extends TSchema, TQuery extends TSchema, TBody extends TSchema, TResponse extends TSchema>(
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: Doc.HttpMethods.GET, path: path }, validation);
232
- this.schemas.set(`GET ${path}`, validation);
332
+ this.docs.push({ method, path }, validation);
333
+ this.schemas.set(`${methodUpper} ${path}`, validation);
233
334
 
234
- const resValidation = validation.res && !(validation.res instanceof Type.Any) && !(validation.res instanceof Type.Unknown) && ajv.compile(validation.res);
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
- if (validation.body) throw new Error(`Body not allowed`);
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 (errors.length) return Err.respond(this.validationError(Doc.HttpMethods.GET, path), res, errors);
251
-
252
- const json = res.json;
253
- res.json = function(obj) {
254
- obj = JSON.parse(JSON.stringify(obj)) // Here as Date => String needs to happen
255
- if ((res.statusCode === null || res.statusCode === 200) && resValidation && !resValidation(obj)) {
256
- res.status(400);
257
- console.error(`Response Validation Error: ${JSON.stringify(obj)}`);
258
- return json.call(this, { type: 'Response', errors: resValidation.errors as ErrorObject[] });
259
- } else {
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.get(path, _handler);
382
+ this.router[method as 'get' | 'delete' | 'post' | 'patch' | 'put'](path, _handler);
268
383
  } catch (err) {
269
- throw new Error(`Get: ${path}: ` + err, { cause: err })
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 delete<TParams extends TSchema, TQuery extends TSchema, TBody extends TSchema, TResponse extends TSchema>(
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
- try {
279
- const validation = this.normalizeValidation(opts);
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
- this.router.delete(path, _handler);
318
- } catch (err) {
319
- throw new Error(`Delete: ${path}: ` + String(err), { cause: err })
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
- try {
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
- try {
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
- try {
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
@@ -4,7 +4,7 @@
4
4
  "root": true,
5
5
  "main": "dist/index.js",
6
6
  "types": "index.ts",
7
- "version": "10.26.0",
7
+ "version": "10.27.0",
8
8
  "description": "Strongly Validated JSON Schema support for address",
9
9
  "repository": {
10
10
  "type": "git",
@@ -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
+ });