@terreno/api 0.15.0 → 0.15.2

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.
@@ -1,7 +1,7 @@
1
1
  import type express from "express";
2
2
  import type { NextFunction } from "express";
3
- import { type Model } from "mongoose";
4
- import { type ModelRouterOptions, type RESTMethod } from "./api";
3
+ import type { Model } from "mongoose";
4
+ import type { ModelRouterOptions, RESTMethod } from "./api";
5
5
  import type { User } from "./auth";
6
6
  export type PermissionMethod<T> = (method: RESTMethod, user?: User, obj?: T) => boolean | Promise<boolean>;
7
7
  export interface RESTPermissions<T> {
@@ -1,37 +1,4 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
3
  function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
4
  return new (P || (P = Promise))(function (resolve, reject) {
@@ -79,14 +46,9 @@ var __values = (this && this.__values) || function(o) {
79
46
  };
80
47
  throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
81
48
  };
82
- var __importDefault = (this && this.__importDefault) || function (mod) {
83
- return (mod && mod.__esModule) ? mod : { "default": mod };
84
- };
85
49
  Object.defineProperty(exports, "__esModule", { value: true });
86
50
  exports.permissionMiddleware = exports.checkPermissions = exports.Permissions = exports.OwnerQueryFilter = void 0;
87
- var Sentry = __importStar(require("@sentry/bun"));
88
- var mongoose_1 = __importDefault(require("mongoose"));
89
- var api_1 = require("./api");
51
+ var docLoader_1 = require("./docLoader");
90
52
  var errors_1 = require("./errors");
91
53
  var logger_1 = require("./logger");
92
54
  var OwnerQueryFilter = function (user) {
@@ -193,7 +155,7 @@ exports.checkPermissions = checkPermissions;
193
155
  // req.obj.
194
156
  var permissionMiddleware = function (model, options) {
195
157
  return function (req, _res, next) { return __awaiter(void 0, void 0, void 0, function () {
196
- var method, reqMethod, builtQuery, populatedQuery, data, error_1, hiddenDoc, error, reason, error, error_2;
158
+ var method, reqMethod, data, error_1;
197
159
  var _a, _b;
198
160
  return __generator(this, function (_c) {
199
161
  switch (_c.label) {
@@ -203,7 +165,7 @@ var permissionMiddleware = function (model, options) {
203
165
  }
204
166
  _c.label = 1;
205
167
  case 1:
206
- _c.trys.push([1, 10, , 11]);
168
+ _c.trys.push([1, 5, , 6]);
207
169
  method = void 0;
208
170
  reqMethod = req.method.toLowerCase();
209
171
  if (reqMethod === "post") {
@@ -242,69 +204,11 @@ var permissionMiddleware = function (model, options) {
242
204
  if (method === "create" || method === "list") {
243
205
  return [2 /*return*/, next()];
244
206
  }
245
- builtQuery = model.findById(req.params.id);
246
- populatedQuery = (0, api_1.addPopulateToQuery)(
247
- // biome-ignore lint/suspicious/noExplicitAny: Query types vary based on populate paths
248
- builtQuery, options.populatePaths);
249
- data = void 0;
250
- _c.label = 3;
207
+ return [4 /*yield*/, (0, docLoader_1.loadDocOr404)(model, req.params.id, options.populatePaths)];
251
208
  case 3:
252
- _c.trys.push([3, 5, , 6]);
253
- return [4 /*yield*/, populatedQuery.exec()];
209
+ data = _c.sent();
210
+ return [4 /*yield*/, (0, exports.checkPermissions)(method, options.permissions[method], req.user, data)];
254
211
  case 4:
255
- data = (_c.sent());
256
- return [3 /*break*/, 6];
257
- case 5:
258
- error_1 = _c.sent();
259
- throw new errors_1.APIError({
260
- error: error_1,
261
- status: 500,
262
- title: "GET failed on ".concat(req.params.id),
263
- });
264
- case 6:
265
- if (!!data) return [3 /*break*/, 8];
266
- return [4 /*yield*/, model.collection.findOne({
267
- _id: new mongoose_1.default.Types.ObjectId(req.params.id),
268
- })];
269
- case 7:
270
- hiddenDoc = _c.sent();
271
- if (!hiddenDoc) {
272
- Sentry.captureMessage("Document ".concat(req.params.id, " not found for model ").concat(model.modelName));
273
- error = new errors_1.APIError({
274
- status: 404,
275
- title: "Document ".concat(req.params.id, " not found for model ").concat(model.modelName),
276
- });
277
- error.meta = undefined;
278
- throw error;
279
- }
280
- reason = null;
281
- if (hiddenDoc.deleted) {
282
- reason = { deleted: "true" };
283
- }
284
- else if (hiddenDoc.disabled) {
285
- reason = { disabled: "true" };
286
- }
287
- else if (hiddenDoc.archived) {
288
- reason = { archived: "true" };
289
- }
290
- // If no reason found, treat as not found
291
- if (!reason) {
292
- error = new errors_1.APIError({
293
- status: 404,
294
- title: "Document ".concat(req.params.id, " not found for model ").concat(model.modelName),
295
- });
296
- error.meta = undefined;
297
- throw error;
298
- }
299
- throw new errors_1.APIError({
300
- // We don't want to send this to Sentry because it's expected behavior.
301
- disableExternalErrorTracking: true,
302
- meta: reason,
303
- status: 404,
304
- title: "Document ".concat(req.params.id, " not found for model ").concat(model.modelName),
305
- });
306
- case 8: return [4 /*yield*/, (0, exports.checkPermissions)(method, options.permissions[method], req.user, data)];
307
- case 9:
308
212
  if (!(_c.sent())) {
309
213
  throw new errors_1.APIError({
310
214
  status: 403,
@@ -313,11 +217,11 @@ var permissionMiddleware = function (model, options) {
313
217
  }
314
218
  req.obj = data;
315
219
  return [2 /*return*/, next()];
316
- case 10:
317
- error_2 = _c.sent();
318
- logger_1.logger.error("Permissions error: ".concat(error_2 instanceof Error ? error_2.message : error_2));
319
- return [2 /*return*/, next(error_2)];
320
- case 11: return [2 /*return*/];
220
+ case 5:
221
+ error_1 = _c.sent();
222
+ logger_1.logger.error("Permissions error: ".concat(error_1 instanceof Error ? error_1.message : error_1));
223
+ return [2 /*return*/, next(error_1)];
224
+ case 6: return [2 /*return*/];
321
225
  }
322
226
  });
323
227
  }); };
@@ -0,0 +1,2 @@
1
+ import { z } from "zod";
2
+ export { z };
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.z = void 0;
4
+ var zod_to_openapi_1 = require("@asteasolutions/zod-to-openapi");
5
+ var zod_1 = require("zod");
6
+ Object.defineProperty(exports, "z", { enumerable: true, get: function () { return zod_1.z; } });
7
+ (0, zod_to_openapi_1.extendZodWithOpenApi)(zod_1.z);
package/package.json CHANGED
@@ -4,6 +4,7 @@
4
4
  "url": "https://github.com/FlourishHealth/terreno/issues"
5
5
  },
6
6
  "dependencies": {
7
+ "@asteasolutions/zod-to-openapi": "^8.5.0",
7
8
  "@sentry/bun": "^10.25.0",
8
9
  "@sentry/profiling-node": "^10.25.0",
9
10
  "@types/qs": "^6.14.0",
@@ -67,7 +68,8 @@
67
68
  "sinon": "^21.0.1",
68
69
  "supertest": "^7.0.0",
69
70
  "typedoc": "~0.28.17",
70
- "typescript": "5.9.3"
71
+ "typescript": "5.9.3",
72
+ "zod": "^4.3.6"
71
73
  },
72
74
  "homepage": "https://github.com/FlourishHealth/terreno#readme",
73
75
  "keywords": [
@@ -83,7 +85,8 @@
83
85
  "access": "public"
84
86
  },
85
87
  "peerDependencies": {
86
- "mongoose": "^8.0.0 || ^9.0.0"
88
+ "mongoose": "^8.0.0 || ^9.0.0",
89
+ "zod": "^4.3.6"
87
90
  },
88
91
  "repository": {
89
92
  "type": "git",
@@ -106,5 +109,5 @@
106
109
  "updateSnapshot": "bun test --update-snapshots"
107
110
  },
108
111
  "types": "dist/index.d.ts",
109
- "version": "0.15.0"
112
+ "version": "0.15.2"
110
113
  }
@@ -244,4 +244,40 @@ describe("VersionCheckPlugin direct usage", () => {
244
244
  expect(res.status).toBe(200);
245
245
  expect(res.body.status).toBe("ok");
246
246
  });
247
+
248
+ it("handles a numeric version query parameter", async () => {
249
+ await setupDb();
250
+ await VersionConfig.deleteMany({});
251
+
252
+ const express = require("express");
253
+ const expressApp = express();
254
+
255
+ // Use a custom query parser that coerces numeric strings to numbers so we
256
+ // exercise the `typeof versionParam === "number"` branch.
257
+ expressApp.set("query parser", (qs: string) => {
258
+ const params: Record<string, string | number> = {};
259
+ for (const pair of qs.split("&")) {
260
+ const [key, val] = pair.split("=");
261
+ if (val !== undefined && /^\d+$/.test(val)) {
262
+ params[decodeURIComponent(key)] = Number(val);
263
+ } else {
264
+ params[decodeURIComponent(key)] = decodeURIComponent(val ?? "");
265
+ }
266
+ }
267
+ return params;
268
+ });
269
+
270
+ const plugin = new VersionCheckPlugin();
271
+ plugin.register(expressApp);
272
+
273
+ await VersionConfig.create({
274
+ webRequiredVersion: 100,
275
+ webWarningVersion: 150,
276
+ });
277
+
278
+ const testApp = supertest(expressApp);
279
+ const res = await testApp.get("/version-check?version=50&platform=web");
280
+ expect(res.status).toBe(200);
281
+ expect(res.body.status).toBe("required");
282
+ });
247
283
  });
@@ -0,0 +1,176 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
2
+ import {beforeEach, describe, expect, it} from "bun:test";
3
+ import type express from "express";
4
+ import supertest from "supertest";
5
+ import {type ModelRouterOptions, modelRouter} from "./api";
6
+ import {addAuthRoutes, setupAuth} from "./auth";
7
+ import {setupServer} from "./expressServer";
8
+ import {Permissions} from "./permissions";
9
+ import {TerrenoApp} from "./terrenoApp";
10
+ import {authAsUser, FoodModel, setupDb, UserModel} from "./tests";
11
+ import {z} from "./zodOpenApi";
12
+
13
+ const foodActionPermissions = {
14
+ create: [Permissions.IsAny],
15
+ delete: [Permissions.IsAny],
16
+ list: [Permissions.IsAny],
17
+ read: [Permissions.IsAny],
18
+ update: [Permissions.IsAny],
19
+ };
20
+
21
+ const foodActionRouterOptions = {
22
+ allowAnonymous: true,
23
+ collectionActions: {
24
+ summarize: {
25
+ body: z
26
+ .object({
27
+ label: z.string(),
28
+ })
29
+ .strict(),
30
+ handler: async ({body}) => ({label: (body as {label: string}).label, total: 1}),
31
+ method: "POST" as const,
32
+ permissions: [Permissions.IsAny],
33
+ response: z.object({label: z.string(), total: z.number()}).strict(),
34
+ summary: "Summarize foods collection",
35
+ tag: "CustomFoodTag",
36
+ },
37
+ },
38
+ instanceActions: {
39
+ ping: {
40
+ handler: async ({doc}) => ({id: String(doc._id)}),
41
+ method: "GET" as const,
42
+ permissions: [Permissions.IsAny],
43
+ response: z.object({id: z.string()}),
44
+ summary: "Ping a food document",
45
+ },
46
+ },
47
+ permissions: foodActionPermissions,
48
+ sort: "-created",
49
+ };
50
+
51
+ const primeActionOpenApiRoutes = async (
52
+ server: ReturnType<typeof supertest>,
53
+ foodId: string
54
+ ): Promise<void> => {
55
+ await server.get(`/food/${foodId}/ping`).expect(200);
56
+ await server.post("/food/summarize").send({label: "test"}).expect(200);
57
+ };
58
+
59
+ const assertActionOpenApiSpec = (spec: Record<string, unknown>): void => {
60
+ const paths = spec.paths as Record<string, Record<string, unknown>>;
61
+ const collectionPath = paths["/food/summarize"];
62
+ const instancePath = paths["/food/{id}/ping"];
63
+
64
+ expect(collectionPath?.post).toBeDefined();
65
+ expect(instancePath?.get).toBeDefined();
66
+
67
+ const collectionOp = collectionPath.post as Record<string, unknown>;
68
+ const instanceOp = instancePath.get as Record<string, unknown>;
69
+
70
+ expect(collectionOp.operationId).toBe("CustomFoodTag_summarize");
71
+ expect(collectionOp.tags).toEqual(["CustomFoodTag"]);
72
+ expect(collectionOp.summary).toBe("Summarize foods collection");
73
+
74
+ expect(instanceOp.operationId).toBe("foods_ping");
75
+ expect(instanceOp.tags).toEqual(["foods"]);
76
+ expect(instanceOp.summary).toBe("Ping a food document");
77
+
78
+ const collectionParams = (collectionOp.parameters as {in: string; name: string}[]) ?? [];
79
+ expect(collectionParams.some((p) => p.in === "path" && p.name === "id")).toBe(false);
80
+
81
+ const instanceParams =
82
+ (instanceOp.parameters as {in: string; name: string; required?: boolean}[]) ?? [];
83
+ const idParam = instanceParams.find((p) => p.in === "path" && p.name === "id");
84
+ expect(idParam).toBeDefined();
85
+ expect(idParam?.required).toBe(true);
86
+
87
+ const collectionRequestSchema = (
88
+ collectionOp.requestBody as {
89
+ content: {"application/json": {schema: {properties: {label: unknown}}}};
90
+ }
91
+ ).content["application/json"].schema;
92
+ expect(collectionRequestSchema.properties?.label).toBeDefined();
93
+
94
+ const collectionResponseSchema = (
95
+ collectionOp.responses as {
96
+ "200": {content: {"application/json": {schema: {properties: {data: unknown}}}}};
97
+ }
98
+ )["200"].content["application/json"].schema;
99
+ expect(collectionResponseSchema.properties?.data).toBeDefined();
100
+
101
+ const instanceResponseSchema = (
102
+ instanceOp.responses as {
103
+ "200": {content: {"application/json": {schema: {properties: {data: unknown}}}}};
104
+ }
105
+ )["200"].content["application/json"].schema;
106
+ expect(instanceResponseSchema.properties?.data).toBeDefined();
107
+ };
108
+
109
+ describe("action OpenAPI emission", () => {
110
+ let admin: Awaited<ReturnType<typeof setupDb>>[0];
111
+ let foodId: string;
112
+
113
+ beforeEach(async () => {
114
+ process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
115
+ process.env.ENABLE_SWAGGER = "true";
116
+ [admin] = await setupDb();
117
+ const food = await FoodModel.create({
118
+ calories: 1,
119
+ hidden: false,
120
+ name: "OpenApiFood",
121
+ ownerId: admin._id,
122
+ source: {name: "test"},
123
+ });
124
+ foodId = String(food._id);
125
+ });
126
+
127
+ describe("TerrenoApp", () => {
128
+ it("includes action operations in openapi.json after first request", async () => {
129
+ const foodRegistration = modelRouter("/food", FoodModel, foodActionRouterOptions);
130
+ const app = new TerrenoApp({
131
+ skipListen: true,
132
+ userModel: UserModel as any,
133
+ })
134
+ .register(foodRegistration)
135
+ .build();
136
+
137
+ const server = supertest(app);
138
+ await primeActionOpenApiRoutes(server, foodId);
139
+
140
+ const specRes = await server.get("/openapi.json").expect(200);
141
+ assertActionOpenApiSpec(specRes.body);
142
+ });
143
+ });
144
+
145
+ describe("setupServer", () => {
146
+ let app: express.Application;
147
+
148
+ beforeEach(() => {
149
+ const addRoutes = (
150
+ router: express.Router,
151
+ routerOptions?: Partial<ModelRouterOptions<unknown>>
152
+ ): void => {
153
+ router.use(
154
+ "/food",
155
+ modelRouter(FoodModel as any, {...foodActionRouterOptions, ...routerOptions})
156
+ );
157
+ };
158
+
159
+ app = setupServer({
160
+ addRoutes,
161
+ skipListen: true,
162
+ userModel: UserModel as any,
163
+ });
164
+ setupAuth(app, UserModel as any);
165
+ addAuthRoutes(app, UserModel as any);
166
+ });
167
+
168
+ it("emits the same action operations on first hit via legacy setupServer", async () => {
169
+ const server = supertest(app);
170
+ await primeActionOpenApiRoutes(server, foodId);
171
+
172
+ const specRes = await server.get("/openapi.json").expect(200);
173
+ assertActionOpenApiSpec(specRes.body);
174
+ });
175
+ });
176
+ });