@openaddresses/batch-schema 10.24.1 → 10.26.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
@@ -13,6 +13,7 @@ import { Ajv } from 'ajv'
13
13
  import type { ErrorObject } from 'ajv'
14
14
  import addFormatsModule from 'ajv-formats'
15
15
  import type { RequestValidation } 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';
@@ -53,6 +54,67 @@ export default class Schemas {
53
54
  return new Err(400, null, `Validation Error ${String(method).toUpperCase()} ${path}`);
54
55
  }
55
56
 
57
+ /**
58
+ * 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.
61
+ */
62
+ compileBodyValidators(
63
+ body: RequestValidation<TSchema, TSchema, TSchema, TSchema>['body']
64
+ ): Map<string, true | ReturnType<typeof ajv.compile>> | null {
65
+ const normalized = normalizeBody(body);
66
+ if (!normalized) return null;
67
+
68
+ const map = new Map<string, true | ReturnType<typeof ajv.compile>>();
69
+ for (const ct of Object.keys(normalized)) {
70
+ const schema = normalized[ct].schema;
71
+ if (schema === true || schema === undefined) {
72
+ map.set(ct, true);
73
+ } else {
74
+ map.set(ct, ajv.compile(schema));
75
+ }
76
+ }
77
+ return map;
78
+ }
79
+
80
+ /**
81
+ * Validate the incoming request body against the compiled validator
82
+ * map for the route. Pushes a Body error onto `errors` if validation
83
+ * fails; returns an Err to respond with for an unsupported content-type.
84
+ *
85
+ * If `bodyRequired` is false and the request has no body / no
86
+ * content-type, validation is skipped.
87
+ */
88
+ validateBody(
89
+ req: Request,
90
+ validators: Map<string, true | ReturnType<typeof ajv.compile>>,
91
+ errors: Array<ErrorListItem>,
92
+ opts: { required?: boolean } = {}
93
+ ): Err | null {
94
+ const raw = (req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
95
+ const required = opts.required !== false;
96
+
97
+ if (!raw && !required) return null;
98
+
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;
103
+
104
+ if (!validator) {
105
+ const supported = Array.from(validators.keys()).join(', ');
106
+ return new Err(400, null, `Content-Type ${raw || '(none)'} not supported. Supported: ${supported}`);
107
+ }
108
+
109
+ if (validator !== true) {
110
+ if (!validator(req.body)) {
111
+ errors.push({ type: 'Body', errors: validator.errors as ErrorObject[] });
112
+ }
113
+ }
114
+
115
+ return null;
116
+ }
117
+
56
118
  constructor(
57
119
  router: Router,
58
120
  opts: {
@@ -74,8 +136,16 @@ export default class Schemas {
74
136
  this.router.use(morgan('combined', morganOptions));
75
137
  }
76
138
 
77
- this.router.use(bodyparser.urlencoded({ extended: true }));
139
+ this.router.use(bodyparser.urlencoded({ extended: true, limit: `${opts.limit || 50}mb` }));
78
140
  this.router.use(bodyparser.json({ limit: `${opts.limit || 50}mb` }));
141
+ this.router.use(bodyparser.text({
142
+ limit: `${opts.limit || 50}mb`,
143
+ type: ['text/*', 'application/xml', 'application/*+xml']
144
+ }));
145
+ this.router.use(bodyparser.raw({
146
+ limit: `${opts.limit || 50}mb`,
147
+ type: ['application/octet-stream']
148
+ }));
79
149
 
80
150
  if (opts.error) {
81
151
  this.error = opts.error;
@@ -91,6 +161,15 @@ export default class Schemas {
91
161
  this.schemas = new Map();
92
162
  }
93
163
 
164
+ normalizeValidation<TParams extends TSchema, TQuery extends TSchema, TBody extends TSchema, TResponse extends TSchema>(
165
+ opts: RequestValidation<TParams, TQuery, TBody, TResponse> = {}
166
+ ): RequestValidation<TParams, TQuery, TBody, TResponse> {
167
+ return {
168
+ deprecated: false,
169
+ ...opts
170
+ };
171
+ }
172
+
94
173
  async api() {
95
174
  await SchemaAPI(this);
96
175
  }
@@ -147,13 +226,15 @@ export default class Schemas {
147
226
  handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>
148
227
  ) {
149
228
  try {
150
- this.docs.push({ method: Doc.HttpMethods.GET, path: path }, opts);
151
- this.schemas.set(`GET ${path}`, opts);
229
+ const validation = this.normalizeValidation(opts);
230
+
231
+ this.docs.push({ method: Doc.HttpMethods.GET, path: path }, validation);
232
+ this.schemas.set(`GET ${path}`, validation);
152
233
 
153
- const resValidation = opts.res && !(opts.res instanceof Type.Any) && !(opts.res instanceof Type.Unknown) && ajv.compile(opts.res);
154
- const paramsValidation = opts.params && ajv.compile(opts.params);
155
- const queryValidation = opts.query && ajv.compile(opts.query);
156
- if (opts.body) throw new Error(`Body not allowed`);
234
+ const resValidation = validation.res && !(validation.res instanceof Type.Any) && !(validation.res instanceof Type.Unknown) && ajv.compile(validation.res);
235
+ const paramsValidation = validation.params && ajv.compile(validation.params);
236
+ const queryValidation = validation.query && ajv.compile(validation.query);
237
+ if (validation.body) throw new Error(`Body not allowed`);
157
238
 
158
239
  const _handler: RequestHandler = (req, res, next) => {
159
240
  if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
@@ -195,13 +276,15 @@ export default class Schemas {
195
276
  handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>
196
277
  ) {
197
278
  try {
198
- this.docs.push({ method: Doc.HttpMethods.DELETE, path: path }, opts);
199
- this.schemas.set(`DELETE ${path}`, opts);
279
+ const validation = this.normalizeValidation(opts);
200
280
 
201
- const resValidation = opts.res && !(opts.res instanceof Type.Any) && !(opts.res instanceof Type.Unknown) && ajv.compile(opts.res);
202
- const paramsValidation = opts.params && ajv.compile(opts.params);
203
- const queryValidation = opts.query && ajv.compile(opts.query);
204
- if (opts.body) throw new Error(`Body not allowed`);
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`);
205
288
 
206
289
  const _handler: RequestHandler = (req, res, next) => {
207
290
  if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
@@ -243,13 +326,15 @@ export default class Schemas {
243
326
  handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>
244
327
  ) {
245
328
  try {
246
- this.docs.push({ method: Doc.HttpMethods.POST, path: path }, opts);
247
- this.schemas.set(`POST ${path}`, opts);
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);
248
333
 
249
- const resValidation = opts.res && !(opts.res instanceof Type.Any) && !(opts.res instanceof Type.Unknown) && ajv.compile(opts.res);
250
- const paramsValidation = opts.params && ajv.compile(opts.params);
251
- const queryValidation = opts.query && ajv.compile(opts.query);
252
- const bodyValidation = opts.body && ajv.compile(opts.body);
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);
253
338
 
254
339
  const _handler: RequestHandler = (req, res, next) => {
255
340
  if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
@@ -262,7 +347,10 @@ export default class Schemas {
262
347
  const errors: Array<ErrorListItem> = [];
263
348
  if (paramsValidation && !paramsValidation(req.params)) errors.push({ type: 'Params', errors: paramsValidation.errors as ErrorObject[] });
264
349
  if (queryValidation && !queryValidation(req.query)) errors.push({ type: 'Query', errors: queryValidation.errors as ErrorObject[] });
265
- if (bodyValidation && !bodyValidation(req.body)) errors.push({ type: 'Body', errors: bodyValidation.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
+ }
266
354
  if (errors.length) return Err.respond(this.validationError(Doc.HttpMethods.POST, path), res, errors);
267
355
 
268
356
  const json = res.json;
@@ -292,13 +380,15 @@ export default class Schemas {
292
380
  handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>
293
381
  ) {
294
382
  try {
295
- this.docs.push({ method: Doc.HttpMethods.PATCH, path: path }, opts);
296
- this.schemas.set(`PATCH ${path}`, opts);
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);
297
387
 
298
- const resValidation = opts.res && !(opts.res instanceof Type.Any) && !(opts.res instanceof Type.Unknown) && ajv.compile(opts.res);
299
- const paramsValidation = opts.params && ajv.compile(opts.params);
300
- const queryValidation = opts.query && ajv.compile(opts.query);
301
- const bodyValidation = opts.body && ajv.compile(opts.body);
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);
302
392
 
303
393
  const _handler: RequestHandler = (req, res, next) => {
304
394
  if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
@@ -311,7 +401,10 @@ export default class Schemas {
311
401
  const errors: Array<ErrorListItem> = [];
312
402
  if (paramsValidation && !paramsValidation(req.params)) errors.push({ type: 'Params', errors: paramsValidation.errors as ErrorObject[] });
313
403
  if (queryValidation && !queryValidation(req.query)) errors.push({ type: 'Query', errors: queryValidation.errors as ErrorObject[] });
314
- if (bodyValidation && !bodyValidation(req.body)) errors.push({ type: 'Body', errors: bodyValidation.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
+ }
315
408
  if (errors.length) return Err.respond(this.validationError(Doc.HttpMethods.PATCH, path), res, errors);
316
409
 
317
410
  const json = res.json;
@@ -341,13 +434,15 @@ export default class Schemas {
341
434
  handler: RequestHandler<Static<TParams>, Static<TResponse>, Static<TBody>, Static<TQuery>>
342
435
  ) {
343
436
  try {
344
- this.docs.push({ method: Doc.HttpMethods.PUT, path: path }, opts);
345
- this.schemas.set(`PUT ${path}`, opts);
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);
346
441
 
347
- const resValidation = opts.res && !(opts.res instanceof Type.Any) && !(opts.res instanceof Type.Unknown) && ajv.compile(opts.res);
348
- const paramsValidation = opts.params && ajv.compile(opts.params);
349
- const queryValidation = opts.query && ajv.compile(opts.query);
350
- const bodyValidation = opts.body && ajv.compile(opts.body);
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);
351
446
 
352
447
  const _handler: RequestHandler = (req, res, next) => {
353
448
  if (req.query) { // Ref: https://github.com/cdimascio/express-openapi-validator/issues/969
@@ -360,7 +455,10 @@ export default class Schemas {
360
455
  const errors: Array<ErrorListItem> = [];
361
456
  if (paramsValidation && !paramsValidation(req.params)) errors.push({ type: 'Params', errors: paramsValidation.errors as ErrorObject[] });
362
457
  if (queryValidation && !queryValidation(req.query)) errors.push({ type: 'Query', errors: queryValidation.errors as ErrorObject[] });
363
- if (bodyValidation && !bodyValidation(req.body)) errors.push({ type: 'Body', errors: bodyValidation.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
+ }
364
462
  if (errors.length) return Err.respond(this.validationError(Doc.HttpMethods.PUT, path), res, errors);
365
463
 
366
464
  const json = res.json;
@@ -396,6 +494,7 @@ export default class Schemas {
396
494
 
397
495
 
398
496
  query(method: Doc.HttpMethods, url: string): {
497
+ deprecated?: boolean;
399
498
  query?: object;
400
499
  body?: object;
401
500
  res?: object;
@@ -407,6 +506,7 @@ export default class Schemas {
407
506
  const schema = JSON.parse(JSON.stringify(this.schemas.get(`${method} ${url}`)));
408
507
 
409
508
  return {
509
+ deprecated: schema.deprecated,
410
510
  query: schema.query,
411
511
  body: schema.body,
412
512
  res: schema.res
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,
@@ -7,11 +90,14 @@ export type RequestValidation<
7
90
  TResponse extends TSchema
8
91
  > = {
9
92
  private?: boolean;
93
+ deprecated?: boolean;
10
94
  name?: string;
11
95
  group?: string;
12
96
  description?: string;
13
97
  params?: TParams;
14
98
  query?: TQuery;
15
- body?: TBody;
99
+ body?: BodyValidation<TBody>;
100
+ /** Whether the request body is required. Defaults to true when `body` is set. */
101
+ bodyRequired?: boolean;
16
102
  res?: TResponse
17
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.24.1",
7
+ "version": "10.26.0",
8
8
  "description": "Strongly Validated JSON Schema support for address",
9
9
  "repository": {
10
10
  "type": "git",