@terreno/api 0.0.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.
Files changed (119) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +170 -0
  3. package/biome.jsonc +22 -0
  4. package/bunfig.toml +4 -0
  5. package/dist/api.d.ts +227 -0
  6. package/dist/api.js +1024 -0
  7. package/dist/api.test.d.ts +1 -0
  8. package/dist/api.test.js +2143 -0
  9. package/dist/auth.d.ts +50 -0
  10. package/dist/auth.js +512 -0
  11. package/dist/auth.test.d.ts +1 -0
  12. package/dist/auth.test.js +778 -0
  13. package/dist/errors.d.ts +75 -0
  14. package/dist/errors.js +216 -0
  15. package/dist/example.d.ts +1 -0
  16. package/dist/example.js +118 -0
  17. package/dist/expressServer.d.ts +35 -0
  18. package/dist/expressServer.js +436 -0
  19. package/dist/index.d.ts +14 -0
  20. package/dist/index.js +30 -0
  21. package/dist/logger.d.ts +23 -0
  22. package/dist/logger.js +249 -0
  23. package/dist/middleware.d.ts +10 -0
  24. package/dist/middleware.js +52 -0
  25. package/dist/notifiers/googleChatNotifier.d.ts +5 -0
  26. package/dist/notifiers/googleChatNotifier.js +130 -0
  27. package/dist/notifiers/googleChatNotifier.test.d.ts +1 -0
  28. package/dist/notifiers/googleChatNotifier.test.js +260 -0
  29. package/dist/notifiers/index.d.ts +3 -0
  30. package/dist/notifiers/index.js +19 -0
  31. package/dist/notifiers/slackNotifier.d.ts +5 -0
  32. package/dist/notifiers/slackNotifier.js +130 -0
  33. package/dist/notifiers/slackNotifier.test.d.ts +1 -0
  34. package/dist/notifiers/slackNotifier.test.js +259 -0
  35. package/dist/notifiers/zoomNotifier.d.ts +34 -0
  36. package/dist/notifiers/zoomNotifier.js +181 -0
  37. package/dist/notifiers/zoomNotifier.test.d.ts +1 -0
  38. package/dist/notifiers/zoomNotifier.test.js +370 -0
  39. package/dist/openApi.d.ts +60 -0
  40. package/dist/openApi.js +441 -0
  41. package/dist/openApi.test.d.ts +1 -0
  42. package/dist/openApi.test.js +445 -0
  43. package/dist/openApiBuilder.d.ts +419 -0
  44. package/dist/openApiBuilder.js +424 -0
  45. package/dist/openApiBuilder.test.d.ts +1 -0
  46. package/dist/openApiBuilder.test.js +509 -0
  47. package/dist/openApiEtag.d.ts +7 -0
  48. package/dist/openApiEtag.js +38 -0
  49. package/dist/permissions.d.ts +26 -0
  50. package/dist/permissions.js +331 -0
  51. package/dist/permissions.test.d.ts +1 -0
  52. package/dist/permissions.test.js +413 -0
  53. package/dist/plugins.d.ts +67 -0
  54. package/dist/plugins.js +315 -0
  55. package/dist/plugins.test.d.ts +1 -0
  56. package/dist/plugins.test.js +639 -0
  57. package/dist/populate.d.ts +14 -0
  58. package/dist/populate.js +315 -0
  59. package/dist/populate.test.d.ts +1 -0
  60. package/dist/populate.test.js +133 -0
  61. package/dist/response.d.ts +0 -0
  62. package/dist/response.js +1 -0
  63. package/dist/tests/bunSetup.d.ts +1 -0
  64. package/dist/tests/bunSetup.js +297 -0
  65. package/dist/tests/index.d.ts +1 -0
  66. package/dist/tests/index.js +17 -0
  67. package/dist/tests.d.ts +99 -0
  68. package/dist/tests.js +273 -0
  69. package/dist/transformers.d.ts +25 -0
  70. package/dist/transformers.js +217 -0
  71. package/dist/transformers.test.d.ts +1 -0
  72. package/dist/transformers.test.js +370 -0
  73. package/dist/utils.d.ts +11 -0
  74. package/dist/utils.js +143 -0
  75. package/dist/utils.test.d.ts +1 -0
  76. package/dist/utils.test.js +14 -0
  77. package/index.ts +1 -0
  78. package/package.json +88 -0
  79. package/src/__snapshots__/openApi.test.ts.snap +4814 -0
  80. package/src/__snapshots__/openApiBuilder.test.ts.snap +1485 -0
  81. package/src/api.test.ts +1661 -0
  82. package/src/api.ts +1036 -0
  83. package/src/auth.test.ts +550 -0
  84. package/src/auth.ts +408 -0
  85. package/src/errors.ts +225 -0
  86. package/src/example.ts +99 -0
  87. package/src/express.d.ts +5 -0
  88. package/src/expressServer.ts +387 -0
  89. package/src/index.ts +14 -0
  90. package/src/logger.ts +190 -0
  91. package/src/middleware.ts +18 -0
  92. package/src/notifiers/googleChatNotifier.test.ts +114 -0
  93. package/src/notifiers/googleChatNotifier.ts +47 -0
  94. package/src/notifiers/index.ts +3 -0
  95. package/src/notifiers/slackNotifier.test.ts +113 -0
  96. package/src/notifiers/slackNotifier.ts +55 -0
  97. package/src/notifiers/zoomNotifier.test.ts +207 -0
  98. package/src/notifiers/zoomNotifier.ts +111 -0
  99. package/src/openApi.test.ts +331 -0
  100. package/src/openApi.ts +494 -0
  101. package/src/openApiBuilder.test.ts +442 -0
  102. package/src/openApiBuilder.ts +636 -0
  103. package/src/openApiEtag.ts +40 -0
  104. package/src/permissions.test.ts +219 -0
  105. package/src/permissions.ts +228 -0
  106. package/src/plugins.test.ts +390 -0
  107. package/src/plugins.ts +289 -0
  108. package/src/populate.test.ts +65 -0
  109. package/src/populate.ts +258 -0
  110. package/src/response.ts +0 -0
  111. package/src/tests/bunSetup.ts +234 -0
  112. package/src/tests/index.ts +1 -0
  113. package/src/tests.ts +218 -0
  114. package/src/transformers.test.ts +202 -0
  115. package/src/transformers.ts +170 -0
  116. package/src/utils.test.ts +14 -0
  117. package/src/utils.ts +47 -0
  118. package/tsconfig.json +60 -0
  119. package/types.d.ts +17 -0
