@terreno/api 0.20.2 → 0.21.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/.ai/guidelines/core.md +71 -0
- package/.ai/skills/mongoose-schema-safety/SKILL.md +143 -0
- package/README.md +54 -1
- package/dist/__tests__/versionCheckPlugin.test.js +29 -7
- package/dist/actions.openApi.test.js +13 -11
- package/dist/api.js +98 -11
- package/dist/api.query.test.js +31 -1
- package/dist/api.test.js +211 -0
- package/dist/auth.test.js +10 -10
- package/dist/betterAuth.d.ts +1 -1
- package/dist/consentApp.test.js +1 -0
- package/dist/example.js +4 -4
- package/dist/expressServer.d.ts +0 -22
- package/dist/expressServer.js +1 -125
- package/dist/expressServer.test.js +90 -91
- package/dist/githubAuth.test.js +22 -22
- package/dist/logger.d.ts +154 -0
- package/dist/logger.js +445 -26
- package/dist/logger.test.js +435 -0
- package/dist/middleware.d.ts +7 -0
- package/dist/middleware.js +58 -1
- package/dist/middleware.test.js +159 -0
- package/dist/openApi.test.js +10 -17
- package/dist/openApiBuilder.test.js +18 -10
- package/dist/realtime/changeStreamWatcher.d.ts +4 -4
- package/dist/realtime/changeStreamWatcher.js +2 -4
- package/dist/realtime/queryMatcher.d.ts +1 -1
- package/dist/realtime/queryMatcher.js +39 -14
- package/dist/realtime/types.d.ts +3 -3
- package/dist/requestContext.d.ts +61 -0
- package/dist/requestContext.js +74 -0
- package/dist/secretProviders.test.js +335 -0
- package/dist/terrenoApp.d.ts +27 -15
- package/dist/terrenoApp.js +24 -14
- package/dist/terrenoApp.test.js +52 -0
- package/dist/tests/bunSetup.js +61 -7
- package/dist/tests.js +27 -4
- package/package.json +1 -1
- package/src/__tests__/versionCheckPlugin.test.ts +43 -15
- package/src/actions.openApi.test.ts +12 -10
- package/src/api.query.test.ts +24 -1
- package/src/api.test.ts +169 -0
- package/src/api.ts +71 -0
- package/src/auth.test.ts +10 -10
- package/src/betterAuth.ts +1 -1
- package/src/consentApp.test.ts +1 -0
- package/src/example.ts +4 -4
- package/src/expressServer.test.ts +82 -85
- package/src/expressServer.ts +1 -213
- package/src/githubAuth.test.ts +22 -22
- package/src/logger.test.ts +466 -1
- package/src/logger.ts +477 -14
- package/src/middleware.test.ts +74 -2
- package/src/middleware.ts +57 -0
- package/src/openApi.test.ts +10 -17
- package/src/openApiBuilder.test.ts +18 -10
- package/src/realtime/changeStreamWatcher.ts +15 -10
- package/src/realtime/queryMatcher.ts +54 -27
- package/src/realtime/types.ts +4 -4
- package/src/requestContext.ts +86 -0
- package/src/secretProviders.test.ts +219 -1
- package/src/terrenoApp.test.ts +38 -0
- package/src/terrenoApp.ts +37 -15
- package/src/tests/bunSetup.ts +16 -3
- package/src/tests.ts +17 -4
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import {beforeEach, describe, expect, it} from "bun:test";
|
|
2
2
|
|
|
3
3
|
import type {SecretProvider} from "./configurationPlugin";
|
|
4
|
-
import {
|
|
4
|
+
import {APIError} from "./errors";
|
|
5
|
+
import {
|
|
6
|
+
CachingSecretProvider,
|
|
7
|
+
CompositeSecretProvider,
|
|
8
|
+
EnvSecretProvider,
|
|
9
|
+
GcpSecretProvider,
|
|
10
|
+
} from "./secretProviders";
|
|
5
11
|
|
|
6
12
|
describe("EnvSecretProvider", () => {
|
|
7
13
|
beforeEach(() => {
|
|
@@ -184,3 +190,215 @@ describe("CachingSecretProvider", () => {
|
|
|
184
190
|
expect(calls).toBe(1);
|
|
185
191
|
});
|
|
186
192
|
});
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// GcpSecretProvider
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
interface MockSecretManagerClient {
|
|
199
|
+
accessSecretVersion: (request: {
|
|
200
|
+
name: string;
|
|
201
|
+
}) => Promise<[{payload?: {data?: string | Uint8Array}}]>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Inject a pre-built mock client into a GcpSecretProvider, bypassing getClient(). */
|
|
205
|
+
const injectClient = (provider: GcpSecretProvider, client: MockSecretManagerClient): void => {
|
|
206
|
+
// Bypass the private `client` field for testing — avoids the dynamic import of
|
|
207
|
+
// @google-cloud/secret-manager which is an optional peer dependency.
|
|
208
|
+
Object.defineProperty(provider, "client", {configurable: true, value: client, writable: true});
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
describe("GcpSecretProvider", () => {
|
|
212
|
+
it("has the name 'gcp'", () => {
|
|
213
|
+
const provider = new GcpSecretProvider({projectId: "my-project"});
|
|
214
|
+
expect(provider.name).toBe("gcp");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("throws APIError when @google-cloud/secret-manager is not installed", async () => {
|
|
218
|
+
const provider = new GcpSecretProvider({projectId: "my-project"});
|
|
219
|
+
try {
|
|
220
|
+
await provider.getSecret("some-secret");
|
|
221
|
+
expect.unreachable("should have thrown");
|
|
222
|
+
} catch (error) {
|
|
223
|
+
expect(error).toBeInstanceOf(APIError);
|
|
224
|
+
expect((error as APIError).title).toContain(
|
|
225
|
+
"GcpSecretProvider requires @google-cloud/secret-manager"
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("resolves a short secret name to the full resource path with default version", async () => {
|
|
231
|
+
const calls: string[] = [];
|
|
232
|
+
const mockClient: MockSecretManagerClient = {
|
|
233
|
+
accessSecretVersion: async (req) => {
|
|
234
|
+
calls.push(req.name);
|
|
235
|
+
return [{payload: {data: "secret-value"}}];
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
const provider = new GcpSecretProvider({projectId: "my-project"});
|
|
239
|
+
injectClient(provider, mockClient);
|
|
240
|
+
|
|
241
|
+
const result = await provider.getSecret("openai-api-key");
|
|
242
|
+
expect(result).toBe("secret-value");
|
|
243
|
+
expect(calls).toEqual(["projects/my-project/secrets/openai-api-key/versions/latest"]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("resolves a short secret name with an explicit version", async () => {
|
|
247
|
+
const calls: string[] = [];
|
|
248
|
+
const mockClient: MockSecretManagerClient = {
|
|
249
|
+
accessSecretVersion: async (req) => {
|
|
250
|
+
calls.push(req.name);
|
|
251
|
+
return [{payload: {data: "v3-value"}}];
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
255
|
+
injectClient(provider, mockClient);
|
|
256
|
+
|
|
257
|
+
const result = await provider.getSecret("my-key", "3");
|
|
258
|
+
expect(result).toBe("v3-value");
|
|
259
|
+
expect(calls).toEqual(["projects/p/secrets/my-key/versions/3"]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("honors a full resource path that already contains /versions/", async () => {
|
|
263
|
+
const calls: string[] = [];
|
|
264
|
+
const mockClient: MockSecretManagerClient = {
|
|
265
|
+
accessSecretVersion: async (req) => {
|
|
266
|
+
calls.push(req.name);
|
|
267
|
+
return [{payload: {data: "pinned"}}];
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
const provider = new GcpSecretProvider({projectId: "ignored"});
|
|
271
|
+
injectClient(provider, mockClient);
|
|
272
|
+
|
|
273
|
+
const result = await provider.getSecret("projects/p/secrets/s/versions/7");
|
|
274
|
+
expect(result).toBe("pinned");
|
|
275
|
+
expect(calls).toEqual(["projects/p/secrets/s/versions/7"]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("appends /versions/latest to a full resource path without a version suffix", async () => {
|
|
279
|
+
const calls: string[] = [];
|
|
280
|
+
const mockClient: MockSecretManagerClient = {
|
|
281
|
+
accessSecretVersion: async (req) => {
|
|
282
|
+
calls.push(req.name);
|
|
283
|
+
return [{payload: {data: "latest-value"}}];
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
const provider = new GcpSecretProvider({projectId: "ignored"});
|
|
287
|
+
injectClient(provider, mockClient);
|
|
288
|
+
|
|
289
|
+
const result = await provider.getSecret("projects/p/secrets/s");
|
|
290
|
+
expect(result).toBe("latest-value");
|
|
291
|
+
expect(calls).toEqual(["projects/p/secrets/s/versions/latest"]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("appends the explicit version when full path lacks /versions/", async () => {
|
|
295
|
+
const calls: string[] = [];
|
|
296
|
+
const mockClient: MockSecretManagerClient = {
|
|
297
|
+
accessSecretVersion: async (req) => {
|
|
298
|
+
calls.push(req.name);
|
|
299
|
+
return [{payload: {data: "v5"}}];
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
const provider = new GcpSecretProvider({projectId: "ignored"});
|
|
303
|
+
injectClient(provider, mockClient);
|
|
304
|
+
|
|
305
|
+
const result = await provider.getSecret("projects/p/secrets/s", "5");
|
|
306
|
+
expect(result).toBe("v5");
|
|
307
|
+
expect(calls).toEqual(["projects/p/secrets/s/versions/5"]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("decodes a Uint8Array payload", async () => {
|
|
311
|
+
const encoded = new TextEncoder().encode("binary-secret");
|
|
312
|
+
const mockClient: MockSecretManagerClient = {
|
|
313
|
+
accessSecretVersion: async () => [{payload: {data: encoded}}],
|
|
314
|
+
};
|
|
315
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
316
|
+
injectClient(provider, mockClient);
|
|
317
|
+
|
|
318
|
+
expect(await provider.getSecret("bin-key")).toBe("binary-secret");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("returns null when the payload is empty", async () => {
|
|
322
|
+
const mockClient: MockSecretManagerClient = {
|
|
323
|
+
accessSecretVersion: async () => [{payload: {}}],
|
|
324
|
+
};
|
|
325
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
326
|
+
injectClient(provider, mockClient);
|
|
327
|
+
|
|
328
|
+
expect(await provider.getSecret("empty-payload")).toBeNull();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("returns null when the payload field is missing entirely", async () => {
|
|
332
|
+
const mockClient: MockSecretManagerClient = {
|
|
333
|
+
accessSecretVersion: async () => [{}],
|
|
334
|
+
};
|
|
335
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
336
|
+
injectClient(provider, mockClient);
|
|
337
|
+
|
|
338
|
+
expect(await provider.getSecret("no-payload")).toBeNull();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("returns null on NOT_FOUND (gRPC code 5)", async () => {
|
|
342
|
+
const notFound = Object.assign(new Error("NOT_FOUND"), {code: 5});
|
|
343
|
+
const mockClient: MockSecretManagerClient = {
|
|
344
|
+
accessSecretVersion: async () => {
|
|
345
|
+
throw notFound;
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
349
|
+
injectClient(provider, mockClient);
|
|
350
|
+
|
|
351
|
+
expect(await provider.getSecret("missing-secret")).toBeNull();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("re-throws non-NOT_FOUND errors", async () => {
|
|
355
|
+
const permissionDenied = Object.assign(new Error("PERMISSION_DENIED"), {code: 7});
|
|
356
|
+
const mockClient: MockSecretManagerClient = {
|
|
357
|
+
accessSecretVersion: async () => {
|
|
358
|
+
throw permissionDenied;
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
362
|
+
injectClient(provider, mockClient);
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
await provider.getSecret("forbidden-secret");
|
|
366
|
+
expect.unreachable("should have thrown");
|
|
367
|
+
} catch (error) {
|
|
368
|
+
expect(error).toBe(permissionDenied);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("re-throws non-Error throwables", async () => {
|
|
373
|
+
const mockClient: MockSecretManagerClient = {
|
|
374
|
+
accessSecretVersion: async () => {
|
|
375
|
+
throw "string-error";
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
379
|
+
injectClient(provider, mockClient);
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
await provider.getSecret("x");
|
|
383
|
+
expect.unreachable("should have thrown");
|
|
384
|
+
} catch (error) {
|
|
385
|
+
expect(error).toBe("string-error");
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("caches the client across multiple getSecret calls", async () => {
|
|
390
|
+
let callCount = 0;
|
|
391
|
+
const mockClient: MockSecretManagerClient = {
|
|
392
|
+
accessSecretVersion: async () => {
|
|
393
|
+
callCount++;
|
|
394
|
+
return [{payload: {data: `call-${callCount}`}}];
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
const provider = new GcpSecretProvider({projectId: "p"});
|
|
398
|
+
injectClient(provider, mockClient);
|
|
399
|
+
|
|
400
|
+
expect(await provider.getSecret("a")).toBe("call-1");
|
|
401
|
+
expect(await provider.getSecret("b")).toBe("call-2");
|
|
402
|
+
expect(callCount).toBe(2);
|
|
403
|
+
});
|
|
404
|
+
});
|
package/src/terrenoApp.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import supertest from "supertest";
|
|
|
6
6
|
import {modelRouter} from "./api";
|
|
7
7
|
import type {UserModel as UserModelType} from "./auth";
|
|
8
8
|
import {configurationPlugin} from "./configurationPlugin";
|
|
9
|
+
import {APIError} from "./errors";
|
|
9
10
|
import {Permissions} from "./permissions";
|
|
10
11
|
import {createdUpdatedPlugin} from "./plugins";
|
|
11
12
|
import {TerrenoApp} from "./terrenoApp";
|
|
@@ -42,6 +43,17 @@ describe("TerrenoApp", () => {
|
|
|
42
43
|
expect(app).toBeDefined();
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
it("does not add requestId to GET /openapi.json document bodies", async () => {
|
|
47
|
+
const app = new TerrenoApp({
|
|
48
|
+
skipListen: true,
|
|
49
|
+
userModel: typedUserModel,
|
|
50
|
+
}).build();
|
|
51
|
+
|
|
52
|
+
const res = await supertest(app).get("/openapi.json").expect(200);
|
|
53
|
+
expect(res.body.openapi).toBe("3.0.0");
|
|
54
|
+
expect(res.body.requestId).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
45
57
|
it("creates server with custom corsOrigin", () => {
|
|
46
58
|
const app = new TerrenoApp({
|
|
47
59
|
corsOrigin: "https://example.com",
|
|
@@ -105,6 +117,7 @@ describe("TerrenoApp", () => {
|
|
|
105
117
|
const res = await agent.get("/food").expect(200);
|
|
106
118
|
expect(res.body.data).toHaveLength(1);
|
|
107
119
|
expect(res.body.data[0].name).toBe("Apple");
|
|
120
|
+
expect(res.body.requestId).toBe(res.headers["x-request-id"]);
|
|
108
121
|
});
|
|
109
122
|
|
|
110
123
|
it("supports chaining multiple registrations", async () => {
|
|
@@ -196,6 +209,7 @@ describe("TerrenoApp", () => {
|
|
|
196
209
|
const agent = await authAsUser(app, "admin");
|
|
197
210
|
const res = await agent.get("/configuration/meta");
|
|
198
211
|
expect(res.status).toBe(200);
|
|
212
|
+
expect(res.body.requestId).toBe(res.headers["x-request-id"]);
|
|
199
213
|
});
|
|
200
214
|
|
|
201
215
|
it("supports custom basePath via configure options", async () => {
|
|
@@ -240,6 +254,30 @@ describe("TerrenoApp", () => {
|
|
|
240
254
|
|
|
241
255
|
const res = await supertest(app).get("/trigger-fallthrough");
|
|
242
256
|
expect(res.status).toBe(500);
|
|
257
|
+
expect(res.body.requestId).toBe(res.headers["x-request-id"]);
|
|
258
|
+
expect(res.body.status).toBe(500);
|
|
259
|
+
expect(res.body.title).toBe("Internal server error");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("adds requestId to APIError JSON responses", async () => {
|
|
263
|
+
const plugin: TerrenoPlugin = {
|
|
264
|
+
register: (pluginApp) => {
|
|
265
|
+
pluginApp.get("/api-error-route", () => {
|
|
266
|
+
throw new APIError({status: 400, title: "Bad request test"});
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
const app = new TerrenoApp({
|
|
271
|
+
skipListen: true,
|
|
272
|
+
userModel: typedUserModel,
|
|
273
|
+
})
|
|
274
|
+
.register(plugin)
|
|
275
|
+
.build();
|
|
276
|
+
|
|
277
|
+
const res = await supertest(app).get("/api-error-route").set("X-Request-ID", "api-err-rid");
|
|
278
|
+
expect(res.status).toBe(400);
|
|
279
|
+
expect(res.body.requestId).toBe("api-err-rid");
|
|
280
|
+
expect(res.body.title).toBe("Bad request test");
|
|
243
281
|
});
|
|
244
282
|
});
|
|
245
283
|
|
package/src/terrenoApp.ts
CHANGED
|
@@ -11,9 +11,10 @@ import {
|
|
|
11
11
|
apiFallthroughErrorMiddleware,
|
|
12
12
|
apiUnauthorizedMiddleware,
|
|
13
13
|
} from "./errors";
|
|
14
|
-
import {type AuthOptions, logRequests} from "./expressServer";
|
|
14
|
+
import {type AddRoutes, type AuthOptions, logRequests} from "./expressServer";
|
|
15
15
|
import {addGitHubAuthRoutes, type GitHubAuthOptions, setupGitHubAuth} from "./githubAuth";
|
|
16
16
|
import {type LoggingOptions, logger, setupLogging} from "./logger";
|
|
17
|
+
import {jsonResponseRequestIdMiddleware} from "./middleware";
|
|
17
18
|
import {openApiCompatMiddleware, patchAppUse} from "./openApiCompat";
|
|
18
19
|
import {openApiEtagMiddleware} from "./openApiEtag";
|
|
19
20
|
import {RealtimeApp} from "./realtime/realtimeApp";
|
|
@@ -68,28 +69,41 @@ export interface TerrenoAppOptions {
|
|
|
68
69
|
* Set to `true` for defaults, or pass a RealtimeAppOptions object for full control.
|
|
69
70
|
*/
|
|
70
71
|
realtime?: boolean | RealtimeAppOptions;
|
|
72
|
+
/**
|
|
73
|
+
* Runs after CORS and before the `addMiddleware` chain and JSON body parsing.
|
|
74
|
+
* Use to attach early middleware via `app.use(...)` before JSON parsing.
|
|
75
|
+
*/
|
|
76
|
+
beforeJsonSetup?: (app: express.Application) => void;
|
|
77
|
+
/**
|
|
78
|
+
* Invoked after registered plugins/model routers and before `/auth/me`.
|
|
79
|
+
* Receives the Express app and OpenAPI bundle for `modelRouter` / `createOpenApiBuilder` wiring.
|
|
80
|
+
*/
|
|
81
|
+
configureApp?: AddRoutes;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
/**
|
|
74
85
|
* Fluent API for building Express applications with Terreno framework.
|
|
75
86
|
*
|
|
76
|
-
* TerrenoApp
|
|
77
|
-
*
|
|
78
|
-
*
|
|
87
|
+
* TerrenoApp is the supported way to assemble the Terreno Express stack.
|
|
88
|
+
* Build applications by registering model routers and plugins (and/or
|
|
89
|
+
* `configureApp`), then calling `start()` to listen.
|
|
79
90
|
*
|
|
80
91
|
* The middleware stack is configured in this order:
|
|
81
92
|
* 1. CORS
|
|
82
|
-
* 2.
|
|
83
|
-
* 3.
|
|
84
|
-
* 4.
|
|
85
|
-
* 5.
|
|
86
|
-
* 6.
|
|
87
|
-
* 7.
|
|
88
|
-
* 8.
|
|
89
|
-
* 9.
|
|
93
|
+
* 2. Optional `beforeJsonSetup` (configure the app before JSON parsing)
|
|
94
|
+
* 3. Custom middleware (via addMiddleware)
|
|
95
|
+
* 4. JSON body parser
|
|
96
|
+
* 5. Auth routes (/auth/login, /auth/signup, etc.)
|
|
97
|
+
* 6. JWT authentication setup
|
|
98
|
+
* 7. Request logging
|
|
99
|
+
* 8. Sentry scopes
|
|
100
|
+
* 9. OpenAPI middleware (including JSON `requestId` on object responses)
|
|
90
101
|
* 10. GitHub OAuth routes (if enabled)
|
|
91
|
-
* 11.
|
|
92
|
-
* 12.
|
|
102
|
+
* 11. Configuration app (if any)
|
|
103
|
+
* 12. Registered model routers and plugins
|
|
104
|
+
* 13. Optional `configureApp` callback
|
|
105
|
+
* 14. /auth/me routes
|
|
106
|
+
* 15. Error handling middleware
|
|
93
107
|
*
|
|
94
108
|
* @example
|
|
95
109
|
* ```typescript
|
|
@@ -126,7 +140,6 @@ export interface TerrenoAppOptions {
|
|
|
126
140
|
* .start();
|
|
127
141
|
* ```
|
|
128
142
|
*
|
|
129
|
-
* @see setupServer for the callback-based alternative
|
|
130
143
|
* @see TerrenoPlugin for creating reusable plugins
|
|
131
144
|
* @see modelRouter for creating CRUD route registrations
|
|
132
145
|
*/
|
|
@@ -263,6 +276,10 @@ export class TerrenoApp {
|
|
|
263
276
|
|
|
264
277
|
app.use(cors({credentials: true, origin: options.corsOrigin ?? "*"}));
|
|
265
278
|
|
|
279
|
+
if (options.beforeJsonSetup) {
|
|
280
|
+
options.beforeJsonSetup(app);
|
|
281
|
+
}
|
|
282
|
+
|
|
266
283
|
// Apply custom middleware before JSON parsing
|
|
267
284
|
for (const fn of this.middlewareFns) {
|
|
268
285
|
if (fn.length <= 3) {
|
|
@@ -317,6 +334,7 @@ export class TerrenoApp {
|
|
|
317
334
|
// OpenAPI
|
|
318
335
|
app.use(openApiCompatMiddleware);
|
|
319
336
|
app.use(openApiEtagMiddleware);
|
|
337
|
+
app.use(jsonResponseRequestIdMiddleware);
|
|
320
338
|
const oapi = openapi({
|
|
321
339
|
info: {
|
|
322
340
|
description: "Generated docs from an Express api",
|
|
@@ -352,6 +370,10 @@ export class TerrenoApp {
|
|
|
352
370
|
}
|
|
353
371
|
}
|
|
354
372
|
|
|
373
|
+
if (options.configureApp) {
|
|
374
|
+
options.configureApp(app, {openApi: oapi});
|
|
375
|
+
}
|
|
376
|
+
|
|
355
377
|
// /auth/me must be registered after plugins so that session middleware
|
|
356
378
|
// (e.g. Better Auth) has a chance to populate req.user first.
|
|
357
379
|
addMeRoutes(app, options.userModel, options.authOptions);
|
package/src/tests/bunSetup.ts
CHANGED
|
@@ -9,12 +9,22 @@ import {logger, winstonLogger} from "../logger";
|
|
|
9
9
|
|
|
10
10
|
const shouldConnectToTestDb = process.env.BUN_TEST_DISABLE_DB !== "true";
|
|
11
11
|
|
|
12
|
+
const defaultLocalMongoUri = "mongodb://127.0.0.1/terreno?&connectTimeoutMS=360000";
|
|
13
|
+
|
|
14
|
+
/** When set by {@link TERRENO_TEST_USE_MEMORY_MONGO}, holds the server to stop in afterAll. */
|
|
15
|
+
let memoryMongo: {getUri: () => string; stop: () => Promise<boolean>} | undefined;
|
|
16
|
+
|
|
12
17
|
// Connect to MongoDB once for all tests
|
|
13
18
|
if (shouldConnectToTestDb) {
|
|
14
19
|
beforeAll(async () => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
let uri = process.env.TERRENO_TEST_MONGODB_URI?.trim();
|
|
21
|
+
if (!uri && process.env.TERRENO_TEST_USE_MEMORY_MONGO === "true") {
|
|
22
|
+
const {MongoMemoryServer} = await import("mongodb-memory-server");
|
|
23
|
+
memoryMongo = await MongoMemoryServer.create();
|
|
24
|
+
uri = memoryMongo.getUri();
|
|
25
|
+
}
|
|
26
|
+
const connectUri = uri ?? defaultLocalMongoUri;
|
|
27
|
+
await mongoose.connect(connectUri).catch(logger.catch);
|
|
18
28
|
});
|
|
19
29
|
}
|
|
20
30
|
|
|
@@ -22,6 +32,9 @@ if (shouldConnectToTestDb) {
|
|
|
22
32
|
if (shouldConnectToTestDb) {
|
|
23
33
|
afterAll(async () => {
|
|
24
34
|
await mongoose.connection.close();
|
|
35
|
+
if (memoryMongo) {
|
|
36
|
+
await memoryMongo.stop();
|
|
37
|
+
}
|
|
25
38
|
});
|
|
26
39
|
}
|
|
27
40
|
|
package/src/tests.ts
CHANGED
|
@@ -177,7 +177,7 @@ export const getBaseServer = (): Express => {
|
|
|
177
177
|
|
|
178
178
|
// Express 5 defaults to 'simple' query parser (Node querystring) which doesn't
|
|
179
179
|
// support nested bracket notation like name[$regex]=Green. Use qs to match
|
|
180
|
-
// what
|
|
180
|
+
// what TerrenoApp.build() configures.
|
|
181
181
|
app.set("query parser", (str: string) => qs.parse(str, {arrayLimit: 200}));
|
|
182
182
|
|
|
183
183
|
// Record mount paths on layers for Express 5 → OpenAPI compat
|
|
@@ -210,10 +210,23 @@ export const authAsUser = async (
|
|
|
210
210
|
return agent;
|
|
211
211
|
};
|
|
212
212
|
|
|
213
|
+
const defaultTestMongoUri = "mongodb://127.0.0.1/terreno?&connectTimeoutMS=360000";
|
|
214
|
+
|
|
215
|
+
/** Ensures Mongoose is connected without replacing an existing test connection (e.g. MongoMemoryServer from bunSetup). */
|
|
216
|
+
const ensureTestMongooseConnected = async (): Promise<void> => {
|
|
217
|
+
if (mongoose.connection.readyState === 1) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (mongoose.connection.readyState === 2) {
|
|
221
|
+
await mongoose.connection.asPromise();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const uri = process.env.TERRENO_TEST_MONGODB_URI?.trim() || defaultTestMongoUri;
|
|
225
|
+
await mongoose.connect(uri).catch(logger.catch);
|
|
226
|
+
};
|
|
227
|
+
|
|
213
228
|
export const setupDb = async () => {
|
|
214
|
-
await
|
|
215
|
-
.connect("mongodb://127.0.0.1/terreno?&connectTimeoutMS=360000")
|
|
216
|
-
.catch(logger.catch);
|
|
229
|
+
await ensureTestMongooseConnected();
|
|
217
230
|
|
|
218
231
|
process.env.REFRESH_TOKEN_SECRET = "refresh_secret";
|
|
219
232
|
process.env.TOKEN_SECRET = "secret";
|