@terreno/api 0.0.11-beta.1 → 0.0.11
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/bunfig.toml +2 -3
- package/dist/api.d.ts +14 -3
- package/dist/api.js +68 -44
- package/dist/api.test.js +166 -2051
- package/dist/permissions.d.ts +1 -1
- package/dist/permissions.js +25 -17
- package/dist/utils.test.js +7 -169
- package/package.json +1 -2
- package/src/api.test.ts +142 -1736
- package/src/api.ts +61 -19
- package/src/permissions.ts +14 -4
- package/src/utils.test.ts +9 -189
- package/CLAUDE.md +0 -107
package/src/api.ts
CHANGED
|
@@ -241,6 +241,16 @@ export interface ModelRouterOptions<T> {
|
|
|
241
241
|
request: express.Request,
|
|
242
242
|
options: ModelRouterOptions<T>
|
|
243
243
|
) => Promise<JSONValue>;
|
|
244
|
+
/**
|
|
245
|
+
* The discriminatorKey that you passed when creating the Mongoose models. Defaults to __t. See:
|
|
246
|
+
* https://mongoosejs.com/docs/discriminators.html If this key is provided,
|
|
247
|
+
* you must provide the same key as part of the top level of the body when making performing
|
|
248
|
+
* update or delete operations on this model.
|
|
249
|
+
* \{discriminatorKey: "__t"\}
|
|
250
|
+
*
|
|
251
|
+
* PATCH \{__t: "SuperUser", name: "Foo"\} // __t is required or there will be a 404 error.
|
|
252
|
+
*/
|
|
253
|
+
discriminatorKey?: string;
|
|
244
254
|
/**
|
|
245
255
|
* The OpenAPI generator for this server. This is used to generate the OpenAPI documentation.
|
|
246
256
|
*/
|
|
@@ -265,6 +275,23 @@ export interface ModelRouterOptions<T> {
|
|
|
265
275
|
openApiExtraModelProperties?: any;
|
|
266
276
|
}
|
|
267
277
|
|
|
278
|
+
// A function to decide which model to use. If no discriminators are provided,
|
|
279
|
+
// just returns the base model. If
|
|
280
|
+
export function getModel(baseModel: Model<any>, body?: any, options?: ModelRouterOptions<any>) {
|
|
281
|
+
const discriminatorKey = options?.discriminatorKey ?? "__t";
|
|
282
|
+
const modelName = body?.[discriminatorKey];
|
|
283
|
+
if (!modelName) {
|
|
284
|
+
return baseModel;
|
|
285
|
+
}
|
|
286
|
+
const model = baseModel.discriminators?.[modelName];
|
|
287
|
+
if (!model) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Could not find discriminator model for key ${modelName}, baseModel: ${baseModel}`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
return model;
|
|
293
|
+
}
|
|
294
|
+
|
|
268
295
|
// Ensures query params are allowed. Also checks nested query params when using $and/$or.
|
|
269
296
|
function checkQueryParamAllowed(
|
|
270
297
|
queryParam: string,
|
|
@@ -310,12 +337,15 @@ function checkQueryParamAllowed(
|
|
|
310
337
|
// }
|
|
311
338
|
|
|
312
339
|
/**
|
|
313
|
-
* Create a set of CRUD routes given a Mongoose model and configuration options.
|
|
340
|
+
* Create a set of CRUD routes given a Mongoose model $baseModel and configuration options.
|
|
314
341
|
*
|
|
315
|
-
* @param
|
|
342
|
+
* @param baseModel A Mongoose Model
|
|
316
343
|
* @param options Options for configuring the REST API, such as permissions, transformers, and hooks.
|
|
317
344
|
*/
|
|
318
|
-
export function modelRouter<T>(
|
|
345
|
+
export function modelRouter<T>(
|
|
346
|
+
baseModel: Model<T>,
|
|
347
|
+
options: ModelRouterOptions<T>
|
|
348
|
+
): express.Router {
|
|
319
349
|
const router = express.Router();
|
|
320
350
|
|
|
321
351
|
// Do before the other router options so endpoints take priority.
|
|
@@ -329,10 +359,12 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
329
359
|
"/",
|
|
330
360
|
[
|
|
331
361
|
authenticateMiddleware(options.allowAnonymous),
|
|
332
|
-
createOpenApiMiddleware(
|
|
333
|
-
permissionMiddleware(
|
|
362
|
+
createOpenApiMiddleware(baseModel, options),
|
|
363
|
+
permissionMiddleware(baseModel, options),
|
|
334
364
|
],
|
|
335
365
|
asyncHandler(async (req: Request, res: Response) => {
|
|
366
|
+
const model = getModel(baseModel, req.body?.__t, options);
|
|
367
|
+
|
|
336
368
|
let body: Partial<T> | (Partial<T> | undefined)[] | null | undefined;
|
|
337
369
|
try {
|
|
338
370
|
body = transform<T>(options, req.body, "create", req.user);
|
|
@@ -394,7 +426,7 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
394
426
|
|
|
395
427
|
if (options.populatePaths) {
|
|
396
428
|
try {
|
|
397
|
-
let populateQuery
|
|
429
|
+
let populateQuery = model.findById(data._id);
|
|
398
430
|
populateQuery = addPopulateToQuery(populateQuery, options.populatePaths);
|
|
399
431
|
data = await populateQuery.exec();
|
|
400
432
|
} catch (error: any) {
|
|
@@ -437,10 +469,13 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
437
469
|
"/",
|
|
438
470
|
[
|
|
439
471
|
authenticateMiddleware(options.allowAnonymous),
|
|
440
|
-
permissionMiddleware(
|
|
441
|
-
listOpenApiMiddleware(
|
|
472
|
+
permissionMiddleware(baseModel, options),
|
|
473
|
+
listOpenApiMiddleware(baseModel, options),
|
|
442
474
|
],
|
|
443
475
|
asyncHandler(async (req: Request, res: Response) => {
|
|
476
|
+
// For pure read queries, Mongoose will return the correct data with just the base model.
|
|
477
|
+
const model = baseModel;
|
|
478
|
+
|
|
444
479
|
let query: any = {};
|
|
445
480
|
for (const queryParam of Object.keys(options.defaultQueryParams ?? [])) {
|
|
446
481
|
query[queryParam] = options.defaultQueryParams?.[queryParam];
|
|
@@ -589,8 +624,8 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
589
624
|
"/:id",
|
|
590
625
|
[
|
|
591
626
|
authenticateMiddleware(options.allowAnonymous),
|
|
592
|
-
getOpenApiMiddleware(
|
|
593
|
-
permissionMiddleware(
|
|
627
|
+
getOpenApiMiddleware(baseModel, options),
|
|
628
|
+
permissionMiddleware(baseModel, options),
|
|
594
629
|
],
|
|
595
630
|
asyncHandler(async (req: Request, res: Response) => {
|
|
596
631
|
const data: mongoose.Document & T = (req as any).obj;
|
|
@@ -623,10 +658,12 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
623
658
|
"/:id",
|
|
624
659
|
[
|
|
625
660
|
authenticateMiddleware(options.allowAnonymous),
|
|
626
|
-
patchOpenApiMiddleware(
|
|
627
|
-
permissionMiddleware(
|
|
661
|
+
patchOpenApiMiddleware(baseModel, options),
|
|
662
|
+
permissionMiddleware(baseModel, options),
|
|
628
663
|
],
|
|
629
664
|
asyncHandler(async (req: Request, res: Response) => {
|
|
665
|
+
const model = getModel(baseModel, req.body, options);
|
|
666
|
+
|
|
630
667
|
let doc: mongoose.Document & T = (req as any).obj;
|
|
631
668
|
|
|
632
669
|
let body;
|
|
@@ -694,7 +731,7 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
694
731
|
}
|
|
695
732
|
|
|
696
733
|
if (options.populatePaths) {
|
|
697
|
-
let populateQuery
|
|
734
|
+
let populateQuery = model.findById(doc._id);
|
|
698
735
|
populateQuery = addPopulateToQuery(populateQuery, options.populatePaths);
|
|
699
736
|
doc = await populateQuery.exec();
|
|
700
737
|
}
|
|
@@ -729,10 +766,12 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
729
766
|
"/:id",
|
|
730
767
|
[
|
|
731
768
|
authenticateMiddleware(options.allowAnonymous),
|
|
732
|
-
deleteOpenApiMiddleware(
|
|
733
|
-
permissionMiddleware(
|
|
769
|
+
deleteOpenApiMiddleware(baseModel, options),
|
|
770
|
+
permissionMiddleware(baseModel, options),
|
|
734
771
|
],
|
|
735
772
|
asyncHandler(async (req: Request, res: Response) => {
|
|
773
|
+
const model = getModel(baseModel, req.body, options);
|
|
774
|
+
|
|
736
775
|
const doc: mongoose.Document & T & {deleted?: boolean} = (req as any).obj;
|
|
737
776
|
|
|
738
777
|
if (options.preDelete) {
|
|
@@ -810,6 +849,7 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
810
849
|
operation: "POST" | "PATCH" | "DELETE"
|
|
811
850
|
) {
|
|
812
851
|
// TODO Combine array operations and .patch(), as they are very similar.
|
|
852
|
+
const model = getModel(baseModel, req.body, options);
|
|
813
853
|
|
|
814
854
|
if (!(await checkPermissions("update", options.permissions.update, req.user))) {
|
|
815
855
|
throw new APIError({
|
|
@@ -821,7 +861,9 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
821
861
|
const doc = await model.findById(req.params.id);
|
|
822
862
|
// Make a copy for passing pre-saved values to hooks.
|
|
823
863
|
const prevDoc = cloneDeep(doc);
|
|
824
|
-
|
|
864
|
+
// We fail here because we might fetch the document without the __t but we'd be missing all the
|
|
865
|
+
// hooks.
|
|
866
|
+
if (!doc || (doc.__t && !req.body.__t)) {
|
|
825
867
|
throw new APIError({
|
|
826
868
|
status: 404,
|
|
827
869
|
title: `Could not find document to PATCH: ${req.params.id}`,
|
|
@@ -937,7 +979,7 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
937
979
|
|
|
938
980
|
if (options.postUpdate) {
|
|
939
981
|
try {
|
|
940
|
-
await options.postUpdate(doc
|
|
982
|
+
await options.postUpdate(doc, body, req, prevDoc);
|
|
941
983
|
} catch (error: any) {
|
|
942
984
|
throw new APIError({
|
|
943
985
|
disableExternalErrorTracking: getDisableExternalErrorTracking(error),
|
|
@@ -947,7 +989,7 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
947
989
|
});
|
|
948
990
|
}
|
|
949
991
|
}
|
|
950
|
-
return res.json({data: serialize<T>(req, options, doc
|
|
992
|
+
return res.json({data: serialize<T>(req, options, doc)});
|
|
951
993
|
}
|
|
952
994
|
|
|
953
995
|
async function arrayPost(req: Request, res: Response) {
|
|
@@ -962,7 +1004,7 @@ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
|
|
|
962
1004
|
return arrayOperation(req, res, "DELETE");
|
|
963
1005
|
}
|
|
964
1006
|
// Set up routes for managing array fields. Check if there any array fields to add this for.
|
|
965
|
-
if (Object.values(
|
|
1007
|
+
if (Object.values(baseModel.schema.paths).find((config: any) => config.instance === "Array")) {
|
|
966
1008
|
router.post(
|
|
967
1009
|
"/:id/:field",
|
|
968
1010
|
authenticateMiddleware(options.allowAnonymous),
|
package/src/permissions.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type express from "express";
|
|
|
4
4
|
import type {NextFunction} from "express";
|
|
5
5
|
import mongoose, {type Model} from "mongoose";
|
|
6
6
|
|
|
7
|
-
import {addPopulateToQuery, type ModelRouterOptions, type RESTMethod} from "./api";
|
|
7
|
+
import {addPopulateToQuery, getModel, type ModelRouterOptions, type RESTMethod} from "./api";
|
|
8
8
|
import type {User} from "./auth";
|
|
9
9
|
import {APIError} from "./errors";
|
|
10
10
|
import {logger} from "./logger";
|
|
@@ -101,8 +101,8 @@ export async function checkPermissions<T>(
|
|
|
101
101
|
// finds the relevant object, checks the permissions, and attaches the object to the request as
|
|
102
102
|
// req.obj.
|
|
103
103
|
export function permissionMiddleware<T>(
|
|
104
|
-
|
|
105
|
-
options: Pick<ModelRouterOptions<T>, "permissions" | "populatePaths">
|
|
104
|
+
baseModel: Model<T>,
|
|
105
|
+
options: Pick<ModelRouterOptions<T>, "permissions" | "populatePaths" | "discriminatorKey">
|
|
106
106
|
) {
|
|
107
107
|
return async (req: express.Request, _res: express.Response, next: NextFunction) => {
|
|
108
108
|
if (req.method === "OPTIONS") {
|
|
@@ -131,6 +131,8 @@ export function permissionMiddleware<T>(
|
|
|
131
131
|
});
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
const model = getModel(baseModel, req.body, options);
|
|
135
|
+
|
|
134
136
|
// All methods check for permissions.
|
|
135
137
|
if (!(await checkPermissions(method, options.permissions[method], req.user))) {
|
|
136
138
|
throw new APIError({
|
|
@@ -157,7 +159,15 @@ export function permissionMiddleware<T>(
|
|
|
157
159
|
title: `GET failed on ${req.params.id}`,
|
|
158
160
|
});
|
|
159
161
|
}
|
|
160
|
-
if (!data) {
|
|
162
|
+
if (!data || (["update", "delete"].includes(method) && data?.__t && !req.body?.__t)) {
|
|
163
|
+
// For discriminated models, return 404 without checking hidden state
|
|
164
|
+
if (["update", "delete"].includes(method) && data?.__t && !req.body?.__t) {
|
|
165
|
+
throw new APIError({
|
|
166
|
+
status: 404,
|
|
167
|
+
title: `Document ${req.params.id} not found for model ${model.modelName}`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
161
171
|
// Check if document exists but is hidden. Completely skip plugins.
|
|
162
172
|
const hiddenDoc = await model.collection.findOne({
|
|
163
173
|
_id: new mongoose.Types.ObjectId(req.params.id),
|
package/src/utils.test.ts
CHANGED
|
@@ -1,194 +1,14 @@
|
|
|
1
|
-
import {describe, expect, it
|
|
2
|
-
import mongoose from "mongoose";
|
|
1
|
+
import {describe, expect, it} from "bun:test";
|
|
3
2
|
|
|
4
|
-
import {
|
|
3
|
+
import {isValidObjectId} from "./utils";
|
|
5
4
|
|
|
6
5
|
describe("utils", () => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
expect(isValidObjectId("62c44da0003d9f8ee8cc925x")).toBe(false);
|
|
15
|
-
});
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
describe("checkModelsStrict", () => {
|
|
19
|
-
it("throws error when toObject.virtuals is not true", () => {
|
|
20
|
-
// Create a schema without toObject.virtuals
|
|
21
|
-
const testSchema = new mongoose.Schema({name: String});
|
|
22
|
-
testSchema.set("strict", "throw");
|
|
23
|
-
// Not setting toObject.virtuals
|
|
24
|
-
|
|
25
|
-
if (mongoose.models.ToObjectTestModel) {
|
|
26
|
-
delete mongoose.models.ToObjectTestModel;
|
|
27
|
-
}
|
|
28
|
-
mongoose.model("ToObjectTestModel", testSchema);
|
|
29
|
-
|
|
30
|
-
try {
|
|
31
|
-
// This should throw because ToObjectTestModel doesn't have toObject.virtuals
|
|
32
|
-
expect(() => checkModelsStrict()).toThrow("toObject.virtuals not set to true");
|
|
33
|
-
} finally {
|
|
34
|
-
delete mongoose.models.ToObjectTestModel;
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("throws error when toJSON.virtuals is not true", () => {
|
|
39
|
-
// Create a schema with toObject.virtuals but without toJSON.virtuals
|
|
40
|
-
const testSchema = new mongoose.Schema({name: String});
|
|
41
|
-
testSchema.set("toObject", {virtuals: true});
|
|
42
|
-
testSchema.set("strict", "throw");
|
|
43
|
-
// Not setting toJSON.virtuals
|
|
44
|
-
|
|
45
|
-
if (mongoose.models.ToJsonTestModel) {
|
|
46
|
-
delete mongoose.models.ToJsonTestModel;
|
|
47
|
-
}
|
|
48
|
-
mongoose.model("ToJsonTestModel", testSchema);
|
|
49
|
-
|
|
50
|
-
// Use spyOn to intercept modelNames and return only our test model
|
|
51
|
-
const spy = spyOn(mongoose, "modelNames").mockReturnValue(["ToJsonTestModel"]);
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
expect(() => checkModelsStrict()).toThrow("toJSON.virtuals not set to true");
|
|
55
|
-
} finally {
|
|
56
|
-
spy.mockRestore();
|
|
57
|
-
delete mongoose.models.ToJsonTestModel;
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("throws error when strict mode is not set to throw", () => {
|
|
62
|
-
// Create a schema with virtuals but without strict mode
|
|
63
|
-
const testSchema = new mongoose.Schema({name: String});
|
|
64
|
-
testSchema.set("toObject", {virtuals: true});
|
|
65
|
-
testSchema.set("toJSON", {virtuals: true});
|
|
66
|
-
// Not setting strict to "throw"
|
|
67
|
-
|
|
68
|
-
if (mongoose.models.StrictTestModel) {
|
|
69
|
-
delete mongoose.models.StrictTestModel;
|
|
70
|
-
}
|
|
71
|
-
mongoose.model("StrictTestModel", testSchema);
|
|
72
|
-
|
|
73
|
-
const spy = spyOn(mongoose, "modelNames").mockReturnValue(["StrictTestModel"]);
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
expect(() => checkModelsStrict()).toThrow("is not set to strict mode");
|
|
77
|
-
} finally {
|
|
78
|
-
spy.mockRestore();
|
|
79
|
-
delete mongoose.models.StrictTestModel;
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("passes when all checks pass", () => {
|
|
84
|
-
// Create a properly configured schema
|
|
85
|
-
const testSchema = new mongoose.Schema({name: String});
|
|
86
|
-
testSchema.set("toObject", {virtuals: true});
|
|
87
|
-
testSchema.set("toJSON", {virtuals: true});
|
|
88
|
-
testSchema.set("strict", "throw");
|
|
89
|
-
|
|
90
|
-
if (mongoose.models.GoodTestModel) {
|
|
91
|
-
delete mongoose.models.GoodTestModel;
|
|
92
|
-
}
|
|
93
|
-
mongoose.model("GoodTestModel", testSchema);
|
|
94
|
-
|
|
95
|
-
const spy = spyOn(mongoose, "modelNames").mockReturnValue(["GoodTestModel"]);
|
|
96
|
-
|
|
97
|
-
try {
|
|
98
|
-
expect(() => checkModelsStrict()).not.toThrow();
|
|
99
|
-
} finally {
|
|
100
|
-
spy.mockRestore();
|
|
101
|
-
delete mongoose.models.GoodTestModel;
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("skips strict mode check for ignored models", () => {
|
|
106
|
-
// Create a properly configured model
|
|
107
|
-
const goodSchema = new mongoose.Schema({name: String});
|
|
108
|
-
goodSchema.set("toObject", {virtuals: true});
|
|
109
|
-
goodSchema.set("toJSON", {virtuals: true});
|
|
110
|
-
goodSchema.set("strict", "throw");
|
|
111
|
-
|
|
112
|
-
if (mongoose.models.GoodModel) {
|
|
113
|
-
delete mongoose.models.GoodModel;
|
|
114
|
-
}
|
|
115
|
-
mongoose.model("GoodModel", goodSchema);
|
|
116
|
-
|
|
117
|
-
// Create a model without strict mode that we'll ignore
|
|
118
|
-
const badSchema = new mongoose.Schema({name: String});
|
|
119
|
-
badSchema.set("toObject", {virtuals: true});
|
|
120
|
-
badSchema.set("toJSON", {virtuals: true});
|
|
121
|
-
// Not setting strict - should fail unless ignored
|
|
122
|
-
|
|
123
|
-
if (mongoose.models.IgnoredModel) {
|
|
124
|
-
delete mongoose.models.IgnoredModel;
|
|
125
|
-
}
|
|
126
|
-
mongoose.model("IgnoredModel", badSchema);
|
|
127
|
-
|
|
128
|
-
const spy = spyOn(mongoose, "modelNames").mockReturnValue(["GoodModel", "IgnoredModel"]);
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
// Without ignoring, should throw for IgnoredModel
|
|
132
|
-
expect(() => checkModelsStrict()).toThrow("is not set to strict mode");
|
|
133
|
-
|
|
134
|
-
// With ignoring IgnoredModel, should pass
|
|
135
|
-
expect(() => checkModelsStrict(["IgnoredModel"])).not.toThrow();
|
|
136
|
-
} finally {
|
|
137
|
-
spy.mockRestore();
|
|
138
|
-
delete mongoose.models.GoodModel;
|
|
139
|
-
delete mongoose.models.IgnoredModel;
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it("handles multiple models and validates all", () => {
|
|
144
|
-
// Create three properly configured models
|
|
145
|
-
const schema1 = new mongoose.Schema({name: String});
|
|
146
|
-
schema1.set("toObject", {virtuals: true});
|
|
147
|
-
schema1.set("toJSON", {virtuals: true});
|
|
148
|
-
schema1.set("strict", "throw");
|
|
149
|
-
|
|
150
|
-
const schema2 = new mongoose.Schema({value: Number});
|
|
151
|
-
schema2.set("toObject", {virtuals: true});
|
|
152
|
-
schema2.set("toJSON", {virtuals: true});
|
|
153
|
-
schema2.set("strict", "throw");
|
|
154
|
-
|
|
155
|
-
const schema3 = new mongoose.Schema({active: Boolean});
|
|
156
|
-
schema3.set("toObject", {virtuals: true});
|
|
157
|
-
schema3.set("toJSON", {virtuals: true});
|
|
158
|
-
schema3.set("strict", "throw");
|
|
159
|
-
|
|
160
|
-
if (mongoose.models.MultiModel1) delete mongoose.models.MultiModel1;
|
|
161
|
-
if (mongoose.models.MultiModel2) delete mongoose.models.MultiModel2;
|
|
162
|
-
if (mongoose.models.MultiModel3) delete mongoose.models.MultiModel3;
|
|
163
|
-
|
|
164
|
-
mongoose.model("MultiModel1", schema1);
|
|
165
|
-
mongoose.model("MultiModel2", schema2);
|
|
166
|
-
mongoose.model("MultiModel3", schema3);
|
|
167
|
-
|
|
168
|
-
const spy = spyOn(mongoose, "modelNames").mockReturnValue([
|
|
169
|
-
"MultiModel1",
|
|
170
|
-
"MultiModel2",
|
|
171
|
-
"MultiModel3",
|
|
172
|
-
]);
|
|
173
|
-
|
|
174
|
-
try {
|
|
175
|
-
expect(() => checkModelsStrict()).not.toThrow();
|
|
176
|
-
} finally {
|
|
177
|
-
spy.mockRestore();
|
|
178
|
-
delete mongoose.models.MultiModel1;
|
|
179
|
-
delete mongoose.models.MultiModel2;
|
|
180
|
-
delete mongoose.models.MultiModel3;
|
|
181
|
-
}
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it("handles empty model list", () => {
|
|
185
|
-
const spy = spyOn(mongoose, "modelNames").mockReturnValue([]);
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
expect(() => checkModelsStrict()).not.toThrow();
|
|
189
|
-
} finally {
|
|
190
|
-
spy.mockRestore();
|
|
191
|
-
}
|
|
192
|
-
});
|
|
6
|
+
it("checks valid ObjectIds", () => {
|
|
7
|
+
expect(isValidObjectId("62c44da0003d9f8ee8cc925c")).toBe(true);
|
|
8
|
+
expect(isValidObjectId("620000000000000000000000")).toBe(true);
|
|
9
|
+
// Mongoose's builtin "ObjectId.isValid" will falsely say this is an ObjectId.
|
|
10
|
+
expect(isValidObjectId("1234567890ab")).toBe(false);
|
|
11
|
+
expect(isValidObjectId("microsoft123")).toBe(false);
|
|
12
|
+
expect(isValidObjectId("62c44da0003d9f8ee8cc925x")).toBe(false);
|
|
193
13
|
});
|
|
194
14
|
});
|
package/CLAUDE.md
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
# @terreno/api
|
|
2
|
-
|
|
3
|
-
REST API framework built on Express/Mongoose, styled after Django REST Framework.
|
|
4
|
-
|
|
5
|
-
## Commands
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
bun run compile # Compile TypeScript
|
|
9
|
-
bun run dev # Watch mode
|
|
10
|
-
bun run test # Run tests
|
|
11
|
-
bun run lint # Lint code
|
|
12
|
-
bun run lint:fix # Fix lint issues
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
## Architecture
|
|
16
|
-
|
|
17
|
-
### modelRouter
|
|
18
|
-
|
|
19
|
-
Automatically creates RESTful CRUD APIs for Mongoose models with built-in permissions, population, filtering, and lifecycle hooks.
|
|
20
|
-
|
|
21
|
-
```typescript
|
|
22
|
-
import {modelRouter, modelRouterOptions, Permissions} from "@terreno/api";
|
|
23
|
-
|
|
24
|
-
const router = modelRouter(YourModel, {
|
|
25
|
-
permissions: {
|
|
26
|
-
list: [Permissions.IsAuthenticated],
|
|
27
|
-
create: [Permissions.IsAuthenticated],
|
|
28
|
-
read: [Permissions.IsOwner],
|
|
29
|
-
update: [Permissions.IsOwner],
|
|
30
|
-
delete: [], // Disabled
|
|
31
|
-
},
|
|
32
|
-
sort: "-created",
|
|
33
|
-
queryFields: ["_id", "type", "name"],
|
|
34
|
-
});
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
### Custom Routes
|
|
38
|
-
|
|
39
|
-
For non-CRUD endpoints, use the OpenAPI builder:
|
|
40
|
-
|
|
41
|
-
```typescript
|
|
42
|
-
import {asyncHandler, authenticateMiddleware, createOpenApiBuilder} from "@terreno/api";
|
|
43
|
-
|
|
44
|
-
router.get("/yourRoute/:id", [
|
|
45
|
-
authenticateMiddleware(),
|
|
46
|
-
createOpenApiBuilder(options)
|
|
47
|
-
.withTags(["yourTag"])
|
|
48
|
-
.withSummary("Brief summary")
|
|
49
|
-
.withPathParameter("id", {type: "string"})
|
|
50
|
-
.withResponse(200, {data: {type: "object"}})
|
|
51
|
-
.build(),
|
|
52
|
-
], asyncHandler(async (req, res) => {
|
|
53
|
-
return res.json({data: result});
|
|
54
|
-
}));
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
## Conventions
|
|
58
|
-
|
|
59
|
-
### Error Handling
|
|
60
|
-
- Throw `APIError` with appropriate status codes: `throw new APIError({status: 400, title: "Message"})`
|
|
61
|
-
- Services should throw user-friendly errors
|
|
62
|
-
|
|
63
|
-
### Mongoose
|
|
64
|
-
- Do not use `Model.findOne` - use `Model.findExactlyOne` or `Model.findOneOrThrow`
|
|
65
|
-
- Define statics/methods by direct assignment: `schema.methods = {bar() {}}`
|
|
66
|
-
- All model types live in `src/modelInterfaces.ts`
|
|
67
|
-
|
|
68
|
-
### User Type Casting
|
|
69
|
-
- In API routes: `req.user` is `UserDocument | undefined`
|
|
70
|
-
- In @terreno/api callbacks: cast with `const user = u as unknown as UserDocument`
|
|
71
|
-
- Never use `as any as UserDocument`
|
|
72
|
-
|
|
73
|
-
### Logging
|
|
74
|
-
- Use `logger.info/warn/error/debug` for permanent logs (not `console.log`)
|
|
75
|
-
|
|
76
|
-
### Testing
|
|
77
|
-
- Use bun test with expect for testing
|
|
78
|
-
- Use existing manual mocks from `src/__mocks__/`
|
|
79
|
-
- Never mock @terreno/api or models
|
|
80
|
-
|
|
81
|
-
## Model Type Generation
|
|
82
|
-
|
|
83
|
-
When creating/modifying Mongoose models, update `src/modelInterfaces.ts`:
|
|
84
|
-
|
|
85
|
-
```typescript
|
|
86
|
-
export type YourModelMethods = {
|
|
87
|
-
customMethod: (this: YourModelDocument, param: string) => Promise<void>;
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
export type YourModelStatics = DefaultStatics<YourModelDocument> & {
|
|
91
|
-
customStatic: (this: YourModelModel, param: string) => Promise<YourModelDocument>;
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
export type YourModelModel = DefaultModel<YourModelDocument> & YourModelStatics;
|
|
95
|
-
export type YourModelSchema = mongoose.Schema<YourModelDocument, YourModelModel, YourModelMethods>;
|
|
96
|
-
export type YourModelDocument = DefaultDoc & YourModelMethods & {
|
|
97
|
-
fieldName: string;
|
|
98
|
-
};
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
## SDK Generation
|
|
102
|
-
|
|
103
|
-
After modifying routes, regenerate the SDK:
|
|
104
|
-
|
|
105
|
-
```bash
|
|
106
|
-
bun run sdk
|
|
107
|
-
```
|