@flink-app/generic-request-plugin 0.12.1-alpha.9 → 0.13.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.
@@ -0,0 +1,459 @@
1
+ import { FlinkApp, FlinkAuthPlugin, FlinkRequest } from "@flink-app/flink";
2
+ import { genericRequestPlugin, HttpMethod } from "../src/index";
3
+
4
+ describe("GenericRequestPlugin", () => {
5
+ let mockApp: any;
6
+ let mockExpressApp: any;
7
+ let mockAuth: FlinkAuthPlugin;
8
+ let registeredRoutes: Map<string, Function>;
9
+
10
+ beforeEach(() => {
11
+ registeredRoutes = new Map();
12
+
13
+ // Mock Express app
14
+ mockExpressApp = {
15
+ get: jasmine.createSpy("get").and.callFake((path: string, handler: Function) => {
16
+ registeredRoutes.set(`GET:${path}`, handler);
17
+ }),
18
+ post: jasmine.createSpy("post").and.callFake((path: string, handler: Function) => {
19
+ registeredRoutes.set(`POST:${path}`, handler);
20
+ }),
21
+ put: jasmine.createSpy("put").and.callFake((path: string, handler: Function) => {
22
+ registeredRoutes.set(`PUT:${path}`, handler);
23
+ }),
24
+ delete: jasmine.createSpy("delete").and.callFake((path: string, handler: Function) => {
25
+ registeredRoutes.set(`DELETE:${path}`, handler);
26
+ }),
27
+ };
28
+
29
+ // Mock auth plugin
30
+ mockAuth = {
31
+ authenticateRequest: jasmine.createSpy("authenticateRequest").and.returnValue(Promise.resolve(true)),
32
+ createToken: jasmine.createSpy("createToken").and.returnValue(Promise.resolve("token")),
33
+ };
34
+
35
+ // Mock FlinkApp
36
+ mockApp = {
37
+ expressApp: mockExpressApp,
38
+ auth: mockAuth,
39
+ } as unknown as FlinkApp<any>;
40
+ });
41
+
42
+ describe("Plugin Registration", () => {
43
+ it("should create a valid plugin", () => {
44
+ const plugin = genericRequestPlugin({
45
+ path: "/test",
46
+ method: HttpMethod.get,
47
+ handler: () => {},
48
+ });
49
+
50
+ expect(plugin).toBeDefined();
51
+ expect(plugin.id).toBe("genericRequestPlugin");
52
+ expect(plugin.init).toBeDefined();
53
+ });
54
+
55
+ it("should throw error if Express app is not initialized", () => {
56
+ const appWithoutExpress = { expressApp: null } as unknown as FlinkApp<any>;
57
+ const plugin = genericRequestPlugin({
58
+ path: "/test",
59
+ method: HttpMethod.get,
60
+ handler: () => {},
61
+ });
62
+
63
+ expect(() => plugin.init(appWithoutExpress)).toThrowError("Express app not initialized");
64
+ });
65
+
66
+ it("should register GET route", () => {
67
+ const plugin = genericRequestPlugin({
68
+ path: "/test",
69
+ method: HttpMethod.get,
70
+ handler: () => {},
71
+ });
72
+
73
+ plugin.init(mockApp);
74
+
75
+ expect(mockExpressApp.get).toHaveBeenCalledWith("/test", jasmine.any(Function));
76
+ });
77
+
78
+ it("should register POST route", () => {
79
+ const plugin = genericRequestPlugin({
80
+ path: "/webhook",
81
+ method: HttpMethod.post,
82
+ handler: () => {},
83
+ });
84
+
85
+ plugin.init(mockApp);
86
+
87
+ expect(mockExpressApp.post).toHaveBeenCalledWith("/webhook", jasmine.any(Function));
88
+ });
89
+
90
+ it("should register PUT route", () => {
91
+ const plugin = genericRequestPlugin({
92
+ path: "/update",
93
+ method: HttpMethod.put,
94
+ handler: () => {},
95
+ });
96
+
97
+ plugin.init(mockApp);
98
+
99
+ expect(mockExpressApp.put).toHaveBeenCalledWith("/update", jasmine.any(Function));
100
+ });
101
+
102
+ it("should register DELETE route", () => {
103
+ const plugin = genericRequestPlugin({
104
+ path: "/remove",
105
+ method: HttpMethod.delete,
106
+ handler: () => {},
107
+ });
108
+
109
+ plugin.init(mockApp);
110
+
111
+ expect(mockExpressApp.delete).toHaveBeenCalledWith("/remove", jasmine.any(Function));
112
+ });
113
+ });
114
+
115
+ describe("Handler Execution - No Permissions", () => {
116
+ it("should call handler directly when no permissions are set", async () => {
117
+ const handlerSpy = jasmine.createSpy("handler");
118
+ const plugin = genericRequestPlugin({
119
+ path: "/public",
120
+ method: HttpMethod.get,
121
+ handler: handlerSpy,
122
+ });
123
+
124
+ plugin.init(mockApp);
125
+
126
+ const routeHandler = registeredRoutes.get("GET:/public");
127
+ if (!routeHandler) {
128
+ fail("Route handler not registered");
129
+ return;
130
+ }
131
+
132
+ const mockReq = {} as any;
133
+ const mockRes = {} as any;
134
+
135
+ await routeHandler(mockReq, mockRes);
136
+
137
+ expect(handlerSpy).toHaveBeenCalledWith(mockReq, mockRes, mockApp);
138
+ expect(mockAuth.authenticateRequest).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it("should pass req, res, and app to handler", async () => {
142
+ let capturedReq: any;
143
+ let capturedRes: any;
144
+ let capturedApp: any;
145
+
146
+ const handler = (req: any, res: any, app: FlinkApp<any>) => {
147
+ capturedReq = req;
148
+ capturedRes = res;
149
+ capturedApp = app;
150
+ };
151
+
152
+ const plugin = genericRequestPlugin({
153
+ path: "/test",
154
+ method: HttpMethod.get,
155
+ handler,
156
+ });
157
+
158
+ plugin.init(mockApp);
159
+
160
+ const routeHandler = registeredRoutes.get("GET:/test");
161
+ if (!routeHandler) {
162
+ fail("Route handler not registered");
163
+ return;
164
+ }
165
+
166
+ const mockReq = { path: "/test" } as any;
167
+ const mockRes = { status: () => ({ json: () => {} }) } as any;
168
+
169
+ await routeHandler(mockReq, mockRes);
170
+
171
+ expect(capturedReq).toBe(mockReq);
172
+ expect(capturedRes).toBe(mockRes);
173
+ expect(capturedApp).toBe(mockApp);
174
+ });
175
+ });
176
+
177
+ describe("Permission Validation", () => {
178
+ it("should validate permissions when set", async () => {
179
+ const handlerSpy = jasmine.createSpy("handler");
180
+ const plugin = genericRequestPlugin({
181
+ path: "/protected",
182
+ method: HttpMethod.get,
183
+ permissions: "read",
184
+ handler: handlerSpy,
185
+ });
186
+
187
+ plugin.init(mockApp);
188
+
189
+ const routeHandler = registeredRoutes.get("GET:/protected");
190
+ if (!routeHandler) {
191
+ fail("Route handler not registered");
192
+ return;
193
+ }
194
+
195
+ const mockReq = {} as FlinkRequest;
196
+ const mockRes = {} as any;
197
+
198
+ await routeHandler(mockReq, mockRes);
199
+
200
+ expect(mockAuth.authenticateRequest).toHaveBeenCalledWith(mockReq, "read");
201
+ expect(handlerSpy).toHaveBeenCalled();
202
+ });
203
+
204
+ it("should validate multiple permissions", async () => {
205
+ const handlerSpy = jasmine.createSpy("handler");
206
+ const plugin = genericRequestPlugin({
207
+ path: "/admin",
208
+ method: HttpMethod.post,
209
+ permissions: ["read", "write", "admin"],
210
+ handler: handlerSpy,
211
+ });
212
+
213
+ plugin.init(mockApp);
214
+
215
+ const routeHandler = registeredRoutes.get("POST:/admin");
216
+ if (!routeHandler) {
217
+ fail("Route handler not registered");
218
+ return;
219
+ }
220
+
221
+ const mockReq = {} as FlinkRequest;
222
+ const mockRes = {} as any;
223
+
224
+ await routeHandler(mockReq, mockRes);
225
+
226
+ expect(mockAuth.authenticateRequest).toHaveBeenCalledWith(mockReq, ["read", "write", "admin"]);
227
+ expect(handlerSpy).toHaveBeenCalled();
228
+ });
229
+
230
+ it("should return 401 when authentication fails", async () => {
231
+ mockAuth.authenticateRequest = jasmine.createSpy("authenticateRequest").and.returnValue(Promise.resolve(false));
232
+
233
+ const handlerSpy = jasmine.createSpy("handler");
234
+ const plugin = genericRequestPlugin({
235
+ path: "/protected",
236
+ method: HttpMethod.get,
237
+ permissions: "read",
238
+ handler: handlerSpy,
239
+ });
240
+
241
+ plugin.init(mockApp);
242
+
243
+ const routeHandler = registeredRoutes.get("GET:/protected");
244
+ if (!routeHandler) {
245
+ fail("Route handler not registered");
246
+ return;
247
+ }
248
+
249
+ const mockReq = {} as FlinkRequest;
250
+ const mockJsonSpy = jasmine.createSpy("json");
251
+ const mockRes = {
252
+ status: jasmine.createSpy("status").and.returnValue({ json: mockJsonSpy }),
253
+ } as any;
254
+
255
+ await routeHandler(mockReq, mockRes);
256
+
257
+ expect(mockAuth.authenticateRequest).toHaveBeenCalledWith(mockReq, "read");
258
+ expect(mockRes.status).toHaveBeenCalledWith(401);
259
+ expect(mockJsonSpy).toHaveBeenCalledWith({
260
+ status: 401,
261
+ error: {
262
+ title: "Unauthorized",
263
+ detail: "Authentication required or insufficient permissions",
264
+ },
265
+ });
266
+ expect(handlerSpy).not.toHaveBeenCalled();
267
+ });
268
+
269
+ it("should not call handler when authentication fails", async () => {
270
+ mockAuth.authenticateRequest = jasmine.createSpy("authenticateRequest").and.returnValue(Promise.resolve(false));
271
+
272
+ const handlerSpy = jasmine.createSpy("handler");
273
+ const plugin = genericRequestPlugin({
274
+ path: "/protected",
275
+ method: HttpMethod.post,
276
+ permissions: "write",
277
+ handler: handlerSpy,
278
+ });
279
+
280
+ plugin.init(mockApp);
281
+
282
+ const routeHandler = registeredRoutes.get("POST:/protected");
283
+ if (!routeHandler) {
284
+ fail("Route handler not registered");
285
+ return;
286
+ }
287
+
288
+ const mockReq = {} as FlinkRequest;
289
+ const mockRes = {
290
+ status: () => ({ json: () => {} }),
291
+ } as any;
292
+
293
+ await routeHandler(mockReq, mockRes);
294
+
295
+ expect(handlerSpy).not.toHaveBeenCalled();
296
+ });
297
+
298
+ it("should throw error if permissions are set but no auth plugin is configured", async () => {
299
+ const appWithoutAuth = {
300
+ expressApp: mockExpressApp,
301
+ auth: null,
302
+ } as unknown as FlinkApp<any>;
303
+
304
+ const plugin = genericRequestPlugin({
305
+ path: "/protected",
306
+ method: HttpMethod.get,
307
+ permissions: "read",
308
+ handler: () => {},
309
+ });
310
+
311
+ plugin.init(appWithoutAuth);
312
+
313
+ const routeHandler = registeredRoutes.get("GET:/protected");
314
+ if (!routeHandler) {
315
+ fail("Route handler not registered");
316
+ return;
317
+ }
318
+
319
+ const mockReq = {} as FlinkRequest;
320
+ const mockRes = {} as any;
321
+
322
+ await expectAsync(routeHandler(mockReq, mockRes)).toBeRejectedWithError(
323
+ "Route GET /protected requires permissions but no auth plugin is configured"
324
+ );
325
+ });
326
+
327
+ it("should call handler after successful authentication", async () => {
328
+ mockAuth.authenticateRequest = jasmine.createSpy("authenticateRequest").and.callFake((req: FlinkRequest) => {
329
+ req.user = { id: "123", username: "testuser" };
330
+ return Promise.resolve(true);
331
+ });
332
+
333
+ const handlerSpy = jasmine.createSpy("handler");
334
+ const plugin = genericRequestPlugin({
335
+ path: "/protected",
336
+ method: HttpMethod.get,
337
+ permissions: "read",
338
+ handler: handlerSpy,
339
+ });
340
+
341
+ plugin.init(mockApp);
342
+
343
+ const routeHandler = registeredRoutes.get("GET:/protected");
344
+ if (!routeHandler) {
345
+ fail("Route handler not registered");
346
+ return;
347
+ }
348
+
349
+ const mockReq = {} as FlinkRequest;
350
+ const mockRes = {} as any;
351
+
352
+ await routeHandler(mockReq, mockRes);
353
+
354
+ expect(mockAuth.authenticateRequest).toHaveBeenCalled();
355
+ expect(mockReq.user).toEqual({ id: "123", username: "testuser" });
356
+ expect(handlerSpy).toHaveBeenCalledWith(mockReq, mockRes, mockApp);
357
+ });
358
+ });
359
+
360
+ describe("Wildcard Permission", () => {
361
+ it("should validate wildcard permission for any authenticated user", async () => {
362
+ const handlerSpy = jasmine.createSpy("handler");
363
+ const plugin = genericRequestPlugin({
364
+ path: "/authenticated",
365
+ method: HttpMethod.get,
366
+ permissions: "*",
367
+ handler: handlerSpy,
368
+ });
369
+
370
+ plugin.init(mockApp);
371
+
372
+ const routeHandler = registeredRoutes.get("GET:/authenticated");
373
+ if (!routeHandler) {
374
+ fail("Route handler not registered");
375
+ return;
376
+ }
377
+
378
+ const mockReq = {} as FlinkRequest;
379
+ const mockRes = {} as any;
380
+
381
+ await routeHandler(mockReq, mockRes);
382
+
383
+ expect(mockAuth.authenticateRequest).toHaveBeenCalledWith(mockReq, "*");
384
+ expect(handlerSpy).toHaveBeenCalled();
385
+ });
386
+ });
387
+
388
+ describe("Real-world Scenarios", () => {
389
+ it("should handle webhook with permissions", async () => {
390
+ let webhookData: any;
391
+
392
+ const webhookHandler = (req: any, res: any) => {
393
+ webhookData = req.body;
394
+ res.json({ received: true });
395
+ };
396
+
397
+ const plugin = genericRequestPlugin({
398
+ path: "/webhook/stripe",
399
+ method: HttpMethod.post,
400
+ permissions: "webhook:stripe",
401
+ handler: webhookHandler,
402
+ });
403
+
404
+ plugin.init(mockApp);
405
+
406
+ const routeHandler = registeredRoutes.get("POST:/webhook/stripe");
407
+ if (!routeHandler) {
408
+ fail("Route handler not registered");
409
+ return;
410
+ }
411
+
412
+ const mockReq = { body: { event: "payment.success" } } as any;
413
+ const mockJsonSpy = jasmine.createSpy("json");
414
+ const mockRes = { json: mockJsonSpy } as any;
415
+
416
+ await routeHandler(mockReq, mockRes);
417
+
418
+ expect(mockAuth.authenticateRequest).toHaveBeenCalledWith(mockReq, "webhook:stripe");
419
+ expect(webhookData).toEqual({ event: "payment.success" });
420
+ expect(mockJsonSpy).toHaveBeenCalledWith({ received: true });
421
+ });
422
+
423
+ it("should handle file download with permissions", async () => {
424
+ const fileHandler = (req: any, res: any) => {
425
+ res.setHeader("Content-Type", "application/pdf");
426
+ res.end("file-data");
427
+ };
428
+
429
+ const plugin = genericRequestPlugin({
430
+ path: "/download/:fileId",
431
+ method: HttpMethod.get,
432
+ permissions: "file:download",
433
+ handler: fileHandler,
434
+ });
435
+
436
+ plugin.init(mockApp);
437
+
438
+ const routeHandler = registeredRoutes.get("GET:/download/:fileId");
439
+ if (!routeHandler) {
440
+ fail("Route handler not registered");
441
+ return;
442
+ }
443
+
444
+ const mockReq = { params: { fileId: "123" } } as any;
445
+ const mockSetHeaderSpy = jasmine.createSpy("setHeader");
446
+ const mockEndSpy = jasmine.createSpy("end");
447
+ const mockRes = {
448
+ setHeader: mockSetHeaderSpy,
449
+ end: mockEndSpy,
450
+ } as any;
451
+
452
+ await routeHandler(mockReq, mockRes);
453
+
454
+ expect(mockAuth.authenticateRequest).toHaveBeenCalledWith(mockReq, "file:download");
455
+ expect(mockSetHeaderSpy).toHaveBeenCalledWith("Content-Type", "application/pdf");
456
+ expect(mockEndSpy).toHaveBeenCalledWith("file-data");
457
+ });
458
+ });
459
+ });
@@ -0,0 +1,22 @@
1
+ import {
2
+ DisplayProcessor,
3
+ SpecReporter,
4
+ StacktraceOption,
5
+ } from "jasmine-spec-reporter";
6
+ import SuiteInfo = jasmine.SuiteInfo;
7
+
8
+ class CustomProcessor extends DisplayProcessor {
9
+ public displayJasmineStarted(info: SuiteInfo, log: string): string {
10
+ return `TypeScript ${log}`;
11
+ }
12
+ }
13
+
14
+ jasmine.getEnv().clearReporters();
15
+ jasmine.getEnv().addReporter(
16
+ new SpecReporter({
17
+ spec: {
18
+ displayStacktrace: StacktraceOption.NONE,
19
+ },
20
+ customProcessors: [CustomProcessor],
21
+ })
22
+ );
@@ -0,0 +1,7 @@
1
+ {
2
+ "spec_dir": "spec",
3
+ "spec_files": ["**/*[sS]pec.ts"],
4
+ "helpers": ["helpers/**/*.ts"],
5
+ "stopSpecOnExpectationFailure": false,
6
+ "random": true
7
+ }
package/src/index.ts CHANGED
@@ -1,52 +1,75 @@
1
- import { FlinkApp, FlinkPlugin } from "@flink-app/flink";
1
+ import { FlinkApp, FlinkPlugin, FlinkRequest } from "@flink-app/flink";
2
2
  import log from "node-color-log";
3
3
 
4
-
5
4
  export enum HttpMethod {
6
5
  get = "get",
7
6
  post = "post",
8
7
  put = "put",
9
8
  delete = "delete",
10
- }
11
-
12
-
9
+ }
13
10
 
14
11
  export type GenericRequestOptions = {
15
- /**
16
- * Path for request
17
- */
18
- path: string;
12
+ /**
13
+ * Path for request
14
+ */
15
+ path: string;
19
16
 
20
- /**
21
- * Function to handle the request
22
- */
23
- handler: any;
17
+ /**
18
+ * Function to handle the request
19
+ */
20
+ handler: any;
24
21
 
25
- /**
26
- * Http method for this request
27
- */
28
- method : HttpMethod;
22
+ /**
23
+ * Http method for this request
24
+ */
25
+ method: HttpMethod;
29
26
 
27
+ /**
28
+ * Optional permission(s) required to access this route.
29
+ * If set, the auth plugin will validate the request before calling the handler.
30
+ * Requires an auth plugin (e.g., jwt-auth-plugin) to be configured in FlinkApp.
31
+ */
32
+ permissions?: string | string[];
30
33
  };
31
34
 
32
35
  export const genericRequestPlugin = (options: GenericRequestOptions): FlinkPlugin => {
33
- return {
34
- id: "genericRequestPlugin",
35
- init: (app) => init(app, options),
36
- };
36
+ return {
37
+ id: "genericRequestPlugin",
38
+ init: (app) => init(app, options),
39
+ };
37
40
  };
38
41
 
39
42
  function init(app: FlinkApp<any>, options: GenericRequestOptions) {
43
+ const { expressApp } = app;
44
+
45
+ if (!expressApp) {
46
+ throw new Error("Express app not initialized");
47
+ }
40
48
 
41
- const { expressApp } = app;
49
+ expressApp[options.method](options.path, async (req, res) => {
50
+ // Validate permissions if set
51
+ if (options.permissions) {
52
+ if (!app.auth) {
53
+ throw new Error(`Route ${options.method.toUpperCase()} ${options.path} requires permissions but no auth plugin is configured`);
54
+ }
42
55
 
43
- if (!expressApp) {
44
- throw new Error("Express app not initialized");
45
- }
56
+ // Express Request is structurally compatible with FlinkRequest for auth purposes
57
+ // (both have headers and user properties). This follows the same pattern as FlinkApp.ts:826
58
+ const authenticated = await app.auth.authenticateRequest(req as FlinkRequest, options.permissions);
46
59
 
47
- expressApp[options.method](options.path, (req, res) => {
48
- options.handler(req, res, app)
49
- });
50
- log.info(`Registered genericRequest route ${options.method} ${options.path}`);
60
+ if (!authenticated) {
61
+ return res.status(401).json({
62
+ status: 401,
63
+ error: {
64
+ title: "Unauthorized",
65
+ detail: "Authentication required or insufficient permissions",
66
+ },
67
+ });
68
+ }
69
+ }
51
70
 
71
+ // Call the handler
72
+ options.handler(req, res, app);
73
+ });
74
+ log.info(`Registered genericRequest route ${options.method} ${options.path}`);
52
75
  }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./tsconfig",
3
+ "exclude": ["spec/**/*.ts"]
4
+ }
package/tsconfig.json CHANGED
@@ -7,6 +7,7 @@
7
7
  "esModuleInterop": true,
8
8
  "allowSyntheticDefaultImports": true,
9
9
  "strict": true,
10
+ "strictNullChecks": false,
10
11
  "forceConsistentCasingInFileNames": true,
11
12
  "module": "commonjs",
12
13
  "moduleResolution": "node",
@@ -15,9 +16,10 @@
15
16
  "noEmit": false,
16
17
  "declaration": true,
17
18
  "experimentalDecorators": true,
18
- "checkJs": true,
19
- "outDir": "dist"
19
+ "checkJs": false,
20
+ "outDir": "dist",
21
+ "typeRoots": ["./node_modules/@types"]
20
22
  },
21
- "include": ["./src/*"],
23
+ "include": ["./src/*", "./spec/*"],
22
24
  "exclude": ["./node_modules/*"]
23
25
  }