diff --git a/README.md b/README.md index cb5e67f..b7c21f8 100644 --- a/README.md +++ b/README.md @@ -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'); diff --git a/package.json b/package.json index c09e812..3a89d6e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ ], "dependencies": { "@linzjs/metrics": "^6.21.1", + "find-my-way": "^7.0.0", "pino": "^7.9.1", "ulid": "^2.3.0" } diff --git a/src/__test__/examples.ts b/src/__test__/examples.ts index 98b1205..7f04429 100644 --- a/src/__test__/examples.ts +++ b/src/__test__/examples.ts @@ -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', @@ -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', @@ -194,3 +198,53 @@ export const UrlExample: UrlEvent = { export function clone(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>(path: string, query: string): LambdaUrlRequest { + 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; +} + +export function newRequestAlb>(path: string, query: string): LambdaAlbRequest { + 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; +} + +export function newRequestApi>( + path: string, + query: string, +): LambdaApiGatewayRequest { + 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; +} + +export function newRequestCloudFront>( + path: string, + query: string, +): LambdaCloudFrontRequest { + 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; +} diff --git a/src/__test__/readme.example.ts b/src/__test__/readme.example.ts index 4ba4cbe..e3b6be2 100644 --- a/src/__test__/readme.example.ts +++ b/src/__test__/readme.example.ts @@ -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'); diff --git a/src/__test__/url.test.ts b/src/__test__/url.test.ts index ea33037..efaf0b7 100644 --- a/src/__test__/url.test.ts +++ b/src/__test__/url.test.ts @@ -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); }); diff --git a/src/__test__/wrap.test.ts b/src/__test__/wrap.test.ts index 1393957..bc39233 100644 --- a/src/__test__/wrap.test.ts +++ b/src/__test__/wrap.test.ts @@ -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); @@ -72,7 +72,6 @@ o.spec('LambdaWrap', () => { const headers: Record = { DELETE: 1, - ALL: 0, GET: 1, OPTIONS: 1, HEAD: 1, @@ -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]; @@ -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))); @@ -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))); @@ -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; diff --git a/src/function.ts b/src/function.ts index 1e22f06..df4a85b 100644 --- a/src/function.ts +++ b/src/function.ts @@ -64,7 +64,9 @@ export async function execute( } 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); @@ -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); diff --git a/src/http/__test__/router.test.ts b/src/http/__test__/router.test.ts new file mode 100644 index 0000000..7899cd8 --- /dev/null +++ b/src/http/__test__/router.test.ts @@ -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); + }); + }); + } +}); diff --git a/src/http/request.http.ts b/src/http/request.http.ts index df6b521..9a1bb8d 100644 --- a/src/http/request.http.ts +++ b/src/http/request.http.ts @@ -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; + } } diff --git a/src/http/router.ts b/src/http/router.ts index 15d229d..bfc5ff7 100644 --- a/src/http/router.ts +++ b/src/http/router.ts @@ -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 = ( req: LambdaHttpRequest, @@ -22,36 +23,26 @@ export type HookRecord = { [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; - }[] = []; - hooks: HookRecord = { /** Hooks to be run before every request */ request: [], /** Hooks to be run after every request */ response: [], }; + router: FindMyWay.Instance; + + constructor() { + this.router = FindMyWay({ defaultRoute: () => new LambdaHttpResponse(404, 'Not found') }); + } register(method: HttpMethods, path: string, fn: Route): 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); + }); } /** @@ -63,10 +54,6 @@ export class Router { this.hooks[name].push(cb); } - /** Register a route for all HTTP types, GET, POST, HEAD, etc... */ - all(path: string, fn: Route): void { - return this.register('ALL', path, fn); - } get(path: string, fn: Route): void { return this.register('GET', path, fn); } @@ -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')); } diff --git a/yarn.lock b/yarn.lock index 211bf36..8e062eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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" @@ -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"