@terreno/api 0.0.4 → 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/CLAUDE.md ADDED
@@ -0,0 +1,107 @@
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
+ ```
package/bunfig.toml CHANGED
@@ -1,6 +1,7 @@
1
1
  [test]
2
2
  preload = ["./src/tests/bunSetup.ts"]
3
3
  root = "./src"
4
- coverageExclude = ["dist/**"]
5
- # Note: No coverageThreshold set - tests will not fail on low coverage
4
+ coverage = true
5
+ coverageExclude = ["dist/**", "**/*.test.ts", "**/tests/**"]
6
+ coverageThreshold = { line = 80, function = 80 }
6
7
 
package/dist/api.d.ts CHANGED
@@ -22,7 +22,7 @@ export type RESTMethod = "list" | "create" | "read" | "update" | "delete";
22
22
  * This is the main configuration.
23
23
  * @param T - the base document type. This should not include Mongoose models, just the types of the object.
24
24
  */
25
- export interface modelRouterOptions<T> {
25
+ export interface ModelRouterOptions<T> {
26
26
  /**
27
27
  * A group of method-level (create/read/update/delete/list) permissions.
28
28
  * Determine if the user can perform the operation at all, and for read/update/delete methods,
@@ -180,17 +180,7 @@ export interface modelRouterOptions<T> {
180
180
  * This is a good spot to remove sensitive information from the object, such as passwords or API
181
181
  * keys. Throw an APIError to return a 400 with an error message.
182
182
  */
183
- responseHandler?: (value: (Document<any, any, any> & T) | (Document<any, any, any> & T)[], method: "list" | "create" | "read" | "update" | "delete", request: express.Request, options: modelRouterOptions<T>) => Promise<JSONValue>;
184
- /**
185
- * The discriminatorKey that you passed when creating the Mongoose models. Defaults to __t. See:
186
- * https://mongoosejs.com/docs/discriminators.html If this key is provided,
187
- * you must provide the same key as part of the top level of the body when making performing
188
- * update or delete operations on this model.
189
- * \{discriminatorKey: "__t"\}
190
- *
191
- * PATCH \{__t: "SuperUser", name: "Foo"\} // __t is required or there will be a 404 error.
192
- */
193
- discriminatorKey?: string;
183
+ responseHandler?: (value: (Document<any, any, any> & T) | (Document<any, any, any> & T)[], method: "list" | "create" | "read" | "update" | "delete", request: express.Request, options: ModelRouterOptions<T>) => Promise<JSONValue>;
194
184
  /**
195
185
  * The OpenAPI generator for this server. This is used to generate the OpenAPI documentation.
196
186
  */
@@ -214,14 +204,13 @@ export interface modelRouterOptions<T> {
214
204
  */
215
205
  openApiExtraModelProperties?: any;
216
206
  }
217
- export declare function getModel(baseModel: Model<any>, body?: any, options?: modelRouterOptions<any>): mongoose.Model<any, {}, {}, {}, any, any, any>;
218
207
  /**
219
- * Create a set of CRUD routes given a Mongoose model $baseModel and configuration options.
208
+ * Create a set of CRUD routes given a Mongoose model and configuration options.
220
209
  *
221
- * @param baseModel A Mongoose Model
210
+ * @param model A Mongoose Model
222
211
  * @param options Options for configuring the REST API, such as permissions, transformers, and hooks.
223
212
  */
224
- export declare function modelRouter<T>(baseModel: Model<T>, options: modelRouterOptions<T>): express.Router;
213
+ export declare function modelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>): express.Router;
225
214
  export declare const asyncHandler: (fn: any) => (req: Request, res: Response, next: NextFunction) => Promise<any>;
226
215
  export declare const gooseRestRouter: typeof modelRouter;
227
- export type GooseRESTOptions<T> = modelRouterOptions<T>;
216
+ export type GooseRESTOptions<T> = ModelRouterOptions<T>;
package/dist/api.js CHANGED
@@ -121,7 +121,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
121
121
  Object.defineProperty(exports, "__esModule", { value: true });
122
122
  exports.gooseRestRouter = exports.asyncHandler = void 0;
123
123
  exports.addPopulateToQuery = addPopulateToQuery;
124
- exports.getModel = getModel;
125
124
  exports.modelRouter = modelRouter;
126
125
  /**
127
126
  * This is the doc comment for api.ts
@@ -168,21 +167,6 @@ function addPopulateToQuery(builtQuery, populatePaths) {
168
167
  var PAGINATION_QUERY_PARAMS = ["limit", "page", "sort"];
169
168
  // Add support for more complex queries.
170
169
  var COMPLEX_QUERY_PARAMS = ["$and", "$or"];
171
- // A function to decide which model to use. If no discriminators are provided,
172
- // just returns the base model. If
173
- function getModel(baseModel, body, options) {
174
- var _a, _b;
175
- var discriminatorKey = (_a = options === null || options === void 0 ? void 0 : options.discriminatorKey) !== null && _a !== void 0 ? _a : "__t";
176
- var modelName = body === null || body === void 0 ? void 0 : body[discriminatorKey];
177
- if (!modelName) {
178
- return baseModel;
179
- }
180
- var model = (_b = baseModel.discriminators) === null || _b === void 0 ? void 0 : _b[modelName];
181
- if (!model) {
182
- throw new Error("Could not find discriminator model for key ".concat(modelName, ", baseModel: ").concat(baseModel));
183
- }
184
- return model;
185
- }
186
170
  // Ensures query params are allowed. Also checks nested query params when using $and/$or.
187
171
  function checkQueryParamAllowed(queryParam, queryParamValue, queryFields) {
188
172
  var e_2, _a, e_3, _b;
@@ -244,12 +228,12 @@ function checkQueryParamAllowed(queryParam, queryParamValue, queryFields) {
244
228
  // return result;
245
229
  // }
246
230
  /**
247
- * Create a set of CRUD routes given a Mongoose model $baseModel and configuration options.
231
+ * Create a set of CRUD routes given a Mongoose model and configuration options.
248
232
  *
249
- * @param baseModel A Mongoose Model
233
+ * @param model A Mongoose Model
250
234
  * @param options Options for configuring the REST API, such as permissions, transformers, and hooks.
251
235
  */
252
- function modelRouter(baseModel, options) {
236
+ function modelRouter(model, options) {
253
237
  var _this = this;
254
238
  var _a;
255
239
  var router = express_1.default.Router();
@@ -260,15 +244,13 @@ function modelRouter(baseModel, options) {
260
244
  var responseHandler = (_a = options.responseHandler) !== null && _a !== void 0 ? _a : transformers_1.defaultResponseHandler;
261
245
  router.post("/", [
262
246
  (0, auth_1.authenticateMiddleware)(options.allowAnonymous),
263
- (0, openApi_1.createOpenApiMiddleware)(baseModel, options),
264
- (0, permissions_1.permissionMiddleware)(baseModel, options),
247
+ (0, openApi_1.createOpenApiMiddleware)(model, options),
248
+ (0, permissions_1.permissionMiddleware)(model, options),
265
249
  ], (0, exports.asyncHandler)(function (req, res) { return __awaiter(_this, void 0, void 0, function () {
266
- var model, body, error_1, data, error_2, populateQuery, error_3, error_4, serialized, error_5;
267
- var _a;
268
- return __generator(this, function (_b) {
269
- switch (_b.label) {
250
+ var body, error_1, data, error_2, populateQuery, error_3, error_4, serialized, error_5;
251
+ return __generator(this, function (_a) {
252
+ switch (_a.label) {
270
253
  case 0:
271
- model = getModel(baseModel, (_a = req.body) === null || _a === void 0 ? void 0 : _a.__t, options);
272
254
  try {
273
255
  body = (0, transformers_1.transform)(options, req.body, "create", req.user);
274
256
  }
@@ -281,15 +263,15 @@ function modelRouter(baseModel, options) {
281
263
  });
282
264
  }
283
265
  if (!options.preCreate) return [3 /*break*/, 5];
284
- _b.label = 1;
266
+ _a.label = 1;
285
267
  case 1:
286
- _b.trys.push([1, 3, , 4]);
268
+ _a.trys.push([1, 3, , 4]);
287
269
  return [4 /*yield*/, options.preCreate(body, req)];
288
270
  case 2:
289
- body = _b.sent();
271
+ body = _a.sent();
290
272
  return [3 /*break*/, 4];
291
273
  case 3:
292
- error_1 = _b.sent();
274
+ error_1 = _a.sent();
293
275
  if ((0, errors_1.isAPIError)(error_1)) {
294
276
  throw error_1;
295
277
  }
@@ -314,7 +296,7 @@ function modelRouter(baseModel, options) {
314
296
  title: "Create not allowed",
315
297
  });
316
298
  }
317
- _b.label = 5;
299
+ _a.label = 5;
318
300
  case 5:
319
301
  if (body === undefined) {
320
302
  throw new errors_1.APIError({
@@ -323,15 +305,15 @@ function modelRouter(baseModel, options) {
323
305
  title: "Invalid request body",
324
306
  });
325
307
  }
326
- _b.label = 6;
308
+ _a.label = 6;
327
309
  case 6:
328
- _b.trys.push([6, 8, , 9]);
310
+ _a.trys.push([6, 8, , 9]);
329
311
  return [4 /*yield*/, model.create(body)];
330
312
  case 7:
331
- data = _b.sent();
313
+ data = _a.sent();
332
314
  return [3 /*break*/, 9];
333
315
  case 8:
334
- error_2 = _b.sent();
316
+ error_2 = _a.sent();
335
317
  throw new errors_1.APIError({
336
318
  disableExternalErrorTracking: (0, errors_1.getDisableExternalErrorTracking)(error_2),
337
319
  error: error_2,
@@ -340,17 +322,17 @@ function modelRouter(baseModel, options) {
340
322
  });
341
323
  case 9:
342
324
  if (!options.populatePaths) return [3 /*break*/, 13];
343
- _b.label = 10;
325
+ _a.label = 10;
344
326
  case 10:
345
- _b.trys.push([10, 12, , 13]);
327
+ _a.trys.push([10, 12, , 13]);
346
328
  populateQuery = model.findById(data._id);
347
329
  populateQuery = addPopulateToQuery(populateQuery, options.populatePaths);
348
330
  return [4 /*yield*/, populateQuery.exec()];
349
331
  case 11:
350
- data = _b.sent();
332
+ data = _a.sent();
351
333
  return [3 /*break*/, 13];
352
334
  case 12:
353
- error_3 = _b.sent();
335
+ error_3 = _a.sent();
354
336
  throw new errors_1.APIError({
355
337
  disableExternalErrorTracking: (0, errors_1.getDisableExternalErrorTracking)(error_3),
356
338
  error: error_3,
@@ -359,15 +341,15 @@ function modelRouter(baseModel, options) {
359
341
  });
360
342
  case 13:
361
343
  if (!options.postCreate) return [3 /*break*/, 17];
362
- _b.label = 14;
344
+ _a.label = 14;
363
345
  case 14:
364
- _b.trys.push([14, 16, , 17]);
346
+ _a.trys.push([14, 16, , 17]);
365
347
  return [4 /*yield*/, options.postCreate(data, req)];
366
348
  case 15:
367
- _b.sent();
349
+ _a.sent();
368
350
  return [3 /*break*/, 17];
369
351
  case 16:
370
- error_4 = _b.sent();
352
+ error_4 = _a.sent();
371
353
  throw new errors_1.APIError({
372
354
  disableExternalErrorTracking: (0, errors_1.getDisableExternalErrorTracking)(error_4),
373
355
  error: error_4,
@@ -375,13 +357,13 @@ function modelRouter(baseModel, options) {
375
357
  title: "postCreate hook error: ".concat(error_4.message),
376
358
  });
377
359
  case 17:
378
- _b.trys.push([17, 19, , 20]);
360
+ _a.trys.push([17, 19, , 20]);
379
361
  return [4 /*yield*/, responseHandler(data, "create", req, options)];
380
362
  case 18:
381
- serialized = _b.sent();
363
+ serialized = _a.sent();
382
364
  return [2 /*return*/, res.status(201).json({ data: serialized })];
383
365
  case 19:
384
- error_5 = _b.sent();
366
+ error_5 = _a.sent();
385
367
  throw new errors_1.APIError({
386
368
  disableExternalErrorTracking: (0, errors_1.getDisableExternalErrorTracking)(error_5),
387
369
  error: error_5,
@@ -394,16 +376,15 @@ function modelRouter(baseModel, options) {
394
376
  // TODO add rate limit
395
377
  router.get("/", [
396
378
  (0, auth_1.authenticateMiddleware)(options.allowAnonymous),
397
- (0, permissions_1.permissionMiddleware)(baseModel, options),
398
- (0, openApi_1.listOpenApiMiddleware)(baseModel, options),
379
+ (0, permissions_1.permissionMiddleware)(model, options),
380
+ (0, openApi_1.listOpenApiMiddleware)(model, options),
399
381
  ], (0, exports.asyncHandler)(function (req, res) { return __awaiter(_this, void 0, void 0, function () {
400
- var model, query, _a, _b, queryParam, _c, _d, queryParam, queryFilter, error_6, limit, builtQuery, total, populatedQuery, data, error_7, serialized, error_8, more, msg;
382
+ var query, _a, _b, queryParam, _c, _d, queryParam, queryFilter, error_6, limit, builtQuery, total, populatedQuery, data, error_7, serialized, error_8, more, msg;
401
383
  var e_4, _e, e_5, _f;
402
384
  var _g, _h, _j, _k, _l, _m;
403
385
  return __generator(this, function (_o) {
404
386
  switch (_o.label) {
405
387
  case 0:
406
- model = baseModel;
407
388
  query = {};
408
389
  try {
409
390
  for (_a = __values(Object.keys((_g = options.defaultQueryParams) !== null && _g !== void 0 ? _g : [])), _b = _a.next(); !_b.done; _b = _a.next()) {
@@ -575,8 +556,8 @@ function modelRouter(baseModel, options) {
575
556
  }); }));
576
557
  router.get("/:id", [
577
558
  (0, auth_1.authenticateMiddleware)(options.allowAnonymous),
578
- (0, openApi_1.getOpenApiMiddleware)(baseModel, options),
579
- (0, permissions_1.permissionMiddleware)(baseModel, options),
559
+ (0, openApi_1.getOpenApiMiddleware)(model, options),
560
+ (0, permissions_1.permissionMiddleware)(model, options),
580
561
  ], (0, exports.asyncHandler)(function (req, res) { return __awaiter(_this, void 0, void 0, function () {
581
562
  var data, serialized, error_9;
582
563
  return __generator(this, function (_a) {
@@ -611,15 +592,14 @@ function modelRouter(baseModel, options) {
611
592
  }); }));
612
593
  router.patch("/:id", [
613
594
  (0, auth_1.authenticateMiddleware)(options.allowAnonymous),
614
- (0, openApi_1.patchOpenApiMiddleware)(baseModel, options),
615
- (0, permissions_1.permissionMiddleware)(baseModel, options),
595
+ (0, openApi_1.patchOpenApiMiddleware)(model, options),
596
+ (0, permissions_1.permissionMiddleware)(model, options),
616
597
  ], (0, exports.asyncHandler)(function (req, res) { return __awaiter(_this, void 0, void 0, function () {
617
- var model, doc, body, error_10, prevDoc, error_11, populateQuery, error_12, serialized, error_13;
598
+ var doc, body, error_10, prevDoc, error_11, populateQuery, error_12, serialized, error_13;
618
599
  var _a;
619
600
  return __generator(this, function (_b) {
620
601
  switch (_b.label) {
621
602
  case 0:
622
- model = getModel(baseModel, req.body, options);
623
603
  doc = req.obj;
624
604
  try {
625
605
  body = (0, transformers_1.transform)(options, req.body, "update", req.user);
@@ -733,14 +713,13 @@ function modelRouter(baseModel, options) {
733
713
  }); }));
734
714
  router.delete("/:id", [
735
715
  (0, auth_1.authenticateMiddleware)(options.allowAnonymous),
736
- (0, openApi_1.deleteOpenApiMiddleware)(baseModel, options),
737
- (0, permissions_1.permissionMiddleware)(baseModel, options),
716
+ (0, openApi_1.deleteOpenApiMiddleware)(model, options),
717
+ (0, permissions_1.permissionMiddleware)(model, options),
738
718
  ], (0, exports.asyncHandler)(function (req, res) { return __awaiter(_this, void 0, void 0, function () {
739
- var model, doc, body, error_14, error_15, error_16;
719
+ var doc, body, error_14, error_15, error_16;
740
720
  return __generator(this, function (_a) {
741
721
  switch (_a.label) {
742
722
  case 0:
743
- model = getModel(baseModel, req.body, options);
744
723
  doc = req.obj;
745
724
  if (!options.preDelete) return [3 /*break*/, 5];
746
725
  body = void 0;
@@ -823,15 +802,14 @@ function modelRouter(baseModel, options) {
823
802
  }); }));
824
803
  function arrayOperation(req, res, operation) {
825
804
  return __awaiter(this, void 0, void 0, function () {
826
- var model, doc, prevDoc, field, array, index, body, error_17, error_18, error_19;
805
+ var doc, prevDoc, field, array, index, body, error_17, error_18, error_19;
827
806
  var _a;
828
807
  var _b, _c;
829
808
  return __generator(this, function (_d) {
830
809
  switch (_d.label) {
831
- case 0:
832
- model = getModel(baseModel, req.body, options);
833
- return [4 /*yield*/, (0, permissions_1.checkPermissions)("update", options.permissions.update, req.user)];
810
+ case 0: return [4 /*yield*/, (0, permissions_1.checkPermissions)("update", options.permissions.update, req.user)];
834
811
  case 1:
812
+ // TODO Combine array operations and .patch(), as they are very similar.
835
813
  if (!(_d.sent())) {
836
814
  throw new errors_1.APIError({
837
815
  status: 405,
@@ -842,9 +820,7 @@ function modelRouter(baseModel, options) {
842
820
  case 2:
843
821
  doc = _d.sent();
844
822
  prevDoc = (0, cloneDeep_1.default)(doc);
845
- // We fail here because we might fetch the document without the __t but we'd be missing all the
846
- // hooks.
847
- if (!doc || (doc.__t && !req.body.__t)) {
823
+ if (!doc) {
848
824
  throw new errors_1.APIError({
849
825
  status: 404,
850
826
  title: "Could not find document to PATCH: ".concat(req.params.id),
@@ -1007,7 +983,7 @@ function modelRouter(baseModel, options) {
1007
983
  });
1008
984
  }
1009
985
  // Set up routes for managing array fields. Check if there any array fields to add this for.
1010
- if (Object.values(baseModel.schema.paths).find(function (config) { return config.instance === "Array"; })) {
986
+ if (Object.values(model.schema.paths).find(function (config) { return config.instance === "Array"; })) {
1011
987
  router.post("/:id/:field", (0, auth_1.authenticateMiddleware)(options.allowAnonymous), (0, exports.asyncHandler)(arrayPost));
1012
988
  router.patch("/:id/:field/:itemId", (0, auth_1.authenticateMiddleware)(options.allowAnonymous), (0, exports.asyncHandler)(arrayPatch));
1013
989
  router.delete("/:id/:field/:itemId", (0, auth_1.authenticateMiddleware)(options.allowAnonymous), (0, exports.asyncHandler)(arrayDelete));