@flink-app/flink 0.14.2 → 1.0.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 CHANGED
@@ -1,5 +1,72 @@
1
1
  # @flink-app/flink
2
2
 
3
+ ## 1.0.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Align minor version, from now all packages in monorepo will have same version
8
+ - 5c2ff97: Add schema validation bypass options to RouteProps
9
+
10
+ Handlers can now control schema validation behavior via the new `validation` property in `RouteProps`:
11
+
12
+ - `ValidationMode.Validate` (default): Validate both request and response
13
+ - `ValidationMode.SkipValidation`: Skip both request and response validation
14
+ - `ValidationMode.ValidateRequest`: Validate only request, skip response validation
15
+ - `ValidationMode.ValidateResponse`: Validate only response, skip request validation
16
+
17
+ This is useful for handlers that need custom validation logic or want to bypass validation for performance reasons. The feature is opt-in and defaults to existing behavior.
18
+
19
+ **Security Considerations:**
20
+
21
+ ⚠️ Use validation bypass options carefully:
22
+
23
+ - `SkipValidation` and `ValidateRequest: false` allow unvalidated data to reach handlers, which can introduce security risks
24
+ - Only skip validation when you have implemented custom validation logic or the endpoint is internal/trusted
25
+ - Suitable use cases include: webhook handlers with custom signature verification, performance-critical internal endpoints, or handlers using alternative validation methods
26
+ - When skipping response validation, ensure your code returns consistent data shapes to avoid breaking API contracts
27
+
28
+ Example usage:
29
+
30
+ ```typescript
31
+ // Skip validation for webhook with custom verification
32
+ export const Route: RouteProps = {
33
+ path: "/webhook",
34
+ validation: ValidationMode.SkipValidation,
35
+ };
36
+
37
+ // Validate request but allow flexible response during development
38
+ export const Route: RouteProps = {
39
+ path: "/api/debug",
40
+ validation: ValidationMode.ValidateRequest,
41
+ };
42
+ ```
43
+
44
+ ### Patch Changes
45
+
46
+ - ee21c29: Normalize query parameters to predictable string types
47
+
48
+ Query parameters are now automatically normalized to `string | string[]` types before reaching handlers:
49
+
50
+ - Single values: converted to strings (e.g., `?page=1` → `{ page: "1" }`)
51
+ - Repeated parameters: converted to string arrays (e.g., `?tag=a&tag=b` → `{ tag: ["a", "b"] }`)
52
+ - All types (numbers, booleans, etc.) from Express parser are converted to strings
53
+
54
+ **Breaking Change (Minor):**
55
+
56
+ - `Query` type changed from `{ [x: string]: string | string[] | undefined }` to `Record<string, string | string[]>`
57
+ - `Params` type changed from `Request["params"]` to explicit `Record<string, string>`
58
+ - Removed `undefined` from query/param types since normalization ensures values are never undefined
59
+ - Updated OAuth and OIDC plugin query type definitions to satisfy new Query constraint
60
+
61
+ This ensures predictable query and path parameter handling regardless of Express parser configuration. Handlers can reliably parse string values as needed using `Number()`, `parseInt()`, boolean comparisons, etc.
62
+
63
+ ## 0.14.3
64
+
65
+ ### Patch Changes
66
+
67
+ - 52eba74: Fix Query type constraint to allow user-defined interfaces without explicit index signatures. The Query type now uses explicit index signature syntax, making it easier for developers to define query parameter interfaces for their handlers without TypeScript compilation errors.
68
+ - b37faa5: Improve schema validation error messages to show specific additional property names. When validation fails due to additional properties, the error now displays which properties are not allowed instead of a generic message.
69
+
3
70
  ## 0.14.2
4
71
 
5
72
  ### Patch Changes
@@ -62,6 +62,7 @@ var ms_1 = __importDefault(require("ms"));
62
62
  var toad_scheduler_1 = require("toad-scheduler");
63
63
  var uuid_1 = require("uuid");
64
64
  var FlinkErrors_1 = require("./FlinkErrors");
65
+ var FlinkHttpHandler_1 = require("./FlinkHttpHandler");
65
66
  var FlinkLog_1 = require("./FlinkLog");
66
67
  var mock_data_generator_1 = __importDefault(require("./mock-data-generator"));
67
68
  var utils_1 = require("./utils");
