@terreno/api 0.0.17 → 0.1.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.
Files changed (77) hide show
  1. package/.claude/CLAUDE.local.md +204 -0
  2. package/.cursor/rules/00-root.mdc +338 -0
  3. package/.github/copilot-instructions.md +333 -0
  4. package/AGENTS.md +333 -0
  5. package/README.md +76 -7
  6. package/biome.jsonc +1 -1
  7. package/dist/api.d.ts +68 -1
  8. package/dist/api.js +140 -5
  9. package/dist/api.query.test.js +1 -1
  10. package/dist/api.test.js +222 -484
  11. package/dist/auth.js +3 -1
  12. package/dist/errors.js +15 -12
  13. package/dist/example.js +7 -7
  14. package/dist/expressServer.d.ts +8 -2
  15. package/dist/expressServer.js +8 -1
  16. package/dist/githubAuth.d.ts +64 -0
  17. package/dist/githubAuth.js +293 -0
  18. package/dist/githubAuth.test.d.ts +1 -0
  19. package/dist/githubAuth.test.js +351 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +3 -0
  22. package/dist/logger.js +1 -1
  23. package/dist/middleware.js +1 -1
  24. package/dist/notifiers/googleChatNotifier.js +1 -1
  25. package/dist/notifiers/googleChatNotifier.test.js +1 -1
  26. package/dist/notifiers/slackNotifier.js +1 -1
  27. package/dist/notifiers/slackNotifier.test.js +1 -1
  28. package/dist/notifiers/zoomNotifier.js +1 -1
  29. package/dist/notifiers/zoomNotifier.test.js +1 -1
  30. package/dist/openApi.test.js +8 -5
  31. package/dist/openApiBuilder.d.ts +69 -1
  32. package/dist/openApiBuilder.js +109 -5
  33. package/dist/openApiValidator.d.ts +296 -0
  34. package/dist/openApiValidator.js +698 -0
  35. package/dist/openApiValidator.test.d.ts +1 -0
  36. package/dist/openApiValidator.test.js +346 -0
  37. package/dist/permissions.js +1 -1
  38. package/dist/plugins.test.js +3 -3
  39. package/dist/terrenoPlugin.d.ts +4 -0
  40. package/dist/terrenoPlugin.js +2 -0
  41. package/dist/tests/bunSetup.js +2 -2
  42. package/dist/tests.js +34 -24
  43. package/package.json +7 -2
  44. package/src/__snapshots__/openApi.test.ts.snap +399 -0
  45. package/src/__snapshots__/openApiBuilder.test.ts.snap +108 -0
  46. package/src/api.query.test.ts +1 -1
  47. package/src/api.test.ts +161 -374
  48. package/src/api.ts +210 -4
  49. package/src/auth.ts +3 -1
  50. package/src/errors.ts +15 -12
  51. package/src/example.ts +7 -7
  52. package/src/expressServer.ts +18 -2
  53. package/src/githubAuth.test.ts +223 -0
  54. package/src/githubAuth.ts +335 -0
  55. package/src/index.ts +3 -0
  56. package/src/logger.ts +1 -1
  57. package/src/middleware.ts +1 -1
  58. package/src/notifiers/googleChatNotifier.test.ts +1 -1
  59. package/src/notifiers/googleChatNotifier.ts +1 -1
  60. package/src/notifiers/slackNotifier.test.ts +1 -1
  61. package/src/notifiers/slackNotifier.ts +1 -1
  62. package/src/notifiers/zoomNotifier.test.ts +1 -1
  63. package/src/notifiers/zoomNotifier.ts +1 -1
  64. package/src/openApi.test.ts +8 -5
  65. package/src/openApiBuilder.ts +188 -15
  66. package/src/openApiValidator.test.ts +241 -0
  67. package/src/openApiValidator.ts +860 -0
  68. package/src/permissions.ts +1 -1
  69. package/src/plugins.test.ts +3 -3
  70. package/src/terrenoPlugin.ts +5 -0
  71. package/src/tests/bunSetup.ts +2 -2
  72. package/src/tests.ts +34 -24
  73. package/CLAUDE.md +0 -107
  74. package/dist/response.d.ts +0 -0
  75. package/dist/response.js +0 -1
  76. package/index.ts +0 -1
  77. package/src/response.ts +0 -0