package/src/api.ts ADDED
@@ -0,0 +1,1036 @@
1
+ /**
2
+ * This is the doc comment for api.ts
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import * as Sentry from "@sentry/node";
7
+ import express, {type NextFunction, type Request, type Response} from "express";
8
+ import cloneDeep from "lodash/cloneDeep";
9
+ import mongoose, {type Document, type Model} from "mongoose";
10
+
11
+ import {authenticateMiddleware, type User} from "./auth";
12
+ import {APIError, apiErrorMiddleware, getDisableExternalErrorTracking, isAPIError} from "./errors";
13
+ import {logger} from "./logger";
14
+ import {
15
+ createOpenApiMiddleware,
16
+ deleteOpenApiMiddleware,
17
+ getOpenApiMiddleware,
18
+ listOpenApiMiddleware,
19
+ patchOpenApiMiddleware,
20
+ } from "./openApi";
21
+ import {checkPermissions, permissionMiddleware, type RESTPermissions} from "./permissions";
22
+ import type {PopulatePath} from "./populate";
23
+ import {
24
+ defaultResponseHandler,
25
+ serialize,
26
+ type TerrenoTransformer,
27
+ transform,
28
+ } from "./transformers";
29
+ import {isValidObjectId} from "./utils";
30
+
31
+ export type JSONPrimitive = string | number | boolean | null;
32
+ export interface JSONArray extends Array<JSONValue> {}
33
+ export type JSONObject = {[member: string]: JSONValue};
34
+ export type JSONValue = JSONPrimitive | JSONObject | JSONArray;
35
+
36
+ export function addPopulateToQuery(
37
+ builtQuery: mongoose.Query<any[], any, Record<string, never>, any>,
38
+ populatePaths?: PopulatePath[]
39
+ ) {
40
+ const paths = populatePaths ?? [];
41
+ let query = builtQuery;
42
+
43
+ for (const populatePath of paths) {
44
+ const path = populatePath.path;
45
+ const select = populatePath.fields;
46
+ query = builtQuery.populate({path, select});
47
+ }
48
+ return query;
49
+ }
50
+
51
+ // TODOS:
52
+ // Support bulk actions
53
+ // Support more complex query fields
54
+ // Rate limiting
55
+
56
+ // These are the query params that are reserved for pagination.
57
+ const PAGINATION_QUERY_PARAMS = ["limit", "page", "sort"];
58
+
59
+ // Add support for more complex queries.
60
+ const COMPLEX_QUERY_PARAMS = ["$and", "$or"];
61
+
62
+ /**
63
+ * @param a - the first number
64
+ * @param b - the second number
65
+ * @returns The sum of `a` and `b`
66
+ */
67
+ export type RESTMethod = "list" | "create" | "read" | "update" | "delete";
68
+
69
+ /**
70
+ * This is the main configuration.
71
+ * @param T - the base document type. This should not include Mongoose models, just the types of the object.
72
+ */
73
+ export interface modelRouterOptions<T> {
74
+ /**
75
+ * A group of method-level (create/read/update/delete/list) permissions.
76
+ * Determine if the user can perform the operation at all, and for read/update/delete methods,
77
+ * whether the user can perform the operation on the object referenced.
78
+ * */
79
+ permissions: RESTPermissions<T>;
80
+ /**
81
+ * Allow anonymous users to access the resource.
82
+ * Defaults to false.
83
+ */
84
+ allowAnonymous?: boolean;
85
+ /**
86
+ * A list of fields on the model that can be queried using standard comparisons for booleans,
87
+ * strings, dates
88
+ * (as ISOStrings), and numbers.
89
+ * For example:
90
+ * ?foo=true // boolean query
91
+ * ?foo=bar // string query
92
+ * ?foo=1 // number query
93
+ * ?foo=2022-07-23T02:34:07.118Z // date query (should first be encoded for query params, not shown here)
94
+ * Note: `limit` and `page` are automatically supported and are reserved. */
95
+ queryFields?: string[];
96
+ /**
97
+ * queryFilter is a function to parse the query params and see if the query should be allowed.
98
+ * This can be used for permissioning to make sure less privileged users are not making
99
+ * privileged queries. If a query should not be allowed,
100
+ * return `null` from the function and an empty query result will be returned to the client
101
+ * without an error. You can also throw an APIError to be explicit about the issues.
102
+ * You can transform the given query params by returning different values.
103
+ * If the query is acceptable as-is, return `query` as-is.
104
+ */
105
+ queryFilter?: (
106
+ user?: User,
107
+ query?: Record<string, any>
108
+ ) => Record<string, any> | null | Promise<Record<string, any> | null>;
109
+ /**
110
+ * Transformers allow data to be transformed before actions are executed,
111
+ * and serialized before being returned to the user.
112
+ *
113
+ * Transformers can be used to throw out fields that the user should not be able to write to, such as the `admin` flag.
114
+ * Serializers can be used to hide data from the client or change how it is presented. Serializers run after the data
115
+ * has been changed or queried but before returning to the client.
116
+ * @deprecated Use preCreate/preUpdate/preDelete hooks instead of transformer.transform. Use serialize instead of
117
+ * transformer.serialize.
118
+ * */
119
+ transformer?: TerrenoTransformer<T>;
120
+ /** Default sort for list operations. Can be a single field, a space-seperated list of fields, or an object.
121
+ * ?sort=foo // single field: foo ascending
122
+ * ?sort=-foo // single field: foo descending
123
+ * ?sort=-foo bar // multi field: foo descending, bar ascending
124
+ * ?sort=\{foo: 'ascending', bar: 'descending'\} // object: foo ascending, bar descending
125
+ *
126
+ * Note: you should have an index field on these fields or Mongo may slow down considerably.
127
+ * */
128
+ sort?: string | {[key: string]: "ascending" | "descending"};
129
+ /**
130
+ * Default queries to provide to Mongo before any user queries or transforms happen when making
131
+ * list queries. Accepts any Mongoose-style queries, and runs for all user types.
132
+ * defaultQueryParams: \{hidden: false\} // By default, don't show objects with hidden=true
133
+ * These can be overridden by the user if not disallowed by queryFilter. */
134
+ defaultQueryParams?: {[key: string]: any};
135
+ /**
136
+ * Manages Mongoose populations before returning from all methods (list, read, create, etc).
137
+ * For each population:
138
+ * path: Accepts Mongoose-style populate strings for path. e.g. "user" or "users.userId"
139
+ * (for an array of subschemas with userId)
140
+ * fields: An array of strings to filter on the populated objects, following Mongoose's select
141
+ * rules. If each field starts a preceding "-", will act as a block list and only remove those
142
+ * fields. If each field does not start with a "-", will act as an allow list and only
143
+ * return those fields. Mixing allow and blocking is not supported. e.g. "-created updated"
144
+ * is an error.
145
+ * openApiComponent: If you have a component already registered,
146
+ * use that instead of autogenerating the types for the populated fields.
147
+ *
148
+ */
149
+ populatePaths?: PopulatePath[];
150
+ /** Default limit applied to list queries if not specified by the user. Defaults to 100. */
151
+ defaultLimit?: number;
152
+ /**
153
+ * Maximum query limit the user can request. Defaults to 500, and is the lowest of the limit
154
+ * query, max limit,
155
+ * or 500. */
156
+ maxLimit?: number; // defaults to 500
157
+ /** */
158
+ endpoints?: (router: any) => void;
159
+ /**
160
+ * Hook that runs after `transformer.transform` but before the object is created.
161
+ * Can update the body fields based on the request or the user.
162
+ * Return null to return a generic 403 error. Throw an APIError to return a 400 with specific
163
+ * error information.
164
+ */
165
+ preCreate?: (value: any, request: express.Request) => T | Promise<T> | null;
166
+ /**
167
+ * Hook that runs after `transformer.transform` but before changes are made for update operations.
168
+ * Can update the body fields based on the request or the user.
169
+ * Also applies to all array operations. Return null to return a generic 403 error.
170
+ * Throw an APIError to return a 400 with specific error information.
171
+ *
172
+ * @param value - The request body relative to the model update (type: Partial<T>). Note: this does not contain the entire document to be updated, only the fields being updated.
173
+ * @param request - The Express request object.
174
+ */
175
+ preUpdate?: (value: Partial<T>, request: express.Request) => T | Promise<T> | null;
176
+ /**
177
+ * Hook that runs after `transformer.transform` but before the object is deleted.
178
+ * Return null to return a generic 403 error.
179
+ * Throw an APIError to return a 400 with specific error information.
180
+ *
181
+ * @param value - The document to be deleted, before the soft update of deleted: true (type: T).
182
+ * @param request - The Express request object.
183
+ */
184
+ preDelete?: (value: T, request: express.Request) => T | Promise<T> | null;
185
+ /**
186
+ * Hook that runs after the object is created but before the responseHandler serializes and
187
+ * returned. This is a good spot to perform dependent changes to other models or performing async
188
+ * tasks/side effects, such as sending a push notification.
189
+ * Throw an APIError to return a 400 with an error message.
190
+ */
191
+ postCreate?: (value: T, request: express.Request) => void | Promise<void>;
192
+ /**
193
+ * Hook that runs after the object is updated but before the responseHandler serializes and
194
+ * returned. This is a good spot to perform dependent changes to other models or perform async
195
+ * tasks/side effects, such as sending a push notification.
196
+ * Throw an APIError to return a 400 with an error message.
197
+ *
198
+ * @param value - The document after it has been updated (type: T).
199
+ * @param cleanedBody - The request body relative to the model update (type: Partial<T>).
200
+ * @param request - The Express request object.
201
+ * @param prevValue - The entire document before it was updated (type: T).
202
+ */
203
+ postUpdate?: (
204
+ value: T,
205
+ cleanedBody: Partial<T>,
206
+ request: express.Request,
207
+ prevValue: T
208
+ ) => void | Promise<void>;
209
+ /**
210
+ * Hook that runs after the object is deleted. This is a good spot to perform dependent changes
211
+ * to other models or performing async tasks/side effects, such as cascading object deletions.
212
+ * Throw an APIError to return a 400 with an error message.
213
+ *
214
+ * @param request - The Express request object.
215
+ * @param value - The document that was deleted, after the soft update of deleted: true (type: T).
216
+ */
217
+ postDelete?: (request: express.Request, value: T) => void | Promise<void>;
218
+ /** Hook that runs after the object is fetched but before it is serialized.
219
+ * Returns a promise so that asynchronous actions can be included in the function.
220
+ * Throw an APIError to return a 400 with an error message.
221
+ * @deprecated: Use responseHandler instead.
222
+ */
223
+ postGet?: (value: T, request: express.Request) => undefined | Promise<T>;
224
+ /** Hook that runs after the list of objects is fetched but before they are serialized.
225
+ * Returns a promise so that asynchronous actions can be included in the function.
226
+ * Throw an APIError to return a 400 with an error message.
227
+ * @deprecated: Use responseHandler instead.
228
+ */
229
+ postList?: (
230
+ value: (Document<any, any, any> & T)[],
231
+ request: express.Request
232
+ ) => Promise<(Document<any, any, any> & T)[]>;
233
+ /**
234
+ * Serialize an object or list of objects before returning to the client.
235
+ * This is a good spot to remove sensitive information from the object, such as passwords or API
236
+ * keys. Throw an APIError to return a 400 with an error message.
237
+ */
238
+ responseHandler?: (
239
+ value: (Document<any, any, any> & T) | (Document<any, any, any> & T)[],
240
+ method: "list" | "create" | "read" | "update" | "delete",
241
+ request: express.Request,
242
+ options: modelRouterOptions<T>
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
+ /**
255
+ * The OpenAPI generator for this server. This is used to generate the OpenAPI documentation.
256
+ */
257
+ openApi?: any;
258
+ /**
259
+ * Overwrite parts of the configuration for the OpenAPI generator.
260
+ * This will be merged with the generated configuration.
261
+ */
262
+ openApiOverwrite?: {
263
+ get?: any;
264
+ list?: any;
265
+ create?: any;
266
+ update?: any;
267
+ delete?: any;
268
+ };
269
+ /**
270
+ * Overwrite parts of the model properties for the OpenAPI generator.
271
+ * This will be merged with the generated configuration.
272
+ * This is useful if you add custom properties to the model during serialize, for example,
273
+ * that you want to be documented and typed in the SDK.
274
+ */
275
+ openApiExtraModelProperties?: any;
276
+ }
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
+
295
+ // Ensures query params are allowed. Also checks nested query params when using $and/$or.
296
+ function checkQueryParamAllowed(
297
+ queryParam: string,
298
+ queryParamValue: any,
299
+ queryFields: string[] = []
300
+ ) {
301
+ // Check the values of each of the complex query params. We don't support recursive queries here,
302
+ // just one level of and/or
303
+ if (COMPLEX_QUERY_PARAMS.includes(queryParam)) {
304
+ // Complex query of the form `$and: [{key1: value1}, {key2: value2}]`
305
+ for (const subQuery of queryParamValue) {
306
+ for (const subKey of Object.keys(subQuery)) {
307
+ checkQueryParamAllowed(subKey, subQuery[subKey], queryFields);
308
+ }
309
+ }
310
+ return;
311
+ }
312
+ if (!queryFields.includes(queryParam)) {
313
+ throw new APIError({
314
+ status: 400,
315
+ title: `${queryParam} is not allowed as a query param.`,
316
+ });
317
+ }
318
+ }
319
+
320
+ // Handles dot notation patches, creates a normal object to be used for updates.
321
+ // function flattenDotNotationPatch(data: any) {
322
+ // const result = {};
323
+ //
324
+ // for (const key in data) {
325
+ // if (data.hasOwnProperty(key)) {
326
+ // if (typeof data[key] === "object" && !key.includes(".")) {
327
+ // // If the value is an object and the key does not contain a dot, merge it
328
+ // merge(result, {[key]: data[key]});
329
+ // } else {
330
+ // // Otherwise, use _.set() to handle dot notation
331
+ // set(result, key, data[key]);
332
+ // }
333
+ // }
334
+ // }
335
+ //
336
+ // return result;
337
+ // }
338
+
339
+ /**
340
+ * Create a set of CRUD routes given a Mongoose model $baseModel and configuration options.
341
+ *
342
+ * @param baseModel A Mongoose Model
343
+ * @param options Options for configuring the REST API, such as permissions, transformers, and hooks.
344
+ */
345
+ export function modelRouter<T>(
346
+ baseModel: Model<T>,
347
+ options: modelRouterOptions<T>
348
+ ): express.Router {
349
+ const router = express.Router();
350
+
351
+ // Do before the other router options so endpoints take priority.
352
+ if (options.endpoints) {
353
+ options.endpoints(router);
354
+ }
355
+
356
+ const responseHandler = options.responseHandler ?? defaultResponseHandler;
357
+
358
+ router.post(
359
+ "/",
360
+ [
361
+ authenticateMiddleware(options.allowAnonymous),
362
+ createOpenApiMiddleware(baseModel, options),
363
+ permissionMiddleware(baseModel, options),
364
+ ],
365
+ asyncHandler(async (req: Request, res: Response) => {
366
+ const model = getModel(baseModel, req.body?.__t, options);
367
+
368
+ let body: Partial<T> | (Partial<T> | undefined)[] | null | undefined;
369
+ try {
370
+ body = transform<T>(options, req.body, "create", req.user);
371
+ } catch (error: any) {
372
+ throw new APIError({
373
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
374
+ error,
375
+ status: 400,
376
+ title: error.message,
377
+ });
378
+ }
379
+ if (options.preCreate) {
380
+ try {
381
+ body = await options.preCreate(body, req);
382
+ } catch (error: any) {
383
+ if (isAPIError(error)) {
384
+ throw error;
385
+ }
386
+ throw new APIError({
387
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
388
+ error,
389
+ status: 400,
390
+ title: `preCreate hook error: ${error.message}`,
391
+ });
392
+ }
393
+ if (body === undefined) {
394
+ throw new APIError({
395
+ detail: "A body must be returned from preCreate",
396
+ status: 403,
397
+ title: "Create not allowed",
398
+ });
399
+ }
400
+ if (body === null) {
401
+ throw new APIError({
402
+ detail: "preCreate hook returned null",
403
+ status: 403,
404
+ title: "Create not allowed",
405
+ });
406
+ }
407
+ }
408
+ if (body === undefined) {
409
+ throw new APIError({
410
+ detail: "Body is undefined",
411
+ status: 400,
412
+ title: "Invalid request body",
413
+ });
414
+ }
415
+ let data;
416
+ try {
417
+ data = await model.create(body as any);
418
+ } catch (error: any) {
419
+ throw new APIError({
420
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
421
+ error,
422
+ status: 400,
423
+ title: error.message,
424
+ });
425
+ }
426
+
427
+ if (options.populatePaths) {
428
+ try {
429
+ let populateQuery = model.findById(data._id);
430
+ populateQuery = addPopulateToQuery(populateQuery, options.populatePaths);
431
+ data = await populateQuery.exec();
432
+ } catch (error: any) {
433
+ throw new APIError({
434
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
435
+ error,
436
+ status: 400,
437
+ title: `Populate error: ${error.message}`,
438
+ });
439
+ }
440
+ }
441
+
442
+ if (options.postCreate) {
443
+ try {
444
+ await options.postCreate(data, req);
445
+ } catch (error: any) {
446
+ throw new APIError({
447
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
448
+ error,
449
+ status: 400,
450
+ title: `postCreate hook error: ${error.message}`,
451
+ });
452
+ }
453
+ }
454
+ try {
455
+ const serialized = await responseHandler(data, "create", req, options);
456
+ return res.status(201).json({data: serialized});
457
+ } catch (error: any) {
458
+ throw new APIError({
459
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
460
+ error,
461
+ title: `responseHandler error: ${error.message}`,
462
+ });
463
+ }
464
+ })
465
+ );
466
+
467
+ // TODO add rate limit
468
+ router.get(
469
+ "/",
470
+ [
471
+ authenticateMiddleware(options.allowAnonymous),
472
+ permissionMiddleware(baseModel, options),
473
+ listOpenApiMiddleware(baseModel, options),
474
+ ],
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
+
479
+ let query: any = {};
480
+ for (const queryParam of Object.keys(options.defaultQueryParams ?? [])) {
481
+ query[queryParam] = options.defaultQueryParams?.[queryParam];
482
+ }
483
+
484
+ for (const queryParam of Object.keys(req.query)) {
485
+ if (PAGINATION_QUERY_PARAMS.includes(queryParam)) {
486
+ continue;
487
+ }
488
+ checkQueryParamAllowed(queryParam, req.query[queryParam], options.queryFields);
489
+
490
+ // Not sure if this is necessary or if mongoose does the right thing.
491
+ if (req.query[queryParam] === "true") {
492
+ query[queryParam] = true;
493
+ } else if (req.query[queryParam] === "false") {
494
+ query[queryParam] = false;
495
+ } else {
496
+ query[queryParam] = req.query[queryParam];
497
+ }
498
+ }
499
+
500
+ // Special operators. NOTE: these request Mongo Atlas.
501
+ if (req.query.$search) {
502
+ mongoose.connection.db?.collection(model.collection.collectionName);
503
+ }
504
+
505
+ if (req.query.$autocomplete) {
506
+ mongoose.connection.db?.collection(model.collection.collectionName);
507
+ }
508
+
509
+ // Check if any of the keys in the query are not allowed by options.queryFilter
510
+ if (options.queryFilter) {
511
+ let queryFilter;
512
+ try {
513
+ queryFilter = await options.queryFilter(req.user, query);
514
+ } catch (error: any) {
515
+ throw new APIError({
516
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
517
+ error,
518
+ status: 400,
519
+ title: `Query filter error: ${error}`,
520
+ });
521
+ }
522
+
523
+ // If the query filter returns null specifically, we know this is a query that shouldn't
524
+ // return any results.
525
+ if (queryFilter === null) {
526
+ return res.json({data: []});
527
+ }
528
+ query = {...query, ...queryFilter};
529
+ }
530
+
531
+ let limit = options.defaultLimit ?? 100;
532
+ if (Number(req.query.limit)) {
533
+ limit = Math.min(Number(req.query.limit), options.maxLimit ?? 500);
534
+ }
535
+
536
+ if (query.period) {
537
+ // need to remove 'period' since it isn't part of any schemas but parsed and applied in
538
+ // queryFilter instead
539
+ query.period = undefined;
540
+ }
541
+
542
+ let builtQuery = model.find(query).limit(limit + 1);
543
+ const total = await model.countDocuments(query);
544
+ if (req.query.page) {
545
+ if (Number(req.query.page) === 0 || Number.isNaN(Number(req.query.page))) {
546
+ throw new APIError({
547
+ status: 400,
548
+ title: `Invalid page: ${req.query.page}`,
549
+ });
550
+ }
551
+ builtQuery = builtQuery.skip((Number(req.query.page) - 1) * limit);
552
+ }
553
+
554
+ // Query param sort takes precedence over options.sort.
555
+ if (req.query.sort) {
556
+ builtQuery = builtQuery.sort(req.query.sort as string);
557
+ } else if (options.sort) {
558
+ builtQuery = builtQuery.sort(options.sort);
559
+ }
560
+
561
+ const populatedQuery = addPopulateToQuery(builtQuery, options.populatePaths);
562
+
563
+ let data: (Document<any, any, any> & T)[];
564
+ try {
565
+ data = await populatedQuery.exec();
566
+ } catch (error: any) {
567
+ throw new APIError({
568
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
569
+ error,
570
+ title: `List error: ${error.stack}`,
571
+ });
572
+ }
573
+
574
+ let serialized;
575
+
576
+ try {
577
+ serialized = await responseHandler(data, "list", req, options);
578
+ } catch (error: any) {
579
+ throw new APIError({
580
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
581
+ error,
582
+ title: `responseHandler error: ${error.message}`,
583
+ });
584
+ }
585
+
586
+ let more;
587
+ try {
588
+ if (serialized && Array.isArray(serialized)) {
589
+ more = serialized.length === limit + 1 && serialized.length > 0;
590
+ if (more) {
591
+ // Slice off the extra document we fetched to determine if more is true or not.
592
+ serialized = serialized.slice(0, limit);
593
+
594
+ if (!req.query.page) {
595
+ const msg = `More than ${limit} results returned for ${model.collection.name} without pagination, data may be silently truncated. req.query: ${JSON.stringify(req.query)}`;
596
+ logger.warn(msg);
597
+ try {
598
+ Sentry.captureMessage(msg);
599
+ } catch (error) {
600
+ logger.error(`Error capturing message: ${error}`);
601
+ }
602
+ }
603
+ }
604
+ return res.json({
605
+ data: serialized,
606
+ limit,
607
+ more,
608
+ page: req.query.page,
609
+ total,
610
+ });
611
+ }
612
+ return res.json({data: serialized});
613
+ } catch (error: any) {
614
+ throw new APIError({
615
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
616
+ error,
617
+ title: `Serialization error: ${error.message}`,
618
+ });
619
+ }
620
+ })
621
+ );
622
+
623
+ router.get(
624
+ "/:id",
625
+ [
626
+ authenticateMiddleware(options.allowAnonymous),
627
+ getOpenApiMiddleware(baseModel, options),
628
+ permissionMiddleware(baseModel, options),
629
+ ],
630
+ asyncHandler(async (req: Request, res: Response) => {
631
+ const data: mongoose.Document & T = (req as any).obj;
632
+
633
+ try {
634
+ const serialized = await responseHandler(data, "read", req, options);
635
+ return res.json({data: serialized});
636
+ } catch (error: any) {
637
+ throw new APIError({
638
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
639
+ error,
640
+ title: `responseHandler error: ${error.message}`,
641
+ });
642
+ }
643
+ })
644
+ );
645
+
646
+ router.put(
647
+ "/:id",
648
+ authenticateMiddleware(options.allowAnonymous),
649
+ asyncHandler(async (_req: Request, _res: Response) => {
650
+ // Patch is what we want 90% of the time
651
+ throw new APIError({
652
+ title: "PUT is not supported.",
653
+ });
654
+ })
655
+ );
656
+
657
+ router.patch(
658
+ "/:id",
659
+ [
660
+ authenticateMiddleware(options.allowAnonymous),
661
+ patchOpenApiMiddleware(baseModel, options),
662
+ permissionMiddleware(baseModel, options),
663
+ ],
664
+ asyncHandler(async (req: Request, res: Response) => {
665
+ const model = getModel(baseModel, req.body, options);
666
+
667
+ let doc: mongoose.Document & T = (req as any).obj;
668
+
669
+ let body;
670
+
671
+ try {
672
+ body = transform<T>(options, req.body, "update", req.user);
673
+ } catch (error: any) {
674
+ throw new APIError({
675
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
676
+ error,
677
+ status: 403,
678
+ title: `PATCH failed on ${req.params.id} for user ${req.user?.id}: ${error.message}`,
679
+ });
680
+ }
681
+
682
+ if (options.preUpdate) {
683
+ try {
684
+ // TODO: Send flattened dot notation body to preUpdate, then merge the returned body
685
+ // with the original body, maintaining the dot notation. This way we don't have to write
686
+ // two preUpdate branches downstream, one looking at the dot notation style and
687
+ // one looking at normal object style.
688
+ body = await options.preUpdate(body, req);
689
+ } catch (error: any) {
690
+ if (isAPIError(error)) {
691
+ throw error;
692
+ }
693
+ throw new APIError({
694
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
695
+ error,
696
+ status: 400,
697
+ title: `preUpdate hook error on ${req.params.id}: ${error.message}`,
698
+ });
699
+ }
700
+ if (body === undefined) {
701
+ throw new APIError({
702
+ detail: "A body must be returned from preUpdate",
703
+ status: 403,
704
+ title: "Update not allowed",
705
+ });
706
+ }
707
+ if (body === null) {
708
+ throw new APIError({
709
+ detail: `preUpdate hook on ${req.params.id} returned null`,
710
+ status: 403,
711
+ title: "Update not allowed",
712
+ });
713
+ }
714
+ }
715
+
716
+ // Make a copy for passing pre-saved values to hooks.
717
+ const prevDoc = cloneDeep(doc);
718
+
719
+ // Using .save here runs the risk of a versioning error if you try to make two simultaneous
720
+ // updates. We won't wind up with corrupted data, just an API error.
721
+ try {
722
+ doc.set(body);
723
+ await doc.save();
724
+ } catch (error: any) {
725
+ throw new APIError({
726
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
727
+ error,
728
+ status: 400,
729
+ title: `preUpdate hook save error on ${req.params.id}: ${error.message}`,
730
+ });
731
+ }
732
+
733
+ if (options.populatePaths) {
734
+ let populateQuery = model.findById(doc._id);
735
+ populateQuery = addPopulateToQuery(populateQuery, options.populatePaths);
736
+ doc = await populateQuery.exec();
737
+ }
738
+
739
+ if (options.postUpdate) {
740
+ try {
741
+ await options.postUpdate(doc, body, req, prevDoc);
742
+ } catch (error: any) {
743
+ throw new APIError({
744
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
745
+ error,
746
+ status: 400,
747
+ title: `postUpdate hook error on ${req.params.id}: ${error.message}`,
748
+ });
749
+ }
750
+ }
751
+
752
+ try {
753
+ const serialized = await responseHandler(doc, "update", req, options);
754
+ return res.json({data: serialized});
755
+ } catch (error: any) {
756
+ throw new APIError({
757
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
758
+ error,
759
+ title: `responseHandler error: ${error.message}`,
760
+ });
761
+ }
762
+ })
763
+ );
764
+
765
+ router.delete(
766
+ "/:id",
767
+ [
768
+ authenticateMiddleware(options.allowAnonymous),
769
+ deleteOpenApiMiddleware(baseModel, options),
770
+ permissionMiddleware(baseModel, options),
771
+ ],
772
+ asyncHandler(async (req: Request, res: Response) => {
773
+ const model = getModel(baseModel, req.body, options);
774
+
775
+ const doc: mongoose.Document & T & {deleted?: boolean} = (req as any).obj;
776
+
777
+ if (options.preDelete) {
778
+ let body;
779
+ try {
780
+ body = await options.preDelete(doc, req);
781
+ } catch (error: any) {
782
+ if (isAPIError(error)) {
783
+ throw error;
784
+ }
785
+ throw new APIError({
786
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
787
+ error,
788
+ status: 403,
789
+ title: `preDelete hook error on ${req.params.id}: ${error.message}`,
790
+ });
791
+ }
792
+ if (body === undefined) {
793
+ throw new APIError({
794
+ detail: "A body must be returned from preDelete",
795
+ status: 403,
796
+ title: "Delete not allowed",
797
+ });
798
+ }
799
+ if (body === null) {
800
+ throw new APIError({
801
+ detail: `preDelete hook for ${req.params.id} returned null`,
802
+ status: 403,
803
+ title: "Delete not allowed",
804
+ });
805
+ }
806
+ }
807
+
808
+ // Support .deleted from isDeleted plugin
809
+ if (
810
+ Object.keys(model.schema.paths).includes("deleted") &&
811
+ model.schema.paths.deleted.instance === "Boolean"
812
+ ) {
813
+ doc.deleted = true;
814
+ await doc.save();
815
+ } else {
816
+ // For models without the isDeleted plugin
817
+ try {
818
+ await doc.deleteOne();
819
+ } catch (error: any) {
820
+ throw new APIError({
821
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
822
+ error,
823
+ status: 400,
824
+ title: error.message,
825
+ });
826
+ }
827
+ }
828
+
829
+ if (options.postDelete) {
830
+ try {
831
+ await options.postDelete(req, doc);
832
+ } catch (error: any) {
833
+ throw new APIError({
834
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
835
+ error,
836
+ status: 400,
837
+ title: `postDelete hook error: ${error.message}`,
838
+ });
839
+ }
840
+ }
841
+
842
+ return res.status(204).json({});
843
+ })
844
+ );
845
+
846
+ async function arrayOperation(
847
+ req: Request,
848
+ res: Response,
849
+ operation: "POST" | "PATCH" | "DELETE"
850
+ ) {
851
+ // TODO Combine array operations and .patch(), as they are very similar.
852
+ const model = getModel(baseModel, req.body, options);
853
+
854
+ if (!(await checkPermissions("update", options.permissions.update, req.user))) {
855
+ throw new APIError({
856
+ status: 405,
857
+ title: `Access to PATCH on ${model.modelName} denied for ${req.user?.id}`,
858
+ });
859
+ }
860
+
861
+ const doc = await model.findById(req.params.id);
862
+ // Make a copy for passing pre-saved values to hooks.
863
+ 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)) {
867
+ throw new APIError({
868
+ status: 404,
869
+ title: `Could not find document to PATCH: ${req.params.id}`,
870
+ });
871
+ }
872
+
873
+ if (!(await checkPermissions("update", options.permissions.update, req.user, doc))) {
874
+ throw new APIError({
875
+ status: 403,
876
+ title: `Patch not allowed for user ${req.user?.id} on doc ${doc._id}`,
877
+ });
878
+ }
879
+
880
+ // We apply the operation *before* the hooks. As far as the callers are concerned, this should
881
+ // be like PATCHing the field and replacing the whole thing.
882
+ if (operation !== "DELETE" && req.body[req.params.field] === undefined) {
883
+ throw new APIError({
884
+ status: 400,
885
+ title: `Malformed body, array operations should have a single, top level key, got: ${Object.keys(
886
+ req.body
887
+ ).join(",")}`,
888
+ });
889
+ }
890
+
891
+ const field = req.params.field;
892
+
893
+ const array = [...doc[field]];
894
+ if (operation === "POST") {
895
+ array.push(req.body[field]);
896
+ } else if (operation === "PATCH" || operation === "DELETE") {
897
+ // Check for subschema vs String array:
898
+ let index;
899
+ if (isValidObjectId(req.params.itemId)) {
900
+ index = array.findIndex((x: any) => x.id === req.params.itemId);
901
+ } else {
902
+ index = array.findIndex((x: string) => x === req.params.itemId);
903
+ }
904
+ if (index === -1) {
905
+ throw new APIError({
906
+ status: 404,
907
+ title: `Could not find ${field}/${req.params.itemId}`,
908
+ });
909
+ }
910
+ // For PATCHing an item by ID, we need to merge the objects so we don't override the _id or
911
+ // other parts of the subdocument.
912
+ if (operation === "PATCH" && isValidObjectId(req.params.itemId)) {
913
+ Object.assign(array[index], req.body[field]);
914
+ } else if (operation === "PATCH") {
915
+ // For PATCHing a string array, we can replace the whole object.
916
+ array[index] = req.body[field];
917
+ } else {
918
+ array.splice(index, 1);
919
+ }
920
+ } else {
921
+ throw new APIError({
922
+ status: 400,
923
+ title: `Invalid array operation: ${operation}`,
924
+ });
925
+ }
926
+ let body: Partial<T> | null = {[field]: array} as unknown as Partial<T>;
927
+
928
+ try {
929
+ body = transform<T>(options, body, "update", req.user) as Partial<T>;
930
+ } catch (error: any) {
931
+ throw new APIError({
932
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
933
+ error,
934
+ status: 403,
935
+ title: error.message,
936
+ });
937
+ }
938
+
939
+ if (options.preUpdate) {
940
+ try {
941
+ body = await options.preUpdate(body, req);
942
+ } catch (error: any) {
943
+ throw new APIError({
944
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
945
+ error,
946
+ status: 400,
947
+ title: `preUpdate hook error on ${req.params.id}: ${error.message}`,
948
+ });
949
+ }
950
+ if (body === undefined) {
951
+ throw new APIError({
952
+ detail: "A body must be returned from preUpdate",
953
+ status: 403,
954
+ title: "Update not allowed",
955
+ });
956
+ }
957
+ if (body === null) {
958
+ throw new APIError({
959
+ detail: `preUpdate hook on ${req.params.id} returned null`,
960
+ status: 403,
961
+ title: "Update not allowed",
962
+ });
963
+ }
964
+ }
965
+
966
+ // Using .save here runs the risk of a versioning error if you try to make two simultaneous
967
+ // updates. We won't wind up with corrupted data, just an API error.
968
+ try {
969
+ Object.assign(doc, body);
970
+ await doc.save();
971
+ } catch (error: any) {
972
+ throw new APIError({
973
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
974
+ error,
975
+ status: 400,
976
+ title: `PATCH Pre Update error on ${req.params.id}: ${error.message}`,
977
+ });
978
+ }
979
+
980
+ if (options.postUpdate) {
981
+ try {
982
+ await options.postUpdate(doc, body, req, prevDoc);
983
+ } catch (error: any) {
984
+ throw new APIError({
985
+ disableExternalErrorTracking: getDisableExternalErrorTracking(error),
986
+ error,
987
+ status: 400,
988
+ title: `PATCH Post Update error on ${req.params.id}: ${error.message}`,
989
+ });
990
+ }
991
+ }
992
+ return res.json({data: serialize<T>(req, options, doc)});
993
+ }
994
+
995
+ async function arrayPost(req: Request, res: Response) {
996
+ return arrayOperation(req, res, "POST");
997
+ }
998
+
999
+ async function arrayPatch(req: Request, res: Response) {
1000
+ return arrayOperation(req, res, "PATCH");
1001
+ }
1002
+
1003
+ async function arrayDelete(req: Request, res: Response) {
1004
+ return arrayOperation(req, res, "DELETE");
1005
+ }
1006
+ // 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")) {
1008
+ router.post(
1009
+ "/:id/:field",
1010
+ authenticateMiddleware(options.allowAnonymous),
1011
+ asyncHandler(arrayPost)
1012
+ );
1013
+ router.patch(
1014
+ "/:id/:field/:itemId",
1015
+ authenticateMiddleware(options.allowAnonymous),
1016
+ asyncHandler(arrayPatch)
1017
+ );
1018
+ router.delete(
1019
+ "/:id/:field/:itemId",
1020
+ authenticateMiddleware(options.allowAnonymous),
1021
+ asyncHandler(arrayDelete)
1022
+ );
1023
+ }
1024
+ router.use(apiErrorMiddleware);
1025
+
1026
+ return router;
1027
+ }
1028
+
1029
+ // Since express doesn't handle async routes well, wrap them with this function.
1030
+ export const asyncHandler = (fn: any) => (req: Request, res: Response, next: NextFunction) => {
1031
+ return Promise.resolve(fn(req, res, next)).catch(next);
1032
+ };
1033
+
1034
+ // For backwards compatibility with the old names.
1035
+ export const gooseRestRouter = modelRouter;
1036
+ export type GooseRESTOptions<T> = modelRouterOptions<T>;