Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ handler.router.get<{ Params: { style: string } }>(
);

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


// create middleware to validate api key on all requests
handler.router.all('*', (req) => {
handler.router.hook('request', (req) => {
const isApiValid = validateApiKey(req.query.get('api'));
// Bail early
if (!isApiValid) return new LambdaHttpResponse(400, 'Invalid api key');
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
],
"dependencies": {
"@linzjs/metrics": "^6.21.1",
"find-my-way": "^7.0.0",
"pino": "^7.9.1",
"ulid": "^2.3.0"
}
Expand Down
60 changes: 57 additions & 3 deletions src/__test__/examples.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { ALBEvent, APIGatewayProxyEvent, CloudFrontRequestEvent } from 'aws-lambda';
import { UrlEvent } from '../http/request.url';
import { ALBEvent, APIGatewayProxyEvent, CloudFrontRequestEvent, Context } from 'aws-lambda';
import { LambdaAlbRequest } from '../http/request.alb.js';
import { LambdaApiGatewayRequest } from '../http/request.api.gateway.js';
import { LambdaCloudFrontRequest } from '../http/request.cloudfront.js';
import { LambdaUrlRequest, UrlEvent } from '../http/request.url.js';
import { fakeLog } from './log.js';

export const ApiGatewayExample: APIGatewayProxyEvent = {
body: 'eyJ0ZXN0IjoiYm9keSJ9',
Expand Down Expand Up @@ -157,7 +161,7 @@ export const AlbExample: ALBEvent = {
export const UrlExample: UrlEvent = {
version: '2.0',
routeKey: '$default',
rawPath: '/v1/🦄/🌈/🦄.json',
rawPath: '/v1/%F0%9F%A6%84/%F0%9F%8C%88/%F0%9F%A6%84.json',
rawQueryString: '%F0%9F%A6%84=abc123',
headers: {
'x-amzn-trace-id': 'Root=1-624e71a0-114297900a437c050c74f1fe',
Expand Down Expand Up @@ -194,3 +198,53 @@ export const UrlExample: UrlEvent = {
export function clone<T>(c: T): T {
return JSON.parse(JSON.stringify(c));
}

const fakeContext = {} as Context;

export const RequestTypes = [
{ type: 'FunctionUrl', create: newRequestUrl },
{ type: 'Alb', create: newRequestAlb },
{ type: 'ApiGateway', create: newRequestApi },
{ type: 'CloudFront', create: newRequestCloudFront },
];

export function newRequestUrl<T extends Record<string, string>>(path: string, query: string): LambdaUrlRequest<T> {
const example = clone(UrlExample);
example.rawPath = encodeURI(path);
example.rawQueryString = encodeURI(query);
example.requestContext.http.path = path;
return new LambdaUrlRequest(example, fakeContext, fakeLog) as LambdaUrlRequest<T>;
}

export function newRequestAlb<T extends Record<string, string>>(path: string, query: string): LambdaAlbRequest<T> {
const example = clone(AlbExample);
example.path = encodeURI(path);
example.queryStringParameters = {};
for (const [key, value] of new URLSearchParams(query).entries()) {
example.queryStringParameters[key] = value;
}
return new LambdaAlbRequest(example, fakeContext, fakeLog) as LambdaAlbRequest<T>;
}

export function newRequestApi<T extends Record<string, string>>(
path: string,
query: string,
): LambdaApiGatewayRequest<T> {
const example = clone(ApiGatewayExample);
example.path = encodeURI(path);
example.multiValueQueryStringParameters = {};
for (const [key, value] of new URLSearchParams(query).entries()) {
example.multiValueQueryStringParameters[key] = [value];
}
return new LambdaApiGatewayRequest(example, fakeContext, fakeLog) as LambdaApiGatewayRequest<T>;
}

export function newRequestCloudFront<T extends Record<string, string>>(
path: string,
query: string,
): LambdaCloudFrontRequest<T> {
const example = clone(CloudfrontExample);
example.Records[0].cf.request.uri = encodeURI(path);
example.Records[0].cf.request.querystring = '?' + query;
return new LambdaCloudFrontRequest(example, fakeContext, fakeLog) as LambdaCloudFrontRequest<T>;
}
4 changes: 2 additions & 2 deletions src/__test__/readme.example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ handler.router.get<{ Params: { style: string } }>(
);

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

function validateApiKey(s?: string | null): boolean {
return s != null;
}
// create middleware to validate api key on all requests
handler.router.all('*', (req) => {
handler.router.hook('request', (req) => {
const isApiValid = validateApiKey(req.query.get('api'));
// Bail early
if (!isApiValid) return new LambdaHttpResponse(400, 'Invalid api key');
Expand Down
2 changes: 1 addition & 1 deletion src/__test__/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ o.spec('FunctionUrl', () => {

o('should support utf8 paths and query', () => {
const req = new LambdaUrlRequest(UrlExample, fakeContext, fakeLog);
o(req.path).equals('/v1/🦄/🌈/🦄.json');
o(req.path).equals('/v1/%F0%9F%A6%84/%F0%9F%8C%88/%F0%9F%A6%84.json');
o(req.query.get('🦄')).equals('abc123');
o(req.body).equals(null);
});
Expand Down
14 changes: 6 additions & 8 deletions src/__test__/wrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ o.spec('LambdaWrap', () => {

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

const newReq = clone(ApiGatewayExample);
newReq.path = '/v1/ping';
newReq.path = '/v1/ping/ok';
const ret = await new Promise((resolve) => fn(newReq, fakeContext, (a, b) => resolve(b)));
assertAlbResult(ret);

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

const headers: Record<HttpMethods, number> = {
DELETE: 1,
ALL: 0,
GET: 1,
OPTIONS: 1,
HEAD: 1,
Expand All @@ -98,7 +97,6 @@ o.spec('LambdaWrap', () => {
const fn = lf.http(fakeLog);
fn.router.get('/v1/tiles/:tileSet/:projection/:z/:x/:y.json', fakeLambda);
await new Promise((resolve) => fn(AlbExample, fakeContext, (a, b) => resolve(b)));

o(fakeLog.logs.length).equals(1);

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

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

o('should handle http exceptions', async () => {
const fn = lf.http(fakeLog);
fn.router.all('*', () => {
fn.router.get('*', () => {
throw new Error('Error');
});
const ret = await new Promise((resolve) => fn(ApiGatewayExample, fakeContext, (a, b) => resolve(b)));
Expand Down Expand Up @@ -252,7 +250,7 @@ o.spec('LambdaWrap', () => {
const serverName = lf.ServerName;
lf.ServerName = null;
const fn = lf.http();
fn.router.all('*', fakeLambda);
fn.router.get('*', fakeLambda);
const ret = await new Promise((resolve) => fn(ApiGatewayExample, fakeContext, (a, b) => resolve(b)));

lf.ServerName = serverName;
Expand Down
8 changes: 6 additions & 2 deletions src/function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ export async function execute<T extends LambdaRequest, K>(
}

req.set('status', status);
req.set('metrics', req.timer.metrics);
if (req.timer.timers.size > 0) {
req.set('metrics', req.timer.metrics);
}

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

Expand Down Expand Up @@ -179,7 +181,9 @@ export class lf {
const cloudFrontId = req.header(HttpHeaderAmazon.CloudfrontId);
const traceId = req.header(HttpHeaderAmazon.TraceId);
const lambdaId = context.awsRequestId;
req.set('aws', { cloudFrontId, traceId, lambdaId });
if (cloudFrontId || traceId || lambdaId) {
req.set('aws', { cloudFrontId, traceId, lambdaId });
}
req.set('method', req.method);
req.set('path', req.path);

Expand Down
38 changes: 38 additions & 0 deletions src/http/__test__/router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import o from 'ospec';
import { RequestTypes } from '../../__test__/examples.js';
import { LambdaHttpRequest } from '../request.http.js';
import { LambdaHttpResponse } from '../response.http.js';
import { Router } from '../router.js';

o.spec('Router', () => {
const router = new Router();
router.get('/v1/🦄/🌈/:fileName', (req: LambdaHttpRequest<{ Params: { fileName: string } }>) => {
return LambdaHttpResponse.ok().json({
fileName: req.params.fileName,
path: req.path,
query: [...req.query.entries()],
});
});
const expectedResult = { fileName: '🦄.json', path: encodeURI('/v1/🦄/🌈/🦄.json'), query: [['🌈', '🦄']] };

for (const rt of RequestTypes) {
o.spec(rt.type, () => {
o(`should route rainbows and unicorns`, async () => {
const urlRoute = rt.create('/v1/🦄/🌈/🦄.json', '🌈=🦄');
const res = await router.handle(urlRoute);
o(res.status).equals(200);
o(res.body).deepEquals(JSON.stringify(expectedResult));
});

o('path should be url encoded', () => {
const req = rt.create('/v1/🦄/🌈/🦄.json', '');
o(req.path).equals('/v1/%F0%9F%A6%84/%F0%9F%8C%88/%F0%9F%A6%84.json');
});

o('should 404 on invalid routes', async () => {
const res = await router.handle(rt.create('/v1/🦄/🦄/🦄.json', '🌈=🦄'));
o(res.status).equals(404);
});
});
}
});
5 changes: 5 additions & 0 deletions src/http/request.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,9 @@ export abstract class LambdaHttpRequest<
if (this._query == null) this._query = this.loadQueryString();
return this._query;
}

/** This is used by the router and is just the path */
protected get url(): string {
return this.path;
}
}
57 changes: 21 additions & 36 deletions src/http/router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { execute, runFunction } from '../function.js';
import { LambdaHttpRequest, RequestTypes } from './request.http.js';
import { LambdaHttpResponse } from './response.http.js';
import FindMyWay from 'find-my-way';

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

export type HttpMethods = 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT' | 'OPTIONS' | 'ALL';
export type HttpMethods = 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT' | 'OPTIONS';
export class Router {
routes: {
path: RegExp;
method: HttpMethods;
// TODO is there a better way to model this route list
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fn: Route<any>;
}[] = [];

hooks: HookRecord<RouteHooks> = {
/** Hooks to be run before every request */
request: [],
/** Hooks to be run after every request */
response: [],
};
router: FindMyWay.Instance<FindMyWay.HTTPVersion.V1>;

constructor() {
this.router = FindMyWay({ defaultRoute: () => new LambdaHttpResponse(404, 'Not found') });
}

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

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

/** Register a route for all HTTP types, GET, POST, HEAD, etc... */
all<T extends RequestTypes>(path: string, fn: Route<T>): void {
return this.register('ALL', path, fn);
}
get<T extends RequestTypes>(path: string, fn: Route<T>): void {
return this.register('GET', path, fn);
}
Expand Down Expand Up @@ -113,17 +100,15 @@ export class Router {
// If a hook returns a response return the response to the user
if (res) return this.after(req, res);
}

for (const r of this.routes) {
if (r.method !== 'ALL' && req.method !== r.method) continue;
const m = req.path.match(r.path);
if (m) {
// TODO this should ideally be validated
req.params = m.groups;
const res = await execute(req, r.fn);
if (res) return this.after(req, res);
}
}
/**
* Work around the very strict typings of find-my-way
* It expects everything to be some sort of http request,
* but internally it only ever uses `req.url` and `req.method`
* it also does not ever do anything with the response
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await this.router.lookup(req as any, null as any);
if (result) return this.after(req, result);

return this.after(req, new LambdaHttpResponse(404, 'Not found'));
}
Expand Down
20 changes: 20 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,14 @@ fill-range@^7.0.1:
dependencies:
to-regex-range "^5.0.1"

find-my-way@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/find-my-way/-/find-my-way-7.0.0.tgz#8e79fde2606624af61775e3d097da4f1872e58d9"
integrity sha512-NHVohYPYRXgj6jxXVRwm4iMQjA2ggJpyewHz7Nq7hvBnHoYJJIyHuxNzs8QLPTLQfoqxZzls2g6Zm79XMbhXjA==
dependencies:
fast-deep-equal "^3.1.3"
safe-regex2 "^2.0.0"

find-up@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
Expand Down Expand Up @@ -2378,6 +2386,11 @@ responselike@1.0.2:
dependencies:
lowercase-keys "^1.0.0"

ret@~0.2.0:
version "0.2.2"
resolved "https://registry.yarnpkg.com/ret/-/ret-0.2.2.tgz#b6861782a1f4762dce43402a71eb7a283f44573c"
integrity sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==

reusify@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
Expand Down Expand Up @@ -2407,6 +2420,13 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1:
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==

safe-regex2@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/safe-regex2/-/safe-regex2-2.0.0.tgz#b287524c397c7a2994470367e0185e1916b1f5b9"
integrity sha512-PaUSFsUaNNuKwkBijoAPHAK6/eM6VirvyPWlZ7BAQy4D+hCvh4B6lIG+nPdhbFfIbP+gTGBcrdsOaUs0F+ZBOQ==
dependencies:
ret "~0.2.0"

safe-stable-stringify@^2.1.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.3.1.tgz#ab67cbe1fe7d40603ca641c5e765cb942d04fc73"
Expand Down