@terreno/api 0.0.18 → 0.1.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/.claude/CLAUDE.local.md +204 -0
- package/.cursor/rules/00-root.mdc +338 -0
- package/.github/copilot-instructions.md +333 -0
- package/AGENTS.md +23 -3
- package/README.md +73 -3
- package/dist/api.d.ts +68 -1
- package/dist/api.js +139 -4
- package/dist/api.test.js +906 -2
- package/dist/auth.js +3 -1
- package/dist/errors.js +14 -11
- package/dist/example.js +7 -7
- package/dist/githubAuth.test.js +3 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/openApi.test.js +8 -5
- package/dist/openApiBuilder.d.ts +69 -1
- package/dist/openApiBuilder.js +109 -5
- package/dist/openApiValidator.d.ts +296 -0
- package/dist/openApiValidator.js +698 -0
- package/dist/openApiValidator.test.d.ts +1 -0
- package/dist/openApiValidator.test.js +346 -0
- package/dist/plugins.test.js +3 -3
- package/dist/terrenoPlugin.d.ts +4 -0
- package/dist/terrenoPlugin.js +2 -0
- package/dist/tests.js +34 -24
- package/package.json +4 -1
- package/src/__snapshots__/openApi.test.ts.snap +399 -0
- package/src/__snapshots__/openApiBuilder.test.ts.snap +108 -0
- package/src/api.test.ts +743 -2
- package/src/api.ts +209 -3
- package/src/auth.ts +3 -1
- package/src/errors.ts +14 -11
- package/src/example.ts +7 -7
- package/src/githubAuth.test.ts +3 -3
- package/src/index.ts +2 -0
- package/src/openApi.test.ts +8 -5
- package/src/openApiBuilder.ts +188 -15
- package/src/openApiValidator.test.ts +241 -0
- package/src/openApiValidator.ts +860 -0
- package/src/plugins.test.ts +3 -3
- package/src/terrenoPlugin.ts +5 -0
- package/src/tests.ts +34 -24
- package/.cursorrules +0 -107
- package/.windsurfrules +0 -107
- package/dist/response.d.ts +0 -0
- package/dist/response.js +0 -1
- package/index.ts +0 -1
- package/src/response.ts +0 -0
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI Request Validator
|
|
3
|
+
*
|
|
4
|
+
* Provides runtime validation of incoming requests against OpenAPI schemas.
|
|
5
|
+
* Uses AJV for JSON Schema validation with OpenAPI-compatible settings.
|
|
6
|
+
*
|
|
7
|
+
* Validation is always installed as middleware but only activates after
|
|
8
|
+
* `configureOpenApiValidator()` is called. This makes it safe to include
|
|
9
|
+
* in modelRouter by default.
|
|
10
|
+
*
|
|
11
|
+
* @module openApiValidator
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* // Enable validation globally at server startup
|
|
18
|
+
* configureOpenApiValidator({
|
|
19
|
+
* removeAdditional: true,
|
|
20
|
+
* onAdditionalPropertiesRemoved: (props, req) => {
|
|
21
|
+
* logger.warn(`Stripped: ${props.join(", ")} on ${req.method} ${req.path}`);
|
|
22
|
+
* },
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* // modelRouter automatically validates when configured
|
|
26
|
+
* modelRouter(Todo, {
|
|
27
|
+
* permissions: {...},
|
|
28
|
+
* validation: {
|
|
29
|
+
* validateCreate: true,
|
|
30
|
+
* validateUpdate: true,
|
|
31
|
+
* validateQuery: true,
|
|
32
|
+
* },
|
|
33
|
+
* });
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import Ajv, {type ErrorObject, type ValidateFunction} from "ajv";
|
|
38
|
+
import addFormats from "ajv-formats";
|
|
39
|
+
import type {NextFunction, Request, Response} from "express";
|
|
40
|
+
import type {Model} from "mongoose";
|
|
41
|
+
import m2s from "mongoose-to-swagger";
|
|
42
|
+
|
|
43
|
+
import {APIError} from "./errors";
|
|
44
|
+
import {logger} from "./logger";
|
|
45
|
+
import type {OpenApiSchema, OpenApiSchemaProperty} from "./openApiBuilder";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Global configuration for OpenAPI validation.
|
|
49
|
+
* This can be set at server startup to control validation behavior.
|
|
50
|
+
*/
|
|
51
|
+
export interface OpenApiValidatorConfig {
|
|
52
|
+
/**
|
|
53
|
+
* Enable or disable request body validation.
|
|
54
|
+
* Default: true (when configureOpenApiValidator is called)
|
|
55
|
+
*/
|
|
56
|
+
validateRequests?: boolean;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Enable or disable response validation.
|
|
60
|
+
* Default: false (response validation has performance overhead)
|
|
61
|
+
*/
|
|
62
|
+
validateResponses?: boolean;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether to coerce types (e.g., string "123" to number 123).
|
|
66
|
+
* Default: true
|
|
67
|
+
*/
|
|
68
|
+
coerceTypes?: boolean;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether to remove additional properties not in the schema.
|
|
72
|
+
* Default: true
|
|
73
|
+
*/
|
|
74
|
+
removeAdditional?: boolean;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Custom error handler for validation failures.
|
|
78
|
+
* If not provided, throws an APIError with status 400.
|
|
79
|
+
*/
|
|
80
|
+
onValidationError?: (errors: ErrorObject[], req: Request) => void;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Log validation errors for debugging.
|
|
84
|
+
* Default: true
|
|
85
|
+
*/
|
|
86
|
+
logValidationErrors?: boolean;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Callback fired when additional properties are removed from a request body.
|
|
90
|
+
* Only fires when `removeAdditional: true` and extra properties are present.
|
|
91
|
+
* Receives the list of removed property names and the request.
|
|
92
|
+
*/
|
|
93
|
+
onAdditionalPropertiesRemoved?: (removedProperties: string[], req: Request) => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Whether configureOpenApiValidator() has been called
|
|
97
|
+
let isConfigured = false;
|
|
98
|
+
|
|
99
|
+
// Global validator configuration - can be modified at runtime
|
|
100
|
+
let globalConfig: OpenApiValidatorConfig = {
|
|
101
|
+
coerceTypes: true,
|
|
102
|
+
logValidationErrors: true,
|
|
103
|
+
removeAdditional: true,
|
|
104
|
+
validateRequests: true,
|
|
105
|
+
validateResponses: false,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check whether `configureOpenApiValidator()` has been called.
|
|
110
|
+
* Validation middleware is a no-op when this returns false.
|
|
111
|
+
*/
|
|
112
|
+
export function isOpenApiValidatorConfigured(): boolean {
|
|
113
|
+
return isConfigured;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Configure the global OpenAPI validator settings.
|
|
118
|
+
* Calling this function activates validation — middleware that was previously
|
|
119
|
+
* installed as a no-op will begin validating requests.
|
|
120
|
+
*
|
|
121
|
+
* @param config - Configuration options to merge with existing config
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* ```typescript
|
|
125
|
+
* configureOpenApiValidator({
|
|
126
|
+
* removeAdditional: true,
|
|
127
|
+
* onAdditionalPropertiesRemoved: (props, req) => {
|
|
128
|
+
* Sentry.captureMessage(`Stripped: ${props.join(", ")} on ${req.method} ${req.path}`);
|
|
129
|
+
* },
|
|
130
|
+
* });
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
export function configureOpenApiValidator(config: Partial<OpenApiValidatorConfig> = {}): void {
|
|
134
|
+
isConfigured = true;
|
|
135
|
+
globalConfig = {...globalConfig, ...config};
|
|
136
|
+
// Clear cached AJV instances so new config takes effect
|
|
137
|
+
ajvCache.clear();
|
|
138
|
+
validatorCache.clear();
|
|
139
|
+
logger.debug(`OpenAPI validator configured: ${JSON.stringify(globalConfig)}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get the current global validator configuration.
|
|
144
|
+
*/
|
|
145
|
+
export function getOpenApiValidatorConfig(): OpenApiValidatorConfig {
|
|
146
|
+
return {...globalConfig};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Reset the global validator configuration to defaults.
|
|
151
|
+
* Also resets `isConfigured` to false.
|
|
152
|
+
* Useful for testing.
|
|
153
|
+
*/
|
|
154
|
+
export function resetOpenApiValidatorConfig(): void {
|
|
155
|
+
isConfigured = false;
|
|
156
|
+
globalConfig = {
|
|
157
|
+
coerceTypes: true,
|
|
158
|
+
logValidationErrors: true,
|
|
159
|
+
removeAdditional: true,
|
|
160
|
+
validateRequests: true,
|
|
161
|
+
validateResponses: false,
|
|
162
|
+
};
|
|
163
|
+
ajvCache.clear();
|
|
164
|
+
validatorCache.clear();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Lazy AJV instance cache keyed by coerceTypes + removeAdditional
|
|
168
|
+
const ajvCache = new Map<string, Ajv>();
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get or create an AJV instance with the current config settings.
|
|
172
|
+
*/
|
|
173
|
+
function getAjvInstance(): Ajv {
|
|
174
|
+
const key = `coerce:${globalConfig.coerceTypes ?? true},remove:${globalConfig.removeAdditional ?? true}`;
|
|
175
|
+
let instance = ajvCache.get(key);
|
|
176
|
+
|
|
177
|
+
if (!instance) {
|
|
178
|
+
instance = new Ajv({
|
|
179
|
+
allErrors: true,
|
|
180
|
+
coerceTypes: globalConfig.coerceTypes ?? true,
|
|
181
|
+
removeAdditional: globalConfig.removeAdditional ?? true,
|
|
182
|
+
strict: false,
|
|
183
|
+
useDefaults: true,
|
|
184
|
+
validateSchema: false,
|
|
185
|
+
});
|
|
186
|
+
addFormats(instance);
|
|
187
|
+
ajvCache.set(key, instance);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return instance;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Cache compiled validators by schema hash + config key
|
|
194
|
+
const validatorCache = new Map<string, ValidateFunction>();
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Generate a simple hash for a schema to use as a cache key.
|
|
198
|
+
*/
|
|
199
|
+
function hashSchema(schema: OpenApiSchema): string {
|
|
200
|
+
return JSON.stringify(schema);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const VALID_JSON_SCHEMA_TYPES = new Set([
|
|
204
|
+
"string",
|
|
205
|
+
"number",
|
|
206
|
+
"integer",
|
|
207
|
+
"boolean",
|
|
208
|
+
"array",
|
|
209
|
+
"object",
|
|
210
|
+
"null",
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
// mongoose-to-swagger emits non-standard type strings for some Mongoose types
|
|
214
|
+
const MONGOOSE_TYPE_MAP: Record<string, {type: string; format?: string}> = {
|
|
215
|
+
dateonly: {format: "date", type: "string"},
|
|
216
|
+
schemaobjectid: {type: "string"},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Recursively replace non-standard mongoose-to-swagger types with valid JSON Schema types
|
|
221
|
+
* so AJV can compile the schema.
|
|
222
|
+
*/
|
|
223
|
+
function sanitizeSchemaForAjv(schema: Record<string, unknown>): Record<string, unknown> {
|
|
224
|
+
if (!schema || typeof schema !== "object") {
|
|
225
|
+
return schema;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const result = {...schema};
|
|
229
|
+
|
|
230
|
+
if (typeof result.type === "string" && !VALID_JSON_SCHEMA_TYPES.has(result.type)) {
|
|
231
|
+
const mapped = MONGOOSE_TYPE_MAP[result.type];
|
|
232
|
+
if (mapped) {
|
|
233
|
+
result.type = mapped.type;
|
|
234
|
+
if (mapped.format && !result.format) {
|
|
235
|
+
result.format = mapped.format;
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
result.type = "string";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (result.items && typeof result.items === "object") {
|
|
243
|
+
result.items = sanitizeSchemaForAjv(result.items as Record<string, unknown>);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (result.properties && typeof result.properties === "object") {
|
|
247
|
+
const sanitizedProps: Record<string, unknown> = {};
|
|
248
|
+
for (const [key, value] of Object.entries(result.properties as Record<string, unknown>)) {
|
|
249
|
+
sanitizedProps[key] =
|
|
250
|
+
typeof value === "object" && value !== null
|
|
251
|
+
? sanitizeSchemaForAjv(value as Record<string, unknown>)
|
|
252
|
+
: value;
|
|
253
|
+
}
|
|
254
|
+
result.properties = sanitizedProps;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (result.additionalProperties && typeof result.additionalProperties === "object") {
|
|
258
|
+
result.additionalProperties = sanitizeSchemaForAjv(
|
|
259
|
+
result.additionalProperties as Record<string, unknown>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Get or create a compiled validator for a schema.
|
|
268
|
+
* Uses the current config so changes take effect on next call.
|
|
269
|
+
* Sanitizes non-standard mongoose-to-swagger types before compilation.
|
|
270
|
+
* Returns null if the schema still cannot be compiled after sanitization.
|
|
271
|
+
*/
|
|
272
|
+
function getValidator(schema: OpenApiSchema): ValidateFunction | null {
|
|
273
|
+
const ajv = getAjvInstance();
|
|
274
|
+
const configKey = `coerce:${globalConfig.coerceTypes ?? true},remove:${globalConfig.removeAdditional ?? true}`;
|
|
275
|
+
const hash = `${configKey}:${hashSchema(schema)}`;
|
|
276
|
+
const cached = validatorCache.get(hash);
|
|
277
|
+
|
|
278
|
+
if (cached !== undefined) {
|
|
279
|
+
return cached;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const sanitized = sanitizeSchemaForAjv(schema);
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const validator = ajv.compile(sanitized);
|
|
286
|
+
validatorCache.set(hash, validator);
|
|
287
|
+
return validator;
|
|
288
|
+
} catch (err) {
|
|
289
|
+
logger.debug(
|
|
290
|
+
`Could not compile validation schema after sanitization: ${(err as Error).message}`
|
|
291
|
+
);
|
|
292
|
+
validatorCache.set(hash, null as unknown as ValidateFunction);
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Format AJV errors into a human-readable string.
|
|
299
|
+
*/
|
|
300
|
+
function formatValidationErrors(errors: ErrorObject[]): string {
|
|
301
|
+
return errors
|
|
302
|
+
.map((err) => {
|
|
303
|
+
const path = err.instancePath || "/";
|
|
304
|
+
const message = err.message || "validation failed";
|
|
305
|
+
return `${path}: ${message}`;
|
|
306
|
+
})
|
|
307
|
+
.join("; ");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Convert OpenApiSchemaProperty to a full OpenApiSchema suitable for AJV.
|
|
312
|
+
* Strips `required` from individual properties (OpenAPI-style) and moves it
|
|
313
|
+
* to the schema-level `required` array (JSON Schema-style) for AJV compatibility.
|
|
314
|
+
*/
|
|
315
|
+
function propertiesToSchema(
|
|
316
|
+
properties: Record<string, OpenApiSchemaProperty>,
|
|
317
|
+
requiredFields?: string[]
|
|
318
|
+
): OpenApiSchema {
|
|
319
|
+
// Extract required fields from properties that have required: true
|
|
320
|
+
const autoRequired = Object.entries(properties)
|
|
321
|
+
.filter(([_, prop]) => prop.required)
|
|
322
|
+
.map(([key]) => key);
|
|
323
|
+
|
|
324
|
+
const allRequired = [...new Set([...(requiredFields ?? []), ...autoRequired])];
|
|
325
|
+
|
|
326
|
+
// Strip `required` from individual properties — AJV only accepts `required` at schema level
|
|
327
|
+
const cleanedProperties: Record<string, OpenApiSchemaProperty> = {};
|
|
328
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
329
|
+
const {required: _, ...rest} = prop;
|
|
330
|
+
cleanedProperties[key] = rest as OpenApiSchemaProperty;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const schema: OpenApiSchema = {
|
|
334
|
+
properties: cleanedProperties,
|
|
335
|
+
required: allRequired.length > 0 ? allRequired : undefined,
|
|
336
|
+
type: "object",
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// When removeAdditional is enabled, set additionalProperties: false
|
|
340
|
+
// so AJV knows to strip unknown properties
|
|
341
|
+
if (globalConfig.removeAdditional) {
|
|
342
|
+
schema.additionalProperties = false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return schema;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Options for the request body validator middleware.
|
|
350
|
+
*/
|
|
351
|
+
export interface RequestBodyValidatorOptions {
|
|
352
|
+
/**
|
|
353
|
+
* Override the global validateRequests setting for this specific route.
|
|
354
|
+
*/
|
|
355
|
+
enabled?: boolean;
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* List of required field names.
|
|
359
|
+
*/
|
|
360
|
+
required?: string[];
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Fields to exclude from validation (e.g. fields set by preCreate hooks).
|
|
364
|
+
* Excluded fields are removed from both the schema properties and the required array.
|
|
365
|
+
*/
|
|
366
|
+
excludeFields?: string[];
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Custom error handler for this specific route.
|
|
370
|
+
*/
|
|
371
|
+
onError?: (errors: ErrorObject[], req: Request) => void;
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Callback fired when additional properties are removed.
|
|
375
|
+
* Overrides the global onAdditionalPropertiesRemoved for this route.
|
|
376
|
+
*/
|
|
377
|
+
onAdditionalPropertiesRemoved?: (removedProperties: string[], req: Request) => void;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Creates middleware that validates the request body against an OpenAPI schema.
|
|
382
|
+
*
|
|
383
|
+
* The middleware checks `isConfigured` at request time — if `configureOpenApiValidator()`
|
|
384
|
+
* has not been called, the middleware is a no-op.
|
|
385
|
+
*
|
|
386
|
+
* @param schema - The schema to validate against (same format as withRequestBody)
|
|
387
|
+
* @param options - Optional configuration for this validator
|
|
388
|
+
* @returns Express middleware function
|
|
389
|
+
*/
|
|
390
|
+
export function validateRequestBody(
|
|
391
|
+
schema: Record<string, OpenApiSchemaProperty>,
|
|
392
|
+
options?: RequestBodyValidatorOptions
|
|
393
|
+
): (req: Request, res: Response, next: NextFunction) => void {
|
|
394
|
+
const fullSchema = propertiesToSchema(schema, options?.required);
|
|
395
|
+
|
|
396
|
+
return (req: Request, _res: Response, next: NextFunction): void => {
|
|
397
|
+
// No-op if not configured
|
|
398
|
+
if (!isConfigured) {
|
|
399
|
+
next();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Check if validation is enabled (route override takes precedence)
|
|
404
|
+
const isEnabled = options?.enabled ?? globalConfig.validateRequests;
|
|
405
|
+
|
|
406
|
+
if (!isEnabled) {
|
|
407
|
+
next();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Capture keys before validation for removeAdditional detection
|
|
412
|
+
const keysBefore = req.body && typeof req.body === "object" ? Object.keys(req.body) : [];
|
|
413
|
+
|
|
414
|
+
// Get validator at request time so config changes take effect
|
|
415
|
+
const validator = getValidator(fullSchema);
|
|
416
|
+
|
|
417
|
+
// If schema couldn't be compiled (e.g., non-standard types), skip validation
|
|
418
|
+
if (!validator) {
|
|
419
|
+
next();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Clone body if we might modify it (coercion or removeAdditional)
|
|
424
|
+
const bodyToValidate: Record<string, unknown> = {...req.body};
|
|
425
|
+
|
|
426
|
+
const valid = validator(bodyToValidate);
|
|
427
|
+
|
|
428
|
+
if (!valid && validator.errors) {
|
|
429
|
+
const errors = validator.errors;
|
|
430
|
+
|
|
431
|
+
if (globalConfig.logValidationErrors) {
|
|
432
|
+
logger.warn(
|
|
433
|
+
`Request body validation failed for ${req.method} ${req.path}: ${formatValidationErrors(errors)}`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Use custom error handler if provided
|
|
438
|
+
const errorHandler = options?.onError ?? globalConfig.onValidationError;
|
|
439
|
+
if (errorHandler) {
|
|
440
|
+
errorHandler(errors, req);
|
|
441
|
+
next();
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Default: throw APIError
|
|
446
|
+
throw new APIError({
|
|
447
|
+
detail: formatValidationErrors(errors),
|
|
448
|
+
disableExternalErrorTracking: true,
|
|
449
|
+
meta: {
|
|
450
|
+
validationErrors: JSON.stringify(
|
|
451
|
+
errors.map((e) => ({
|
|
452
|
+
message: e.message,
|
|
453
|
+
params: e.params,
|
|
454
|
+
path: e.instancePath,
|
|
455
|
+
}))
|
|
456
|
+
),
|
|
457
|
+
},
|
|
458
|
+
source: {
|
|
459
|
+
pointer: "/body",
|
|
460
|
+
},
|
|
461
|
+
status: 400,
|
|
462
|
+
title: "Request validation failed",
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Update req.body with coerced/stripped values
|
|
467
|
+
if (valid) {
|
|
468
|
+
// Detect removed properties (top-level only)
|
|
469
|
+
if (globalConfig.removeAdditional) {
|
|
470
|
+
const keysAfter = Object.keys(bodyToValidate);
|
|
471
|
+
const removedProperties = keysBefore.filter((k) => !keysAfter.includes(k));
|
|
472
|
+
|
|
473
|
+
if (removedProperties.length > 0) {
|
|
474
|
+
const hook =
|
|
475
|
+
options?.onAdditionalPropertiesRemoved ?? globalConfig.onAdditionalPropertiesRemoved;
|
|
476
|
+
if (hook) {
|
|
477
|
+
hook(removedProperties, req);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (globalConfig.logValidationErrors) {
|
|
481
|
+
logger.debug(
|
|
482
|
+
`Stripped additional properties from ${req.method} ${req.path}: ${removedProperties.join(", ")}`
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
req.body = bodyToValidate;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
next();
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Options for the query parameter validator middleware.
|
|
497
|
+
*/
|
|
498
|
+
export interface QueryValidatorOptions {
|
|
499
|
+
/**
|
|
500
|
+
* Override the global validateRequests setting for this specific route.
|
|
501
|
+
*/
|
|
502
|
+
enabled?: boolean;
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Custom error handler for this specific route.
|
|
506
|
+
*/
|
|
507
|
+
onError?: (errors: ErrorObject[], req: Request) => void;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Creates middleware that validates query parameters against an OpenAPI schema.
|
|
512
|
+
*
|
|
513
|
+
* @param schema - The schema to validate against
|
|
514
|
+
* @param options - Optional configuration for this validator
|
|
515
|
+
* @returns Express middleware function
|
|
516
|
+
*/
|
|
517
|
+
export function validateQueryParams(
|
|
518
|
+
schema: Record<string, OpenApiSchemaProperty>,
|
|
519
|
+
options?: QueryValidatorOptions
|
|
520
|
+
): (req: Request, res: Response, next: NextFunction) => void {
|
|
521
|
+
const fullSchema = propertiesToSchema(schema);
|
|
522
|
+
|
|
523
|
+
return (req: Request, _res: Response, next: NextFunction): void => {
|
|
524
|
+
// No-op if not configured
|
|
525
|
+
if (!isConfigured) {
|
|
526
|
+
next();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const isEnabled = options?.enabled ?? globalConfig.validateRequests;
|
|
531
|
+
|
|
532
|
+
if (!isEnabled) {
|
|
533
|
+
next();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Get validator at request time
|
|
538
|
+
const validator = getValidator(fullSchema);
|
|
539
|
+
|
|
540
|
+
// If schema couldn't be compiled, skip validation
|
|
541
|
+
if (!validator) {
|
|
542
|
+
next();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const queryToValidate = globalConfig.coerceTypes ? {...req.query} : req.query;
|
|
547
|
+
const valid = validator(queryToValidate);
|
|
548
|
+
|
|
549
|
+
if (!valid && validator.errors) {
|
|
550
|
+
const errors = validator.errors;
|
|
551
|
+
|
|
552
|
+
if (globalConfig.logValidationErrors) {
|
|
553
|
+
logger.warn(
|
|
554
|
+
`Query parameter validation failed for ${req.method} ${req.path}: ${formatValidationErrors(errors)}`
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const errorHandler = options?.onError ?? globalConfig.onValidationError;
|
|
559
|
+
if (errorHandler) {
|
|
560
|
+
errorHandler(errors, req);
|
|
561
|
+
next();
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
throw new APIError({
|
|
566
|
+
detail: formatValidationErrors(errors),
|
|
567
|
+
disableExternalErrorTracking: true,
|
|
568
|
+
meta: {
|
|
569
|
+
validationErrors: JSON.stringify(
|
|
570
|
+
errors.map((e) => ({
|
|
571
|
+
message: e.message,
|
|
572
|
+
params: e.params,
|
|
573
|
+
path: e.instancePath,
|
|
574
|
+
}))
|
|
575
|
+
),
|
|
576
|
+
},
|
|
577
|
+
source: {
|
|
578
|
+
parameter: errors[0]?.instancePath?.replace("/", "") || "unknown",
|
|
579
|
+
},
|
|
580
|
+
status: 400,
|
|
581
|
+
title: "Query parameter validation failed",
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (globalConfig.coerceTypes && valid) {
|
|
586
|
+
// Note: req.query is read-only in some Express versions,
|
|
587
|
+
// so we may need to work around this
|
|
588
|
+
Object.assign(req.query, queryToValidate);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
next();
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Options for creating a combined validation middleware.
|
|
597
|
+
*/
|
|
598
|
+
export interface CreateValidatorOptions {
|
|
599
|
+
/**
|
|
600
|
+
* Schema for request body validation.
|
|
601
|
+
*/
|
|
602
|
+
body?: Record<string, OpenApiSchemaProperty>;
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Schema for query parameter validation.
|
|
606
|
+
*/
|
|
607
|
+
query?: Record<string, OpenApiSchemaProperty>;
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Override the global validation enabled setting.
|
|
611
|
+
*/
|
|
612
|
+
enabled?: boolean;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Creates a combined validation middleware for both body and query parameters.
|
|
617
|
+
*
|
|
618
|
+
* @param options - Configuration for what to validate
|
|
619
|
+
* @returns Express middleware function
|
|
620
|
+
*
|
|
621
|
+
* @example
|
|
622
|
+
* ```typescript
|
|
623
|
+
* router.post("/search", [
|
|
624
|
+
* openApiMiddleware,
|
|
625
|
+
* createValidator({
|
|
626
|
+
* body: {query: {type: "string", required: true}},
|
|
627
|
+
* query: {limit: {type: "number"}},
|
|
628
|
+
* }),
|
|
629
|
+
* ], handler);
|
|
630
|
+
* ```
|
|
631
|
+
*/
|
|
632
|
+
export function createValidator(
|
|
633
|
+
options: CreateValidatorOptions
|
|
634
|
+
): (req: Request, res: Response, next: NextFunction) => void {
|
|
635
|
+
const bodyValidator = options.body
|
|
636
|
+
? validateRequestBody(options.body, {enabled: options.enabled})
|
|
637
|
+
: null;
|
|
638
|
+
|
|
639
|
+
const queryValidator = options.query
|
|
640
|
+
? validateQueryParams(options.query, {enabled: options.enabled})
|
|
641
|
+
: null;
|
|
642
|
+
|
|
643
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
644
|
+
// Run body validation first
|
|
645
|
+
if (bodyValidator) {
|
|
646
|
+
bodyValidator(req, res, ((err?: any) => {
|
|
647
|
+
if (err) {
|
|
648
|
+
next(err);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Then run query validation
|
|
653
|
+
if (queryValidator) {
|
|
654
|
+
queryValidator(req, res, next);
|
|
655
|
+
} else {
|
|
656
|
+
next();
|
|
657
|
+
}
|
|
658
|
+
}) as NextFunction);
|
|
659
|
+
} else if (queryValidator) {
|
|
660
|
+
queryValidator(req, res, next);
|
|
661
|
+
} else {
|
|
662
|
+
next();
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Validates response data against a schema.
|
|
669
|
+
* This is primarily for development/testing to ensure responses match documentation.
|
|
670
|
+
*
|
|
671
|
+
* @param data - The response data to validate
|
|
672
|
+
* @param schema - The expected schema
|
|
673
|
+
* @returns Object with valid flag and any errors
|
|
674
|
+
*/
|
|
675
|
+
export function validateResponseData(
|
|
676
|
+
data: unknown,
|
|
677
|
+
schema: Record<string, OpenApiSchemaProperty>
|
|
678
|
+
): {valid: boolean; errors?: ErrorObject[]} {
|
|
679
|
+
if (!globalConfig.validateResponses) {
|
|
680
|
+
return {valid: true};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const fullSchema = propertiesToSchema(schema);
|
|
684
|
+
const validator = getValidator(fullSchema);
|
|
685
|
+
|
|
686
|
+
if (!validator) {
|
|
687
|
+
return {valid: true};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const valid = validator(data);
|
|
691
|
+
|
|
692
|
+
if (!valid && validator.errors) {
|
|
693
|
+
if (globalConfig.logValidationErrors) {
|
|
694
|
+
logger.warn(`Response validation failed: ${formatValidationErrors(validator.errors)}`);
|
|
695
|
+
}
|
|
696
|
+
return {errors: validator.errors, valid: false};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return {valid: true};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const m2sOptions = {
|
|
703
|
+
props: ["readOnly", "required", "enum", "default"],
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Extract an OpenAPI-compatible schema from a Mongoose model.
|
|
708
|
+
* This allows you to use the same schema definitions for both documentation
|
|
709
|
+
* and runtime validation.
|
|
710
|
+
*
|
|
711
|
+
* @param model - A Mongoose model
|
|
712
|
+
* @returns Schema properties suitable for validation
|
|
713
|
+
*/
|
|
714
|
+
export function getSchemaFromModel<T>(model: Model<T>): Record<string, OpenApiSchemaProperty> {
|
|
715
|
+
const modelSwagger = m2s(model, m2sOptions);
|
|
716
|
+
return modelSwagger.properties as Record<string, OpenApiSchemaProperty>;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Extract required field names from a Mongoose model's swagger schema.
|
|
721
|
+
*/
|
|
722
|
+
function getRequiredFieldsFromModel<T>(model: Model<T>): string[] {
|
|
723
|
+
const modelSwagger = m2s(model, m2sOptions);
|
|
724
|
+
return (modelSwagger.required as string[]) ?? [];
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Creates a request body validator middleware from a Mongoose model.
|
|
729
|
+
* This is a convenience function that combines getSchemaFromModel and validateRequestBody.
|
|
730
|
+
*
|
|
731
|
+
* @param model - A Mongoose model to derive the schema from
|
|
732
|
+
* @param options - Optional configuration for the validator
|
|
733
|
+
* @returns Express middleware function
|
|
734
|
+
*/
|
|
735
|
+
export function validateModelRequestBody<T>(
|
|
736
|
+
model: Model<T>,
|
|
737
|
+
options?: RequestBodyValidatorOptions
|
|
738
|
+
): (req: Request, res: Response, next: NextFunction) => void {
|
|
739
|
+
let schema = getSchemaFromModel(model);
|
|
740
|
+
let requiredFields = getRequiredFieldsFromModel(model);
|
|
741
|
+
|
|
742
|
+
if (options?.excludeFields?.length) {
|
|
743
|
+
const excluded = new Set(options.excludeFields);
|
|
744
|
+
schema = Object.fromEntries(Object.entries(schema).filter(([key]) => !excluded.has(key)));
|
|
745
|
+
requiredFields = requiredFields.filter((f) => !excluded.has(f));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return validateRequestBody(schema, {
|
|
749
|
+
...options,
|
|
750
|
+
required: [...(options?.required ?? []), ...requiredFields],
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Options for creating validation middleware for a modelRouter.
|
|
756
|
+
*/
|
|
757
|
+
export interface ModelRouterValidationOptions {
|
|
758
|
+
/**
|
|
759
|
+
* Enable validation for create (POST) requests.
|
|
760
|
+
* Default: true (when validation is globally enabled)
|
|
761
|
+
*/
|
|
762
|
+
validateCreate?: boolean;
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Enable validation for update (PATCH) requests.
|
|
766
|
+
* Default: true (when validation is globally enabled)
|
|
767
|
+
*/
|
|
768
|
+
validateUpdate?: boolean;
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Enable validation for query (GET list) requests.
|
|
772
|
+
* Default: true (when validation is globally enabled)
|
|
773
|
+
*/
|
|
774
|
+
validateQuery?: boolean;
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Fields to exclude from create validation (e.g. fields injected by preCreate).
|
|
778
|
+
*/
|
|
779
|
+
excludeFromCreate?: string[];
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Fields to exclude from update validation (e.g. fields injected by preUpdate).
|
|
783
|
+
*/
|
|
784
|
+
excludeFromUpdate?: string[];
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Custom error handler for validation failures.
|
|
788
|
+
*/
|
|
789
|
+
onError?: (errors: ErrorObject[], req: Request) => void;
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Callback fired when additional properties are removed from a request body.
|
|
793
|
+
* Overrides the global onAdditionalPropertiesRemoved for this router.
|
|
794
|
+
*/
|
|
795
|
+
onAdditionalPropertiesRemoved?: (removedProperties: string[], req: Request) => void;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Creates validation middleware for use with modelRouter.
|
|
800
|
+
* Returns an object with middleware for each operation type.
|
|
801
|
+
*
|
|
802
|
+
* @param model - The Mongoose model
|
|
803
|
+
* @param options - Configuration options
|
|
804
|
+
* @returns Object with create and update validation middleware
|
|
805
|
+
*/
|
|
806
|
+
export function createModelValidators<T>(
|
|
807
|
+
model: Model<T>,
|
|
808
|
+
options?: ModelRouterValidationOptions
|
|
809
|
+
): {
|
|
810
|
+
create: (req: Request, res: Response, next: NextFunction) => void;
|
|
811
|
+
update: (req: Request, res: Response, next: NextFunction) => void;
|
|
812
|
+
} {
|
|
813
|
+
const schema = getSchemaFromModel(model);
|
|
814
|
+
|
|
815
|
+
return {
|
|
816
|
+
create: validateRequestBody(schema, {
|
|
817
|
+
enabled: options?.validateCreate,
|
|
818
|
+
onAdditionalPropertiesRemoved: options?.onAdditionalPropertiesRemoved,
|
|
819
|
+
onError: options?.onError,
|
|
820
|
+
}),
|
|
821
|
+
update: validateRequestBody(schema, {
|
|
822
|
+
enabled: options?.validateUpdate,
|
|
823
|
+
onAdditionalPropertiesRemoved: options?.onAdditionalPropertiesRemoved,
|
|
824
|
+
onError: options?.onError,
|
|
825
|
+
}),
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Build a query parameter schema from a model's Mongoose schema and queryFields array.
|
|
831
|
+
* Always includes pagination parameters (limit, page, sort).
|
|
832
|
+
*
|
|
833
|
+
* @param model - A Mongoose model
|
|
834
|
+
* @param queryFields - Array of field names allowed for querying
|
|
835
|
+
* @returns Schema properties suitable for query validation
|
|
836
|
+
*/
|
|
837
|
+
export function buildQuerySchemaFromFields<T>(
|
|
838
|
+
model: Model<T>,
|
|
839
|
+
queryFields: string[] = []
|
|
840
|
+
): Record<string, OpenApiSchemaProperty> {
|
|
841
|
+
const modelSchema = getSchemaFromModel(model);
|
|
842
|
+
const querySchema: Record<string, OpenApiSchemaProperty> = {
|
|
843
|
+
limit: {type: "number"},
|
|
844
|
+
page: {type: "number"},
|
|
845
|
+
sort: {type: "string"},
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
for (const field of queryFields) {
|
|
849
|
+
const modelField = modelSchema[field];
|
|
850
|
+
if (modelField) {
|
|
851
|
+
// Use the model's type info, but mark as not required for queries
|
|
852
|
+
querySchema[field] = {...modelField, required: false};
|
|
853
|
+
} else {
|
|
854
|
+
// Field not in model schema — allow as string
|
|
855
|
+
querySchema[field] = {type: "string"};
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
return querySchema;
|
|
860
|
+
}
|