Skip to content

Commit 627e391

Browse files
authored
feat: add hooks to before and after requests (#149)
1 parent 48f0422 commit 627e391

File tree

4 files changed

+194
-7
lines changed

4 files changed

+194
-7
lines changed

src/__test__/wrap.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import { lf } from '../function.js';
66
import { LambdaRequest } from '../request.js';
77
import { LambdaHttpRequest } from '../http/request.http.js';
88
import { LambdaHttpResponse } from '../http/response.http.js';
9-
import { AlbExample, ApiGatewayExample, clone, CloudfrontExample } from './examples.js';
9+
import { AlbExample, ApiGatewayExample, clone, CloudfrontExample, UrlExample } from './examples.js';
1010
import { fakeLog } from './log.js';
1111
import { HttpMethods } from '../http/router.js';
12+
import { UrlResult } from '../http/request.url.js';
1213

1314
function assertAlbResult(x: unknown): asserts x is ALBResult {}
1415
function assertCloudfrontResult(x: unknown): asserts x is CloudFrontResultResponse {}
1516
function assertsApiGatewayResult(x: unknown): asserts x is APIGatewayProxyStructuredResultV2 {}
17+
function assertsUrlResult(x: unknown): asserts x is UrlResult {}
1618

1719
o.spec('LambdaWrap', () => {
1820
const fakeContext = {} as Context;
@@ -160,6 +162,35 @@ o.spec('LambdaWrap', () => {
160162
o(body.id).equals(requests[0].id);
161163
});
162164

165+
o('should respond to function url events', async () => {
166+
const fn = lf.http(fakeLog);
167+
const req = clone(UrlExample);
168+
req.rawPath = '/v1/tiles/aerial/EPSG:3857/6/3/41.json';
169+
fn.router.get('/v1/tiles/:tileSet/:projection/:z/:x/:y.json', fakeLambda);
170+
const ret = await new Promise((resolve) => fn(req, fakeContext, (a, b) => resolve(b)));
171+
172+
assertsUrlResult(ret);
173+
o(ret.statusCode).equals(200);
174+
o(ret.isBase64Encoded).equals(false);
175+
o(ret.headers?.['content-type']).deepEquals('application/json');
176+
177+
const body = JSON.parse(ret.body ?? '');
178+
o(body.message).equals('ok');
179+
o(body.status).equals(200);
180+
o(body.id).equals(requests[0].id);
181+
});
182+
183+
o('should handle thrown http responses', async () => {
184+
const fn = lf.http(fakeLog);
185+
fn.router.all('*', () => {
186+
throw new LambdaHttpResponse(400, 'Error');
187+
});
188+
const ret = await new Promise((resolve) => fn(ApiGatewayExample, fakeContext, (a, b) => resolve(b)));
189+
190+
assertsUrlResult(ret);
191+
o(ret.statusCode).equals(400);
192+
});
193+
163194
o('should handle http exceptions', async () => {
164195
const fn = lf.http(fakeLog);
165196
fn.router.all('*', () => {

src/function.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const hash = process.env.GIT_HASH;
3333
const versionInfo = { version, hash };
3434

3535
/** Run the request catching any errors */
36-
async function runFunction<T extends LambdaRequest, K>(
36+
export async function runFunction<T extends LambdaRequest, K>(
3737
req: T,
3838
fn: (req: T) => K | Promise<K>,
3939
): Promise<K | LambdaHttpResponse> {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { Context } from 'aws-lambda';
2+
import o from 'ospec';
3+
import sinon from 'sinon';
4+
import { UrlExample } from '../../__test__/examples.js';
5+
import { fakeLog } from '../../__test__/log.js';
6+
import { LambdaUrlRequest } from '../request.url.js';
7+
import { LambdaHttpResponse } from '../response.http.js';
8+
import { Router } from '../router.js';
9+
10+
o.spec('RouterHook', () => {
11+
const fakeContext = {} as Context;
12+
const sandbox = sinon.createSandbox();
13+
const req = new LambdaUrlRequest(UrlExample, fakeContext, fakeLog);
14+
15+
o.afterEach(() => sandbox.restore());
16+
17+
o.spec('request', () => {
18+
o('should run before every request', async () => {
19+
const r = new Router();
20+
21+
const hook = sandbox.stub();
22+
r.hook('request', hook);
23+
24+
const res = await r.handle(req);
25+
26+
o(res.status).equals(404);
27+
o(hook.calledOnce).equals(true);
28+
29+
const resB = await r.handle(req);
30+
o(resB.status).equals(404);
31+
o(hook.calledTwice).equals(true);
32+
});
33+
34+
o('should allow request hooks to make responses', async () => {
35+
const r = new Router();
36+
r.hook('request', () => {
37+
return new LambdaHttpResponse(200, 'ok');
38+
});
39+
40+
const res = await r.handle(req);
41+
o(res.status).equals(200);
42+
});
43+
o('should allow request hooks to throw responses', async () => {
44+
const r = new Router();
45+
r.hook('request', () => {
46+
throw new LambdaHttpResponse(500, 'ok');
47+
});
48+
49+
const res = await r.handle(req);
50+
o(res.status).equals(500);
51+
o(res.statusDescription).equals('ok');
52+
});
53+
o('should catch unhandled exceptions', async () => {
54+
const r = new Router();
55+
r.hook('request', (req) => {
56+
req.path = ''; // Path is readonly
57+
});
58+
59+
const res = await r.handle(req);
60+
o(res.status).equals(500);
61+
o(res.statusDescription).equals('Internal Server Error');
62+
});
63+
});
64+
65+
o.spec('response', () => {
66+
o('should allow overriding of response', async () => {
67+
const r = new Router();
68+
r.hook('response', (req, res) => {
69+
o(res.status).equals(404);
70+
res.status = 200;
71+
});
72+
73+
const res = await r.handle(req);
74+
o(res.status).equals(200);
75+
});
76+
77+
o('should allow throwing of errors', async () => {
78+
const r = new Router();
79+
r.hook('response', () => {
80+
throw new LambdaHttpResponse(400, 'ok');
81+
});
82+
83+
const res = await r.handle(req);
84+
o(res.status).equals(400);
85+
});
86+
87+
o('should catch unhandled exceptions', async () => {
88+
const r = new Router();
89+
r.hook('response', (req) => {
90+
req.path = ''; // Path is readonly
91+
});
92+
93+
const res = await r.handle(req);
94+
o(res.status).equals(500);
95+
o(res.statusDescription).equals('Internal Server Error');
96+
});
97+
});
98+
});

src/http/router.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
1-
import { execute } from '../function.js';
1+
import { execute, runFunction } from '../function.js';
22
import { LambdaHttpRequest, RequestTypes } from './request.http.js';
33
import { LambdaHttpResponse } from './response.http.js';
44

5-
export type Route<T extends RequestTypes> = (
5+
export type Route<T extends RequestTypes = RequestTypes> = (
66
req: LambdaHttpRequest<T>,
77
) => Promise<LambdaHttpResponse> | LambdaHttpResponse | void;
8+
9+
export type RouteResponse<T extends RequestTypes> = (
10+
req: LambdaHttpRequest<T>,
11+
res: LambdaHttpResponse,
12+
) => Promise<void> | void;
13+
14+
export type RouteHooks = {
15+
/** Before the request is executed */
16+
request: Route<RequestTypes>;
17+
/** Before the response is returned to the lambda */
18+
response: RouteResponse<RequestTypes>;
19+
};
20+
21+
export type HookRecord<T extends RouteHooks> = {
22+
[K in keyof T]: T[K][];
23+
};
24+
825
export type HttpMethods = 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT' | 'OPTIONS' | 'ALL';
926
export class Router {
1027
routes: {
@@ -15,6 +32,13 @@ export class Router {
1532
fn: Route<any>;
1633
}[] = [];
1734

35+
hooks: HookRecord<RouteHooks> = {
36+
/** Hooks to be run before every request */
37+
request: [],
38+
/** Hooks to be run after every request */
39+
response: [],
40+
};
41+
1842
register<T extends RequestTypes>(method: HttpMethods, path: string, fn: Route<T>): void {
1943
// Stolen from https://github.com/kwhitley/itty-router
2044
const regex = RegExp(
@@ -30,6 +54,16 @@ export class Router {
3054
this.routes.push({ path: regex, method, fn });
3155
}
3256

57+
/**
58+
* Attach a hook to the router
59+
* @param name hook to attach too
60+
* @param cb Function to call on hook
61+
*/
62+
hook<K extends keyof RouteHooks>(name: K, cb: RouteHooks[K]): void {
63+
this.hooks[name].push(cb);
64+
}
65+
66+
/** Register a route for all HTTP types, GET, POST, HEAD, etc... */
3367
all<T extends RequestTypes>(path: string, fn: Route<T>): void {
3468
return this.register('ALL', path, fn);
3569
}
@@ -55,18 +89,42 @@ export class Router {
5589
return this.register('PUT', path, fn);
5690
}
5791

92+
/** After a route has finished processing run the response hooks on the request/response pair */
93+
async after(req: LambdaHttpRequest, response: LambdaHttpResponse): Promise<LambdaHttpResponse> {
94+
try {
95+
for (const hook of this.hooks.response) await hook(req, response);
96+
} catch (e) {
97+
if (LambdaHttpResponse.is(e)) return e;
98+
req.set('err', e);
99+
return new LambdaHttpResponse(500, 'Internal Server Error');
100+
}
101+
return response;
102+
}
103+
104+
/**
105+
* Handle a incoming request
106+
*
107+
* Request flow: hook.request(req) -> requestHandler(req) -> hook.response(req, res)
108+
*/
58109
async handle(req: LambdaHttpRequest): Promise<LambdaHttpResponse> {
110+
// On before request
111+
for (const hook of this.hooks.request) {
112+
const res = await runFunction(req, hook);
113+
// If a hook returns a response return the response to the user
114+
if (res) return this.after(req, res);
115+
}
116+
59117
for (const r of this.routes) {
60118
if (r.method !== 'ALL' && req.method !== r.method) continue;
61119
const m = req.path.match(r.path);
62120
if (m) {
63121
// TODO this should ideally be validated
64122
req.params = m.groups;
65-
const ret = await execute(req, r.fn);
66-
if (ret) return ret;
123+
const res = await execute(req, r.fn);
124+
if (res) return this.after(req, res);
67125
}
68126
}
69127

70-
return new LambdaHttpResponse(404, 'Not found');
128+
return this.after(req, new LambdaHttpResponse(404, 'Not found'));
71129
}
72130
}

0 commit comments

Comments
 (0)