@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/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 { 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
+ 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
- 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,
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: Doc.HttpMethods.GET, path: path }, validation);
162
- this.schemas.set(`GET ${path}`, validation);
332
+ this.docs.push({ method, path }, validation);
333
+ this.schemas.set(`${methodUpper} ${path}`, validation);
163
334
 
164
- 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);
165
336
  const paramsValidation = validation.params && ajv.compile(validation.params);
166
337
  const queryValidation = validation.query && ajv.compile(validation.query);
167
- 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
+ }
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 (errors.length) return Err.respond(this.validationError(Doc.HttpMethods.GET, path), res, errors);
181
-
182
- const json = res.json;
183
- res.json = function(obj) {
184
- obj = JSON.parse(JSON.stringify(obj)) // Here as Date => String needs to happen
185
- if ((res.statusCode === null || res.statusCode === 200) && resValidation && !resValidation(obj)) {
186
- res.status(400);
187
- console.error(`Response Validation Error: ${JSON.stringify(obj)}`);
188
- return json.call(this, { type: 'Response', errors: resValidation.errors as ErrorObject[] });
189
- } 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
+ }
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.get(path, _handler);
382
+ this.router[method as 'get' | 'delete' | 'post' | 'patch' | 'put'](path, _handler);
198
383
  } catch (err) {
199
- 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 })
200
386
  }
201
387
  }
202
388
 
203
- 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>(
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
- try {
209
- const validation = this.normalizeValidation(opts);
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
- this.router.delete(path, _handler);
248
- } catch (err) {
249
- throw new Error(`Delete: ${path}: ` + String(err), { cause: err })
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
- try {
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
- try {
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
- try {
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
  };
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.25.0",
7
+ "version": "10.27.0",
8
8
  "description": "Strongly Validated JSON Schema support for address",
9
9
  "repository": {
10
10
  "type": "git",