@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
@@ -0,0 +1,442 @@
1
+ import {beforeEach, describe, expect, it} from "bun:test";
2
+ import type express from "express";
3
+ import type {Router} from "express";
4
+ import supertest from "supertest";
5
+ import type TestAgent from "supertest/lib/agent";
6
+
7
+ import {modelRouter, type modelRouterOptions} from "./api";
8
+ import {addAuthRoutes, setupAuth} from "./auth";
9
+ import {setupServer} from "./expressServer";
10
+ import {createOpenApiBuilder, OpenApiMiddlewareBuilder} from "./openApiBuilder";
11
+ import {Permissions} from "./permissions";
12
+ import {FoodModel, UserModel} from "./tests";
13
+
14
+ function addRoutesWithBuilder(router: Router, options?: Partial<modelRouterOptions<any>>): void {
15
+ // Add a custom endpoint using the OpenApiMiddlewareBuilder
16
+ const statsMiddleware = createOpenApiBuilder(options ?? {})
17
+ .withTags(["Stats"])
18
+ .withSummary("Get food statistics")
19
+ .withDescription("Returns aggregated statistics about food items")
20
+ .withQueryParameter(
21
+ "category",
22
+ {type: "string"},
23
+ {
24
+ description: "Filter by food category",
25
+ required: false,
26
+ }
27
+ )
28
+ .withResponse<{count: number; avgCalories: number}>(200, {
29
+ avgCalories: {description: "Average calories", type: "number"},
30
+ count: {description: "Total number of food items", type: "number"},
31
+ })
32
+ .build();
33
+
34
+ router.get("/food/stats", statsMiddleware, async (_req, res) => {
35
+ res.json({avgCalories: 250, count: 10});
36
+ });
37
+
38
+ // Add endpoint with request body
39
+ const createReportMiddleware = createOpenApiBuilder(options ?? {})
40
+ .withTags(["Reports"])
41
+ .withSummary("Create a food report")
42
+ .withDescription("Generates a report based on provided criteria")
43
+ .withRequestBody<{
44
+ startDate: string;
45
+ endDate: string;
46
+ includeDeleted: boolean;
47
+ }>({
48
+ endDate: {
49
+ description: "Report end date",
50
+ format: "date",
51
+ required: true,
52
+ type: "string",
53
+ },
54
+ includeDeleted: {
55
+ description: "Whether to include deleted items",
56
+ type: "boolean",
57
+ },
58
+ startDate: {
59
+ description: "Report start date",
60
+ format: "date",
61
+ required: true,
62
+ type: "string",
63
+ },
64
+ })
65
+ .withResponse<{reportId: string}>(
66
+ 201,
67
+ {
68
+ reportId: {description: "Generated report ID", type: "string"},
69
+ },
70
+ {description: "Report created successfully"}
71
+ )
72
+ .build();
73
+
74
+ router.post("/food/reports", createReportMiddleware, async (_req, res) => {
75
+ res.status(201).json({reportId: "report-123"});
76
+ });
77
+
78
+ // Add endpoint with array response
79
+ const listCategoriesMiddleware = createOpenApiBuilder(options ?? {})
80
+ .withTags(["Categories"])
81
+ .withSummary("List food categories")
82
+ .withArrayResponse<{id: string; name: string; count: number}>(
83
+ 200,
84
+ {
85
+ count: {description: "Number of items in category", type: "number"},
86
+ id: {description: "Category ID", type: "string"},
87
+ name: {description: "Category name", type: "string"},
88
+ },
89
+ {description: "List of categories"}
90
+ )
91
+ .build();
92
+
93
+ router.get("/food/categories", listCategoriesMiddleware, async (_req, res) => {
94
+ res.json([
95
+ {count: 5, id: "1", name: "Fruits"},
96
+ {count: 3, id: "2", name: "Vegetables"},
97
+ ]);
98
+ });
99
+
100
+ // Add endpoint with path parameter
101
+ const getCategoryMiddleware = createOpenApiBuilder(options ?? {})
102
+ .withTags(["Categories"])
103
+ .withSummary("Get category by ID")
104
+ .withPathParameter(
105
+ "categoryId",
106
+ {type: "string"},
107
+ {
108
+ description: "The category identifier",
109
+ }
110
+ )
111
+ .withResponse<{id: string; name: string}>(200, {
112
+ id: {type: "string"},
113
+ name: {type: "string"},
114
+ })
115
+ .withResponse(404, "Category not found")
116
+ .build();
117
+
118
+ router.get("/food/categories/:categoryId", getCategoryMiddleware, async (req, res) => {
119
+ res.json({id: req.params.categoryId, name: "Fruits"});
120
+ });
121
+
122
+ // Standard modelRouter for food
123
+ router.use(
124
+ "/food",
125
+ modelRouter(FoodModel as any, {
126
+ ...options,
127
+ allowAnonymous: true,
128
+ permissions: {
129
+ create: [Permissions.IsAny],
130
+ delete: [Permissions.IsAny],
131
+ list: [Permissions.IsAny],
132
+ read: [Permissions.IsAny],
133
+ update: [Permissions.IsAny],
134
+ },
135
+ })
136
+ );
137
+ }
138
+
139
+ describe("OpenApiMiddlewareBuilder", () => {
140
+ let server: TestAgent;
141
+ let app: express.Application;
142
+
143
+ beforeEach(async () => {
144
+ process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
145
+ process.env.ENABLE_SWAGGER = "true";
146
+
147
+ app = setupServer({
148
+ addRoutes: addRoutesWithBuilder,
149
+ skipListen: true,
150
+ userModel: UserModel as any,
151
+ });
152
+ setupAuth(app, UserModel as any);
153
+ addAuthRoutes(app, UserModel as any);
154
+ });
155
+
156
+ describe("builder pattern", () => {
157
+ it("returns a builder instance from createOpenApiBuilder", () => {
158
+ const builder = createOpenApiBuilder({});
159
+ expect(builder).toBeInstanceOf(OpenApiMiddlewareBuilder);
160
+ });
161
+
162
+ it("supports method chaining", () => {
163
+ const builder = createOpenApiBuilder({});
164
+ const result = builder
165
+ .withTags(["test"])
166
+ .withSummary("Test summary")
167
+ .withDescription("Test description");
168
+
169
+ expect(result).toBe(builder);
170
+ });
171
+
172
+ it("returns noop middleware when openApi is not configured", () => {
173
+ const middleware = createOpenApiBuilder({}).build();
174
+ expect(typeof middleware).toBe("function");
175
+ expect(middleware.length).toBe(3); // Express middleware signature
176
+ });
177
+ });
178
+
179
+ describe("OpenAPI spec generation", () => {
180
+ it("includes custom endpoint with query parameter in OpenAPI spec", async () => {
181
+ server = supertest(app);
182
+ const res = await server.get("/openapi.json").expect(200);
183
+
184
+ const statsPath = res.body.paths["/food/stats"];
185
+ expect(statsPath).toBeDefined();
186
+ expect(statsPath.get).toBeDefined();
187
+ expect(statsPath.get.tags).toContain("Stats");
188
+ expect(statsPath.get.summary).toBe("Get food statistics");
189
+ expect(statsPath.get.description).toBe("Returns aggregated statistics about food items");
190
+
191
+ // Check query parameter
192
+ const categoryParam = statsPath.get.parameters.find((p: any) => p.name === "category");
193
+ expect(categoryParam).toBeDefined();
194
+ expect(categoryParam.in).toBe("query");
195
+ expect(categoryParam.schema.type).toBe("string");
196
+ expect(categoryParam.description).toBe("Filter by food category");
197
+ expect(categoryParam.required).toBe(false);
198
+ });
199
+
200
+ it("includes request body schema in OpenAPI spec", async () => {
201
+ server = supertest(app);
202
+ const res = await server.get("/openapi.json").expect(200);
203
+
204
+ const reportsPath = res.body.paths["/food/reports"];
205
+ expect(reportsPath).toBeDefined();
206
+ expect(reportsPath.post).toBeDefined();
207
+ expect(reportsPath.post.tags).toContain("Reports");
208
+
209
+ const requestBody = reportsPath.post.requestBody;
210
+ expect(requestBody).toBeDefined();
211
+ expect(requestBody.required).toBe(true);
212
+
213
+ const schema = requestBody.content["application/json"].schema;
214
+ expect(schema.type).toBe("object");
215
+ expect(schema.properties.startDate.type).toBe("string");
216
+ expect(schema.properties.startDate.format).toBe("date");
217
+ expect(schema.properties.endDate.type).toBe("string");
218
+ expect(schema.properties.includeDeleted.type).toBe("boolean");
219
+ expect(schema.required).toContain("startDate");
220
+ expect(schema.required).toContain("endDate");
221
+ expect(schema.required).not.toContain("includeDeleted");
222
+ });
223
+
224
+ it("includes response schema in OpenAPI spec", async () => {
225
+ server = supertest(app);
226
+ const res = await server.get("/openapi.json").expect(200);
227
+
228
+ const statsPath = res.body.paths["/food/stats"];
229
+ const response200 = statsPath.get.responses["200"];
230
+ expect(response200).toBeDefined();
231
+ expect(response200.description).toBe("Success");
232
+
233
+ const schema = response200.content["application/json"].schema;
234
+ expect(schema.type).toBe("object");
235
+ expect(schema.properties.count.type).toBe("number");
236
+ expect(schema.properties.avgCalories.type).toBe("number");
237
+ });
238
+
239
+ it("includes array response schema in OpenAPI spec", async () => {
240
+ server = supertest(app);
241
+ const res = await server.get("/openapi.json").expect(200);
242
+
243
+ const categoriesPath = res.body.paths["/food/categories"];
244
+ expect(categoriesPath).toBeDefined();
245
+
246
+ const response200 = categoriesPath.get.responses["200"];
247
+ expect(response200.description).toBe("List of categories");
248
+
249
+ const schema = response200.content["application/json"].schema;
250
+ expect(schema.type).toBe("array");
251
+ expect(schema.items.type).toBe("object");
252
+ expect(schema.items.properties.id.type).toBe("string");
253
+ expect(schema.items.properties.name.type).toBe("string");
254
+ expect(schema.items.properties.count.type).toBe("number");
255
+ });
256
+
257
+ it("includes path parameter in OpenAPI spec", async () => {
258
+ server = supertest(app);
259
+ const res = await server.get("/openapi.json").expect(200);
260
+
261
+ const categoryPath = res.body.paths["/food/categories/{categoryId}"];
262
+ expect(categoryPath).toBeDefined();
263
+
264
+ const pathParam = categoryPath.get.parameters.find((p: any) => p.name === "categoryId");
265
+ expect(pathParam).toBeDefined();
266
+ expect(pathParam.in).toBe("path");
267
+ expect(pathParam.required).toBe(true);
268
+ expect(pathParam.schema.type).toBe("string");
269
+ expect(pathParam.description).toBe("The category identifier");
270
+ });
271
+
272
+ it("includes custom response without body in OpenAPI spec", async () => {
273
+ server = supertest(app);
274
+ const res = await server.get("/openapi.json").expect(200);
275
+
276
+ // Test with the 201 response from reports endpoint which has a custom description
277
+ const reportsPath = res.body.paths["/food/reports"];
278
+ const response201 = reportsPath.post.responses["201"];
279
+ expect(response201).toBeDefined();
280
+ expect(response201.description).toBe("Report created successfully");
281
+ expect(response201.content).toBeDefined();
282
+ });
283
+
284
+ it("includes default error responses", async () => {
285
+ server = supertest(app);
286
+ const res = await server.get("/openapi.json").expect(200);
287
+
288
+ const statsPath = res.body.paths["/food/stats"];
289
+ const responses = statsPath.get.responses;
290
+
291
+ // Default error responses should be merged
292
+ expect(responses["400"]).toBeDefined();
293
+ expect(responses["401"]).toBeDefined();
294
+ expect(responses["403"]).toBeDefined();
295
+ expect(responses["404"]).toBeDefined();
296
+ expect(responses["405"]).toBeDefined();
297
+ });
298
+ });
299
+
300
+ describe("endpoint functionality", () => {
301
+ it("stats endpoint returns correct data", async () => {
302
+ server = supertest(app);
303
+ const res = await server.get("/food/stats").expect(200);
304
+ expect(res.body).toEqual({avgCalories: 250, count: 10});
305
+ });
306
+
307
+ it("reports endpoint returns correct data", async () => {
308
+ server = supertest(app);
309
+ const res = await server
310
+ .post("/food/reports")
311
+ .send({endDate: "2024-12-31", startDate: "2024-01-01"})
312
+ .expect(201);
313
+ expect(res.body).toEqual({reportId: "report-123"});
314
+ });
315
+
316
+ it("categories endpoint returns array data", async () => {
317
+ server = supertest(app);
318
+ const res = await server.get("/food/categories").expect(200);
319
+ expect(res.body).toHaveLength(2);
320
+ expect(res.body[0]).toHaveProperty("id");
321
+ expect(res.body[0]).toHaveProperty("name");
322
+ });
323
+
324
+ it("category by id endpoint returns correct data", async () => {
325
+ server = supertest(app);
326
+ const res = await server.get("/food/categories/cat-123").expect(200);
327
+ expect(res.body).toEqual({id: "cat-123", name: "Fruits"});
328
+ });
329
+ });
330
+
331
+ describe("snapshot tests", () => {
332
+ it("matches OpenAPI spec snapshot", async () => {
333
+ server = supertest(app);
334
+ const res = await server.get("/openapi.json").expect(200);
335
+ expect(res.body).toMatchSnapshot();
336
+ });
337
+ });
338
+ });
339
+
340
+ describe("OpenApiMiddlewareBuilder without OpenAPI", () => {
341
+ it("build returns noop middleware when openApi.path is not configured", () => {
342
+ const builder = new OpenApiMiddlewareBuilder({});
343
+ const middleware = builder
344
+ .withTags(["test"])
345
+ .withSummary("Test")
346
+ .withResponse(200, {id: {type: "string"}})
347
+ .build();
348
+
349
+ // Middleware should be a function
350
+ expect(typeof middleware).toBe("function");
351
+
352
+ // Should call next() without error
353
+ let nextCalled = false;
354
+ middleware({}, {}, () => {
355
+ nextCalled = true;
356
+ });
357
+ expect(nextCalled).toBe(true);
358
+ });
359
+
360
+ it("build returns noop middleware when options is empty", () => {
361
+ const middleware = createOpenApiBuilder({}).build();
362
+
363
+ let nextCalled = false;
364
+ middleware({}, {}, () => {
365
+ nextCalled = true;
366
+ });
367
+ expect(nextCalled).toBe(true);
368
+ });
369
+ });
370
+
371
+ describe("OpenApiMiddlewareBuilder configuration", () => {
372
+ it("correctly extracts required fields from request body schema", () => {
373
+ // We can't easily test this without a mock openApi.path, but we can at least
374
+ // verify the builder accepts the configuration
375
+ const builder = createOpenApiBuilder({}).withRequestBody<{
376
+ required1: string;
377
+ required2: string;
378
+ optional: string;
379
+ }>({
380
+ optional: {type: "string"},
381
+ required1: {required: true, type: "string"},
382
+ required2: {required: true, type: "string"},
383
+ });
384
+
385
+ expect(builder).toBeInstanceOf(OpenApiMiddlewareBuilder);
386
+ });
387
+
388
+ it("supports custom media types for request body", () => {
389
+ const builder = createOpenApiBuilder({}).withRequestBody(
390
+ {data: {type: "string"}},
391
+ {mediaType: "application/xml"}
392
+ );
393
+
394
+ expect(builder).toBeInstanceOf(OpenApiMiddlewareBuilder);
395
+ });
396
+
397
+ it("supports custom media types for response", () => {
398
+ const builder = createOpenApiBuilder({}).withResponse(
399
+ 200,
400
+ {data: {type: "string"}},
401
+ {mediaType: "text/plain"}
402
+ );
403
+
404
+ expect(builder).toBeInstanceOf(OpenApiMiddlewareBuilder);
405
+ });
406
+
407
+ it("supports optional request body", () => {
408
+ const builder = createOpenApiBuilder({}).withRequestBody(
409
+ {data: {type: "string"}},
410
+ {required: false}
411
+ );
412
+
413
+ expect(builder).toBeInstanceOf(OpenApiMiddlewareBuilder);
414
+ });
415
+
416
+ it("supports multiple query parameters", () => {
417
+ const builder = createOpenApiBuilder({})
418
+ .withQueryParameter("limit", {type: "number"}, {required: false})
419
+ .withQueryParameter("offset", {type: "number"}, {required: false})
420
+ .withQueryParameter("search", {type: "string"});
421
+
422
+ expect(builder).toBeInstanceOf(OpenApiMiddlewareBuilder);
423
+ });
424
+
425
+ it("supports multiple path parameters", () => {
426
+ const builder = createOpenApiBuilder({})
427
+ .withPathParameter("userId", {type: "string"})
428
+ .withPathParameter("postId", {type: "string"});
429
+
430
+ expect(builder).toBeInstanceOf(OpenApiMiddlewareBuilder);
431
+ });
432
+
433
+ it("supports multiple responses", () => {
434
+ const builder = createOpenApiBuilder({})
435
+ .withResponse(200, {data: {type: "string"}})
436
+ .withResponse(201, {id: {type: "string"}})
437
+ .withResponse(204, "No content")
438
+ .withResponse(404, "Not found");
439
+
440
+ expect(builder).toBeInstanceOf(OpenApiMiddlewareBuilder);
441
+ });
442
+ });