@flink-app/flink 0.13.4 → 0.13.5

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,11 @@
1
1
  # @flink-app/flink
2
2
 
3
+ ## 0.13.5
4
+
5
+ ### Patch Changes
6
+
7
+ - Schema error DX improvement - i.e. better logs
8
+
3
9
  ## 0.13.4
4
10
 
5
11
  ### Patch Changes
@@ -341,7 +341,7 @@ var FlinkApp = /** @class */ (function () {
341
341
  validateRes_1 = ajv.compile(schema.resSchema);
342
342
  }
343
343
  this.expressApp[method](routeProps.path, function (req, res) { return __awaiter(_this, void 0, void 0, function () {
344
- var valid, data, handlerRes, err_1, valid;
344
+ var valid, formattedErrors, data, handlerRes, err_1, valid, formattedErrors;
345
345
  return __generator(this, function (_a) {
346
346
  switch (_a.label) {
347
347
  case 0:
@@ -356,8 +356,8 @@ var FlinkApp = /** @class */ (function () {
356
356
  if (validateReq_1) {
357
357
  valid = validateReq_1(req.body);
358
358
  if (!valid) {
359
- FlinkLog_1.log.warn("".concat(methodAndRoute_1, ": Bad request ").concat(JSON.stringify(validateReq_1.errors, null, 2)));
360
- FlinkLog_1.log.debug("Invalid json: ".concat(JSON.stringify(req.body)));
359
+ formattedErrors = (0, utils_1.formatValidationErrors)(validateReq_1.errors, req.body);
360
+ FlinkLog_1.log.warn("[".concat(req.reqId, "] ").concat(methodAndRoute_1, ": Bad request\n").concat(formattedErrors));
361
361
  return [2 /*return*/, res.status(400).json({
362
362
  status: 400,
363
363
  error: {
@@ -410,9 +410,8 @@ var FlinkApp = /** @class */ (function () {
410
410
  if (validateRes_1 && !(0, utils_1.isError)(handlerRes)) {
411
411
  valid = validateRes_1(JSON.parse(JSON.stringify(handlerRes.data)));
412
412
  if (!valid) {
413
- FlinkLog_1.log.warn("[".concat(req.reqId, "] ").concat(methodAndRoute_1, ": Bad response ").concat(JSON.stringify(validateRes_1.errors, null, 2)));
414
- FlinkLog_1.log.debug("Invalid json: ".concat(JSON.stringify(handlerRes.data)));
415
- // log.debug(JSON.stringify(schema, null, 2));
413
+ formattedErrors = (0, utils_1.formatValidationErrors)(validateRes_1.errors, handlerRes.data);
414
+ FlinkLog_1.log.warn("[".concat(req.reqId, "] ").concat(methodAndRoute_1, ": Bad response\n").concat(formattedErrors));
416
415
  return [2 /*return*/, res.status(500).json({
417
416
  status: 500,
418
417
  error: {
@@ -25,3 +25,15 @@ export declare function getJsDocComment(comment: string): string;
25
25
  * @returns
26
26
  */
27
27
  export declare function getPathParams(path: string): string[];
28
+ /**
29
+ * Extracts data at a given JSON path (e.g., "/jobs/5/result")
30
+ * Returns the value at that path, or undefined if not found
31
+ */
32
+ export declare function getDataAtPath(data: any, instancePath: string): any;
33
+ /**
34
+ * Formats validation errors with context about the problematic data
35
+ * @param errors AJV validation errors
36
+ * @param data The full data object that failed validation
37
+ * @param maxDataLength Maximum length of data to show (default 500)
38
+ */
39
+ export declare function formatValidationErrors(errors: any[] | null | undefined, data: any, maxDataLength?: number): string;
package/dist/src/utils.js CHANGED
@@ -50,6 +50,8 @@ exports.getRepoInstanceName = getRepoInstanceName;
50
50
  exports.getHttpMethodFromHandlerName = getHttpMethodFromHandlerName;
51
51
  exports.getJsDocComment = getJsDocComment;
52
52
  exports.getPathParams = getPathParams;
53
+ exports.getDataAtPath = getDataAtPath;
54
+ exports.formatValidationErrors = formatValidationErrors;
53
55
  var path_1 = require("path");
54
56
  var tiny_glob_1 = __importDefault(require("tiny-glob"));
55
57
  var FlinkHttpHandler_1 = require("./FlinkHttpHandler");
@@ -162,3 +164,72 @@ function getPathParams(path) {
162
164
  var _a;
163
165
  return ((_a = path.match(pathParamsRegex)) === null || _a === void 0 ? void 0 : _a.map(function (match) { return match.slice(1); })) || [];
164
166
  }
167
+ /**
168
+ * Extracts data at a given JSON path (e.g., "/jobs/5/result")
169
+ * Returns the value at that path, or undefined if not found
170
+ */
171
+ function getDataAtPath(data, instancePath) {
172
+ if (!instancePath || instancePath === "/") {
173
+ return data;
174
+ }
175
+ var parts = instancePath.split("/").filter(function (p) { return p.length > 0; });
176
+ var current = data;
177
+ for (var _i = 0, parts_1 = parts; _i < parts_1.length; _i++) {
178
+ var part = parts_1[_i];
179
+ if (current === undefined || current === null) {
180
+ return undefined;
181
+ }
182
+ current = current[part];
183
+ }
184
+ return current;
185
+ }
186
+ /**
187
+ * Formats validation errors with context about the problematic data
188
+ * @param errors AJV validation errors
189
+ * @param data The full data object that failed validation
190
+ * @param maxDataLength Maximum length of data to show (default 500)
191
+ */
192
+ function formatValidationErrors(errors, data, maxDataLength) {
193
+ if (maxDataLength === void 0) { maxDataLength = 500; }
194
+ if (!errors || errors.length === 0) {
195
+ return "Unknown validation error";
196
+ }
197
+ var formatted = [];
198
+ // Group errors by instance path to avoid repetition
199
+ var errorsByPath = new Map();
200
+ for (var _i = 0, errors_1 = errors; _i < errors_1.length; _i++) {
201
+ var error = errors_1[_i];
202
+ var path = error.instancePath || "/";
203
+ if (!errorsByPath.has(path)) {
204
+ errorsByPath.set(path, []);
205
+ }
206
+ errorsByPath.get(path).push(error);
207
+ }
208
+ errorsByPath.forEach(function (pathErrors, path) {
209
+ var dataAtPath = getDataAtPath(data, path);
210
+ var dataStr = JSON.stringify(dataAtPath);
211
+ // Truncate if too long
212
+ if (dataStr.length > maxDataLength) {
213
+ dataStr = dataStr.substring(0, maxDataLength) + "... (truncated)";
214
+ }
215
+ formatted.push("\nPath: ".concat(path));
216
+ formatted.push("Data: ".concat(dataStr));
217
+ formatted.push("Errors:");
218
+ for (var _i = 0, pathErrors_1 = pathErrors; _i < pathErrors_1.length; _i++) {
219
+ var error = pathErrors_1[_i];
220
+ if (error.keyword === "required") {
221
+ formatted.push(" - Missing required property: ".concat(error.params.missingProperty));
222
+ }
223
+ else if (error.keyword === "type") {
224
+ formatted.push(" - Invalid type at ".concat(error.schemaPath, ": expected ").concat(error.params.type, ", got ").concat(typeof dataAtPath));
225
+ }
226
+ else if (error.keyword === "anyOf" || error.keyword === "oneOf") {
227
+ formatted.push(" - ".concat(error.message));
228
+ }
229
+ else {
230
+ formatted.push(" - ".concat(error.message, " (").concat(error.keyword, ")"));
231
+ }
232
+ }
233
+ });
234
+ return formatted.join("\n");
235
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flink-app/flink",
3
- "version": "0.13.4",
3
+ "version": "0.13.5",
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",
@@ -1,5 +1,5 @@
1
1
  import { JSONSchema7 } from "json-schema";
2
- import { getJsDocComment } from "../src/utils";
2
+ import { formatValidationErrors, getDataAtPath, getJsDocComment } from "../src/utils";
3
3
 
4
4
  describe("Utils", () => {
5
5
  describe("getJsDocComment", () => {
@@ -15,6 +15,140 @@ describe("Utils", () => {
15
15
  expect(getJsDocComment(comment)).toBe("Hello world\nThis is another line\nThis line contains a * (asterisk)");
16
16
  });
17
17
  });
18
+
19
+ describe("getDataAtPath", () => {
20
+ it("should extract data at root path", () => {
21
+ const data = { foo: "bar" };
22
+ expect(getDataAtPath(data, "/")).toEqual({ foo: "bar" });
23
+ });
24
+
25
+ it("should extract nested data", () => {
26
+ const data = { jobs: [{ id: 1 }, { id: 2 }, { id: 3, result: { seed: "123" } }] };
27
+ expect(getDataAtPath(data, "/jobs/2/result")).toEqual({ seed: "123" });
28
+ });
29
+
30
+ it("should return undefined for non-existent path", () => {
31
+ const data = { jobs: [{ id: 1 }] };
32
+ expect(getDataAtPath(data, "/jobs/5/result")).toBeUndefined();
33
+ });
34
+ });
35
+
36
+ describe("formatValidationErrors", () => {
37
+ it("should format simple validation errors with data context", () => {
38
+ const errors = [
39
+ {
40
+ instancePath: "/name",
41
+ schemaPath: "#/properties/name/type",
42
+ keyword: "type",
43
+ params: { type: "string" },
44
+ message: "must be string",
45
+ },
46
+ ];
47
+ const data = { name: 123 };
48
+
49
+ const formatted = formatValidationErrors(errors, data);
50
+
51
+ expect(formatted).toContain('Path: /name');
52
+ expect(formatted).toContain('Data: 123');
53
+ expect(formatted).toContain('expected string');
54
+ });
55
+
56
+ it("should format missing required property errors", () => {
57
+ const errors = [
58
+ {
59
+ instancePath: "/user",
60
+ schemaPath: "#/properties/user/required",
61
+ keyword: "required",
62
+ params: { missingProperty: "email" },
63
+ message: "must have required property 'email'",
64
+ },
65
+ ];
66
+ const data = { user: { name: "John" } };
67
+
68
+ const formatted = formatValidationErrors(errors, data);
69
+
70
+ expect(formatted).toContain('Path: /user');
71
+ expect(formatted).toContain('Data: {"name":"John"}');
72
+ expect(formatted).toContain('Missing required property: email');
73
+ });
74
+
75
+ it("should format errors for array items", () => {
76
+ const errors = [
77
+ {
78
+ instancePath: "/jobs/5/result",
79
+ schemaPath: "#/properties/jobs/items/properties/result/anyOf",
80
+ keyword: "anyOf",
81
+ params: {},
82
+ message: "must match a schema in anyOf",
83
+ },
84
+ ];
85
+ const data = {
86
+ jobs: [
87
+ { result: { seed: 1 } },
88
+ { result: { seed: 2 } },
89
+ { result: { seed: 3 } },
90
+ { result: { seed: 4 } },
91
+ { result: { seed: 5 } },
92
+ { result: { seed: "wrong-type" } }, // This is item 5 (0-indexed)
93
+ ],
94
+ };
95
+
96
+ const formatted = formatValidationErrors(errors, data);
97
+
98
+ expect(formatted).toContain('Path: /jobs/5/result');
99
+ expect(formatted).toContain('Data: {"seed":"wrong-type"}');
100
+ expect(formatted).toContain('must match a schema in anyOf');
101
+ });
102
+
103
+ it("should truncate very long data", () => {
104
+ const errors = [
105
+ {
106
+ instancePath: "/data",
107
+ schemaPath: "#/properties/data/type",
108
+ keyword: "type",
109
+ params: { type: "string" },
110
+ message: "must be string",
111
+ },
112
+ ];
113
+ const longString = "x".repeat(1000);
114
+ const data = { data: longString };
115
+
116
+ const formatted = formatValidationErrors(errors, data, 100);
117
+
118
+ expect(formatted).toContain('(truncated)');
119
+ expect(formatted.length).toBeLessThan(500);
120
+ });
121
+
122
+ it("should group multiple errors for the same path", () => {
123
+ const errors = [
124
+ {
125
+ instancePath: "/user",
126
+ schemaPath: "#/properties/user/required",
127
+ keyword: "required",
128
+ params: { missingProperty: "email" },
129
+ message: "must have required property 'email'",
130
+ },
131
+ {
132
+ instancePath: "/user",
133
+ schemaPath: "#/properties/user/required",
134
+ keyword: "required",
135
+ params: { missingProperty: "age" },
136
+ message: "must have required property 'age'",
137
+ },
138
+ ];
139
+ const data = { user: { name: "John" } };
140
+
141
+ const formatted = formatValidationErrors(errors, data);
142
+
143
+ // Should only show the path and data once
144
+ const pathCount = (formatted.match(/Path: \/user/g) || []).length;
145
+ expect(pathCount).toBe(1);
146
+
147
+ // But should list both missing properties
148
+ expect(formatted).toContain('Missing required property: email');
149
+ expect(formatted).toContain('Missing required property: age');
150
+ });
151
+ });
18
152
  });
19
153
 
20
154
  const jsonSchemas: JSONSchema7 = {
package/src/FlinkApp.ts CHANGED
@@ -19,7 +19,7 @@ import { FlinkPlugin } from "./FlinkPlugin";
19
19
  import { FlinkRepo } from "./FlinkRepo";
20
20
  import { FlinkResponse } from "./FlinkResponse";
21
21
  import generateMockData from "./mock-data-generator";
22
- import { getPathParams, isError } from "./utils";
22
+ import { formatValidationErrors, getPathParams, isError } from "./utils";
23
23
 
24
24
  const ajv = new Ajv();
25
25
  addFormats(ajv);
@@ -504,9 +504,8 @@ export class FlinkApp<C extends FlinkContext> {
504
504
  const valid = validateReq(req.body);
505
505
 
506
506
  if (!valid) {
507
- log.warn(`${methodAndRoute}: Bad request ${JSON.stringify(validateReq.errors, null, 2)}`);
508
-
509
- log.debug(`Invalid json: ${JSON.stringify(req.body)}`);
507
+ const formattedErrors = formatValidationErrors(validateReq.errors, req.body);
508
+ log.warn(`[${req.reqId}] ${methodAndRoute}: Bad request\n${formattedErrors}`);
510
509
 
511
510
  return res.status(400).json({
512
511
  status: 400,
@@ -563,9 +562,8 @@ export class FlinkApp<C extends FlinkContext> {
563
562
  const valid = validateRes(JSON.parse(JSON.stringify(handlerRes.data)));
564
563
 
565
564
  if (!valid) {
566
- log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response ${JSON.stringify(validateRes.errors, null, 2)}`);
567
- log.debug(`Invalid json: ${JSON.stringify(handlerRes.data)}`);
568
- // log.debug(JSON.stringify(schema, null, 2));
565
+ const formattedErrors = formatValidationErrors(validateRes.errors, handlerRes.data);
566
+ log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response\n${formattedErrors}`);
569
567
 
570
568
  return res.status(500).json({
571
569
  status: 500,
package/src/utils.ts CHANGED
@@ -101,3 +101,77 @@ const pathParamsRegex = /:([a-zA-Z0-9]+)/g;
101
101
  export function getPathParams(path: string) {
102
102
  return path.match(pathParamsRegex)?.map((match) => match.slice(1)) || [];
103
103
  }
104
+
105
+ /**
106
+ * Extracts data at a given JSON path (e.g., "/jobs/5/result")
107
+ * Returns the value at that path, or undefined if not found
108
+ */
109
+ export function getDataAtPath(data: any, instancePath: string): any {
110
+ if (!instancePath || instancePath === "/") {
111
+ return data;
112
+ }
113
+
114
+ const parts = instancePath.split("/").filter((p) => p.length > 0);
115
+ let current = data;
116
+
117
+ for (const part of parts) {
118
+ if (current === undefined || current === null) {
119
+ return undefined;
120
+ }
121
+ current = current[part];
122
+ }
123
+
124
+ return current;
125
+ }
126
+
127
+ /**
128
+ * Formats validation errors with context about the problematic data
129
+ * @param errors AJV validation errors
130
+ * @param data The full data object that failed validation
131
+ * @param maxDataLength Maximum length of data to show (default 500)
132
+ */
133
+ export function formatValidationErrors(errors: any[] | null | undefined, data: any, maxDataLength = 500): string {
134
+ if (!errors || errors.length === 0) {
135
+ return "Unknown validation error";
136
+ }
137
+
138
+ const formatted: string[] = [];
139
+
140
+ // Group errors by instance path to avoid repetition
141
+ const errorsByPath = new Map<string, any[]>();
142
+ for (const error of errors) {
143
+ const path = error.instancePath || "/";
144
+ if (!errorsByPath.has(path)) {
145
+ errorsByPath.set(path, []);
146
+ }
147
+ errorsByPath.get(path)!.push(error);
148
+ }
149
+
150
+ errorsByPath.forEach((pathErrors, path) => {
151
+ const dataAtPath = getDataAtPath(data, path);
152
+ let dataStr = JSON.stringify(dataAtPath);
153
+
154
+ // Truncate if too long
155
+ if (dataStr.length > maxDataLength) {
156
+ dataStr = dataStr.substring(0, maxDataLength) + "... (truncated)";
157
+ }
158
+
159
+ formatted.push(`\nPath: ${path}`);
160
+ formatted.push(`Data: ${dataStr}`);
161
+ formatted.push(`Errors:`);
162
+
163
+ for (const error of pathErrors) {
164
+ if (error.keyword === "required") {
165
+ formatted.push(` - Missing required property: ${error.params.missingProperty}`);
166
+ } else if (error.keyword === "type") {
167
+ formatted.push(` - Invalid type at ${error.schemaPath}: expected ${error.params.type}, got ${typeof dataAtPath}`);
168
+ } else if (error.keyword === "anyOf" || error.keyword === "oneOf") {
169
+ formatted.push(` - ${error.message}`);
170
+ } else {
171
+ formatted.push(` - ${error.message} (${error.keyword})`);
172
+ }
173
+ }
174
+ });
175
+
176
+ return formatted.join("\n");
177
+ }