@terreno/api 0.16.0 → 0.17.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.
@@ -70,7 +70,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
70
70
  var bun_test_1 = require("bun:test");
71
71
  var mongoose_1 = require("mongoose");
72
72
  var supertest_1 = __importDefault(require("supertest"));
73
- var zod_1 = require("zod");
74
73
  var actions_1 = require("./actions");
75
74
  var api_1 = require("./api");
76
75
  var auth_1 = require("./auth");
@@ -78,6 +77,7 @@ var errors_1 = require("./errors");
78
77
  var permissions_1 = require("./permissions");
79
78
  var plugins_1 = require("./plugins");
80
79
  var tests_1 = require("./tests");
80
+ var zodOpenApi_1 = require("./zodOpenApi");
81
81
  var stuffSchema = new mongoose_1.Schema({
82
82
  name: { type: String },
83
83
  ownerId: { type: String },
@@ -561,7 +561,7 @@ var allPermissions = {
561
561
  mountFoodRouter({
562
562
  collectionActions: {
563
563
  notify: {
564
- body: zod_1.z.object({ email: zod_1.z.string().email() }),
564
+ body: zodOpenApi_1.z.object({ email: zodOpenApi_1.z.string().email() }),
565
565
  handler: function (_a) { return __awaiter(void 0, [_a], void 0, function (_b) {
566
566
  var body = _b.body;
567
567
  return __generator(this, function (_c) {
@@ -591,7 +591,7 @@ var allPermissions = {
591
591
  mountFoodRouter({
592
592
  collectionActions: {
593
593
  notify: {
594
- body: zod_1.z.object({ email: zod_1.z.string().email() }),
594
+ body: zodOpenApi_1.z.object({ email: zodOpenApi_1.z.string().email() }),
595
595
  handler: function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
596
596
  return [2 /*return*/, ({ sent: true })];
597
597
  }); }); },
@@ -628,7 +628,7 @@ var allPermissions = {
628
628
  }); },
629
629
  method: "GET",
630
630
  permissions: [permissions_1.Permissions.IsAny],
631
- query: zod_1.z.object({ count: zod_1.z.coerce.number() }),
631
+ query: zodOpenApi_1.z.object({ count: zodOpenApi_1.z.coerce.number() }),
632
632
  },
633
633
  },
634
634
  permissions: allPermissions,
@@ -642,6 +642,33 @@ var allPermissions = {
642
642
  }
643
643
  });
644
644
  }); });
645
+ (0, bun_test_1.it)("returns 400 with meta.fields for invalid query", function () { return __awaiter(void 0, void 0, void 0, function () {
646
+ var res;
647
+ return __generator(this, function (_a) {
648
+ switch (_a.label) {
649
+ case 0:
650
+ mountFoodRouter({
651
+ collectionActions: {
652
+ lookup: {
653
+ handler: function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
654
+ return [2 /*return*/, ({ ok: true })];
655
+ }); }); },
656
+ method: "GET",
657
+ permissions: [permissions_1.Permissions.IsAny],
658
+ query: zodOpenApi_1.z.object({ email: zodOpenApi_1.z.string().email() }),
659
+ },
660
+ },
661
+ permissions: allPermissions,
662
+ });
663
+ return [4 /*yield*/, server.get("/food/lookup?email=bad").expect(400)];
664
+ case 1:
665
+ res = _a.sent();
666
+ (0, bun_test_1.expect)(res.body.title).toBe("Validation failed");
667
+ (0, bun_test_1.expect)(res.body.meta.fields.email).toBeDefined();
668
+ return [2 /*return*/];
669
+ }
670
+ });
671
+ }); });
645
672
  (0, bun_test_1.it)("coerces body values via zod in ctx", function () { return __awaiter(void 0, void 0, void 0, function () {
646
673
  var seenCount, res;
647
674
  return __generator(this, function (_a) {
@@ -650,7 +677,7 @@ var allPermissions = {
650
677
  mountFoodRouter({
651
678
  collectionActions: {
652
679
  tally: {
653
- body: zod_1.z.object({ count: zod_1.z.coerce.number() }),
680
+ body: zodOpenApi_1.z.object({ count: zodOpenApi_1.z.coerce.number() }),
654
681
  handler: function (_a) { return __awaiter(void 0, [_a], void 0, function (_b) {
655
682
  var parsed;
656
683
  var body = _b.body;
@@ -684,7 +711,7 @@ var allPermissions = {
684
711
  mountFoodRouter({
685
712
  collectionActions: {
686
713
  strictish: {
687
- body: zod_1.z.object({ known: zod_1.z.string() }),
714
+ body: zodOpenApi_1.z.object({ known: zodOpenApi_1.z.string() }),
688
715
  handler: function (_a) { return __awaiter(void 0, [_a], void 0, function (_b) {
689
716
  var body = _b.body;
690
717
  return __generator(this, function (_c) {
@@ -910,7 +937,7 @@ var allPermissions = {
910
937
  (0, bun_test_1.describe)("defineInstanceAction type ergonomics", function () {
911
938
  (0, bun_test_1.it)("preserves handler types at compile time", function () {
912
939
  var action = (0, actions_1.defineInstanceAction)({
913
- body: zod_1.z.object({ notifyUsers: zod_1.z.boolean() }),
940
+ body: zodOpenApi_1.z.object({ notifyUsers: zodOpenApi_1.z.boolean() }),
914
941
  handler: function (_a) { return __awaiter(void 0, [_a], void 0, function (_b) {
915
942
  var _doc, _notify;
916
943
  var _c, _d;
@@ -928,7 +955,7 @@ var allPermissions = {
928
955
  });
929
956
  (0, bun_test_1.it)("defineCollectionAction preserves body types", function () {
930
957
  var action = (0, actions_1.defineCollectionAction)({
931
- body: zod_1.z.object({ ids: zod_1.z.array(zod_1.z.string()) }),
958
+ body: zodOpenApi_1.z.object({ ids: zodOpenApi_1.z.array(zodOpenApi_1.z.string()) }),
932
959
  handler: function (_a) { return __awaiter(void 0, [_a], void 0, function (_b) {
933
960
  var _ids;
934
961
  var body = _b.body;
@@ -943,4 +970,113 @@ var allPermissions = {
943
970
  (0, bun_test_1.expect)(action.method).toBe("POST");
944
971
  });
945
972
  });
973
+ (0, bun_test_1.describe)("createActionOpenApiMiddleware", function () {
974
+ var createMockOpenApi = function () {
975
+ var captured;
976
+ var pathFn = function (op) {
977
+ captured = op;
978
+ return function (_req, _res, next) {
979
+ return next();
980
+ };
981
+ };
982
+ return {
983
+ get captured() {
984
+ return captured;
985
+ },
986
+ middleware: { path: pathFn },
987
+ };
988
+ };
989
+ (0, bun_test_1.it)("returns no-op middleware when openApi is not configured", function () {
990
+ var handler = (0, actions_1.createActionOpenApiMiddleware)({
991
+ action: { method: "GET", permissions: [] },
992
+ actionName: "test",
993
+ model: tests_1.FoodModel,
994
+ options: {},
995
+ scope: "collection",
996
+ });
997
+ (0, bun_test_1.expect)(typeof handler).toBe("function");
998
+ });
999
+ (0, bun_test_1.it)("generates query parameters from action.query schema", function () {
1000
+ var _a;
1001
+ var mock = createMockOpenApi();
1002
+ (0, actions_1.createActionOpenApiMiddleware)({
1003
+ action: {
1004
+ method: "GET",
1005
+ permissions: [],
1006
+ query: zodOpenApi_1.z.object({ page: zodOpenApi_1.z.coerce.number(), search: zodOpenApi_1.z.string() }),
1007
+ },
1008
+ actionName: "search",
1009
+ model: tests_1.FoodModel,
1010
+ options: { openApi: mock.middleware },
1011
+ scope: "collection",
1012
+ });
1013
+ var params = (_a = mock.captured) === null || _a === void 0 ? void 0 : _a.parameters;
1014
+ (0, bun_test_1.expect)(params.length).toBe(2);
1015
+ (0, bun_test_1.expect)(params.some(function (p) { return p.name === "search"; })).toBe(true);
1016
+ (0, bun_test_1.expect)(params.some(function (p) { return p.name === "page"; })).toBe(true);
1017
+ });
1018
+ (0, bun_test_1.it)("includes id path parameter for instance-scoped actions", function () {
1019
+ var _a;
1020
+ var mock = createMockOpenApi();
1021
+ (0, actions_1.createActionOpenApiMiddleware)({
1022
+ action: { method: "GET", permissions: [] },
1023
+ actionName: "peek",
1024
+ model: tests_1.FoodModel,
1025
+ options: { openApi: mock.middleware },
1026
+ scope: "instance",
1027
+ });
1028
+ var params = (_a = mock.captured) === null || _a === void 0 ? void 0 : _a.parameters;
1029
+ (0, bun_test_1.expect)(params.some(function (p) { return p.name === "id" && p.in === "path"; })).toBe(true);
1030
+ });
1031
+ (0, bun_test_1.it)("uses fallback data schema when response is not provided", function () {
1032
+ var _a;
1033
+ var mock = createMockOpenApi();
1034
+ (0, actions_1.createActionOpenApiMiddleware)({
1035
+ action: { method: "POST", permissions: [] },
1036
+ actionName: "noop",
1037
+ model: tests_1.FoodModel,
1038
+ options: { openApi: mock.middleware },
1039
+ scope: "collection",
1040
+ });
1041
+ var responses = (_a = mock.captured) === null || _a === void 0 ? void 0 : _a.responses;
1042
+ var ok = responses["200"];
1043
+ var schema = ok.content["application/json"].schema;
1044
+ (0, bun_test_1.expect)(schema.properties.data).toEqual({ type: "object" });
1045
+ });
1046
+ (0, bun_test_1.it)("uses zod response schema when provided", function () {
1047
+ var _a;
1048
+ var mock = createMockOpenApi();
1049
+ (0, actions_1.createActionOpenApiMiddleware)({
1050
+ action: {
1051
+ method: "POST",
1052
+ permissions: [],
1053
+ response: zodOpenApi_1.z.object({ count: zodOpenApi_1.z.number() }),
1054
+ },
1055
+ actionName: "tally",
1056
+ model: tests_1.FoodModel,
1057
+ options: { openApi: mock.middleware },
1058
+ scope: "collection",
1059
+ });
1060
+ var responses = (_a = mock.captured) === null || _a === void 0 ? void 0 : _a.responses;
1061
+ var ok = responses["200"];
1062
+ var schema = ok.content["application/json"].schema;
1063
+ (0, bun_test_1.expect)(schema.properties.data.properties).toBeDefined();
1064
+ });
1065
+ (0, bun_test_1.it)("includes requestBody when action.body is defined", function () {
1066
+ var _a;
1067
+ var mock = createMockOpenApi();
1068
+ (0, actions_1.createActionOpenApiMiddleware)({
1069
+ action: {
1070
+ body: zodOpenApi_1.z.object({ name: zodOpenApi_1.z.string() }),
1071
+ method: "POST",
1072
+ permissions: [],
1073
+ },
1074
+ actionName: "create",
1075
+ model: tests_1.FoodModel,
1076
+ options: { openApi: mock.middleware },
1077
+ scope: "collection",
1078
+ });
1079
+ (0, bun_test_1.expect)((_a = mock.captured) === null || _a === void 0 ? void 0 : _a.requestBody).toBeDefined();
1080
+ });
1081
+ });
946
1082
  });
package/package.json CHANGED
@@ -109,5 +109,5 @@
109
109
  "updateSnapshot": "bun test --update-snapshots"
110
110
  },
111
111
  "types": "dist/index.d.ts",
112
- "version": "0.16.0"
112
+ "version": "0.17.0"
113
113
  }
@@ -4,14 +4,19 @@ import type express from "express";
4
4
  import {type Model, model, Schema} from "mongoose";
5
5
  import supertest from "supertest";
6
6
  import type TestAgent from "supertest/lib/agent";
7
- import {z} from "zod";
8
- import {ACTION_NAME_PATTERN, defineCollectionAction, defineInstanceAction} from "./actions";
9
- import {modelRouter} from "./api";
7
+ import {
8
+ ACTION_NAME_PATTERN,
9
+ createActionOpenApiMiddleware,
10
+ defineCollectionAction,
11
+ defineInstanceAction,
12
+ } from "./actions";
13
+ import {modelRouter, type OpenApiMiddleware} from "./api";
10
14
  import {addAuthRoutes, setupAuth} from "./auth";
11
15
  import {apiUnauthorizedMiddleware} from "./errors";
12
16
  import {Permissions} from "./permissions";
13
17
  import {type IsDeleted, isDeletedPlugin} from "./plugins";
14
18
  import {authAsUser, type Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests";
19
+ import {z} from "./zodOpenApi";
15
20
 
16
21
  interface Stuff extends IsDeleted {
17
22
  _id: string;
@@ -430,6 +435,23 @@ describe("modelRouter actions", () => {
430
435
  expect(originalQ).toBe("5");
431
436
  });
432
437
 
438
+ it("returns 400 with meta.fields for invalid query", async () => {
439
+ mountFoodRouter({
440
+ collectionActions: {
441
+ lookup: {
442
+ handler: async () => ({ok: true}),
443
+ method: "GET",
444
+ permissions: [Permissions.IsAny],
445
+ query: z.object({email: z.string().email()}),
446
+ },
447
+ },
448
+ permissions: allPermissions,
449
+ });
450
+ const res = await server.get("/food/lookup?email=bad").expect(400);
451
+ expect(res.body.title).toBe("Validation failed");
452
+ expect(res.body.meta.fields.email).toBeDefined();
453
+ });
454
+
433
455
  it("coerces body values via zod in ctx", async () => {
434
456
  let seenCount: number | undefined;
435
457
  mountFoodRouter({
@@ -633,4 +655,129 @@ describe("modelRouter actions", () => {
633
655
  expect(action.method).toBe("POST");
634
656
  });
635
657
  });
658
+
659
+ describe("createActionOpenApiMiddleware", () => {
660
+ const createMockOpenApi = (): {
661
+ captured: Record<string, unknown> | undefined;
662
+ middleware: OpenApiMiddleware;
663
+ } => {
664
+ let captured: Record<string, unknown> | undefined;
665
+ const pathFn = (op: Record<string, unknown>): express.RequestHandler => {
666
+ captured = op;
667
+ return (_req: express.Request, _res: express.Response, next: express.NextFunction) =>
668
+ next();
669
+ };
670
+ return {
671
+ get captured() {
672
+ return captured;
673
+ },
674
+ middleware: {path: pathFn} as unknown as OpenApiMiddleware,
675
+ };
676
+ };
677
+
678
+ it("returns no-op middleware when openApi is not configured", () => {
679
+ const handler = createActionOpenApiMiddleware({
680
+ action: {method: "GET", permissions: []},
681
+ actionName: "test",
682
+ model: FoodModel as Model<Food>,
683
+ options: {},
684
+ scope: "collection",
685
+ });
686
+ expect(typeof handler).toBe("function");
687
+ });
688
+
689
+ it("generates query parameters from action.query schema", () => {
690
+ const mock = createMockOpenApi();
691
+ createActionOpenApiMiddleware({
692
+ action: {
693
+ method: "GET",
694
+ permissions: [],
695
+ query: z.object({page: z.coerce.number(), search: z.string()}),
696
+ },
697
+ actionName: "search",
698
+ model: FoodModel as Model<Food>,
699
+ options: {openApi: mock.middleware},
700
+ scope: "collection",
701
+ });
702
+ const params = mock.captured?.parameters as Record<string, unknown>[];
703
+ expect(params.length).toBe(2);
704
+ expect(params.some((p) => p.name === "search")).toBe(true);
705
+ expect(params.some((p) => p.name === "page")).toBe(true);
706
+ });
707
+
708
+ it("includes id path parameter for instance-scoped actions", () => {
709
+ const mock = createMockOpenApi();
710
+ createActionOpenApiMiddleware({
711
+ action: {method: "GET", permissions: []},
712
+ actionName: "peek",
713
+ model: FoodModel as Model<Food>,
714
+ options: {openApi: mock.middleware},
715
+ scope: "instance",
716
+ });
717
+ const params = mock.captured?.parameters as Record<string, unknown>[];
718
+ expect(params.some((p) => p.name === "id" && p.in === "path")).toBe(true);
719
+ });
720
+
721
+ it("uses fallback data schema when response is not provided", () => {
722
+ const mock = createMockOpenApi();
723
+ createActionOpenApiMiddleware({
724
+ action: {method: "POST", permissions: []},
725
+ actionName: "noop",
726
+ model: FoodModel as Model<Food>,
727
+ options: {openApi: mock.middleware},
728
+ scope: "collection",
729
+ });
730
+ const responses = mock.captured?.responses as Record<string, Record<string, unknown>>;
731
+ const ok = responses["200"] as Record<
732
+ string,
733
+ Record<string, Record<string, Record<string, unknown>>>
734
+ >;
735
+ const schema = ok.content["application/json"].schema as Record<
736
+ string,
737
+ Record<string, unknown>
738
+ >;
739
+ expect(schema.properties.data).toEqual({type: "object"});
740
+ });
741
+
742
+ it("uses zod response schema when provided", () => {
743
+ const mock = createMockOpenApi();
744
+ createActionOpenApiMiddleware({
745
+ action: {
746
+ method: "POST",
747
+ permissions: [],
748
+ response: z.object({count: z.number()}),
749
+ },
750
+ actionName: "tally",
751
+ model: FoodModel as Model<Food>,
752
+ options: {openApi: mock.middleware},
753
+ scope: "collection",
754
+ });
755
+ const responses = mock.captured?.responses as Record<string, Record<string, unknown>>;
756
+ const ok = responses["200"] as Record<
757
+ string,
758
+ Record<string, Record<string, Record<string, unknown>>>
759
+ >;
760
+ const schema = ok.content["application/json"].schema as Record<
761
+ string,
762
+ Record<string, unknown>
763
+ >;
764
+ expect((schema.properties.data as Record<string, unknown>).properties).toBeDefined();
765
+ });
766
+
767
+ it("includes requestBody when action.body is defined", () => {
768
+ const mock = createMockOpenApi();
769
+ createActionOpenApiMiddleware({
770
+ action: {
771
+ body: z.object({name: z.string()}),
772
+ method: "POST",
773
+ permissions: [],
774
+ },
775
+ actionName: "create",
776
+ model: FoodModel as Model<Food>,
777
+ options: {openApi: mock.middleware},
778
+ scope: "collection",
779
+ });
780
+ expect(mock.captured?.requestBody).toBeDefined();
781
+ });
782
+ });
636
783
  });