Skip to content

Commit bf79b01

Browse files
authored
feat: add itty router for http requests (#122)
BREAKING CHANGE: this removes lf.http and makes it return a router, if you want to route all requests use `handler.router.all('*', fn)` to restore previous behaviour
1 parent 42416e7 commit bf79b01

15 files changed

+540
-458
lines changed

README.md

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,28 @@ This repository wraps the default lambda handler so it can be invoked by ALB, AP
1717
import {lf, LambdaHttpResponse} from '@linzjs/lambda';
1818

1919
// This works for Cloud front, ALB or API Gateway events
20-
export const handler = lf.http(async (req) => {
21-
if (req.method !== 'POST') throw new LambdaHttpResponse(400, 'Invalid method');
22-
return new LambdaHttpResponse(200, 'Ok');
20+
export const handler = lf.http();
21+
22+
handler.router.get('/v1/ping', () => new LambdaHttpResponse(200, 'Ok'));
23+
handler.router.get<{ Params: { style: string } }>(
24+
'/v1/style/:style.json',
25+
(req) => new LambdaHttpResponse(200, 'Style: ' + req.params.style),
26+
);
27+
28+
// Handle all requests
29+
handler.router.all('*', () => new LambdaHttpResponse(404, 'Not found'));
30+
31+
32+
// create middleware to validate api key on all requests
33+
handler.router.all('*', (req) => {
34+
const isApiValid = validateApiKey(req.query.get('api'));
35+
// Bail early
36+
if (!isApiValid) return new LambdaHttpResponse(400, 'Invalid api key');
37+
38+
// Continue
39+
return;
2340
});
41+
2442
```
2543

2644
#### Lambda
@@ -75,11 +93,11 @@ function doRequest(req) {
7593

7694
This can be overridden at either the wrapper
7795
```typescript
78-
export const handler = LambdaFunction.wrap(doRequest, myOwnLogger)
96+
export const handler = lf.wrap(doRequest, myOwnLogger)
7997
```
8098

8199
of set a different default logger
82100
```typescript
83-
LambdaFunction.logger = myOwnLogger;
84-
export const handler = LambdaFunction.wrap(doRequest)
101+
lf.logger = myOwnLogger;
102+
export const handler = lf.wrap(doRequest)
85103
```

package.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,16 @@
1515
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
1616
},
1717
"devDependencies": {
18-
"@linzjs/style": "^3.1.0",
19-
"@types/aws-lambda": "^8.10.85",
18+
"@linzjs/style": "^3.7.0",
19+
"@types/aws-lambda": "^8.10.93",
2020
"@types/node": "^16.4.13",
21-
"@types/ospec": "^4.0.2",
22-
"@types/sinon": "^10.0.6",
23-
"conventional-changelog-cli": "^2.1.1",
21+
"@types/ospec": "^4.0.3",
22+
"@types/pino": "^7.0.5",
23+
"@types/sinon": "^10.0.11",
24+
"conventional-changelog-cli": "^2.2.2",
2425
"conventional-github-releaser": "^3.1.5",
2526
"ospec": "^4.1.1",
26-
"sinon": "^12.0.1",
27+
"sinon": "^13.0.1",
2728
"source-map-support": "^0.5.21"
2829
},
2930
"scripts": {
@@ -39,9 +40,8 @@
3940
"build/src/**"
4041
],
4142
"dependencies": {
42-
"@linzjs/metrics": "^6.0.0",
43-
"@types/pino": "^6.3.11",
44-
"pino": "^7.5.0",
43+
"@linzjs/metrics": "^6.21.1",
44+
"pino": "^7.9.1",
4545
"ulid": "^2.3.0"
4646
}
4747
}

src/__test__/alb.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ o.spec('AlbGateway', () => {
2222

2323
o('should extract methods', () => {
2424
const req = new LambdaAlbRequest(AlbExample, fakeContext, fakeLog);
25-
o(req.method).equals('POST');
25+
o(req.method).equals('GET');
2626
});
2727

2828
o('should upper case method', () => {
2929
const newReq = clone(AlbExample);
30-
newReq.httpMethod = 'get';
30+
newReq.httpMethod = 'post';
3131
const req = new LambdaAlbRequest(newReq, fakeContext, fakeLog);
32-
o(req.method).equals('GET');
32+
o(req.method).equals('POST');
3333
});
3434

3535
o('should extract query parameters', () => {

src/__test__/api.gateway.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,14 @@ o.spec('ApiGateway', () => {
2121

2222
o('should extract methods', () => {
2323
const req = new LambdaApiGatewayRequest(ApiGatewayExample, fakeContext, fakeLog);
24-
o(req.method).equals('POST');
24+
o(req.method).equals('GET');
2525
});
2626

2727
o('should upper case method', () => {
2828
const newReq = clone(ApiGatewayExample);
29-
newReq.httpMethod = 'get';
29+
newReq.httpMethod = 'post';
3030
const req = new LambdaApiGatewayRequest(newReq, fakeContext, fakeLog);
31-
o(req.method).equals('GET');
31+
o(req.method).equals('POST');
3232
});
3333

3434
o('should extract query parameters', () => {

src/__test__/examples.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { ALBEvent, APIGatewayProxyEvent, CloudFrontRequestEvent } from 'aws-lamb
44
export const ApiGatewayExample: APIGatewayProxyEvent = {
55
body: 'eyJ0ZXN0IjoiYm9keSJ9',
66
resource: '/{proxy+}',
7-
path: '/path/to/resource',
8-
httpMethod: 'POST',
7+
path: '/v1/tiles/aerial/EPSG:3857/6/3/41.json',
8+
httpMethod: 'GET',
99
isBase64Encoded: true,
1010
queryStringParameters: {
1111
foo: 'bar',
@@ -104,7 +104,7 @@ export const CloudfrontExample: CloudFrontRequestEvent = {
104104
},
105105
request: {
106106
body: undefined,
107-
uri: '/test',
107+
uri: '/v1/tiles/aerial/EPSG:3857/6/3/41.json',
108108
method: 'GET',
109109
clientIp: '2001:cdba::3257:9652',
110110
querystring: '?foo=bar',
@@ -129,7 +129,7 @@ export const AlbExample: ALBEvent = {
129129
'arn:aws:elasticloadbalancing:ap-southeast-2:000000000:targetgroup/Serve-LBHtt-1OHAJAJC2EOCV/c7cdb5edeadbeefa9',
130130
},
131131
},
132-
httpMethod: 'POST',
132+
httpMethod: 'GET',
133133
path: '/v1/tiles/aerial/EPSG:3857/6/3/41.json',
134134
queryStringParameters: {
135135
api: 'abc123',

src/__test__/readme.example.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,26 @@ export const handlerB = lf.handler<S3Event>(async (req) => {
1818
});
1919

2020
// This works for Cloud front, ALB or API Gateway events
21-
export const handlerC = lf.http(async (req) => {
22-
if (req.method !== 'POST') throw new LambdaHttpResponse(400, 'Invalid method');
23-
return new LambdaHttpResponse(200, 'Ok');
21+
export const handler = lf.http();
22+
23+
handler.router.get('/v1/ping', () => new LambdaHttpResponse(200, 'Ok'));
24+
handler.router.get<{ Params: { style: string } }>(
25+
'/v1/style/:style.json',
26+
(req) => new LambdaHttpResponse(200, 'Style: ' + req.params.style),
27+
);
28+
29+
// Handle all requests
30+
handler.router.all('*', () => new LambdaHttpResponse(404, 'Not found'));
31+
32+
function validateApiKey(s?: string | null): boolean {
33+
return s != null;
34+
}
35+
// create middleware to validate api key on all requests
36+
handler.router.all('*', (req) => {
37+
const isApiValid = validateApiKey(req.query.get('api'));
38+
// Bail early
39+
if (!isApiValid) return new LambdaHttpResponse(400, 'Invalid api key');
40+
41+
// Continue
42+
return;
2443
});

src/__test__/wrap.test.ts

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { lf } from '../function.js';
66
import { LambdaRequest } from '../request.js';
77
import { LambdaHttpRequest } from '../request.http.js';
88
import { LambdaHttpResponse } from '../response.http.js';
9-
import { AlbExample, ApiGatewayExample, CloudfrontExample } from './examples.js';
9+
import { AlbExample, ApiGatewayExample, clone, CloudfrontExample } from './examples.js';
1010
import { fakeLog } from './log.js';
11+
import { HttpMethods } from '../router.js';
1112

1213
function assertAlbResult(x: unknown): asserts x is ALBResult {}
1314
function assertCloudfrontResult(x: unknown): asserts x is CloudFrontResultResponse {}
@@ -29,8 +30,71 @@ o.spec('LambdaWrap', () => {
2930
fakeLog.logs = [];
3031
});
3132

33+
o('should handle middleware', async () => {
34+
const fn = lf.http(fakeLog);
35+
fn.router.all('*', (req): LambdaHttpResponse | void => {
36+
if (req.path.includes('fail')) return new LambdaHttpResponse(500, 'Failed');
37+
});
38+
fn.router.get('/v1/ping', () => new LambdaHttpResponse(200, 'Ok'));
39+
40+
const newReq = clone(ApiGatewayExample);
41+
newReq.path = '/v1/ping';
42+
const ret = await new Promise((resolve) => fn(newReq, fakeContext, (a, b) => resolve(b)));
43+
assertAlbResult(ret);
44+
45+
o(ret.statusCode).equals(200);
46+
47+
newReq.path = '/v1/ping/fail';
48+
const retB = await new Promise((resolve) => fn(newReq, fakeContext, (a, b) => resolve(b)));
49+
assertAlbResult(retB);
50+
o(retB.statusCode).equals(500);
51+
});
52+
53+
o('should wrap all http methods', async () => {
54+
const fn = lf.http(fakeLog);
55+
const methods: string[] = [];
56+
57+
function bind(r: string) {
58+
return (): LambdaHttpResponse => {
59+
methods.push(r.toUpperCase());
60+
return new LambdaHttpResponse(200, 'Ok');
61+
};
62+
}
63+
fn.router.get('*', bind('get'));
64+
fn.router.delete('*', bind('delete'));
65+
fn.router.head('*', bind('head'));
66+
fn.router.options('*', bind('options'));
67+
fn.router.post('*', bind('post'));
68+
fn.router.patch('*', bind('patch'));
69+
fn.router.put('*', bind('put'));
70+
71+
const headers: Record<HttpMethods, number> = {
72+
DELETE: 1,
73+
ALL: 0,
74+
GET: 1,
75+
OPTIONS: 1,
76+
HEAD: 1,
77+
POST: 1,
78+
PATCH: 1,
79+
PUT: 1,
80+
};
81+
82+
const requests = Object.entries(headers)
83+
.filter((f) => f[1] === 1)
84+
.map((f) => f[0]);
85+
86+
for (const req of requests) {
87+
const newReq = clone(ApiGatewayExample);
88+
newReq.httpMethod = req;
89+
await new Promise((resolve) => fn(newReq, fakeContext, (a, b) => resolve(b)));
90+
}
91+
92+
o(methods).deepEquals(requests);
93+
});
94+
3295
o('should log a metalog at the end of the request', async () => {
33-
const fn = lf.http(fakeLambda, fakeLog);
96+
const fn = lf.http(fakeLog);
97+
fn.router.get('/v1/tiles/:tileSet/:projection/:z/:x/:y.json', fakeLambda);
3498
await new Promise((resolve) => fn(AlbExample, fakeContext, (a, b) => resolve(b)));
3599

36100
o(fakeLog.logs.length).equals(1);
@@ -39,15 +103,16 @@ o.spec('LambdaWrap', () => {
39103
o(firstLog['@type']).equals('report');
40104
o(typeof firstLog['duration'] === 'number').equals(true);
41105
o(firstLog['status']).equals(200);
42-
o(firstLog['method']).equals('POST');
106+
o(firstLog['method']).equals('GET');
43107
o(firstLog['path']).equals('/v1/tiles/aerial/EPSG:3857/6/3/41.json');
44108
o(firstLog['id']).equals(requests[0].id);
45109
o(firstLog['setTest']).equals(requests[0].id);
46110
o(firstLog['correlationId']).equals(requests[0].correlationId);
47111
});
48112

49113
o('should respond to alb events', async () => {
50-
const fn = lf.http(fakeLambda, fakeLog);
114+
const fn = lf.http(fakeLog);
115+
fn.router.get('/v1/tiles/:tileSet/:projection/:z/:x/:y.json', fakeLambda);
51116
const ret = await new Promise((resolve) => fn(AlbExample, fakeContext, (a, b) => resolve(b)));
52117

53118
assertAlbResult(ret);
@@ -64,7 +129,8 @@ o.spec('LambdaWrap', () => {
64129
});
65130

66131
o('should respond to cloudfront events', async () => {
67-
const fn = lf.http(fakeLambda);
132+
const fn = lf.http(fakeLog);
133+
fn.router.get('/v1/tiles/:tileSet/:projection/:z/:x/:y.json', fakeLambda);
68134
const ret = await new Promise((resolve) => fn(CloudfrontExample, fakeContext, (a, b) => resolve(b)));
69135

70136
assertCloudfrontResult(ret);
@@ -79,7 +145,8 @@ o.spec('LambdaWrap', () => {
79145
});
80146

81147
o('should respond to api gateway events', async () => {
82-
const fn = lf.http(fakeLambda);
148+
const fn = lf.http(fakeLog);
149+
fn.router.get('/v1/tiles/:tileSet/:projection/:z/:x/:y.json', fakeLambda);
83150
const ret = await new Promise((resolve) => fn(ApiGatewayExample, fakeContext, (a, b) => resolve(b)));
84151

85152
assertsApiGatewayResult(ret);
@@ -94,8 +161,9 @@ o.spec('LambdaWrap', () => {
94161
});
95162

96163
o('should handle http exceptions', async () => {
97-
const fn = lf.http(() => {
98-
throw new Error('Fake');
164+
const fn = lf.http(fakeLog);
165+
fn.router.all('*', () => {
166+
throw new Error('Error');
99167
});
100168
const ret = await new Promise((resolve) => fn(ApiGatewayExample, fakeContext, (a, b) => resolve(b)));
101169

@@ -152,7 +220,8 @@ o.spec('LambdaWrap', () => {
152220
o('should disable "server" header if no server name set', async () => {
153221
const serverName = lf.ServerName;
154222
lf.ServerName = null;
155-
const fn = lf.http(fakeLambda);
223+
const fn = lf.http();
224+
fn.router.all('*', fakeLambda);
156225
const ret = await new Promise((resolve) => fn(ApiGatewayExample, fakeContext, (a, b) => resolve(b)));
157226

158227
lf.ServerName = serverName;

src/function.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { LambdaApiGatewayRequest } from './request.api.gateway.js';
99
import { LambdaCloudFrontRequest } from './request.cloudfront.js';
1010
import { HttpRequestEvent, HttpResponse, LambdaHttpRequest } from './request.http.js';
1111
import { LambdaHttpResponse } from './response.http.js';
12+
import { Router } from './router.js';
1213

1314
export interface HttpStatus {
1415
statusCode: string;
@@ -47,7 +48,7 @@ async function runFunction<T extends LambdaRequest, K>(
4748
}
4849
}
4950

50-
async function execute<T extends LambdaRequest, K>(
51+
export async function execute<T extends LambdaRequest, K>(
5152
req: T,
5253
fn: (req: T) => K | Promise<K>,
5354
): Promise<K | LambdaHttpResponse> {
@@ -160,15 +161,15 @@ export class lf {
160161
return handler;
161162
}
162163
/**
163-
* Wrap a lambda function to provide extra functionality
164+
* Create a route lambda function to provide extra functionality
164165
*
165166
* - Log metadata about the call on every request
166167
* - Catch errors and log them before exiting
167168
*
168-
* @param fn Function to wrap
169169
* @param logger optional logger to use for the request @see lf.Logger
170170
*/
171-
public static http(fn: LambdaWrappedFunctionHttp, logger?: LogType): LambdaHandler<HttpRequestEvent, HttpResponse> {
171+
public static http(logger?: LogType): LambdaHandler<HttpRequestEvent, HttpResponse> & { router: Router } {
172+
const router = new Router();
172173
function httpHandler(event: HttpRequestEvent, context: Context, callback: Callback<HttpResponse>): void {
173174
const req = lf.request(event, context, logger ?? lf.Logger);
174175

@@ -180,7 +181,7 @@ export class lf {
180181
req.set('method', req.method);
181182
req.set('path', req.path);
182183

183-
execute(req, fn).then((res: LambdaHttpResponse) => {
184+
router.handle(req).then((res: LambdaHttpResponse) => {
184185
// Do not cache http 500 errors
185186
if (res.status === 500) res.header(HttpHeader.CacheControl, 'no-store');
186187
res.header(HttpHeaderRequestId.RequestId, req.id);
@@ -198,6 +199,7 @@ export class lf {
198199
callback(null, req.toResponse(res));
199200
});
200201
}
202+
httpHandler.router = router;
201203
return httpHandler;
202204
}
203205
}

0 commit comments

Comments
 (0)