@flink-app/flink 0.14.1 → 0.14.3

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,18 @@
1
1
  # @flink-app/flink
2
2
 
3
+ ## 0.14.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 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.
8
+ - 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.
9
+
10
+ ## 0.14.2
11
+
12
+ ### Patch Changes
13
+
14
+ - 6d39832: Add optional onError callback to FlinkApp for custom error handling. The callback receives a properly typed FlinkResponse<FlinkError> and rich request context (including request object, HTTP method, path, and request ID) when handler execution throws an error. Supports both synchronous and asynchronous callbacks, with automatic error handling to prevent callback failures from affecting the error response flow. Enables custom logging or monitoring without modifying the response flow.
15
+
3
16
  ## 0.14.1
4
17
 
5
18
  ### Patch Changes
@@ -5,10 +5,12 @@ import { Db, MongoClient } from "mongodb";
5
5
  import { ToadScheduler } from "toad-scheduler";
6
6
  import { FlinkAuthPlugin } from "./auth/FlinkAuthPlugin";
7
7
  import { FlinkContext } from "./FlinkContext";
8
- import { HandlerFile, HttpMethod, QueryParamMetadata, RouteProps } from "./FlinkHttpHandler";
8
+ import { FlinkError } from "./FlinkErrors";
9
+ import { FlinkRequest, HandlerFile, HttpMethod, QueryParamMetadata, RouteProps } from "./FlinkHttpHandler";
9
10
  import { FlinkJobFile } from "./FlinkJob";
10
11
  import { FlinkPlugin } from "./FlinkPlugin";
11
12
  import { FlinkRepo } from "./FlinkRepo";
13
+ import { FlinkResponse } from "./FlinkResponse";
12
14
  export type JSONSchema = JSONSchema7;
13
15
  /**
14
16
  * Re-export express factory function so sub packages
@@ -136,6 +138,50 @@ export interface FlinkOptions {
136
138
  */
137
139
  format?: string;
138
140
  };
141
+ /**
142
+ * Optional callback invoked when an error occurs in a handler.
143
+ * The error response and request context are passed for custom
144
+ * error logging or monitoring. This is a side-effect only and
145
+ * will not modify the response flow.
146
+ *
147
+ * Supports both synchronous and asynchronous callbacks. Any errors
148
+ * thrown or rejected by the callback will be caught and logged
149
+ * without affecting the error response to the client.
150
+ *
151
+ * @param error - The error response with status and error details
152
+ * @param context - Request context including method, path, and request ID
153
+ *
154
+ * @example
155
+ * ```ts
156
+ * // Synchronous callback
157
+ * onError: (error, context) => {
158
+ * if (error.status >= 500) {
159
+ * logger.error('Server error occurred', {
160
+ * status: error.status,
161
+ * code: error.error?.code,
162
+ * route: `${context.method} ${context.path}`,
163
+ * reqId: context.reqId
164
+ * });
165
+ * }
166
+ * }
167
+ *
168
+ * // Asynchronous callback
169
+ * onError: async (error, context) => {
170
+ * if (error.status >= 500) {
171
+ * await monitoringService.reportError({
172
+ * error,
173
+ * context
174
+ * });
175
+ * }
176
+ * }
177
+ * ```
178
+ */
179
+ onError?: (error: FlinkResponse<FlinkError>, context: {
180
+ req: FlinkRequest;
181
+ method: HttpMethod;
182
+ path: string;
183
+ reqId: string;
184
+ }) => void | Promise<void>;
139
185
  }