@@ -34,6 +34,12 @@ import merge from "lodash/merge";
34
34
  import type {ModelRouterOptions} from "./api";
35
35
  import {logger} from "./logger";
36
36
  import {defaultOpenApiErrorResponses} from "./openApi";
37
+ import {
38
+ getOpenApiValidatorConfig,
39
+ isOpenApiValidatorConfigured,
40
+ validateQueryParams,
41
+ validateRequestBody,
42
+ } from "./openApiValidator";
37
43
 
38
44
  /**
39
45
  * Defines a property within an OpenAPI schema.
@@ -222,6 +228,33 @@ interface OpenApiConfig {
222
228
  responses: Record<number | string, OpenApiResponse>;
223
229
  }
224
230
 
231
+ /**
232
+ * Internal validation configuration for the builder.
233
+ */
234
+ interface ValidationConfig {
235
+ /** Whether to validate request body */
236
+ validateBody?: boolean;
237
+ /** Whether to validate query parameters */
238
+ validateQuery?: boolean;
239
+ /** Override the global validation enabled setting */
240
+ enabled?: boolean;
241
+ }
242
+
243
+ /**
244
+ * Result from building OpenAPI middleware with schemas exposed.
245
+ * Useful when you want to use the schemas with asyncHandler's validation.
246
+ */
247
+ export interface OpenApiBuildResult {
248
+ /** The OpenAPI documentation middleware */
249
+ middleware: any;
250
+ /** Request body schema if defined */
251
+ bodySchema?: Record<string, OpenApiSchemaProperty>;
252
+ /** Query parameter schemas if defined */
253
+ querySchema?: Record<string, OpenApiSchemaProperty>;
254
+ /** Whether validation was enabled on this builder */
255
+ validationEnabled: boolean;
256
+ }
257
+
225
258
  /**
226
259
  * A fluent builder for constructing OpenAPI middleware.
227
260
  *
@@ -255,6 +288,15 @@ export class OpenApiMiddlewareBuilder {
255
288
  /** Accumulated OpenAPI configuration from builder methods */
256
289
  private config: OpenApiConfig;
257
290
 
