@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.
- package/README.md +73 -3
- package/dist/api.d.ts +96 -3
- package/dist/api.js +159 -11
- package/dist/api.test.js +906 -2
- package/dist/auth.js +3 -1
- package/dist/betterAuth.d.ts +91 -0
- package/dist/betterAuth.js +8 -0
- package/dist/betterAuth.test.d.ts +1 -0
- package/dist/betterAuth.test.js +181 -0
- package/dist/betterAuthApp.d.ts +22 -0
- package/dist/betterAuthApp.js +38 -0
- package/dist/betterAuthApp.test.d.ts +1 -0
- package/dist/betterAuthApp.test.js +242 -0
- package/dist/betterAuthSetup.d.ts +60 -0
- package/dist/betterAuthSetup.js +278 -0
- package/dist/betterAuthSetup.test.d.ts +1 -0
- package/dist/betterAuthSetup.test.js +684 -0
- package/dist/errors.js +14 -11
- package/dist/example.js +7 -7
- package/dist/expressServer.js +2 -2
- package/dist/githubAuth.test.js +3 -3
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -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/terrenoApp.d.ts +189 -0
- package/dist/terrenoApp.js +352 -0
- package/dist/terrenoApp.test.d.ts +1 -0
- package/dist/terrenoApp.test.js +264 -0
- package/dist/terrenoPlugin.d.ts +38 -0
- package/dist/terrenoPlugin.js +2 -0
- package/dist/tests.js +34 -24
- package/package.json +8 -2
- 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 +270 -6
- package/src/auth.ts +3 -1
- package/src/betterAuth.test.ts +160 -0
- package/src/betterAuth.ts +104 -0
- package/src/betterAuthApp.test.ts +114 -0
- package/src/betterAuthApp.ts +60 -0
- package/src/betterAuthSetup.test.ts +485 -0
- package/src/betterAuthSetup.ts +251 -0
- package/src/errors.ts +14 -11
- package/src/example.ts +7 -7
- package/src/expressServer.ts +4 -5
- package/src/githubAuth.test.ts +3 -3
- package/src/index.ts +6 -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/terrenoApp.test.ts +201 -0
- package/src/terrenoApp.ts +347 -0
- package/src/terrenoPlugin.ts +39 -0
- package/src/tests.ts +34 -24
- package/.cursorrules +0 -107
- package/.windsurfrules +0 -107
- package/AGENTS.md +0 -313
- 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,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:
|
|
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);
|
package/src/expressServer.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/githubAuth.test.ts
CHANGED
|
@@ -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";
|
package/src/openApi.test.ts
CHANGED
|
@@ -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: {
|
package/src/openApiBuilder.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
|