@terreno/api 0.0.10 → 0.0.11-beta.1

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/src/api.ts CHANGED
@@ -241,16 +241,6 @@ 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;
254
244
  /**
255
245
  * The OpenAPI generator for this server. This is used to generate the OpenAPI documentation.
256
246
  */
@@ -275,23 +265,6 @@ export interface ModelRouterOptions<T> {
275
265
  openApiExtraModelProperties?: any;
276
266
  }
277
267
 
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
-
295
268
  // Ensures query params are allowed. Also checks nested query params when using $and/$or.
296
269
  function checkQueryParamAllowed(
297
270
  queryParam: string,
@@ -337,15 +310,12 @@ function checkQueryParamAllowed(
337
310
  // }
338
311
 
339
312
  /**
340
- * Create a set of CRUD routes given a Mongoose model $baseModel and configuration options.
313
+ * Create a set of CRUD routes given a Mongoose model and configuration options.
341
314
  *
342
- * @param baseModel A Mongoose Model
315
+ * @param model A Mongoose Model
343
316
  * @param options Options for configuring the REST API, such as permissions, transformers, and hooks.
344
317
  */
345
- export function modelRouter<T>(
346
- baseModel: Model<T>,
347
- options: ModelRouterOptions<T>
348
- ): express.Router {
318
+ export function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>): express.Router {
349
319
  const router = express.Router();
350
320
 
351
321
  // Do before the other router options so endpoints take priority.
@@ -359,12 +329,10 @@ export function modelRouter<T>(
359
329
  "/",
360
330
  [
361
331
  authenticateMiddleware(options.allowAnonymous),
362
- createOpenApiMiddleware(baseModel, options),
363
- permissionMiddleware(baseModel, options),
332
+ createOpenApiMiddleware(model, options),
333
+ permissionMiddleware(model, options),
364
334
  ],
365
335
  asyncHandler(async (req: Request, res: Response) => {
366
- const model = getModel(baseModel, req.body?.__t, options);
367
-
368
336
  let body: Partial<T> | (Partial<T> | undefined)[] | null | undefined;
369
337
  try {
370
338
  body = transform<T>(options, req.body, "create", req.user);
@@ -426,7 +394,7 @@ export function modelRouter<T>(
426
394
 
427
395
  if (options.populatePaths) {
428
396
  try {
429
- let populateQuery = model.findById(data._id);
397
+ let populateQuery: any = model.findById(data._id);
430
398
  populateQuery = addPopulateToQuery(populateQuery, options.populatePaths);
431
399
  data = await populateQuery.exec();
432
400
  } catch (error: any) {
@@ -469,13 +437,10 @@ export function modelRouter<T>(
469
437
  "/",
470
438
  [
471
439
  authenticateMiddleware(options.allowAnonymous),
472
- permissionMiddleware(baseModel, options),
473
- listOpenApiMiddleware(baseModel, options),
440
+ permissionMiddleware(model, options),
441
+ listOpenApiMiddleware(model, options),
474
442
  ],
475
443
  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
-
479
444
  let query: any = {};
480
445
  for (const queryParam of Object.keys(options.defaultQueryParams ?? [])) {
481
446
  query[queryParam] = options.defaultQueryParams?.[queryParam];
@@ -624,8 +589,8 @@ export function modelRouter<T>(
624
589
  "/:id",
625
590
  [
626
591
  authenticateMiddleware(options.allowAnonymous),
627
- getOpenApiMiddleware(baseModel, options),
628
- permissionMiddleware(baseModel, options),
592
+ getOpenApiMiddleware(model, options),
593
+ permissionMiddleware(model, options),
629
594
  ],
630
595
  asyncHandler(async (req: Request, res: Response) => {
631
596
  const data: mongoose.Document & T = (req as any).obj;
@@ -658,12 +623,10 @@ export function modelRouter<T>(
658
623
  "/:id",
659
624
  [
660
625
  authenticateMiddleware(options.allowAnonymous),
661
- patchOpenApiMiddleware(baseModel, options),
662
- permissionMiddleware(baseModel, options),
626
+ patchOpenApiMiddleware(model, options),
627
+ permissionMiddleware(model, options),
663
628
  ],
664
629
  asyncHandler(async (req: Request, res: Response) => {
665
- const model = getModel(baseModel, req.body, options);
666
-
667
630
  let doc: mongoose.Document & T = (req as any).obj;
668
631
 
669
632
  let body;
@@ -731,7 +694,7 @@ export function modelRouter<T>(
731
694
  }
732
695
 
733
696
  if (options.populatePaths) {
734
- let populateQuery = model.findById(doc._id);
697
+ let populateQuery: any = model.findById(doc._id);
735
698
  populateQuery = addPopulateToQuery(populateQuery, options.populatePaths);
736
699
  doc = await populateQuery.exec();
737
700
  }
@@ -766,12 +729,10 @@ export function modelRouter<T>(
766
729
  "/:id",
767
730
  [
768
731
  authenticateMiddleware(options.allowAnonymous),
769
- deleteOpenApiMiddleware(baseModel, options),
770
- permissionMiddleware(baseModel, options),
732
+ deleteOpenApiMiddleware(model, options),
733
+ permissionMiddleware(model, options),
771
734
  ],
772
735
  asyncHandler(async (req: Request, res: Response) => {
773
- const model = getModel(baseModel, req.body, options);
774
-
775
736
  const doc: mongoose.Document & T & {deleted?: boolean} = (req as any).obj;
776
737
 
777
738
  if (options.preDelete) {
@@ -849,7 +810,6 @@ export function modelRouter<T>(
849
810
  operation: "POST" | "PATCH" | "DELETE"
850
811
  ) {
851
812
  // TODO Combine array operations and .patch(), as they are very similar.
852
- const model = getModel(baseModel, req.body, options);
853
813
 
854
814
  if (!(await checkPermissions("update", options.permissions.update, req.user))) {
855
815
  throw new APIError({
@@ -861,9 +821,7 @@ export function modelRouter<T>(
861
821
  const doc = await model.findById(req.params.id);
862
822
  // Make a copy for passing pre-saved values to hooks.
863
823
  const prevDoc = cloneDeep(doc);
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)) {
824
+ if (!doc) {
867
825
  throw new APIError({
868
826
  status: 404,
869
827
  title: `Could not find document to PATCH: ${req.params.id}`,
@@ -979,7 +937,7 @@ export function modelRouter<T>(
979
937
 
980
938
  if (options.postUpdate) {
981
939
  try {
982
- await options.postUpdate(doc, body, req, prevDoc);
940
+ await options.postUpdate(doc as any, body, req, prevDoc as any);
983
941
  } catch (error: any) {
984
942
  throw new APIError({
985
943
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
@@ -989,7 +947,7 @@ export function modelRouter<T>(
989
947
  });
990
948
  }
991
949
  }
992
- return res.json({data: serialize<T>(req, options, doc)});
950
+ return res.json({data: serialize<T>(req, options, doc as any)});
993
951
  }
994
952
 
995
953
  async function arrayPost(req: Request, res: Response) {
@@ -1004,7 +962,7 @@ export function modelRouter<T>(
1004
962
  return arrayOperation(req, res, "DELETE");
1005
963
  }
1006
964
  // Set up routes for managing array fields. Check if there any array fields to add this for.
1007
- if (Object.values(baseModel.schema.paths).find((config: any) => config.instance === "Array")) {
965
+ if (Object.values(model.schema.paths).find((config: any) => config.instance === "Array")) {
1008
966
  router.post(
1009
967
  "/:id/:field",
1010
968
  authenticateMiddleware(options.allowAnonymous),
@@ -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, getModel, type ModelRouterOptions, type RESTMethod} from "./api";
7
+ import {addPopulateToQuery, 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
- baseModel: Model<T>,
105
- options: Pick<ModelRouterOptions<T>, "permissions" | "populatePaths" | "discriminatorKey">
104
+ model: Model<T>,
105
+ options: Pick<ModelRouterOptions<T>, "permissions" | "populatePaths">
106
106
  ) {
107
107
  return async (req: express.Request, _res: express.Response, next: NextFunction) => {
108
108
  if (req.method === "OPTIONS") {
@@ -131,8 +131,6 @@ export function permissionMiddleware<T>(
131
131
  });
132
132
  }
133
133
 
134
- const model = getModel(baseModel, req.body, options);
135
-
136
134
  // All methods check for permissions.
137
135
  if (!(await checkPermissions(method, options.permissions[method], req.user))) {
138
136
  throw new APIError({
@@ -159,15 +157,7 @@ export function permissionMiddleware<T>(
159
157
  title: `GET failed on ${req.params.id}`,
160
158
  });
161
159
  }
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
-
160
+ if (!data) {
171
161
  // Check if document exists but is hidden. Completely skip plugins.
172
162
  const hiddenDoc = await model.collection.findOne({
173
163
  _id: new mongoose.Types.ObjectId(req.params.id),
package/src/utils.test.ts CHANGED
@@ -1,14 +1,194 @@
1
- import {describe, expect, it} from "bun:test";
1
+ import {describe, expect, it, spyOn} from "bun:test";
2
+ import mongoose from "mongoose";
2
3
 
3
- import {isValidObjectId} from "./utils";
4
+ import {checkModelsStrict, isValidObjectId} from "./utils";
4
5
 
5
6
  describe("utils", () => {
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);
7
+ describe("isValidObjectId", () => {
8
+ it("checks valid ObjectIds", () => {
9
+ expect(isValidObjectId("62c44da0003d9f8ee8cc925c")).toBe(true);
10
+ expect(isValidObjectId("620000000000000000000000")).toBe(true);
11
+ // Mongoose's builtin "ObjectId.isValid" will falsely say this is an ObjectId.
12
+ expect(isValidObjectId("1234567890ab")).toBe(false);
13
+ expect(isValidObjectId("microsoft123")).toBe(false);
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
+ });
13
193
  });
14
194
  });