@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.
Files changed (48) hide show
  1. package/.claude/CLAUDE.local.md +204 -0
  2. package/.cursor/rules/00-root.mdc +338 -0
  3. package/.github/copilot-instructions.md +333 -0
  4. package/AGENTS.md +23 -3
  5. package/README.md +73 -3
  6. package/dist/api.d.ts +68 -1
  7. package/dist/api.js +139 -4
  8. package/dist/api.test.js +906 -2
  9. package/dist/auth.js +3 -1
  10. package/dist/errors.js +14 -11
  11. package/dist/example.js +7 -7
  12. package/dist/githubAuth.test.js +3 -3
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.js +2 -0
  15. package/dist/openApi.test.js +8 -5
  16. package/dist/openApiBuilder.d.ts +69 -1
  17. package/dist/openApiBuilder.js +109 -5
  18. package/dist/openApiValidator.d.ts +296 -0
  19. package/dist/openApiValidator.js +698 -0
  20. package/dist/openApiValidator.test.d.ts +1 -0
  21. package/dist/openApiValidator.test.js +346 -0
  22. package/dist/plugins.test.js +3 -3
  23. package/dist/terrenoPlugin.d.ts +4 -0
  24. package/dist/terrenoPlugin.js +2 -0
  25. package/dist/tests.js +34 -24
  26. package/package.json +4 -1
  27. package/src/__snapshots__/openApi.test.ts.snap +399 -0
  28. package/src/__snapshots__/openApiBuilder.test.ts.snap +108 -0
  29. package/src/api.test.ts +743 -2
  30. package/src/api.ts +209 -3
  31. package/src/auth.ts +3 -1
  32. package/src/errors.ts +14 -11
  33. package/src/example.ts +7 -7
  34. package/src/githubAuth.test.ts +3 -3
  35. package/src/index.ts +2 -0
  36. package/src/openApi.test.ts +8 -5
  37. package/src/openApiBuilder.ts +188 -15
  38. package/src/openApiValidator.test.ts +241 -0
  39. package/src/openApiValidator.ts +860 -0
  40. package/src/plugins.test.ts +3 -3
  41. package/src/terrenoPlugin.ts +5 -0
  42. package/src/tests.ts +34 -24
  43. package/.cursorrules +0 -107
  44. package/.windsurfrules +0 -107
  45. package/dist/response.d.ts +0 -0
  46. package/dist/response.js +0 -1
  47. package/index.ts +0 -1
  48. package/src/response.ts +0 -0
package/src/api.ts CHANGED
@@ -18,6 +18,12 @@ import {
18
18
  listOpenApiMiddleware,
19
19
  patchOpenApiMiddleware,
20
20
  } from "./openApi";
21
+ import {
22
+ buildQuerySchemaFromFields,
23
+ type ModelRouterValidationOptions,
24
+ validateModelRequestBody,
25
+ validateQueryParams,
26
+ } from "./openApiValidator";
21
27
  import {checkPermissions, permissionMiddleware, type RESTPermissions} from "./permissions";
22
28
  import type {PopulatePath} from "./populate";
