@terreno/api 0.15.1 → 0.16.0
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/CHANGELOG.md +21 -0
- package/dist/actions.d.ts +55 -0
- package/dist/actions.js +472 -0
- package/dist/actions.openApi.test.d.ts +1 -0
- package/dist/actions.openApi.test.js +252 -0
- package/dist/actions.test.d.ts +1 -0
- package/dist/actions.test.js +946 -0
- package/dist/api.d.ts +5 -0
- package/dist/api.js +4 -1
- package/dist/consentApp.js +118 -102
- package/dist/docLoader.d.ts +7 -0
- package/dist/docLoader.js +154 -0
- package/dist/docLoader.test.d.ts +1 -0
- package/dist/docLoader.test.js +137 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/permissions.d.ts +2 -2
- package/dist/permissions.js +11 -107
- package/dist/zodOpenApi.d.ts +2 -0
- package/dist/zodOpenApi.js +7 -0
- package/package.json +6 -3
- package/src/actions.openApi.test.ts +176 -0
- package/src/actions.test.ts +636 -0
- package/src/actions.ts +441 -0
- package/src/api.ts +14 -1
- package/src/consentApp.ts +80 -81
- package/src/docLoader.test.ts +58 -0
- package/src/docLoader.ts +77 -0
- package/src/index.ts +2 -0
- package/src/permissions.ts +4 -62
- package/src/zodOpenApi.ts +6 -0
package/dist/permissions.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type express from "express";
|
|
2
2
|
import type { NextFunction } from "express";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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> {
|
package/dist/permissions.js
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
253
|
-
return [4 /*yield*/,
|
|
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
|
|
317
|
-
|
|
318
|
-
logger_1.logger.error("Permissions error: ".concat(
|
|
319
|
-
return [2 /*return*/, next(
|
|
320
|
-
case
|
|
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,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.
|
|
112
|
+
"version": "0.16.0"
|
|
110
113
|
}
|
|
@@ -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
|
+
});
|