Skip to content

Commit 3f7c9f9

Browse files
authored
feat: switch to a more complete routing engine find-my-way (#169)
* fix: lambda function urls do not decode rawPath * feat: switch to a more complete routing engine find-my-way * wip: test cloudfront with rainbows * refactor: cleanup types * refactor: remove commented out code
1 parent 0162a08 commit 3f7c9f9

File tree

11 files changed

+159
-54
lines changed

11 files changed

+159
-54
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ handler.router.get<{ Params: { style: string } }>(
2626
);
2727

2828
// Handle all requests
29-
handler.router.all('*', () => new LambdaHttpResponse(404, 'Not found'));
29+
handler.router.get('*', () => new LambdaHttpResponse(404, 'Not found'));
3030

3131

3232
// create middleware to validate api key on all requests
33-
handler.router.all('*', (req) => {
33+
handler.router.hook('request', (req) => {
3434
const isApiValid = validateApiKey(req.query.get('api'));
3535
// Bail early
3636
if (!isApiValid) return new LambdaHttpResponse(400, 'Invalid api key');

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
],
4242
"dependencies": {
4343
"@linzjs/metrics": "^6.21.1",
44+
"find-my-way": "^7.0.0",
4445
"pino": "^7.9.1",
4546
"ulid": "^2.3.0"
4647
}

src/__test__/examples.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { ALBEvent, APIGatewayProxyEvent, CloudFrontRequestEvent } from 'aws-lambda';
2-
import { UrlEvent } from '../http/request.url';
1+
import { ALBEvent, APIGatewayProxyEvent, CloudFrontRequestEvent, Context } from 'aws-lambda';
2+
import { LambdaAlbRequest } from '../http/request.alb.js';
3+
import { LambdaApiGatewayRequest } from '../http/request.api.gateway.js';
4+
import { LambdaCloudFrontRequest } from '../http/request.cloudfront.js';
5+
import { LambdaUrlRequest, UrlEvent } from '../http/request.url.js';
6+
import { fakeLog } from './log.js';
37

48
export const ApiGatewayExample: APIGatewayProxyEvent = {
59
body: 'eyJ0ZXN0IjoiYm9keSJ9',
@@ -157,7 +161,7 @@ export const AlbExample: ALBEvent = {
157161
export const UrlExample: UrlEvent = {
158162
version: '2.0',
159163
routeKey: '$default',
160-
rawPath: '/v1/🦄/🌈/🦄.json',
164+
rawPath: '/v1/%F0%9F%A6%84/%F0%9F%8C%88/%F0%9F%A6%84.json',
161165
rawQueryString: '%F0%9F%A6%84=abc123',
162166
headers: {
163167
'x-amzn-trace-id': 'Root=1-624e71a0-114297900a437c050c74f1fe',
@@ -194,3 +198,53 @@ export const UrlExample: UrlEvent = {
194198
export function clone<T>(c: T): T {
195199
return JSON.parse(JSON.stringify(c));
196200
}
201+
202+
const fakeContext = {} as Context;
203+
204+
export const RequestTypes = [
205+
{ type: 'FunctionUrl', create: newRequestUrl },
206+
{ type: 'Alb', create: newRequestAlb },
207+
{ type: 'ApiGateway', create: newRequestApi },
208+
{ type: 'CloudFront', create: newRequestCloudFront },
209+
];
210+
211+
export function newRequestUrl<T extends Record<string, string>>(path: string, query: string): LambdaUrlRequest<T> {
212+
const example = clone(UrlExample);
213+
example.rawPath = encodeURI(path);
214+
example.rawQueryString = encodeURI(query);
215+
example.requestContext.http.path = path;
216+
return new LambdaUrlRequest(example, fakeContext, fakeLog) as LambdaUrlRequest<T>;
217+
}
218+
219+
export function newRequestAlb<T extends Record<string, string>>(path: string, query: string): LambdaAlbRequest<T> {
220+
const example = clone(AlbExample);
221+
example.path = encodeURI(path);
222+
example.queryStringParameters = {};
223+
for (const [key, value] of new URLSearchParams(query).entries()) {
224+
example.queryStringParameters[key] = value;
225+
}
226+
return new LambdaAlbRequest(example, fakeContext, fakeLog) as LambdaAlbRequest<T>;
227+
}
228+
229+
export function newRequestApi<T extends Record<string, string>>(
230+
path: string,
231+
query: string,
232+
): LambdaApiGatewayRequest<T> {
233+
const example = clone(ApiGatewayExample);
234+
example.path = encodeURI(path);
235+
example.multiValueQueryStringParameters = {};
236+
for (const [key, value] of new URLSearchParams(query).entries()) {
237+
example.multiValueQueryStringParameters[key] = [value];
238+
}
239+
return new LambdaApiGatewayRequest(example, fakeContext, fakeLog) as LambdaApiGatewayRequest<T>;
240+
}
241+
242+
export function newRequestCloudFront<T extends Record<string, string>>(
243+
path: string,
244+
query: string,
245+
): LambdaCloudFrontRequest<T> {
246+
const example = clone(CloudfrontExample);
247+
example.Records[0].cf.request.uri = encodeURI(path);
248+
example.Records[0].cf.request.querystring = '?' + query;
249+
return new LambdaCloudFrontRequest(example, fakeContext, fakeLog) as LambdaCloudFrontRequest<T>;
250+
}

src/__test__/readme.example.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ handler.router.get<{ Params: { style: string } }>(
2727
);
2828

2929
// Handle all requests
30-
handler.router.all('*', () => new LambdaHttpResponse(404, 'Not found'));
30+
handler.router.get('*', () => new LambdaHttpResponse(404, 'Not found'));
3131

3232
function validateApiKey(s?: string | null): boolean {
3333
return s != null;
3434
}
3535
// create middleware to validate api key on all requests
36-
handler.router.all('*', (req) => {
36+
handler.router.hook('request', (req) => {
3737
const isApiValid = validateApiKey(req.query.get('api'));
3838
// Bail early
3939
if (!isApiValid) return new LambdaHttpResponse(400, 'Invalid api key');

src/__test__/url.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ o.spec('FunctionUrl', () => {
4444

4545
o('should support utf8 paths and query', () => {
4646
const req = new LambdaUrlRequest(UrlExample, fakeContext, fakeLog);
47-
o(req.path).equals('/v1/🦄/🌈/🦄.json');
47+
o(req.path).equals('/v1/%F0%9F%A6%84/%F0%9F%8C%88/%F0%9F%A6%84.json');
4848
o(req.query.get('🦄')).equals('abc123');
4949
o(req.body).equals(null);
5050
});

src/__test__/wrap.test.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@ o.spec('LambdaWrap', () => {
3434

3535
o('should handle middleware', async () => {
3636
const fn = lf.http(fakeLog);
37-
fn.router.all('*', (req): LambdaHttpResponse | void => {
37+
fn.router.hook('request', (req): LambdaHttpResponse | void => {
3838
if (req.path.includes('fail')) return new LambdaHttpResponse(500, 'Failed');
3939
});
40-
fn.router.get('/v1/ping', () => new LambdaHttpResponse(200, 'Ok'));
40+
fn.router.get('/v1/ping/:message', () => new LambdaHttpResponse(200, 'Ok'));
4141

4242
const newReq = clone(ApiGatewayExample);
43-
newReq.path = '/v1/ping';
43+
newReq.path = '/v1/ping/ok';
4444
const ret = await new Promise((resolve) => fn(newReq, fakeContext, (a, b) => resolve(b)));
4545
assertAlbResult(ret);
4646

@@ -72,7 +72,6 @@ o.spec('LambdaWrap', () => {
7272

7373
const headers: Record<HttpMethods, number> = {
7474
DELETE: 1,
75-
ALL: 0,
7675
GET: 1,
7776
OPTIONS: 1,
7877
HEAD: 1,
@@ -98,7 +97,6 @@ o.spec('LambdaWrap', () => {
9897
const fn = lf.http(fakeLog);
9998
fn.router.get('/v1/tiles/:tileSet/:projection/:z/:x/:y.json', fakeLambda);
10099
await new Promise((resolve) => fn(AlbExample, fakeContext, (a, b) => resolve(b)));
101-
102100
o(fakeLog.logs.length).equals(1);
103101

104102
const firstLog = fakeLog.logs[0];
@@ -182,7 +180,7 @@ o.spec('LambdaWrap', () => {
182180

183181
o('should handle thrown http responses', async () => {
184182
const fn = lf.http(fakeLog);
185-
fn.router.all('*', () => {
183+
fn.router.get('*', () => {
186184
throw new LambdaHttpResponse(400, 'Error');
187185
});
188186
const ret = await new Promise((resolve) => fn(ApiGatewayExample, fakeContext, (a, b) => resolve(b)));
@@ -193,7 +191,7 @@ o.spec('LambdaWrap', () => {
193191

194192
o('should handle http exceptions', async () => {
195193
const fn = lf.http(fakeLog);
196-
fn.router.all('*', () => {
194+
fn.router.get('*', () => {
197195
throw new Error('Error');
198196
});
199197
const ret = await new Promise((resolve) => fn(ApiGatewayExample, fakeContext, (a, b) => resolve(b)));
@@ -252,7 +250,7 @@ o.spec('LambdaWrap', () => {
252250
const serverName = lf.ServerName;
253251
lf.ServerName = null;
254252
const fn = lf.http();
255-
fn.router.all('*', fakeLambda);
253+
fn.router.get('*', fakeLambda);
256254
const ret = await new Promise((resolve) => fn(ApiGatewayExample, fakeContext, (a, b) => resolve(b)));
257255

258256
lf.ServerName = serverName;

src/function.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ export async function execute<T extends LambdaRequest, K>(
6464
}
6565

6666
req.set('status', status);
67-
req.set('metrics', req.timer.metrics);
67+
if (req.timer.timers.size > 0) {
68+
req.set('metrics', req.timer.metrics);
69+
}
6870

6971
if (versionInfo.hash) req.set('package', versionInfo);
7072

@@ -179,7 +181,9 @@ export class lf {
179181
const cloudFrontId = req.header(HttpHeaderAmazon.CloudfrontId);
180182
const traceId = req.header(HttpHeaderAmazon.TraceId);
181183
const lambdaId = context.awsRequestId;
182-
req.set('aws', { cloudFrontId, traceId, lambdaId });
184+
if (cloudFrontId || traceId || lambdaId) {
185+
req.set('aws', { cloudFrontId, traceId, lambdaId });
186+
}
183187
req.set('method', req.method);
184188
req.set('path', req.path);
185189

src/http/__test__/router.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import o from 'ospec';
2+
import { RequestTypes } from '../../__test__/examples.js';
3+
import { LambdaHttpRequest } from '../request.http.js';
4+
import { LambdaHttpResponse } from '../response.http.js';
5+
import { Router } from '../router.js';
6+
7+
o.spec('Router', () => {
8+
const router = new Router();
9+
router.get('/v1/🦄/🌈/:fileName', (req: LambdaHttpRequest<{ Params: { fileName: string } }>) => {
10+
return LambdaHttpResponse.ok().json({
11+
fileName: req.params.fileName,
12+
path: req.path,
13+
query: [...req.query.entries()],
14+
});
15+
});
16+
const expectedResult = { fileName: '🦄.json', path: encodeURI('/v1/🦄/🌈/🦄.json'), query: [['🌈', '🦄']] };
17+
18+
for (const rt of RequestTypes) {
19+
o.spec(rt.type, () => {
20+
o(`should route rainbows and unicorns`, async () => {
21+
const urlRoute = rt.create('/v1/🦄/🌈/🦄.json', '🌈=🦄');
22+
const res = await router.handle(urlRoute);
23+
o(res.status).equals(200);
24+
o(res.body).deepEquals(JSON.stringify(expectedResult));
25+
});
26+
27+
o('path should be url encoded', () => {
28+
const req = rt.create('/v1/🦄/🌈/🦄.json', '');
29+
o(req.path).equals('/v1/%F0%9F%A6%84/%F0%9F%8C%88/%F0%9F%A6%84.json');
30+
});
31+
32+
o('should 404 on invalid routes', async () => {
33+
const res = await router.handle(rt.create('/v1/🦄/🦄/🦄.json', '🌈=🦄'));
34+
o(res.status).equals(404);
35+
});
36+
});
37+
}
38+
});

src/http/request.http.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,9 @@ export abstract class LambdaHttpRequest<
9898
if (this._query == null) this._query = this.loadQueryString();
9999
return this._query;
100100
}
101+
102+
/** This is used by the router and is just the path */
103+
protected get url(): string {
104+
return this.path;
105+
}
101106
}

src/http/router.ts

Lines changed: 21 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { execute, runFunction } from '../function.js';
22
import { LambdaHttpRequest, RequestTypes } from './request.http.js';
33
import { LambdaHttpResponse } from './response.http.js';
4+
import FindMyWay from 'find-my-way';
45

56
export type Route<T extends RequestTypes = RequestTypes> = (
67
req: LambdaHttpRequest<T>,
@@ -22,36 +23,26 @@ export type HookRecord<T extends RouteHooks> = {
2223
[K in keyof T]: T[K][];
2324
};
2425

25-
export type HttpMethods = 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT' | 'OPTIONS' | 'ALL';
26+
export type HttpMethods = 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT' | 'OPTIONS';
2627
export class Router {
27-
routes: {
28-
path: RegExp;
29-
method: HttpMethods;
30-
// TODO is there a better way to model this route list
31-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
32-
fn: Route<any>;
33-
}[] = [];
34-
3528
hooks: HookRecord<RouteHooks> = {
3629
/** Hooks to be run before every request */
3730
request: [],
3831
/** Hooks to be run after every request */
3932
response: [],
4033
};
34+
router: FindMyWay.Instance<FindMyWay.HTTPVersion.V1>;
35+
36+
constructor() {
37+
this.router = FindMyWay({ defaultRoute: () => new LambdaHttpResponse(404, 'Not found') });
38+
}
4139

4240
register<T extends RequestTypes>(method: HttpMethods, path: string, fn: Route<T>): void {
43-
// Stolen from https://github.com/kwhitley/itty-router
44-
const regex = RegExp(
45-
`^${
46-
path
47-
.replace(/(\/?)\*/g, '($1.*)?')
48-
.replace(/\/$/, '')
49-
.replace(/:(\w+)(\?)?(\.)?/g, '$2(?<$1>[^/]+)$2$3')
50-
.replace(/\.(?=[\w(])/, '\\.')
51-
.replace(/\)\.\?\(([^\[]+)\[\^/g, '?)\\.?($1(?<=\\.)[^\\.') // RIP all the bytes lost :'(
52-
}/*$`,
53-
);
54-
this.routes.push({ path: regex, method, fn });
41+
this.router.on(method, path, async (req: unknown, res, params) => {
42+
if (!(req instanceof LambdaHttpRequest)) return new LambdaHttpResponse(500, 'Internal server error');
43+
req.params = params;
44+
return execute(req, fn);
45+
});
5546
}
5647

5748
/**
@@ -63,10 +54,6 @@ export class Router {
6354
this.hooks[name].push(cb);
6455
}
6556

66-
/** Register a route for all HTTP types, GET, POST, HEAD, etc... */
67-
all<T extends RequestTypes>(path: string, fn: Route<T>): void {
68-
return this.register('ALL', path, fn);
69-
}
7057
get<T extends RequestTypes>(path: string, fn: Route<T>): void {
7158
return this.register('GET', path, fn);
7259
}
@@ -113,17 +100,15 @@ export class Router {
113100
// If a hook returns a response return the response to the user
114101
if (res) return this.after(req, res);
115102
}
116-
117-
for (const r of this.routes) {
118-
if (r.method !== 'ALL' && req.method !== r.method) continue;
119-
const m = req.path.match(r.path);
120-
if (m) {
121-
// TODO this should ideally be validated
122-
req.params = m.groups;
123-
const res = await execute(req, r.fn);
124-
if (res) return this.after(req, res);
125-
}
126-
}
103+
/**
104+
* Work around the very strict typings of find-my-way
105+
* It expects everything to be some sort of http request,
106+
* but internally it only ever uses `req.url` and `req.method`
107+
* it also does not ever do anything with the response
108+
*/
109+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
110+
const result = await this.router.lookup(req as any, null as any);
111+
if (result) return this.after(req, result);
127112

128113
return this.after(req, new LambdaHttpResponse(404, 'Not found'));
129114
}

0 commit comments

Comments
 (0)