23
29
  import {
@@ -263,6 +269,19 @@ export interface ModelRouterOptions<T> {
263
269
  * that you want to be documented and typed in the SDK.
264
270
  */
265
271
  openApiExtraModelProperties?: any;
272
+ /**
273
+ * Enable runtime validation of request bodies against the OpenAPI schema.
274
+ * When enabled, requests that don't match the documented schema will return 400 errors.
275
+ *
276
+ * Can be set to:
277
+ * - `true`: Enable validation for create and update operations
278
+ * - `false`: Disable validation (default)
279
+ * - Object with `validateCreate` and `validateUpdate` booleans for fine-grained control
280
+ *
281
+ * Note: Global validation can be enabled via `configureOpenApiValidator()`.
282
+ * This option overrides the global setting for this specific router.
283
+ */
284
+ validation?: boolean | ModelRouterValidationOptions;
266
285
  }
267
286
 
268
287
  // Ensures query params are allowed. Also checks nested query params when using $and/$or.
@@ -309,6 +328,78 @@ function checkQueryParamAllowed(
309
328
  // return result;
310
329
  // }
311
330
 
331
+ // Helper to determine if validation should be enabled for a specific operation.
332
+ // When options.validation is not set, returns true — the middleware's own
333
+ // isConfigured check will decide whether to actually validate.
334
+ function shouldValidate(
335
+ options: ModelRouterOptions<any>,
336
+ operation: "create" | "update" | "query"
337
+ ): boolean {
338
+ // Check route-specific validation option first
339
+ if (options.validation !== undefined) {
340
+ if (typeof options.validation === "boolean") {
341
+ return options.validation;
342
+ }
343
+ if (operation === "create") {
344
+ return options.validation.validateCreate ?? true;
345
+ }
346
+ if (operation === "update") {
347
+ return options.validation.validateUpdate ?? true;
348
+ }
349
+ return options.validation.validateQuery ?? true;
350
+ }
351
+
352
+ // Default: let middleware's isConfigured check decide
353
+ return true;
354
+ }
355
+
356
+ // Get body validation middleware if validation is enabled
357
+ function getBodyValidationMiddleware<T>(
358
+ model: Model<T>,
359
+ options: ModelRouterOptions<T>,
360
+ operation: "create" | "update"
361
+ ): (req: Request, res: Response, next: NextFunction) => void {
362
+ const validationOptions: import("./openApiValidator").RequestBodyValidatorOptions = {};
363
+ if (!shouldValidate(options, operation)) {
364
+ validationOptions.enabled = false;
365
+ }
366
+ if (typeof options.validation === "object") {
367
+ if (options.validation.onError) {
368
+ validationOptions.onError = options.validation.onError;
369
+ }
370
+ if (options.validation.onAdditionalPropertiesRemoved) {
371
+ validationOptions.onAdditionalPropertiesRemoved =
372
+ options.validation.onAdditionalPropertiesRemoved;
373
+ }
374
+ const excludeFields =
375
+ operation === "create"
376
+ ? options.validation.excludeFromCreate
377
+ : options.validation.excludeFromUpdate;
378
+ if (excludeFields?.length) {
379
+ validationOptions.excludeFields = excludeFields;
380
+ }
381
+ }
382
+
383
+ return validateModelRequestBody(model, validationOptions);
384
+ }
385
+
386
+ // Get query validation middleware if validation is enabled
387
+ function getQueryValidationMiddleware<T>(
388
+ model: Model<T>,
389
+ options: ModelRouterOptions<T>
390
+ ): (req: Request, res: Response, next: NextFunction) => void {
391
+ const querySchema = buildQuerySchemaFromFields(model, options.queryFields);
392
+ const validationOptions: import("./openApiValidator").QueryValidatorOptions = {};
393
+ if (!shouldValidate(options, "query")) {
394
+ validationOptions.enabled = false;
395
+ }
396
+ if (typeof options.validation === "object" && options.validation.onError) {
397
+ validationOptions.onError = options.validation.onError;
398
+ }
399
+
400
+ return validateQueryParams(querySchema, validationOptions);
401
+ }
402
+
312
403
  /**
313
404
  * Create a set of CRUD routes given a Mongoose model and configuration options.
314
405
  *
@@ -325,12 +416,18 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
325
416
 
326
417
  const responseHandler = options.responseHandler ?? defaultResponseHandler;
327
418
 
419
+ // Always install validation middleware — they are no-ops until configureOpenApiValidator() is called
420
+ const createValidation = getBodyValidationMiddleware(model, options, "create");
421
+ const updateValidation = getBodyValidationMiddleware(model, options, "update");
422
+ const queryValidation = getQueryValidationMiddleware(model, options);
423
+
328
424
  router.post(
329
425
  "/",
330
426
  [
331
427
  authenticateMiddleware(options.allowAnonymous),
332
428
  createOpenApiMiddleware(model, options),
333
429
  permissionMiddleware(model, options),
430
+ createValidation,
334
431
  ],
335
432
  asyncHandler(async (req: Request, res: Response) => {
336
433
  let body: Partial<T> | (Partial<T> | undefined)[] | null | undefined;
@@ -439,6 +536,7 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
439
536
  authenticateMiddleware(options.allowAnonymous),
440
537
  permissionMiddleware(model, options),
441
538
  listOpenApiMiddleware(model, options),
539
+ queryValidation,
442
540
  ],
443
541
  asyncHandler(async (req: Request, res: Response) => {
444
542
  let query: any = {};
@@ -625,6 +723,7 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
625
723
  authenticateMiddleware(options.allowAnonymous),
626
724
  patchOpenApiMiddleware(model, options),
627
725
  permissionMiddleware(model, options),
726
+ updateValidation,
628
727
  ],
629
728
  asyncHandler(async (req: Request, res: Response) => {
630
729
  let doc: mongoose.Document & T = (req as any).obj;
@@ -984,9 +1083,116 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
984
1083
  return router;
985
1084
  }
986
1085
 
987
- // Since express doesn't handle async routes well, wrap them with this function.
988
- export const asyncHandler = (fn: any) => (req: Request, res: Response, next: NextFunction) => {
989
- return Promise.resolve(fn(req, res, next)).catch(next);
1086
+ /**
1087
+ * Options for the asyncHandler function.
1088
+ */
1089
+ export interface AsyncHandlerOptions {
1090
+ /**
1091
+ * Schema for validating request body.
1092
+ * When provided and validation is enabled, the request body will be validated
1093
+ * against this schema before the handler runs.
1094
+ */
1095
+ bodySchema?: Record<string, import("./openApiBuilder").OpenApiSchemaProperty>;
1096
+
1097
+ /**
1098
+ * Schema for validating query parameters.
1099
+ * When provided and validation is enabled, query params will be validated
1100
+ * against this schema before the handler runs.
1101
+ */
1102
+ querySchema?: Record<string, import("./openApiBuilder").OpenApiSchemaProperty>;
1103
+
1104
+ /**
1105
+ * Override global validation setting for this handler.
1106
+ * - `true`: Enable validation regardless of global setting
1107
+ * - `false`: Disable validation regardless of global setting
1108
+ * - `undefined`: Use global setting
1109
+ */
1110
+ validate?: boolean;
1111
+ }
1112
+
1113
+ /**
1114
+ * Wraps async route handlers to properly catch and forward errors.
1115
+ *
1116
+ * Since Express doesn't handle async routes well, wrap them with this function.
1117
+ * Optionally supports integrated request validation.
1118
+ *
1119
+ * @param fn - The async route handler function
1120
+ * @param options - Optional configuration for validation
1121
+ * @returns Express middleware function
1122
+ *
1123
+ * @example
1124
+ * ```typescript
1125
+ * // Basic usage without validation
1126
+ * router.post("/users", asyncHandler(async (req, res) => {
1127
+ * // handler code
1128
+ * }));
1129
+ *
1130
+ * // With integrated validation
1131
+ * router.post("/users", asyncHandler(async (req, res) => {
1132
+ * // handler code - body is already validated
1133
+ * }, {
1134
+ * bodySchema: {
1135
+ * name: {type: "string", required: true},
1136
+ * email: {type: "string", format: "email", required: true},
1137
+ * },
1138
+ * validate: true,
1139
+ * }));
1140
+ * ```
1141
+ */
1142
+ export const asyncHandler = (fn: any, options?: AsyncHandlerOptions) => {
1143
+ // If no validation options, return simple handler
1144
+ if (!options?.bodySchema && !options?.querySchema) {
1145
+ return (req: Request, res: Response, next: NextFunction) => {
1146
+ return Promise.resolve(fn(req, res, next)).catch(next);
1147
+ };
1148
+ }
1149
+
1150
+ // Import validation functions dynamically to avoid circular deps at module load
1151
+ const {
1152
+ validateRequestBody,
1153
+ validateQueryParams,
1154
+ getOpenApiValidatorConfig,
1155
+ } = require("./openApiValidator");
1156
+
1157
+ // Build validation middleware
1158
+ const validators: ((req: Request, res: Response, next: NextFunction) => void)[] = [];
1159
+
1160
+ // Determine if validation should be enabled
1161
+ const shouldValidate = options.validate ?? getOpenApiValidatorConfig().validateRequests ?? false;
1162
+
1163
+ if (shouldValidate) {
1164
+ if (options.bodySchema) {
1165
+ validators.push(validateRequestBody(options.bodySchema, {enabled: true}));
1166
+ }
1167
+ if (options.querySchema) {
1168
+ validators.push(validateQueryParams(options.querySchema, {enabled: true}));
1169
+ }
1170
+ }
1171
+
1172
+ return (req: Request, res: Response, next: NextFunction) => {
1173
+ // Run validators sequentially, then the handler
1174
+ const runValidators = (index: number): void => {
1175
+ if (index >= validators.length) {
1176
+ // All validators passed, run the actual handler
1177
+ Promise.resolve(fn(req, res, next)).catch(next);
1178
+ return;
1179
+ }
1180
+
1181
+ try {
1182
+ validators[index](req, res, (err?: any) => {
1183
+ if (err) {
1184
+ next(err);
1185
+ return;
1186
+ }
1187
+ runValidators(index + 1);
1188
+ });
1189
+ } catch (err) {
1190
+ next(err);
1191
+ }
1192
+ };
1193
+
1194
+ runValidators(0);
1195
+ };
990
1196
  };
991
1197
 
992
1198
  // For backwards compatibility with the old names.
package/src/auth.ts CHANGED
@@ -299,7 +299,9 @@ export function addAuthRoutes(
299
299
  logger.warn(`Invalid login: ${info}`);
300
300
  return res.status(401).json({message: info?.message});
301
301
  }
302
- logger.info(`User logged in: ${user._id}, type: ${(user as any).type || "N/A"}`);
302
+ if (process.env.NODE_ENV !== "test") {
303
+ logger.info(`User logged in: ${user._id}, type: ${(user as any).type || "N/A"}`);
304
+ }
303
305
  const tokens = await generateTokens(user, authOptions);
304
306
  return res.json({
305
307
  data: {refreshToken: tokens.refreshToken, token: tokens.token, userId: user?._id},
package/src/errors.ts CHANGED
@@ -136,21 +136,24 @@ export class APIError extends Error {
136
136
  // model.
137
137
  export function errorsPlugin(schema: Schema): void {
138
138
  const errorSchema = new Schema({
139
- code: String,
140
- detail: String,
141
- id: String,
139
+ code: {description: "Application-specific error code", type: String},
140
+ detail: {description: "Human-readable explanation of the error", type: String},
141
+ id: {description: "Unique identifier for this error occurrence", type: String},
142
142
  links: {
143
- about: String,
144
- type: String,
143
+ about: {description: "Link to documentation about this error", type: String},
144
+ type: {description: "Link describing the error type", type: String},
145
145
  },
146
- meta: Schema.Types.Mixed,
146
+ meta: {description: "Non-standard meta information about the error", type: Schema.Types.Mixed},
147
147
  source: {
148
- header: String,
149
- parameter: String,
150
- pointer: String,
148
+ header: {description: "HTTP header that caused the error", type: String},
149
+ parameter: {description: "Query parameter that caused the error", type: String},
150
+ pointer: {
151
+ description: "JSON pointer to the request field that caused the error",
152
+ type: String,
153
+ },
151
154
  },
152
- status: Number,
153
- title: {required: true, type: String},
155
+ status: {description: "HTTP status code for this error", type: Number},
156
+ title: {description: "Short summary of the error", required: true, type: String},
154
157
  });
155
158
 
156
159
  schema.add({apiErrors: errorSchema});
package/src/example.ts CHANGED
@@ -32,8 +32,8 @@ interface Food {
32
32
  }
33
33
 
34
34
  const userSchema = new Schema<User>({
35
- admin: {default: false, type: Boolean},
36
- username: String,
35
+ admin: {default: false, description: "Whether the user has admin privileges", type: Boolean},
36
+ username: {description: "The user's username", type: String},
37
37
  });
38
38
 
39
39
  userSchema.plugin(passportLocalMongoose as any, {usernameField: "email"});
@@ -42,11 +42,11 @@ userSchema.plugin(baseUserPlugin);
42
42
  const UserModel = model<User>("User", userSchema);
43
43
 
44
44
  const schema = new Schema<Food>({
45
- calories: Number,
46
- created: Date,
47
- hidden: {default: false, type: Boolean},
48
- name: String,
49
- ownerId: {ref: "User", type: "ObjectId"},
45
+ calories: {description: "Number of calories in the food", type: Number},
46
+ created: {description: "When this food was created", type: Date},
47
+ hidden: {default: false, description: "Whether this food is hidden from listings", type: Boolean},
48
+ name: {description: "The name of the food", type: String},
49
+ ownerId: {description: "The user who owns this food entry", ref: "User", type: "ObjectId"},
50
50
  });
51
51
 
52
52
  const FoodModel = model<Food>("Food", schema);
@@ -20,9 +20,9 @@ interface TestUser extends GitHubUserFields {
20
20
 
21
21
  // Create schema for GitHub-enabled user
22
22
  const testUserSchema = new Schema<TestUser>({
23
- admin: {default: false, type: Boolean},
24
- name: String,
25
- username: String,
23
+ admin: {default: false, description: "Whether the user has admin privileges", type: Boolean},
24
+ name: {description: "The user's display name", type: String},
25
+ username: {description: "The user's username", type: String},
26
26
  });
27
27
 
28
28
  testUserSchema.plugin(passportLocalMongoose as any, {
package/src/index.ts CHANGED
@@ -8,8 +8,10 @@ export * from "./middleware";
8
8
  export * from "./notifiers";
9
9
  export * from "./openApiBuilder";
10
10
  export * from "./openApiEtag";
11
+ export * from "./openApiValidator";
11
12
  export * from "./permissions";
12
13
  export * from "./plugins";
13
14
  export * from "./populate";
15
+ export * from "./terrenoPlugin";
14
16
  export * from "./transformers";
15
17
  export * from "./utils";
@@ -160,13 +160,13 @@ describe("openApi", () => {
160
160
  // Ensure that a Number query field supports gt/gte/lt/lte and just a Number
161
161
  expect(foodQuery.schema).toEqual({
162
162
  oneOf: [
163
- {type: "number"},
163
+ {description: "Number of calories in the food", type: "number"},
164
164
  {
165
165
  properties: {
166
- $gt: {type: "number"},
167
- $gte: {type: "number"},
168
- $lt: {type: "number"},
169
- $lte: {type: "number"},
166
+ $gt: {description: "Number of calories in the food", type: "number"},
167
+ $gte: {description: "Number of calories in the food", type: "number"},
168
+ $lt: {description: "Number of calories in the food", type: "number"},
169
+ $lte: {description: "Number of calories in the food", type: "number"},
170
170
  },
171
171
  type: "object",
172
172
  },
@@ -278,6 +278,7 @@ describe("openApi populate", () => {
278
278
  type: "string",
279
279
  },
280
280
  name: {
281
+ description: "The user's display name",
281
282
  type: "string",
282
283
  },
283
284
  },
@@ -293,12 +294,14 @@ describe("openApi populate", () => {
293
294
  });
294
295
 
295
296
  expect(properties.likesIds).toEqual({
297
+ description: "User likes for this food",
296
298
  items: {
297
299
  properties: {
298
300
  _id: {
299
301
  type: "string",
300
302
  },
301
303
  likes: {
304
+ description: "Whether the user liked the item",
302
305
  type: "boolean",
303
306
  },
304
307
  userId: {
@@ -34,6 +34,12 @@ import merge from "lodash/merge";
34
34
  import type {ModelRouterOptions} from "./api";
35
35
  import {logger} from "./logger";
36
36
  import {defaultOpenApiErrorResponses} from "./openApi";
37
+ import {
38
+ getOpenApiValidatorConfig,
39
+ isOpenApiValidatorConfigured,
40
+ validateQueryParams,
41
+ validateRequestBody,
42
+ } from "./openApiValidator";
37
43
 
38
44
  /**
39
45
  * Defines a property within an OpenAPI schema.
@@ -222,6 +228,33 @@ interface OpenApiConfig {
222
228
  responses: Record<number | string, OpenApiResponse>;
223
229
  }
224
230
 
231
+ /**
232
+ * Internal validation configuration for the builder.
233
+ */
234
+ interface ValidationConfig {
235
+ /** Whether to validate request body */
236
+ validateBody?: boolean;
237
+ /** Whether to validate query parameters */
238
+ validateQuery?: boolean;
239
+ /** Override the global validation enabled setting */
240
+ enabled?: boolean;
241
+ }
242
+
243
+ /**
244
+ * Result from building OpenAPI middleware with schemas exposed.
245
+ * Useful when you want to use the schemas with asyncHandler's validation.
246
+ */
247
+ export interface OpenApiBuildResult {
248
+ /** The OpenAPI documentation middleware */
249
+ middleware: any;
250
+ /** Request body schema if defined */
251
+ bodySchema?: Record<string, OpenApiSchemaProperty>;
252
+ /** Query parameter schemas if defined */
253
+ querySchema?: Record<string, OpenApiSchemaProperty>;
254
+ /** Whether validation was enabled on this builder */
255
+ validationEnabled: boolean;
256
+ }
257
+
225
258
  /**
226
259
  * A fluent builder for constructing OpenAPI middleware.
227
260
  *
@@ -255,6 +288,15 @@ export class OpenApiMiddlewareBuilder {
255
288
  /** Accumulated OpenAPI configuration from builder methods */
256
289
  private config: OpenApiConfig;
257
290
 
291
+ /** Validation configuration */
292
+ private validationConfig: ValidationConfig;
293
+
294
+ /** Store the raw request body schema for validation */
295
+ private requestBodySchema?: Record<string, OpenApiSchemaProperty>;
296
+
297
+ /** Store the raw query parameter schemas for validation */
298
+ private queryParamSchemas: Record<string, OpenApiSchemaProperty> = {};
299
+
258
300
  /**
259
301
  * Creates a new OpenApiMiddlewareBuilder instance.
260
302
  *
@@ -265,6 +307,7 @@ export class OpenApiMiddlewareBuilder {
265
307
  this.config = {
266
308
  responses: {},
267
309
  };
310
+ this.validationConfig = {};
268
311
  }
269
312
 
270
313
  /**
@@ -368,6 +411,10 @@ export class OpenApiMiddlewareBuilder {
368
411
  },
369
412
  required: options?.required ?? true,
370
413
  };
414
+
415
+ // Store the schema for validation
416
+ this.requestBodySchema = schema as Record<string, OpenApiSchemaProperty>;
417
+
371
418
  return this;
372
419
  }
373
420
 
@@ -515,6 +562,13 @@ export class OpenApiMiddlewareBuilder {
515
562
  required: options?.required ?? false,
516
563
  schema,
517
564
  });
565
+
566
+ // Store for validation
567
+ this.queryParamSchemas[name] = {
568
+ ...schema,
569
+ required: options?.required,
570
+ };
571
+
518
572
  return this;
519
573
  }
520
574
 
@@ -557,6 +611,90 @@ export class OpenApiMiddlewareBuilder {
557
611
  return this;
558
612
  }
559
613
 
614
+ /**
615
+ * Enables runtime validation for this route.
616
+ *
617
+ * When enabled, the built middleware will validate incoming requests
618
+ * against the documented schema before the handler runs.
619
+ *
620
+ * @param options - Optional configuration for validation
621
+ * @param options.body - Enable body validation (default: true if request body is defined)
622
+ * @param options.query - Enable query parameter validation (default: true if query params are defined)
623
+ * @param options.enabled - Override the global validation enabled setting
624
+ * @returns The builder instance for chaining
625
+ *
626
+ * @example
627
+ * ```typescript
628
+ * createOpenApiBuilder(options)
629
+ * .withRequestBody<{name: string}>({name: {type: "string", required: true}})
630
+ * .withValidation() // Enable validation
631
+ * .build();
632
+ * ```
633
+ */
634
+ withValidation(options?: {body?: boolean; query?: boolean; enabled?: boolean}): this {
635
+ this.validationConfig = {
636
+ enabled: options?.enabled ?? true,
637
+ validateBody: options?.body ?? true,
638
+ validateQuery: options?.query ?? true,
639
+ };
640
+ return this;
641
+ }
642
+
643
+ /**
644
+ * Builds and returns the OpenAPI middleware along with schemas.
645
+ *
646
+ * This method is useful when you want to use asyncHandler's integrated
647
+ * validation instead of separate validation middleware.
648
+ *
649
+ * @returns Object containing middleware and schemas
650
+ *
651
+ * @example
652
+ * ```typescript
653
+ * const {middleware, bodySchema} = createOpenApiBuilder(options)
654
+ * .withRequestBody<{name: string}>({name: {type: "string", required: true}})
655
+ * .buildWithSchemas();
656
+ *
657
+ * router.post("/users", middleware, asyncHandler(async (req, res) => {
658
+ * // handler code
659
+ * }, {bodySchema, validate: true}));
660
+ * ```
661
+ */
662
+ buildWithSchemas(): OpenApiBuildResult {
663
+ const noop = (_a: any, _b: any, next: () => void): void => next();
664
+
665
+ // Build the OpenAPI documentation middleware only (no validation middleware)
666
+ let openApiMiddleware: any = noop;
667
+ if (this.options.openApi?.path) {
668
+ openApiMiddleware = this.options.openApi.path(
669
+ merge(
670
+ {
671
+ ...this.config,
672
+ responses: {
673
+ ...this.config.responses,
674
+ ...defaultOpenApiErrorResponses,
675
+ },
676
+ },
677
+ this.options.openApiOverwrite?.get ?? {}
678
+ )
679
+ );
680
+ } else {
681
+ logger.debug("No options.openApi provided, skipping OpenApiMiddleware");
682
+ }
683
+
684
+ const globalConfig = getOpenApiValidatorConfig();
685
+ const validationEnabled =
686
+ this.validationConfig.enabled ??
687
+ (isOpenApiValidatorConfigured() && (globalConfig.validateRequests ?? false));
688
+
689
+ return {
690
+ bodySchema: this.requestBodySchema,
691
+ middleware: openApiMiddleware,
692
+ querySchema:
693
+ Object.keys(this.queryParamSchemas).length > 0 ? this.queryParamSchemas : undefined,
694
+ validationEnabled,
695
+ };
696
+ }
697
+
560
698
  /**
561
699
  * Builds and returns the OpenAPI middleware.
562
700
  *
@@ -564,10 +702,13 @@ export class OpenApiMiddlewareBuilder {
564
702
  * that integrates with the OpenAPI documentation system. If no OpenAPI
565
703
  * path is configured in options, returns a no-op middleware.
566
704
  *
705
+ * If validation was enabled via `withValidation()`, returns an array
706
+ * of middleware: [openApiDocMiddleware, validationMiddleware].
707
+ *
567
708
  * Default error responses (400, 401, 403, 404, 405) are automatically
568
709
  * merged with the configured responses.
569
710
  *
570
- * @returns Express middleware function for OpenAPI documentation
711
+ * @returns Express middleware function(s) for OpenAPI documentation and optional validation
571
712
  *
572
713
  * @example
573
714
  * ```typescript
@@ -582,23 +723,55 @@ export class OpenApiMiddlewareBuilder {
582
723
  build(): any {
583
724
  const noop = (_a: any, _b: any, next: () => void): void => next();
584
725
 
585
- if (!this.options.openApi?.path) {
726
+ // Build the OpenAPI documentation middleware
727
+ let openApiMiddleware: any = noop;
728
+ if (this.options.openApi?.path) {
729
+ openApiMiddleware = this.options.openApi.path(
730
+ merge(
731
+ {
732
+ ...this.config,
733
+ responses: {
734
+ ...this.config.responses,
735
+ ...defaultOpenApiErrorResponses,
736
+ },
737
+ },
738
+ this.options.openApiOverwrite?.get ?? {}
739
+ )
740
+ );
741
+ } else {
586
742
  logger.debug("No options.openApi provided, skipping OpenApiMiddleware");
587
- return noop;
588
743
  }
589
744
 
590
- return this.options.openApi.path(
591
- merge(
592
- {
593
- ...this.config,
594
- responses: {
595
- ...this.config.responses,
596
- ...defaultOpenApiErrorResponses,
597
- },
598
- },
599
- this.options.openApiOverwrite?.get ?? {}
600
- )
601
- );
745
+ // Check if validation should be enabled
746
+ const globalConfig = getOpenApiValidatorConfig();
747
+ const shouldValidate =
748
+ this.validationConfig.enabled ??
749
+ (isOpenApiValidatorConfigured() && (globalConfig.validateRequests ?? false));
750
+
751
+ if (!shouldValidate) {
752
+ return openApiMiddleware;
753
+ }
754
+
755
+ // Build validation middleware
756
+ const validators: any[] = [openApiMiddleware];
757
+
758
+ // Add body validation if we have a request body schema
759
+ if (this.validationConfig.validateBody && this.requestBodySchema) {
760
+ validators.push(validateRequestBody(this.requestBodySchema, {enabled: true}));
761
+ }
762
+
763
+ // Add query validation if we have query parameter schemas
764
+ if (this.validationConfig.validateQuery && Object.keys(this.queryParamSchemas).length > 0) {
765
+ validators.push(validateQueryParams(this.queryParamSchemas, {enabled: true}));
766
+ }
767
+
768
+ // If only one middleware (the openApi one), return it directly
769
+ if (validators.length === 1) {
770
+ return openApiMiddleware;
771
+ }
772
+
773
+ // Return array of middleware to be spread in route definition
774
+ return validators;
602
775
  }
603
776
  }
604
777