@@ -335,24 +336,28 @@ var FlinkApp = /** @class */ (function () {
335
336
  }
336
337
  var validateReq_1;
337
338
  var validateRes_1;
338
- if (schema.reqSchema) {
339
+ // Determine validation mode (default to Validate if not specified)
340
+ var validationMode = routeProps.validation || FlinkHttpHandler_1.ValidationMode.Validate;
341
+ // Compile request schema if validation mode requires it
342
+ if (schema.reqSchema && validationMode !== FlinkHttpHandler_1.ValidationMode.SkipValidation && validationMode !== FlinkHttpHandler_1.ValidationMode.ValidateResponse) {
339
343
  validateReq_1 = ajv.compile(schema.reqSchema);
340
344
  }
341
- if (schema.resSchema) {
345
+ // Compile response schema if validation mode requires it
346
+ if (schema.resSchema && validationMode !== FlinkHttpHandler_1.ValidationMode.SkipValidation && validationMode !== FlinkHttpHandler_1.ValidationMode.ValidateRequest) {
342
347
  validateRes_1 = ajv.compile(schema.resSchema);
343
348
  }
344
349
  this.expressApp[method](routeProps.path, function (req, res) { return __awaiter(_this, void 0, void 0, function () {
345
- var valid, formattedErrors, data, handlerRes, err_1, errorResponse, result, valid, formattedErrors;
346
- return __generator(this, function (_a) {
347
- switch (_a.label) {
350
+ var valid, formattedErrors, data, normalizedQuery, _i, _a, _b, key, value, handlerRes, err_1, errorResponse, result, valid, formattedErrors;
351
+ return __generator(this, function (_c) {
352
+ switch (_c.label) {
348
353
  case 0:
349
354
  if (!routeProps.permissions) return [3 /*break*/, 2];
350
355
  return [4 /*yield*/, this.authenticate(req, routeProps.permissions)];
351
356
  case 1:
352
- if (!(_a.sent())) {
357
+ if (!(_c.sent())) {
353
358
  return [2 /*return*/, res.status(401).json((0, FlinkErrors_1.unauthorized)())];
354
359
  }
355
- _a.label = 2;
360
+ _c.label = 2;
356
361
  case 2:
357
362
  if (validateReq_1) {
358
363
  valid = validateReq_1(req.body);
@@ -364,7 +369,7 @@ var FlinkApp = /** @class */ (function () {
364
369
  error: {
365
370
  id: (0, uuid_1.v4)(),
366
371
  title: "Bad request",
367
- detail: "Schema did not validate ".concat(JSON.stringify(validateReq_1.errors)),
372
+ detail: formattedErrors,
368
373
  },
369
374
  })];
370
375
  }
@@ -378,9 +383,28 @@ var FlinkApp = /** @class */ (function () {
378
383
  });
379
384
  return [2 /*return*/];
380
385
  }
381
- _a.label = 3;
386
+ // Normalize query parameters to predictable string or string[] types
387
+ // Express query parser can produce numbers, booleans, objects, etc.
388
+ // We normalize everything to strings or string arrays for consistency
389
+ if (req.query && typeof req.query === "object") {
390
+ normalizedQuery = {};
391
+ for (_i = 0, _a = Object.entries(req.query); _i < _a.length; _i++) {
392
+ _b = _a[_i], key = _b[0], value = _b[1];
393
+ if (Array.isArray(value)) {
394
+ // Handle array values (e.g., ?tag=a&tag=b)
395
+ normalizedQuery[key] = value.map(function (v) { return String(v); });
396
+ }
397
+ else if (value !== undefined && value !== null) {
398
+ // Convert single values to strings
399
+ normalizedQuery[key] = String(value);
400
+ }
401
+ // Skip undefined/null values - they won't appear in the normalized query
402
+ }
403
+ req.query = normalizedQuery;
404
+ }
405
+ _c.label = 3;
382
406
  case 3:
383
- _a.trys.push([3, 5, , 6]);
407
+ _c.trys.push([3, 5, , 6]);
384
408
  return [4 /*yield*/, handler({
385
409
  req: req,
386
410
  ctx: this.ctx,
@@ -388,10 +412,10 @@ var FlinkApp = /** @class */ (function () {
388
412
  })];
389
413
  case 4:
390
414
  // 👇 This is where the actual handler gets invoked
391
- handlerRes = _a.sent();
415
+ handlerRes = _c.sent();
392
416
  return [3 /*break*/, 6];
393
417
  case 5:
394
- err_1 = _a.sent();
418
+ err_1 = _c.sent();
395
419
  errorResponse = void 0;
396
420
  // duck typing to check if it is a FlinkError
397
421
  if (typeof err_1.status === "number" && err_1.status >= 400 && err_1.status < 600 && err_1.error) {
@@ -442,7 +466,7 @@ var FlinkApp = /** @class */ (function () {
442
466
  error: {
443
467
  id: (0, uuid_1.v4)(),
444
468
  title: "Bad response",
445
- detail: "Schema did not validate ".concat(JSON.stringify(validateRes_1.errors)),
469
+ detail: formattedErrors,
446
470
  },
447
471
  })];
448
472
  }
@@ -10,13 +10,52 @@ export declare enum HttpMethod {
10
10
  delete = "delete",
11
11
  patch = "patch"
12
12
  }
13
- type Params = Request["params"];
13
+ /**
14
+ * Validation mode for handler request and response schemas.
15
+ *
16
+ * Controls whether request and/or response data is validated against JSON schemas.
17
+ *
18
+ * **Security Note:** Skipping validation can introduce security risks. Only use
19
+ * SkipValidation or ValidateResponse when you have implemented custom validation
20
+ * or the endpoint is internal/trusted.
21
+ *
22
+ * - Validate: Validate both request and response (default behavior)
23
+ * - SkipValidation: Skip both request and response validation
24
+ * - ValidateRequest: Validate only request, skip response validation
25
+ * - ValidateResponse: Validate only response, skip request validation
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * // Skip validation for webhook with custom signature verification
30
+ * export const Route: RouteProps = {
31
+ * path: "/webhook",
32
+ * validation: ValidationMode.SkipValidation
33
+ * };
34
+ *
35
+ * // Validate request but allow flexible response during development
36
+ * export const Route: RouteProps = {
37
+ * path: "/api/data",
38
+ * validation: ValidationMode.ValidateRequest
39
+ * };
40
+ * ```
41
+ */
42
+ export declare enum ValidationMode {
43
+ Validate = "Validate",
44
+ SkipValidation = "SkipValidation",
45
+ ValidateRequest = "ValidateRequest",
46
+ ValidateResponse = "ValidateResponse"
47
+ }
48
+ type Params = Record<string, string>;
14
49
  /**
15
50
  * Query type for request query parameters.
16
- * Does currently not allow nested objects, although
17
- * underlying express Request does allow it.
51
+ *
52
+ * All query parameter values are normalized to strings or string arrays:
53
+ * - Single values: string (e.g., ?name=John becomes { name: "John" })
54
+ * - Multiple values: string[] (e.g., ?tag=a&tag=b becomes { tag: ["a", "b"] })
55
+ *
56
+ * Does not allow nested objects, although underlying Express Request does allow it.
18
57
  */
19
- type Query = Record<string, string | string[] | undefined>;
58
+ type Query = Record<string, string | string[]>;
20
59
  /**
21
60
  * Flink request extends express Request but adds reqId and user object.
22
61
  */
@@ -77,6 +116,26 @@ export interface RouteProps {
77
116
  * to avoid conflicts you can set a negative order.
78
117
  */
79
118
  order?: number;
119
+ /**
120
+ * Validation mode for request and response schemas.
121
+ *
122
+ * Controls schema validation behavior for this handler. Use with caution as
123
+ * skipping validation can introduce security vulnerabilities.
124
+ *
125
+ * **Options:**
126
+ * - Validate: Validate both request and response (default)
127
+ * - SkipValidation: Skip both request and response validation
128
+ * - ValidateRequest: Validate only request, skip response validation
129
+ * - ValidateResponse: Validate only response, skip request validation
130
+ *
131
+ * **When to skip validation:**
132
+ * - Webhook handlers with custom signature verification
133
+ * - Performance-critical internal endpoints
134
+ * - Handlers using alternative validation methods (e.g., Zod, Joi)
135
+ *
136
+ * @default ValidationMode.Validate
137
+ */
138
+ validation?: ValidationMode;
80
139
  }
81
140
  /**
82
141
  * Http handler function that handlers implements in order to
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HttpMethod = void 0;
3
+ exports.ValidationMode = exports.HttpMethod = void 0;
4
4
  var HttpMethod;
5
5
  (function (HttpMethod) {
6
6
  HttpMethod["get"] = "get";
@@ -9,3 +9,39 @@ var HttpMethod;
9
9
  HttpMethod["delete"] = "delete";
10
10
  HttpMethod["patch"] = "patch";
11
11
  })(HttpMethod || (exports.HttpMethod = HttpMethod = {}));
12
+ /**
13
+ * Validation mode for handler request and response schemas.
14
+ *
15
+ * Controls whether request and/or response data is validated against JSON schemas.
16
+ *
17
+ * **Security Note:** Skipping validation can introduce security risks. Only use
18
+ * SkipValidation or ValidateResponse when you have implemented custom validation
19
+ * or the endpoint is internal/trusted.
20
+ *
21
+ * - Validate: Validate both request and response (default behavior)
22
+ * - SkipValidation: Skip both request and response validation
23
+ * - ValidateRequest: Validate only request, skip response validation
24
+ * - ValidateResponse: Validate only response, skip request validation
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * // Skip validation for webhook with custom signature verification
29
+ * export const Route: RouteProps = {
30
+ * path: "/webhook",
31
+ * validation: ValidationMode.SkipValidation
32
+ * };
33
+ *
34
+ * // Validate request but allow flexible response during development
35
+ * export const Route: RouteProps = {
36
+ * path: "/api/data",
37
+ * validation: ValidationMode.ValidateRequest
38
+ * };
39
+ * ```
40
+ */
41
+ var ValidationMode;
42
+ (function (ValidationMode) {
43
+ ValidationMode["Validate"] = "Validate";
44
+ ValidationMode["SkipValidation"] = "SkipValidation";
45
+ ValidationMode["ValidateRequest"] = "ValidateRequest";
46
+ ValidationMode["ValidateResponse"] = "ValidateResponse";
47
+ })(ValidationMode || (exports.ValidationMode = ValidationMode = {}));
package/dist/src/utils.js CHANGED
@@ -223,6 +223,9 @@ function formatValidationErrors(errors, data, maxDataLength) {
223
223
  else if (error.keyword === "type") {
224
224
  formatted.push(" - Invalid type at ".concat(error.schemaPath, ": expected ").concat(error.params.type, ", got ").concat(typeof dataAtPath));
225
225
  }
226
+ else if (error.keyword === "additionalProperties") {
227
+ formatted.push(" - Additional property not allowed: ".concat(error.params.additionalProperty));
228
+ }
226
229
  else if (error.keyword === "anyOf" || error.keyword === "oneOf") {
227
230
  formatted.push(" - ".concat(error.message));
228
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flink-app/flink",
3
- "version": "0.14.2",
3
+ "version": "1.0.0",
4
4
  "description": "Typescript only framework for creating REST-like APIs on top of Express and mongodb",
5
5
  "types": "dist/src/index.d.ts",
6
6
  "main": "dist/src/index.js",
@@ -50,7 +50,9 @@
50
50
  "@types/mkdirp": "^1.0.1",
51
51
  "@types/morgan": "^1.9.4",
52
52
  "@types/node": "22.13.10",
53
+ "@types/supertest": "^6.0.3",
53
54
  "mongodb": "^6.15.0",
55
+ "supertest": "^7.2.2",
54
56
  "ts-node": "^10.9.2"
55
57
  },
56
58
  "peerDependencies": {
@@ -0,0 +1,107 @@
1
+ import { FlinkApp } from "../src/FlinkApp";
2
+ import { FlinkContext } from "../src/FlinkContext";
3
+ import { GetHandler, HttpMethod } from "../src/FlinkHttpHandler";
4
+
5
+ const request = require("supertest");
6
+
7
+ interface TestContext extends FlinkContext {}
8
+
9
+ describe("Query parameter normalization", () => {
10
+ let app: FlinkApp<TestContext>;
11
+
12
+ afterEach(async () => {
13
+ if (app && app.started) {
14
+ await app.stop();
15
+ }
16
+ });
17
+
18
+ it("should normalize query params to strings", async () => {
19
+ const handler: GetHandler<TestContext, any> = async ({ req }) => {
20
+ return { status: 200, data: { query: req.query } };
21
+ };
22
+
23
+ app = new FlinkApp<TestContext>({ name: "test-query-norm", port: 4000 });
24
+ await app.start();
25
+
26
+ app.addHandler({
27
+ default: handler,
28
+ Route: { method: HttpMethod.get, path: "/test" },
29
+ });
30
+
31
+ const response = await request(app.expressApp).get("/test?name=John&age=25");
32
+
33
+ expect(response.status).toBe(200);
34
+ expect(response.body.data.query.name).toBe("John");
35
+ expect(response.body.data.query.age).toBe("25");
36
+ expect(typeof response.body.data.query.name).toBe("string");
37
+ expect(typeof response.body.data.query.age).toBe("string");
38
+ });
39
+
40
+ it("should normalize array query params to string arrays", async () => {
41
+ const handler: GetHandler<TestContext, any> = async ({ req }) => {
42
+ return { status: 200, data: { query: req.query } };
43
+ };
44
+
45
+ app = new FlinkApp<TestContext>({ name: "test-query-array", port: 4001 });
46
+ await app.start();
47
+
48
+ app.addHandler({
49
+ default: handler,
50
+ Route: { method: HttpMethod.get, path: "/test-array" },
51
+ });
52
+
53
+ const response = await request(app.expressApp).get("/test-array?tag=a&tag=b&tag=c");
54
+
55
+ expect(response.status).toBe(200);
56
+ expect(Array.isArray(response.body.data.query.tag)).toBe(true);
57
+ expect(response.body.data.query.tag).toEqual(["a", "b", "c"]);
58
+ response.body.data.query.tag.forEach((tag: string) => {
59
+ expect(typeof tag).toBe("string");
60
+ });
61
+ });
62
+
63
+ it("should normalize numeric values to strings", async () => {
64
+ const handler: GetHandler<TestContext, any> = async ({ req }) => {
65
+ return { status: 200, data: { query: req.query } };
66
+ };
67
+
68
+ app = new FlinkApp<TestContext>({ name: "test-query-numbers", port: 4002 });
69
+ await app.start();
70
+
71
+ app.addHandler({
72
+ default: handler,
73
+ Route: { method: HttpMethod.get, path: "/test-numbers" },
74
+ });
75
+
76
+ const response = await request(app.expressApp).get("/test-numbers?count=100&price=99.99");
77
+
78
+ expect(response.status).toBe(200);
79
+ expect(response.body.data.query.count).toBe("100");
80
+ expect(response.body.data.query.price).toBe("99.99");
81
+ expect(typeof response.body.data.query.count).toBe("string");
82
+ expect(typeof response.body.data.query.price).toBe("string");
83
+ });
84
+
85
+ it("should allow handlers to parse strings as needed", async () => {
86
+ const handler: GetHandler<TestContext, any> = async ({ req }) => {
87
+ const page = Number(req.query.page) || 1;
88
+ const active = req.query.active === "true";
89
+
90
+ return { status: 200, data: { page, active } };
91
+ };
92
+
93
+ app = new FlinkApp<TestContext>({ name: "test-query-parsing", port: 4003 });
94
+ await app.start();
95
+
96
+ app.addHandler({
97
+ default: handler,
98
+ Route: { method: HttpMethod.get, path: "/test-parsing" },
99
+ });
100
+
101
+ const response = await request(app.expressApp).get("/test-parsing?page=2&active=true");
102
+
103
+ expect(response.status).toBe(200);
104
+ expect(response.body.data.page).toBe(2);
105
+ expect(response.body.data.active).toBe(true);
106
+ });
107
+ });
@@ -0,0 +1,155 @@
1
+ import { FlinkApp } from "../src/FlinkApp";
2
+ import { FlinkContext } from "../src/FlinkContext";
3
+ import { Handler, RouteProps, ValidationMode } from "../src/FlinkHttpHandler";
4
+
5
+ interface TestContext extends FlinkContext {}
6
+
7
+ // Test schemas
8
+ interface ValidRequest {
9
+ name: string;
10
+ age: number;
11
+ }
12
+
13
+ interface ValidResponse {
14
+ message: string;
15
+ status: string;
16
+ }
17
+
18
+ describe("FlinkApp validation modes", () => {
19
+ let app: FlinkApp<TestContext>;
20
+
21
+ beforeEach(async () => {
22
+ app = new FlinkApp<TestContext>({
23
+ name: "validation-test-app",
24
+ debug: false,
25
+ });
26
+ });
27
+
28
+ afterEach(async () => {
29
+ if (app && app.started) {
30
+ await app.stop();
31
+ }
32
+ });
33
+
34
+ describe("ValidationMode enum", () => {
35
+ it("should have all validation mode values", () => {
36
+ expect(ValidationMode.Validate).toBe("Validate");
37
+ expect(ValidationMode.SkipValidation).toBe("SkipValidation");
38
+ expect(ValidationMode.ValidateRequest).toBe("ValidateRequest");
39
+ expect(ValidationMode.ValidateResponse).toBe("ValidateResponse");
40
+ });
41
+ });
42
+
43
+ describe("RouteProps validation property", () => {
44
+ it("should accept validation property with ValidationMode.Validate", () => {
45
+ const route: RouteProps = {
46
+ path: "/test",
47
+ validation: ValidationMode.Validate,
48
+ };
49
+
50
+ expect(route.validation).toBe(ValidationMode.Validate);
51
+ });
52
+
53
+ it("should accept validation property with ValidationMode.SkipValidation", () => {
54
+ const route: RouteProps = {
55
+ path: "/test",
56
+ validation: ValidationMode.SkipValidation,
57
+ };
58
+
59
+ expect(route.validation).toBe(ValidationMode.SkipValidation);
60
+ });
61
+
62
+ it("should accept validation property with ValidationMode.ValidateRequest", () => {
63
+ const route: RouteProps = {
64
+ path: "/test",
65
+ validation: ValidationMode.ValidateRequest,
66
+ };
67
+
68
+ expect(route.validation).toBe(ValidationMode.ValidateRequest);
69
+ });
70
+
71
+ it("should accept validation property with ValidationMode.ValidateResponse", () => {
72
+ const route: RouteProps = {
73
+ path: "/test",
74
+ validation: ValidationMode.ValidateResponse,
75
+ };
76
+
77
+ expect(route.validation).toBe(ValidationMode.ValidateResponse);
78
+ });
79
+
80
+ it("should be optional and default to undefined", () => {
81
+ const route: RouteProps = {
82
+ path: "/test",
83
+ };
84
+
85
+ expect(route.validation).toBeUndefined();
86
+ });
87
+ });
88
+
89
+ describe("Handler type compatibility", () => {
90
+ it("should allow handlers with validation modes to compile", () => {
91
+ // This test verifies TypeScript compilation
92
+
93
+ const handler: Handler<TestContext, ValidRequest, ValidResponse> = async ({ ctx, req }) => {
94
+ return {
95
+ status: 200,
96
+ data: {
97
+ message: "success",
98
+ status: "ok",
99
+ },
100
+ };
101
+ };
102
+
103
+ expect(handler).toBeDefined();
104
+ expect(typeof handler).toBe("function");
105
+ });
106
+
107
+ it("should allow route props with different validation modes", () => {
108
+ const routes: RouteProps[] = [
109
+ { path: "/test1", validation: ValidationMode.Validate },
110
+ { path: "/test2", validation: ValidationMode.SkipValidation },
111
+ { path: "/test3", validation: ValidationMode.ValidateRequest },
112
+ { path: "/test4", validation: ValidationMode.ValidateResponse },
113
+ { path: "/test5" }, // No validation specified
114
+ ];
115
+
116
+ expect(routes.length).toBe(5);
117
+ expect(routes[0].validation).toBe(ValidationMode.Validate);
118
+ expect(routes[1].validation).toBe(ValidationMode.SkipValidation);
119
+ expect(routes[2].validation).toBe(ValidationMode.ValidateRequest);
120
+ expect(routes[3].validation).toBe(ValidationMode.ValidateResponse);
121
+ expect(routes[4].validation).toBeUndefined();
122
+ });
123
+ });
124
+
125
+ describe("Backward compatibility", () => {
126
+ it("should not break existing code without validation property", () => {
127
+ const route: RouteProps = {
128
+ path: "/legacy",
129
+ permissions: ["admin"],
130
+ };
131
+
132
+ // TypeScript should compile this without errors
133
+ expect(route.path).toBe("/legacy");
134
+ expect(route.validation).toBeUndefined();
135
+ });
136
+
137
+ it("should allow all existing RouteProps properties with validation", () => {
138
+ const route: RouteProps = {
139
+ path: "/test",
140
+ permissions: ["admin", "user"],
141
+ skipAutoRegister: true,
142
+ docs: "Test endpoint",
143
+ order: -1,
144
+ validation: ValidationMode.SkipValidation,
145
+ };
146
+
147
+ expect(route.path).toBe("/test");
148
+ expect(route.permissions).toEqual(["admin", "user"]);
149
+ expect(route.skipAutoRegister).toBe(true);
150
+ expect(route.docs).toBe("Test endpoint");
151
+ expect(route.order).toBe(-1);
152
+ expect(route.validation).toBe(ValidationMode.SkipValidation);
153
+ });
154
+ });
155
+ });
@@ -148,6 +148,33 @@ describe("Utils", () => {
148
148
  expect(formatted).toContain('Missing required property: email');
149
149
  expect(formatted).toContain('Missing required property: age');
150
150
  });
151
+
152
+ it("should show specific additional property names", () => {
153
+ const errors = [
154
+ {
155
+ instancePath: "",
156
+ schemaPath: "#/additionalProperties",
157
+ keyword: "additionalProperties",
158
+ params: { additionalProperty: "extraField1" },
159
+ message: "must NOT have additional properties",
160
+ },
161
+ {
162
+ instancePath: "",
163
+ schemaPath: "#/additionalProperties",
164
+ keyword: "additionalProperties",
165
+ params: { additionalProperty: "extraField2" },
166
+ message: "must NOT have additional properties",
167
+ },
168
+ ];
169
+ const data = { name: "John", age: 30, extraField1: "value1", extraField2: "value2" };
170
+
171
+ const formatted = formatValidationErrors(errors, data);
172
+
173
+ // This formatted output is used in HTTP error responses (400/500) in the error.detail field
174
+ expect(formatted).toContain('Path: /');
175
+ expect(formatted).toContain('Additional property not allowed: extraField1');
176
+ expect(formatted).toContain('Additional property not allowed: extraField2');
177
+ });
151
178
  });
152
179
  });
153
180
 
package/src/FlinkApp.ts CHANGED
@@ -12,7 +12,7 @@ import { v4 } from "uuid";
12
12
  import { FlinkAuthPlugin } from "./auth/FlinkAuthPlugin";
13
13
  import { FlinkContext } from "./FlinkContext";
14
14
  import { FlinkError, internalServerError, notFound, unauthorized } from "./FlinkErrors";
15
- import { FlinkRequest, Handler, HandlerFile, HttpMethod, QueryParamMetadata, RouteProps } from "./FlinkHttpHandler";
15
+ import { FlinkRequest, Handler, HandlerFile, HttpMethod, QueryParamMetadata, RouteProps, ValidationMode } from "./FlinkHttpHandler";
16
16
  import { FlinkJobFile } from "./FlinkJob";
17
17
  import { log } from "./FlinkLog";
18
18
  import { FlinkPlugin } from "./FlinkPlugin";
@@ -535,11 +535,16 @@ export class FlinkApp<C extends FlinkContext> {
535
535
  let validateReq: ValidateFunction<any> | undefined;
536
536
  let validateRes: ValidateFunction<any> | undefined;
537
537
 
538
- if (schema.reqSchema) {
538
+ // Determine validation mode (default to Validate if not specified)
539
+ const validationMode = routeProps.validation || ValidationMode.Validate;
540
+
541
+ // Compile request schema if validation mode requires it
542
+ if (schema.reqSchema && validationMode !== ValidationMode.SkipValidation && validationMode !== ValidationMode.ValidateResponse) {
539
543
  validateReq = ajv.compile(schema.reqSchema);
540
544
  }
541
545
 
542
- if (schema.resSchema) {
546
+ // Compile response schema if validation mode requires it
547
+ if (schema.resSchema && validationMode !== ValidationMode.SkipValidation && validationMode !== ValidationMode.ValidateRequest) {
543
548
  validateRes = ajv.compile(schema.resSchema);
544
549
  }
545
550
 
@@ -562,7 +567,7 @@ export class FlinkApp<C extends FlinkContext> {
562
567
  error: {
563
568
  id: v4(),
564
569
  title: "Bad request",
565
- detail: `Schema did not validate ${JSON.stringify(validateReq.errors)}`,
570
+ detail: formattedErrors,
566
571
  },
567
572
  });
568
573
  }
@@ -580,6 +585,24 @@ export class FlinkApp<C extends FlinkContext> {
580
585
  return;
581
586
  }
582
587
 
588
+ // Normalize query parameters to predictable string or string[] types
589
+ // Express query parser can produce numbers, booleans, objects, etc.
590
+ // We normalize everything to strings or string arrays for consistency
591
+ if (req.query && typeof req.query === "object") {
592
+ const normalizedQuery: Record<string, string | string[]> = {};
593
+ for (const [key, value] of Object.entries(req.query)) {
594
+ if (Array.isArray(value)) {
595
+ // Handle array values (e.g., ?tag=a&tag=b)
596
+ normalizedQuery[key] = value.map((v) => String(v));
597
+ } else if (value !== undefined && value !== null) {
598
+ // Convert single values to strings
599
+ normalizedQuery[key] = String(value);
600
+ }
601
+ // Skip undefined/null values - they won't appear in the normalized query
602
+ }
603
+ req.query = normalizedQuery;
604
+ }
605
+
583
606
  let handlerRes: FlinkResponse<any>;
584
607
 
585
608
  try {
@@ -645,7 +668,7 @@ export class FlinkApp<C extends FlinkContext> {
645
668
  error: {
646
669
  id: v4(),
647
670
  title: "Bad response",
648
- detail: `Schema did not validate ${JSON.stringify(validateRes.errors)}`,
671
+ detail: formattedErrors,
649
672
  },
650
673
  });
651
674
  }
@@ -12,14 +12,54 @@ export enum HttpMethod {
12
12
  patch = "patch",
13
13
  }
14
14
 
15
- type Params = Request["params"];
15
+ /**
16
+ * Validation mode for handler request and response schemas.
17
+ *
18
+ * Controls whether request and/or response data is validated against JSON schemas.
19
+ *
20
+ * **Security Note:** Skipping validation can introduce security risks. Only use
21
+ * SkipValidation or ValidateResponse when you have implemented custom validation
22
+ * or the endpoint is internal/trusted.
23
+ *
24
+ * - Validate: Validate both request and response (default behavior)
25
+ * - SkipValidation: Skip both request and response validation
26
+ * - ValidateRequest: Validate only request, skip response validation
27
+ * - ValidateResponse: Validate only response, skip request validation
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * // Skip validation for webhook with custom signature verification
32
+ * export const Route: RouteProps = {
33
+ * path: "/webhook",
34
+ * validation: ValidationMode.SkipValidation
35
+ * };
36
+ *
37
+ * // Validate request but allow flexible response during development
38
+ * export const Route: RouteProps = {
39
+ * path: "/api/data",
40
+ * validation: ValidationMode.ValidateRequest
41
+ * };
42
+ * ```
43
+ */
44
+ export enum ValidationMode {
45
+ Validate = "Validate",
46
+ SkipValidation = "SkipValidation",
47
+ ValidateRequest = "ValidateRequest",
48
+ ValidateResponse = "ValidateResponse",
49
+ }
50
+
51
+ type Params = Record<string, string>;
16
52
 
17
53
  /**
18
54
  * Query type for request query parameters.
19
- * Does currently not allow nested objects, although
20
- * underlying express Request does allow it.
55
+ *
56
+ * All query parameter values are normalized to strings or string arrays:
57
+ * - Single values: string (e.g., ?name=John becomes { name: "John" })
58
+ * - Multiple values: string[] (e.g., ?tag=a&tag=b becomes { tag: ["a", "b"] })
59
+ *
60
+ * Does not allow nested objects, although underlying Express Request does allow it.
21
61
  */
22
- type Query = Record<string, string | string[] | undefined>;
62
+ type Query = Record<string, string | string[]>;
23
63
 
24
64
  /**
25
65
  * Flink request extends express Request but adds reqId and user object.
@@ -86,6 +126,27 @@ export interface RouteProps {
86
126
  * to avoid conflicts you can set a negative order.
87
127
  */
88
128
  order?: number;
129
+
130
+ /**
131
+ * Validation mode for request and response schemas.
132
+ *
133
+ * Controls schema validation behavior for this handler. Use with caution as
134
+ * skipping validation can introduce security vulnerabilities.
135
+ *
136
+ * **Options:**
137
+ * - Validate: Validate both request and response (default)
138
+ * - SkipValidation: Skip both request and response validation
139
+ * - ValidateRequest: Validate only request, skip response validation
140
+ * - ValidateResponse: Validate only response, skip request validation
141
+ *
142
+ * **When to skip validation:**
143
+ * - Webhook handlers with custom signature verification
144
+ * - Performance-critical internal endpoints
145
+ * - Handlers using alternative validation methods (e.g., Zod, Joi)
146
+ *
147
+ * @default ValidationMode.Validate
148
+ */
149
+ validation?: ValidationMode;
89
150
  }
90
151
 
91
152
  /**
package/src/utils.ts CHANGED
@@ -165,6 +165,8 @@ export function formatValidationErrors(errors: any[] | null | undefined, data: a
165
165
  formatted.push(` - Missing required property: ${error.params.missingProperty}`);
166
166
  } else if (error.keyword === "type") {
167
167
  formatted.push(` - Invalid type at ${error.schemaPath}: expected ${error.params.type}, got ${typeof dataAtPath}`);
168
+ } else if (error.keyword === "additionalProperties") {
169
+ formatted.push(` - Additional property not allowed: ${error.params.additionalProperty}`);
168
170
  } else if (error.keyword === "anyOf" || error.keyword === "oneOf") {
169
171
  formatted.push(` - ${error.message}`);
170
172
  } else {