@terreno/api 0.0.18 → 0.2.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 (71) hide show
  1. package/README.md +73 -3
  2. package/dist/api.d.ts +96 -3
  3. package/dist/api.js +159 -11
  4. package/dist/api.test.js +906 -2
  5. package/dist/auth.js +3 -1
  6. package/dist/betterAuth.d.ts +91 -0
  7. package/dist/betterAuth.js +8 -0
  8. package/dist/betterAuth.test.d.ts +1 -0
  9. package/dist/betterAuth.test.js +181 -0
  10. package/dist/betterAuthApp.d.ts +22 -0
  11. package/dist/betterAuthApp.js +38 -0
  12. package/dist/betterAuthApp.test.d.ts +1 -0
  13. package/dist/betterAuthApp.test.js +242 -0
  14. package/dist/betterAuthSetup.d.ts +60 -0
  15. package/dist/betterAuthSetup.js +278 -0
  16. package/dist/betterAuthSetup.test.d.ts +1 -0
  17. package/dist/betterAuthSetup.test.js +684 -0
  18. package/dist/errors.js +14 -11
  19. package/dist/example.js +7 -7
  20. package/dist/expressServer.js +2 -2
  21. package/dist/githubAuth.test.js +3 -3
  22. package/dist/index.d.ts +6 -0
  23. package/dist/index.js +6 -0
  24. package/dist/openApi.test.js +8 -5
  25. package/dist/openApiBuilder.d.ts +69 -1
  26. package/dist/openApiBuilder.js +109 -5
  27. package/dist/openApiValidator.d.ts +296 -0
  28. package/dist/openApiValidator.js +698 -0
  29. package/dist/openApiValidator.test.d.ts +1 -0
  30. package/dist/openApiValidator.test.js +346 -0
  31. package/dist/plugins.test.js +3 -3
  32. package/dist/terrenoApp.d.ts +189 -0
  33. package/dist/terrenoApp.js +352 -0
  34. package/dist/terrenoApp.test.d.ts +1 -0
  35. package/dist/terrenoApp.test.js +264 -0
  36. package/dist/terrenoPlugin.d.ts +38 -0
  37. package/dist/terrenoPlugin.js +2 -0
  38. package/dist/tests.js +34 -24
  39. package/package.json +8 -2
  40. package/src/__snapshots__/openApi.test.ts.snap +399 -0
  41. package/src/__snapshots__/openApiBuilder.test.ts.snap +108 -0
  42. package/src/api.test.ts +743 -2
  43. package/src/api.ts +270 -6
  44. package/src/auth.ts +3 -1
  45. package/src/betterAuth.test.ts +160 -0
  46. package/src/betterAuth.ts +104 -0
  47. package/src/betterAuthApp.test.ts +114 -0
  48. package/src/betterAuthApp.ts +60 -0
  49. package/src/betterAuthSetup.test.ts +485 -0
  50. package/src/betterAuthSetup.ts +251 -0
  51. package/src/errors.ts +14 -11
  52. package/src/example.ts +7 -7
  53. package/src/expressServer.ts +4 -5
  54. package/src/githubAuth.test.ts +3 -3
  55. package/src/index.ts +6 -0
  56. package/src/openApi.test.ts +8 -5
  57. package/src/openApiBuilder.ts +188 -15
  58. package/src/openApiValidator.test.ts +241 -0
  59. package/src/openApiValidator.ts +860 -0
  60. package/src/plugins.test.ts +3 -3
  61. package/src/terrenoApp.test.ts +201 -0
  62. package/src/terrenoApp.ts +347 -0
  63. package/src/terrenoPlugin.ts +39 -0
  64. package/src/tests.ts +34 -24
  65. package/.cursorrules +0 -107
  66. package/.windsurfrules +0 -107
  67. package/AGENTS.md +0 -313
  68. package/dist/response.d.ts +0 -0
  69. package/dist/response.js +0 -1
  70. package/index.ts +0 -1
  71. package/src/response.ts +0 -0
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Better Auth setup and initialization for @terreno/api.
3
+ *
4
+ * This module provides functions to initialize Better Auth with MongoDB,
5
+ * create session middleware, and sync users with the application User model.
6
+ */
7
+
8
+ import {betterAuth} from "better-auth";
9
+ import {mongodbAdapter} from "better-auth/adapters/mongodb";
10
+ import {toNodeHandler} from "better-auth/node";
11
+ import type {Application, NextFunction, Request, Response} from "express";
12
+ import mongoose from "mongoose";
13
+ import type {UserModel} from "./auth";
14
+ import type {BetterAuthConfig, BetterAuthSessionData, BetterAuthUser} from "./betterAuth";
15
+ import {logger} from "./logger";
16
+
17
+ /**
18
+ * The Better Auth instance type.
19
+ */
20
+ export type BetterAuthInstance = ReturnType<typeof betterAuth>;
21
+
22
+ /**
23
+ * Options for creating a Better Auth instance.
24
+ */
25
+ export interface CreateBetterAuthOptions {
26
+ config: BetterAuthConfig;
27
+ mongoClient: any;
28
+ userModel?: UserModel;
29
+ }
30
+
31
+ /**
32
+ * Creates a Better Auth instance with MongoDB adapter.
33
+ */
34
+ export const createBetterAuth = (options: CreateBetterAuthOptions): BetterAuthInstance => {
35
+ const {config, mongoClient} = options;
36
+
37
+ const secret = config.secret || process.env.BETTER_AUTH_SECRET;
38
+ if (!secret) {
39
+ throw new Error("BETTER_AUTH_SECRET must be set in env or config.secret must be provided.");
40
+ }
41
+
42
+ const baseURL = config.baseURL || process.env.BETTER_AUTH_URL;
43
+ if (!baseURL) {
44
+ throw new Error("BETTER_AUTH_URL must be set in env or config.baseURL must be provided.");
45
+ }
46
+
47
+ const basePath = config.basePath ?? "/api/auth";
48
+
49
+ const socialProviders: Record<string, {clientId: string; clientSecret: string}> = {};
50
+
51
+ if (config.googleOAuth) {
52
+ socialProviders.google = {
53
+ clientId: config.googleOAuth.clientId,
54
+ clientSecret: config.googleOAuth.clientSecret,
55
+ };
56
+ }
57
+
58
+ if (config.appleOAuth) {
59
+ socialProviders.apple = {
60
+ clientId: config.appleOAuth.clientId,
61
+ clientSecret: config.appleOAuth.clientSecret,
62
+ };
63
+ }
64
+
65
+ if (config.githubOAuth) {
66
+ socialProviders.github = {
67
+ clientId: config.githubOAuth.clientId,
68
+ clientSecret: config.githubOAuth.clientSecret,
69
+ };
70
+ }
71
+
72
+ const auth = betterAuth({
73
+ basePath,
74
+ baseURL,
75
+ database: mongodbAdapter(mongoClient.db()),
76
+ emailAndPassword: {
77
+ enabled: true,
78
+ },
79
+ secret,
80
+ session: {
81
+ cookieCache: {
82
+ enabled: true,
83
+ maxAge: 5 * 60, // 5 minutes
84
+ },
85
+ },
86
+ socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
87
+ trustedOrigins: config.trustedOrigins ?? [],
88
+ });
89
+
90
+ return auth;
91
+ };
92
+
93
+ /**
94
+ * Creates Express middleware that extracts the Better Auth session
95
+ * and populates req.user with the application User model.
96
+ */
97
+ export const createBetterAuthSessionMiddleware = (
98
+ auth: BetterAuthInstance,
99
+ userModel?: UserModel
100
+ ) => {
101
+ return async (req: Request, _res: Response, next: NextFunction): Promise<void> => {
102
+ try {
103
+ const session = await auth.api.getSession({
104
+ headers: req.headers as Record<string, string>,
105
+ });
106
+
107
+ if (session?.user && session?.session) {
108
+ const betterAuthUser = session.user as BetterAuthUser;
109
+
110
+ if (userModel) {
111
+ // Look up the application user by betterAuthId
112
+ const appUser = await userModel.findOne({betterAuthId: betterAuthUser.id});
113
+ if (appUser) {
114
+ (req as any).user = appUser;
115
+ (req as any).betterAuthSession = session;
116
+ } else {
117
+ // User exists in Better Auth but not synced yet - create them
118
+ const newUser = await syncBetterAuthUser(userModel, betterAuthUser);
119
+ (req as any).user = newUser;
120
+ (req as any).betterAuthSession = session;
121
+ }
122
+ } else {
123
+ // No user model - just attach the Better Auth user directly
124
+ (req as any).user = {
125
+ _id: betterAuthUser.id,
126
+ admin: false,
127
+ betterAuthId: betterAuthUser.id,
128
+ email: betterAuthUser.email,
129
+ id: betterAuthUser.id,
130
+ name: betterAuthUser.name,
131
+ };
132
+ (req as any).betterAuthSession = session;
133
+ }
134
+ }
135
+
136
+ next();
137
+ } catch (error) {
138
+ logger.debug(`Better Auth session extraction error: ${error}`);
139
+ next();
140
+ }
141
+ };
142
+ };
143
+
144
+ /**
145
+ * Syncs a Better Auth user to the application User model.
146
+ * Creates or updates the user as needed.
147
+ */
148
+ export const syncBetterAuthUser = async (
149
+ userModel: UserModel,
150
+ betterAuthUser: BetterAuthUser,
151
+ oauthProvider?: string
152
+ ): Promise<any> => {
153
+ try {
154
+ const existingUser: any = await userModel.findOne({betterAuthId: betterAuthUser.id});
155
+
156
+ if (existingUser) {
157
+ // Update existing user if needed
158
+ existingUser.email = betterAuthUser.email;
159
+ if (betterAuthUser.name) {
160
+ existingUser.name = betterAuthUser.name;
161
+ }
162
+ await existingUser.save();
163
+ return existingUser;
164
+ }
165
+
166
+ // Check if user exists by email (migration case)
167
+ const userByEmail: any = await userModel.findOne({email: betterAuthUser.email});
168
+ if (userByEmail) {
169
+ // Link existing user to Better Auth
170
+ userByEmail.betterAuthId = betterAuthUser.id;
171
+ if (oauthProvider) {
172
+ userByEmail.oauthProvider = oauthProvider;
173
+ }
174
+ await userByEmail.save();
175
+ return userByEmail;
176
+ }
177
+
178
+ // Create new user
179
+ const newUser: any = new (userModel as any)({
180
+ admin: false,
181
+ betterAuthId: betterAuthUser.id,
182
+ email: betterAuthUser.email,
183
+ name: betterAuthUser.name || betterAuthUser.email.split("@")[0],
184
+ oauthProvider: oauthProvider || null,
185
+ });
186
+ await newUser.save();
187
+ logger.info(`Created new user from Better Auth: ${newUser.id}`);
188
+ return newUser;
189
+ } catch (error) {
190
+ logger.error(`Error syncing Better Auth user: ${error}`);
191
+ throw error;
192
+ }
193
+ };
194
+
195
+ /**
196
+ * Mounts Better Auth routes on the Express app.
197
+ */
198
+ export const mountBetterAuthRoutes = (
199
+ app: Application,
200
+ auth: BetterAuthInstance,
201
+ basePath = "/api/auth"
202
+ ): void => {
203
+ const handler = toNodeHandler(auth);
204
+
205
+ // Mount at the base path with wildcard
206
+ app.all(`${basePath}/*`, (req, res) => {
207
+ return handler(req, res);
208
+ });
209
+
210
+ logger.info(`Better Auth routes mounted at ${basePath}/*`);
211
+ };
212
+
213
+ /**
214
+ * Gets the MongoDB client from the mongoose connection.
215
+ */
216
+ export const getMongoClientFromMongoose = (): any => {
217
+ const connection = mongoose.connection;
218
+ const client = (connection as any).client;
219
+ if (!client) {
220
+ throw new Error("Mongoose is not connected. Ensure MongoDB connection is established first.");
221
+ }
222
+ return client;
223
+ };
224
+
225
+ /**
226
+ * Sets up Better Auth user sync hooks.
227
+ * This ensures users created/updated in Better Auth are synced to the application User model.
228
+ *
229
+ * Note: Better Auth doesn't have built-in event hooks, so we rely on the session middleware
230
+ * to create users on first session access.
231
+ */
232
+ export const setupBetterAuthUserSync = (_auth: BetterAuthInstance, _userModel: UserModel): void => {
233
+ // Better Auth v1.x doesn't expose event hooks for user creation.
234
+ // User sync is handled in createBetterAuthSessionMiddleware when a session is accessed.
235
+ // This function is a placeholder for future versions that may support hooks.
236
+ logger.debug("Better Auth user sync configured (via session middleware)");
237
+ };
238
+
239
+ /**
240
+ * Extracts Better Auth session data from the request.
241
+ */
242
+ export const getBetterAuthSession = (req: Request): BetterAuthSessionData | null => {
243
+ return (req as any).betterAuthSession ?? null;
244
+ };
245
+
246
+ /**
247
+ * Checks if the request has a valid Better Auth session.
248
+ */
249
+ export const hasBetterAuthSession = (req: Request): boolean => {
250
+ return Boolean((req as any).betterAuthSession);
251
+ };
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);
@@ -177,7 +177,7 @@ function initializeRoutes(
177
177
  UserModel: UserMongooseModel,
178
178
  addRoutes: AddRoutes,
179
179
  options: InitializeRoutesOptions = {}
180
- ) {
180
+ ): express.Application {
181
181
  const app = express();
182
182
 
183
183
  // TODO: Log a warning when we hit the array limit.
@@ -197,7 +197,6 @@ function initializeRoutes(
197
197
 
198
198
  // Add login/signup/refresh_token before the JWT/auth middlewares
199
199
  addAuthRoutes(app, UserModel as any, options?.authOptions);
200
-
201
200
  setupAuth(app as any, UserModel as any);
202
201
 
203
202
  if (options.logRequests !== false) {
@@ -245,7 +244,7 @@ function initializeRoutes(
245
244
 
246
245
  addMeRoutes(app, UserModel as any, options?.authOptions);
247
246
 
248
- // Set up GitHub OAuth if configured
247
+ // Set up GitHub OAuth if configured (works with JWT auth)
249
248
  if (options.githubAuth) {
250
249
  setupGitHubAuth(app, UserModel as any, options.githubAuth);
251
250
  addGitHubAuthRoutes(app, UserModel as any, options.githubAuth, options.authOptions);
@@ -297,8 +296,8 @@ export interface SetupServerOptions {
297
296
  sentryOptions?: Sentry.BunOptions;
298
297
  }
299
298
 
300
- // Sets up the routes and returns a function to launch the API.
301
- export function setupServer(options: SetupServerOptions) {
299
+ // Sets up the routes and returns the app.
300
+ export function setupServer(options: SetupServerOptions): express.Application {
302
301
  const UserModel = options.userModel;
303
302
  const addRoutes = options.addRoutes;
304
303
 
@@ -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
@@ -1,5 +1,8 @@
1
1
  export * from "./api";
2
2
  export * from "./auth";
3
+ export * from "./betterAuth";
4
+ export * from "./betterAuthApp";
5
+ export * from "./betterAuthSetup";
3
6
  export * from "./errors";
4
7
  export * from "./expressServer";
5
8
  export * from "./githubAuth";
@@ -8,8 +11,11 @@ export * from "./middleware";
8
11
  export * from "./notifiers";
9
12
  export * from "./openApiBuilder";
10
13
  export * from "./openApiEtag";
14
+ export * from "./openApiValidator";
11
15
  export * from "./permissions";
12
16
  export * from "./plugins";
13
17
  export * from "./populate";
18
+ export * from "./terrenoApp";
19
+ export * from "./terrenoPlugin";
14
20
  export * from "./transformers";
15
21
  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