291
+ /** Validation configuration */
292
+ private validationConfig: ValidationConfig;
293
+
294
+ /** Store the raw request body schema for validation */
295
+ private requestBodySchema?: Record<string, OpenApiSchemaProperty>;
296
+
297
+ /** Store the raw query parameter schemas for validation */
298
+ private queryParamSchemas: Record<string, OpenApiSchemaProperty> = {};
299
+
258
300
  /**
259
301
  * Creates a new OpenApiMiddlewareBuilder instance.
260
302
  *
@@ -265,6 +307,7 @@ export class OpenApiMiddlewareBuilder {
265
307
  this.config = {
266
308
  responses: {},
267
309
  };
310
+ this.validationConfig = {};
268
311
  }
269
312
 
270
313
  /**
@@ -368,6 +411,10 @@ export class OpenApiMiddlewareBuilder {
368
411
  },
369
412
  required: options?.required ?? true,
370
413
  };
414
+
415
+ // Store the schema for validation
416
+ this.requestBodySchema = schema as Record<string, OpenApiSchemaProperty>;
417
+
371
418
  return this;
372
419
  }
373
420
 
@@ -515,6 +562,13 @@ export class OpenApiMiddlewareBuilder {
515
562
  required: options?.required ?? false,
516
563
  schema,
517
564
  });
565
+
566
+ // Store for validation
567
+ this.queryParamSchemas[name] = {
568
+ ...schema,
569
+ required: options?.required,
570
+ };
571
+
518
572
  return this;
519
573
  }
520
574
 
@@ -557,6 +611,90 @@ export class OpenApiMiddlewareBuilder {
557
611
  return this;
558
612
  }
559
613
 
614
+ /**
615
+ * Enables runtime validation for this route.
616
+ *
617
+ * When enabled, the built middleware will validate incoming requests
618
+ * against the documented schema before the handler runs.
619
+ *
620
+ * @param options - Optional configuration for validation
621
+ * @param options.body - Enable body validation (default: true if request body is defined)
622
+ * @param options.query - Enable query parameter validation (default: true if query params are defined)
623
+ * @param options.enabled - Override the global validation enabled setting
624
+ * @returns The builder instance for chaining
625
+ *
626
+ * @example
627
+ * ```typescript
628
+ * createOpenApiBuilder(options)
629
+ * .withRequestBody<{name: string}>({name: {type: "string", required: true}})
630
+ * .withValidation() // Enable validation
631
+ * .build();
632
+ * ```
633
+ */
634
+ withValidation(options?: {body?: boolean; query?: boolean; enabled?: boolean}): this {
635
+ this.validationConfig = {
636
+ enabled: options?.enabled ?? true,
637
+ validateBody: options?.body ?? true,
638
+ validateQuery: options?.query ?? true,
639
+ };
640
+ return this;
641
+ }
642
+
643
+ /**
644
+ * Builds and returns the OpenAPI middleware along with schemas.
645
+ *
646
+ * This method is useful when you want to use asyncHandler's integrated
647
+ * validation instead of separate validation middleware.
648
+ *
649
+ * @returns Object containing middleware and schemas
650
+ *
651
+ * @example
652
+ * ```typescript
653
+ * const {middleware, bodySchema} = createOpenApiBuilder(options)
654
+ * .withRequestBody<{name: string}>({name: {type: "string", required: true}})
655
+ * .buildWithSchemas();
656
+ *
657
+ * router.post("/users", middleware, asyncHandler(async (req, res) => {
658
+ * // handler code
659
+ * }, {bodySchema, validate: true}));
660
+ * ```
661
+ */
662
+ buildWithSchemas(): OpenApiBuildResult {
663
+ const noop = (_a: any, _b: any, next: () => void): void => next();
664
+
665
+ // Build the OpenAPI documentation middleware only (no validation middleware)
666
+ let openApiMiddleware: any = noop;
667
+ if (this.options.openApi?.path) {
668
+ openApiMiddleware = this.options.openApi.path(
669
+ merge(
670
+ {
671
+ ...this.config,
672
+ responses: {
673
+ ...this.config.responses,
674
+ ...defaultOpenApiErrorResponses,
675
+ },
676
+ },
677
+ this.options.openApiOverwrite?.get ?? {}
678
+ )
679
+ );
680
+ } else {
681
+ logger.debug("No options.openApi provided, skipping OpenApiMiddleware");
682
+ }
683
+
684
+ const globalConfig = getOpenApiValidatorConfig();
685
+ const validationEnabled =
686
+ this.validationConfig.enabled ??
687
+ (isOpenApiValidatorConfigured() && (globalConfig.validateRequests ?? false));
688
+
689
+ return {
690
+ bodySchema: this.requestBodySchema,
691
+ middleware: openApiMiddleware,
692
+ querySchema:
693
+ Object.keys(this.queryParamSchemas).length > 0 ? this.queryParamSchemas : undefined,
694
+ validationEnabled,
695
+ };
696
+ }
697
+
560
698
  /**
561
699
  * Builds and returns the OpenAPI middleware.
562
700
  *
@@ -564,10 +702,13 @@ export class OpenApiMiddlewareBuilder {
564
702
  * that integrates with the OpenAPI documentation system. If no OpenAPI
565
703
  * path is configured in options, returns a no-op middleware.
566
704
  *
705
+ * If validation was enabled via `withValidation()`, returns an array
706
+ * of middleware: [openApiDocMiddleware, validationMiddleware].
707
+ *
567
708
  * Default error responses (400, 401, 403, 404, 405) are automatically
568
709
  * merged with the configured responses.
569
710
  *
570
- * @returns Express middleware function for OpenAPI documentation
711
+ * @returns Express middleware function(s) for OpenAPI documentation and optional validation
571
712
  *
572
713
  * @example
573
714
  * ```typescript
@@ -582,23 +723,55 @@ export class OpenApiMiddlewareBuilder {
582
723
  build(): any {
583
724
  const noop = (_a: any, _b: any, next: () => void): void => next();
584
725
 
585
- if (!this.options.openApi?.path) {
726
+ // Build the OpenAPI documentation middleware
727
+ let openApiMiddleware: any = noop;
728
+ if (this.options.openApi?.path) {
729
+ openApiMiddleware = this.options.openApi.path(
730
+ merge(
731
+ {
732
+ ...this.config,
733
+ responses: {
734
+ ...this.config.responses,
735
+ ...defaultOpenApiErrorResponses,
736
+ },
737
+ },
738
+ this.options.openApiOverwrite?.get ?? {}
739
+ )
740
+ );
741
+ } else {
586
742
  logger.debug("No options.openApi provided, skipping OpenApiMiddleware");
587
- return noop;
588
743
  }
589
744
 
590
- return this.options.openApi.path(
591
- merge(
592
- {
593
- ...this.config,
594
- responses: {
595
- ...this.config.responses,
596
- ...defaultOpenApiErrorResponses,
597
- },
598
- },
599
- this.options.openApiOverwrite?.get ?? {}
600
- )
601
- );
745
+ // Check if validation should be enabled
746
+ const globalConfig = getOpenApiValidatorConfig();
747
+ const shouldValidate =
748
+ this.validationConfig.enabled ??
749
+ (isOpenApiValidatorConfigured() && (globalConfig.validateRequests ?? false));
750
+
751
+ if (!shouldValidate) {
752
+ return openApiMiddleware;
753
+ }
754
+
755
+ // Build validation middleware
756
+ const validators: any[] = [openApiMiddleware];
757
+
758
+ // Add body validation if we have a request body schema
759
+ if (this.validationConfig.validateBody && this.requestBodySchema) {
760
+ validators.push(validateRequestBody(this.requestBodySchema, {enabled: true}));
761
+ }
762
+
763
+ // Add query validation if we have query parameter schemas
764
+ if (this.validationConfig.validateQuery && Object.keys(this.queryParamSchemas).length > 0) {
765
+ validators.push(validateQueryParams(this.queryParamSchemas, {enabled: true}));
766
+ }
767
+
768
+ // If only one middleware (the openApi one), return it directly
769
+ if (validators.length === 1) {
770
+ return openApiMiddleware;
771
+ }
772
+
773
+ // Return array of middleware to be spread in route definition
774
+ return validators;
602
775
  }
603
776
  }
604
777
 
@@ -0,0 +1,241 @@
1
+ import {afterEach, beforeEach, describe, expect, it} from "bun:test";
2
+
3
+ import {modelRouter} from "./api";
4
+ import {addAuthRoutes, setupAuth} from "./auth";
5
+ import {
6
+ buildQuerySchemaFromFields,
7
+ configureOpenApiValidator,
8
+ isOpenApiValidatorConfigured,
9
+ resetOpenApiValidatorConfig,
10
+ validateRequestBody,
11
+ } from "./openApiValidator";
12
+ import {Permissions} from "./permissions";
13
+ import {authAsUser, FoodModel, getBaseServer, RequiredModel, setupDb, UserModel} from "./tests";
14
+
15
+ // RequiredModel has a clean schema that AJV can compile (no non-standard types).
16
+ // It has: name (String, required), about (String, optional)
17
+ const requiredRouterOptions = {
18
+ permissions: {
19
+ create: [Permissions.IsAuthenticated],
20
+ delete: [Permissions.IsAdmin],
21
+ list: [Permissions.IsAuthenticated],
22
+ read: [Permissions.IsAuthenticated],
23
+ update: [Permissions.IsAuthenticated],
24
+ },
25
+ queryFields: ["name"],
26
+ sort: "-name" as const,
27
+ };
28
+
29
+ const setupFreshApp = async () => {
30
+ const freshApp = getBaseServer();
31
+ setupAuth(freshApp, UserModel as any);
32
+ addAuthRoutes(freshApp, UserModel as any);
33
+ return freshApp;
34
+ };
35
+
36
+ describe("openApiValidator", () => {
37
+ beforeEach(async () => {
38
+ resetOpenApiValidatorConfig();
39
+ await setupDb();
40
+ await RequiredModel.deleteMany({});
41
+ });
42
+
43
+ afterEach(() => {
44
+ resetOpenApiValidatorConfig();
45
+ });
46
+
47
+ describe("isConfigured flag", () => {
48
+ it("is false by default", () => {
49
+ expect(isOpenApiValidatorConfigured()).toBe(false);
50
+ });
51
+
52
+ it("becomes true after configureOpenApiValidator()", () => {
53
+ configureOpenApiValidator();
54
+ expect(isOpenApiValidatorConfigured()).toBe(true);
55
+ });
56
+
57
+ it("resets to false after resetOpenApiValidatorConfig()", () => {
58
+ configureOpenApiValidator();
59
+ expect(isOpenApiValidatorConfigured()).toBe(true);
60
+ resetOpenApiValidatorConfig();
61
+ expect(isOpenApiValidatorConfigured()).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe("no-op when not configured", () => {
66
+ it("does not strip or validate when not configured", async () => {
67
+ const freshApp = await setupFreshApp();
68
+ freshApp.use("/required", modelRouter(RequiredModel, requiredRouterOptions));
69
+ const admin = await authAsUser(freshApp, "admin");
70
+
71
+ // When not configured, validation is a no-op — valid requests pass through
72
+ const res = await admin.post("/required").send({name: "Apple"}).expect(201);
73
+ expect(res.body.data.name).toBe("Apple");
74
+ });
75
+ });
76
+
77
+ describe("active after configuration", () => {
78
+ it("strips extra properties when removeAdditional is true", async () => {
79
+ configureOpenApiValidator({removeAdditional: true});
80
+
81
+ const freshApp = await setupFreshApp();
82
+ freshApp.use("/required", modelRouter(RequiredModel, requiredRouterOptions));
83
+ const admin = await authAsUser(freshApp, "admin");
84
+
85
+ const res = await admin
86
+ .post("/required")
87
+ .send({fakeField: "this should be stripped", name: "Apple"})
88
+ .expect(201);
89
+
90
+ expect(res.body.data.name).toBe("Apple");
91
+ expect(res.body.data.fakeField).toBeUndefined();
92
+ });
93
+
94
+ it("rejects missing required fields", async () => {
95
+ configureOpenApiValidator();
96
+
97
+ const freshApp = await setupFreshApp();
98
+ freshApp.use("/required", modelRouter(RequiredModel, requiredRouterOptions));
99
+ const admin = await authAsUser(freshApp, "admin");
100
+
101
+ const res = await admin.post("/required").send({about: "no name"}).expect(400);
102
+ expect(res.body.title).toBe("Request validation failed");
103
+ });
104
+ });
105
+
106
+ describe("onAdditionalPropertiesRemoved hook", () => {
107
+ it("fires callback with removed property names", async () => {
108
+ const removedProps: string[] = [];
109
+
110
+ configureOpenApiValidator({
111
+ onAdditionalPropertiesRemoved: (props) => {
112
+ removedProps.push(...props);
113
+ },
114
+ removeAdditional: true,
115
+ });
116
+
117
+ const freshApp = await setupFreshApp();
118
+ freshApp.use("/required", modelRouter(RequiredModel, requiredRouterOptions));
119
+ const admin = await authAsUser(freshApp, "admin");
120
+
121
+ await admin
122
+ .post("/required")
123
+ .send({extraA: "stripped", extraB: "also stripped", name: "Apple"})
124
+ .expect(201);
125
+
126
+ expect(removedProps).toContain("extraA");
127
+ expect(removedProps).toContain("extraB");
128
+ });
129
+ });
130
+
131
+ describe("per-route validation: false override", () => {
132
+ it("skips validation when validation is false", async () => {
133
+ configureOpenApiValidator({removeAdditional: true});
134
+
135
+ const freshApp = await setupFreshApp();
136
+ freshApp.use(
137
+ "/required",
138
+ modelRouter(RequiredModel, {
139
+ ...requiredRouterOptions,
140
+ validation: false,
141
+ })
142
+ );
143
+ const admin = await authAsUser(freshApp, "admin");
144
+
145
+ // With validation: false, extra properties are NOT stripped by validator
146
+ // RequiredModel does not have strict: "throw" so the extra field will just be ignored by Mongoose
147
+ const res = await admin
148
+ .post("/required")
149
+ .send({fakeField: "not stripped", name: "Apple"})
150
+ .expect(201);
151
+
152
+ expect(res.body.data.name).toBe("Apple");
153
+ });
154
+ });
155
+
156
+ describe("sanitization of non-standard mongoose-to-swagger types", () => {
157
+ it("validates models with ObjectId and DateOnly fields after sanitization", async () => {
158
+ configureOpenApiValidator({removeAdditional: true});
159
+
160
+ const freshApp = await setupFreshApp();
161
+ freshApp.use(
162
+ "/food",
163
+ modelRouter(FoodModel, {
164
+ permissions: {
165
+ create: [Permissions.IsAuthenticated],
166
+ delete: [Permissions.IsAdmin],
167
+ list: [Permissions.IsAuthenticated],
168
+ read: [Permissions.IsAuthenticated],
169
+ update: [Permissions.IsAuthenticated],
170
+ },
171
+ queryFields: ["name", "calories", "hidden"],
172
+ sort: "-created",
173
+ })
174
+ );
175
+ const admin = await authAsUser(freshApp, "admin");
176
+
177
+ const res = await admin
178
+ .post("/food")
179
+ .send({calories: 100, likesIds: [], name: "Apple", source: {name: "Test"}})
180
+ .expect(201);
181
+
182
+ expect(res.body.data.name).toBe("Apple");
183
+ });
184
+ });
185
+
186
+ describe("buildQuerySchemaFromFields", () => {
187
+ it("always includes limit, page, and sort", () => {
188
+ const schema = buildQuerySchemaFromFields(FoodModel, []);
189
+ expect(schema.limit).toBeDefined();
190
+ expect(schema.page).toBeDefined();
191
+ expect(schema.sort).toBeDefined();
192
+ });
193
+
194
+ it("includes queryFields from model schema", () => {
195
+ const schema = buildQuerySchemaFromFields(FoodModel, ["name", "calories"]);
196
+ expect(schema.name).toBeDefined();
197
+ expect(schema.calories).toBeDefined();
198
+ expect(schema.hidden).toBeUndefined();
199
+ });
200
+
201
+ it("marks query fields as not required", () => {
202
+ const schema = buildQuerySchemaFromFields(FoodModel, ["name"]);
203
+ expect(schema.name.required).toBe(false);
204
+ });
205
+ });
206
+
207
+ describe("validateRequestBody middleware", () => {
208
+ it("is a no-op when not configured", () => {
209
+ resetOpenApiValidatorConfig();
210
+
211
+ const middleware = validateRequestBody({
212
+ name: {required: true, type: "string"},
213
+ });
214
+
215
+ let nextCalled = false;
216
+ const req = {body: {}} as any;
217
+ const res = {} as any;
218
+ const next = () => {
219
+ nextCalled = true;
220
+ };
221
+
222
+ middleware(req, res, next);
223
+ expect(nextCalled).toBe(true);
224
+ });
225
+
226
+ it("validates when configured", () => {
227
+ configureOpenApiValidator();
228
+
229
+ const middleware = validateRequestBody({
230
+ name: {required: true, type: "string"},
231
+ });
232
+
233
+ const req = {body: {}, method: "POST", path: "/test"} as any;
234
+ const res = {} as any;
235
+
236
+ expect(() => {
237
+ middleware(req, res, () => {});
238
+ }).toThrow();
239
+ });
240
+ });
241
+ });