140
186
  export interface HandlerConfig {
141
187
  schema?: {
@@ -178,6 +224,7 @@ export declare class FlinkApp<C extends FlinkContext> {
178
224
  private schedulingOptions?;
179
225
  private disableHttpServer;
180
226
  private expressServer;
227
+ private onError?;
181
228
  private repos;
182
229
  /**
183
230
  * Internal cache used to track registered handlers and potentially any overlapping routes
@@ -122,6 +122,7 @@ var FlinkApp = /** @class */ (function () {
122
122
  this.schedulingOptions = opts.scheduling;
123
123
  this.disableHttpServer = !!opts.disableHttpServer;
124
124
  this.accessLog = __assign({ enabled: true, format: "dev" }, opts.accessLog);
125
+ this.onError = opts.onError;
125
126
  }
126
127
  Object.defineProperty(FlinkApp.prototype, "ctx", {
127
128
  get: function () {
@@ -341,7 +342,7 @@ var FlinkApp = /** @class */ (function () {
341
342
  validateRes_1 = ajv.compile(schema.resSchema);
342
343
  }
343
344
  this.expressApp[method](routeProps.path, function (req, res) { return __awaiter(_this, void 0, void 0, function () {
344
- var valid, formattedErrors, data, handlerRes, err_1, valid, formattedErrors;
345
+ var valid, formattedErrors, data, handlerRes, err_1, errorResponse, result, valid, formattedErrors;
345
346
  return __generator(this, function (_a) {
346
347
  switch (_a.label) {
347
348
  case 0:
@@ -363,7 +364,7 @@ var FlinkApp = /** @class */ (function () {
363
364
  error: {
364
365
  id: (0, uuid_1.v4)(),
365
366
  title: "Bad request",
366
- detail: "Schema did not validate ".concat(JSON.stringify(validateReq_1.errors)),
367
+ detail: formattedErrors,
367
368
  },
368
369
  })];
369
370
  }
@@ -391,21 +392,45 @@ var FlinkApp = /** @class */ (function () {
391
392
  return [3 /*break*/, 6];
392
393
  case 5:
393
394
  err_1 = _a.sent();
395
+ errorResponse = void 0;
394
396
  // duck typing to check if it is a FlinkError
395
397
  if (typeof err_1.status === "number" && err_1.status >= 400 && err_1.status < 600 && err_1.error) {
396
- return [2 /*return*/, res.status(err_1.status).json({
397
- status: err_1.status,
398
- error: {
399
- id: err_1.error.id || (0, uuid_1.v4)(),
400
- title: err_1.error.title || "Unhandled error: ".concat(err_1.error.code || err_1.status),
401
- detail: err_1.error.detail,
402
- code: err_1.error.code,
403
- },
404
- })];
398
+ errorResponse = {
399
+ status: err_1.status,
400
+ error: {
401
+ id: err_1.error.id || (0, uuid_1.v4)(),
402
+ title: err_1.error.title || "Unhandled error: ".concat(err_1.error.code || err_1.status),
403
+ detail: err_1.error.detail,
404
+ code: err_1.error.code,
405
+ },
406
+ };
405
407
  }
406
- FlinkLog_1.log.warn("Handler '".concat(methodAndRoute_1, "' threw unhandled exception ").concat(err_1));
407
- console.error(err_1);
408
- return [2 /*return*/, res.status(500).json((0, FlinkErrors_1.internalServerError)(err_1))];
408
+ else {
409
+ FlinkLog_1.log.warn("Handler '".concat(methodAndRoute_1, "' threw unhandled exception ").concat(err_1));
410
+ console.error(err_1);
411
+ errorResponse = (0, FlinkErrors_1.internalServerError)(err_1);
412
+ }
413
+ // Invoke onError callback if provided
414
+ if (this.onError) {
415
+ try {
416
+ result = this.onError(errorResponse, {
417
+ req: req,
418
+ method: method,
419
+ path: routeProps.path,
420
+ reqId: req.reqId,
421
+ });
422
+ // Handle async callbacks - don't wait for them
423
+ if (result instanceof Promise) {
424
+ result.catch(function (callbackErr) {
425
+ FlinkLog_1.log.error("onError callback rejected with: ".concat(callbackErr));
426
+ });
427
+ }
428
+ }
429
+ catch (callbackErr) {
430
+ FlinkLog_1.log.error("onError callback threw an exception: ".concat(callbackErr));
431
+ }
432
+ }
433
+ return [2 /*return*/, res.status(errorResponse.status || 500).json(errorResponse)];
409
434
  case 6:
410
435
  if (validateRes_1 && !(0, utils_1.isError)(handlerRes)) {
411
436
  valid = validateRes_1(JSON.parse(JSON.stringify(handlerRes.data)));
@@ -417,7 +442,7 @@ var FlinkApp = /** @class */ (function () {
417
442
  error: {
418
443
  id: (0, uuid_1.v4)(),
419
444
  title: "Bad response",
420
- detail: "Schema did not validate ".concat(JSON.stringify(validateRes_1.errors)),
445
+ detail: formattedErrors,
421
446
  },
422
447
  })];
423
448
  }
@@ -15,8 +15,13 @@ type Params = Request["params"];
15
15
  * Query type for request query parameters.
16
16
  * Does currently not allow nested objects, although
17
17
  * underlying express Request does allow it.
18
+ *
19
+ * Uses index signature to allow both Record types and interface types
20
+ * to be assignable to Query without requiring explicit index signatures.
18
21
  */
19
- type Query = Record<string, string | string[] | undefined>;
22
+ type Query = {
23
+ [x: string]: string | string[] | undefined;
24
+ };
20
25
  /**
21
26
  * Flink request extends express Request but adds reqId and user object.
22
27
  */
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.1",
3
+ "version": "0.14.3",
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",
@@ -0,0 +1,80 @@
1
+ import { FlinkApp } from "../src/FlinkApp";
2
+ import { FlinkContext } from "../src/FlinkContext";
3
+ import { FlinkResponse } from "../src/FlinkResponse";
4
+ import { FlinkError } from "../src/FlinkErrors";
5
+ import { badRequest, internalServerError, notFound } from "../src/FlinkErrors";
6
+ import { HttpMethod } from "../src/FlinkHttpHandler";
7
+
8
+ interface TestContext extends FlinkContext {}
9
+
10
+ describe("FlinkApp onError callback", () => {
11
+ let app: FlinkApp<TestContext>;
12
+ let capturedError: FlinkResponse<FlinkError> | null;
13
+ let capturedContext: any;
14
+
15
+ beforeEach(() => {
16
+ capturedError = null;
17
+ capturedContext = null;
18
+ });
19
+
20
+ afterEach(async () => {
21
+ if (app && app.started) {
22
+ await app.stop();
23
+ }
24
+ });
25
+
26
+ it("should be configurable in FlinkOptions", () => {
27
+ const callback = (error: FlinkResponse<FlinkError>, context: any) => {
28
+ capturedError = error;
29
+ capturedContext = context;
30
+ };
31
+
32
+ app = new FlinkApp<TestContext>({
33
+ name: "test-app",
34
+ disableHttpServer: true,
35
+ onError: callback,
36
+ });
37
+
38
+ expect(app).toBeDefined();
39
+ });
40
+
41
+ it("should have proper type signature for onError callback", () => {
42
+ // This test verifies TypeScript compilation
43
+ // The callback should accept FlinkResponse<FlinkError> and context object
44
+
45
+ const syncCallback = (error: FlinkResponse<FlinkError>, context: { req: any; method: HttpMethod; path: string; reqId: string }) => {
46
+ console.log(error.status, context.reqId);
47
+ };
48
+
49
+ const asyncCallback = async (error: FlinkResponse<FlinkError>, context: { req: any; method: HttpMethod; path: string; reqId: string }) => {
50
+ await Promise.resolve();
51
+ console.log(error.status, context.reqId);
52
+ };
53
+
54
+ const app1 = new FlinkApp<TestContext>({
55
+ name: "test-app-1",
56
+ disableHttpServer: true,
57
+ onError: syncCallback,
58
+ });
59
+
60
+ const app2 = new FlinkApp<TestContext>({
61
+ name: "test-app-2",
62
+ disableHttpServer: true,
63
+ onError: asyncCallback,
64
+ });
65
+
66
+ expect(app1).toBeDefined();
67
+ expect(app2).toBeDefined();
68
+ });
69
+
70
+ it("should be optional in FlinkOptions", async () => {
71
+ app = new FlinkApp<TestContext>({
72
+ name: "test-app",
73
+ disableHttpServer: true,
74
+ // No onError callback
75
+ });
76
+
77
+ await app.start();
78
+ expect(app.started).toBe(true);
79
+ });
80
+ });
@@ -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
@@ -11,7 +11,7 @@ import { AsyncTask, CronJob, SimpleIntervalJob, ToadScheduler } from "toad-sched
11
11
  import { v4 } from "uuid";
12
12
  import { FlinkAuthPlugin } from "./auth/FlinkAuthPlugin";
13
13
  import { FlinkContext } from "./FlinkContext";
14
- import { internalServerError, notFound, unauthorized } from "./FlinkErrors";
14
+ import { FlinkError, internalServerError, notFound, unauthorized } from "./FlinkErrors";
15
15
  import { FlinkRequest, Handler, HandlerFile, HttpMethod, QueryParamMetadata, RouteProps } from "./FlinkHttpHandler";
16
16
  import { FlinkJobFile } from "./FlinkJob";
17
17
  import { log } from "./FlinkLog";
@@ -197,6 +197,54 @@ export interface FlinkOptions {
197
197
  */
198
198
  format?: string;
199
199
  };
200
+
201
+ /**
202
+ * Optional callback invoked when an error occurs in a handler.
203
+ * The error response and request context are passed for custom
204
+ * error logging or monitoring. This is a side-effect only and
205
+ * will not modify the response flow.
206
+ *
207
+ * Supports both synchronous and asynchronous callbacks. Any errors
208
+ * thrown or rejected by the callback will be caught and logged
209
+ * without affecting the error response to the client.
210
+ *
211
+ * @param error - The error response with status and error details
212
+ * @param context - Request context including method, path, and request ID
213
+ *
214
+ * @example
215
+ * ```ts
216
+ * // Synchronous callback
217
+ * onError: (error, context) => {
218
+ * if (error.status >= 500) {
219
+ * logger.error('Server error occurred', {
220
+ * status: error.status,
221
+ * code: error.error?.code,
222
+ * route: `${context.method} ${context.path}`,
223
+ * reqId: context.reqId
224
+ * });
225
+ * }
226
+ * }
227
+ *
228
+ * // Asynchronous callback
229
+ * onError: async (error, context) => {
230
+ * if (error.status >= 500) {
231
+ * await monitoringService.reportError({
232
+ * error,
233
+ * context
234
+ * });
235
+ * }
236
+ * }
237
+ * ```
238
+ */
239
+ onError?: (
240
+ error: FlinkResponse<FlinkError>,
241
+ context: {
242
+ req: FlinkRequest;
243
+ method: HttpMethod;
244
+ path: string;
245
+ reqId: string;
246
+ }
247
+ ) => void | Promise<void>;
200
248
  }
201
249
 
202
250
  export interface HandlerConfig {
@@ -242,6 +290,7 @@ export class FlinkApp<C extends FlinkContext> {
242
290
  private schedulingOptions?: FlinkOptions["scheduling"];
243
291
  private disableHttpServer = false;
244
292
  private expressServer: any; // for simplicity, we don't want to import types from express/node here
293
+ private onError?: FlinkOptions["onError"];
245
294
 
246
295
  private repos: { [x: string]: FlinkRepo<C, any> } = {};
247
296
 
@@ -272,6 +321,7 @@ export class FlinkApp<C extends FlinkContext> {
272
321
  this.schedulingOptions = opts.scheduling;
273
322
  this.disableHttpServer = !!opts.disableHttpServer;
274
323
  this.accessLog = { enabled: true, format: "dev", ...opts.accessLog };
324
+ this.onError = opts.onError;
275
325
  }
276
326
 
277
327
  get ctx() {
@@ -512,7 +562,7 @@ export class FlinkApp<C extends FlinkContext> {
512
562
  error: {
513
563
  id: v4(),
514
564
  title: "Bad request",
515
- detail: `Schema did not validate ${JSON.stringify(validateReq.errors)}`,
565
+ detail: formattedErrors,
516
566
  },
517
567
  });
518
568
  }
@@ -540,9 +590,11 @@ export class FlinkApp<C extends FlinkContext> {
540
590
  origin: routeProps.origin,
541
591
  });
542
592
  } catch (err: any) {
593
+ let errorResponse: FlinkResponse<FlinkError>;
594
+
543
595
  // duck typing to check if it is a FlinkError
544
596
  if (typeof err.status === "number" && err.status >= 400 && err.status < 600 && err.error) {
545
- return res.status(err.status).json({
597
+ errorResponse = {
546
598
  status: err.status,
547
599
  error: {
548
600
  id: err.error.id || v4(),
@@ -550,12 +602,35 @@ export class FlinkApp<C extends FlinkContext> {
550
602
  detail: err.error.detail,
551
603
  code: err.error.code,
552
604
  },
553
- });
605
+ };
606
+ } else {
607
+ log.warn(`Handler '${methodAndRoute}' threw unhandled exception ${err}`);
608
+ console.error(err);
609
+ errorResponse = internalServerError(err as any);
554
610
  }
555
611
 
556
- log.warn(`Handler '${methodAndRoute}' threw unhandled exception ${err}`);
557
- console.error(err);
558
- return res.status(500).json(internalServerError(err as any));
612
+ // Invoke onError callback if provided
613
+ if (this.onError) {
614
+ try {
615
+ const result = this.onError(errorResponse, {
616
+ req: req as FlinkRequest,
617
+ method: method!,
618
+ path: routeProps.path,
619
+ reqId: req.reqId,
620
+ });
621
+
622
+ // Handle async callbacks - don't wait for them
623
+ if (result instanceof Promise) {
624
+ result.catch((callbackErr) => {
625
+ log.error(`onError callback rejected with: ${callbackErr}`);
626
+ });
627
+ }
628
+ } catch (callbackErr) {
629
+ log.error(`onError callback threw an exception: ${callbackErr}`);
630
+ }
631
+ }
632
+
633
+ return res.status(errorResponse.status || 500).json(errorResponse);
559
634
  }
560
635
 
561
636
  if (validateRes && !isError(handlerRes)) {
@@ -570,7 +645,7 @@ export class FlinkApp<C extends FlinkContext> {
570
645
  error: {
571
646
  id: v4(),
572
647
  title: "Bad response",
573
- detail: `Schema did not validate ${JSON.stringify(validateRes.errors)}`,
648
+ detail: formattedErrors,
574
649
  },
575
650
  });
576
651
  }
@@ -18,8 +18,13 @@ type Params = Request["params"];
18
18
  * Query type for request query parameters.
19
19
  * Does currently not allow nested objects, although
20
20
  * underlying express Request does allow it.
21
+ *
22
+ * Uses index signature to allow both Record types and interface types
23
+ * to be assignable to Query without requiring explicit index signatures.
21
24
  */
22
- type Query = Record<string, string | string[] | undefined>;
25
+ type Query = {
26
+ [x: string]: string | string[] | undefined;
27
+ };
23
28
 
24
29
  /**
25
30
  * Flink request extends express Request but adds reqId and user object.
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 {