diff --git a/src/__test__/wrap.test.ts b/src/__test__/wrap.test.ts index 51f537b..1393957 100644 --- a/src/__test__/wrap.test.ts +++ b/src/__test__/wrap.test.ts @@ -6,13 +6,15 @@ import { lf } from '../function.js'; import { LambdaRequest } from '../request.js'; import { LambdaHttpRequest } from '../http/request.http.js'; import { LambdaHttpResponse } from '../http/response.http.js'; -import { AlbExample, ApiGatewayExample, clone, CloudfrontExample } from './examples.js'; +import { AlbExample, ApiGatewayExample, clone, CloudfrontExample, UrlExample } from './examples.js'; import { fakeLog } from './log.js'; import { HttpMethods } from '../http/router.js'; +import { UrlResult } from '../http/request.url.js'; function assertAlbResult(x: unknown): asserts x is ALBResult {} function assertCloudfrontResult(x: unknown): asserts x is CloudFrontResultResponse {} function assertsApiGatewayResult(x: unknown): asserts x is APIGatewayProxyStructuredResultV2 {} +function assertsUrlResult(x: unknown): asserts x is UrlResult {} o.spec('LambdaWrap', () => { const fakeContext = {} as Context; @@ -160,6 +162,35 @@ o.spec('LambdaWrap', () => { o(body.id).equals(requests[0].id); }); + o('should respond to function url events', async () => { + const fn = lf.http(fakeLog); + const req = clone(UrlExample); + req.rawPath = '/v1/tiles/aerial/EPSG:3857/6/3/41.json'; + fn.router.get('/v1/tiles/:tileSet/:projection/:z/:x/:y.json', fakeLambda); + const ret = await new Promise((resolve) => fn(req, fakeContext, (a, b) => resolve(b))); + + assertsUrlResult(ret); + o(ret.statusCode).equals(200); + o(ret.isBase64Encoded).equals(false); + o(ret.headers?.['content-type']).deepEquals('application/json'); + + const body = JSON.parse(ret.body ?? ''); + o(body.message).equals('ok'); + o(body.status).equals(200); + o(body.id).equals(requests[0].id); + }); + + o('should handle thrown http responses', async () => { + const fn = lf.http(fakeLog); + fn.router.all('*', () => { + throw new LambdaHttpResponse(400, 'Error'); + }); + const ret = await new Promise((resolve) => fn(ApiGatewayExample, fakeContext, (a, b) => resolve(b))); + + assertsUrlResult(ret); + o(ret.statusCode).equals(400); + }); + o('should handle http exceptions', async () => { const fn = lf.http(fakeLog); fn.router.all('*', () => { diff --git a/src/function.ts b/src/function.ts index ffcd608..1e22f06 100644 --- a/src/function.ts +++ b/src/function.ts @@ -33,7 +33,7 @@ const hash = process.env.GIT_HASH; const versionInfo = { version, hash }; /** Run the request catching any errors */ -async function runFunction( +export async function runFunction( req: T, fn: (req: T) => K | Promise, ): Promise { diff --git a/src/http/__test__/router.hook.test.ts b/src/http/__test__/router.hook.test.ts new file mode 100644 index 0000000..22d2c89 --- /dev/null +++ b/src/http/__test__/router.hook.test.ts @@ -0,0 +1,98 @@ +import { Context } from 'aws-lambda'; +import o from 'ospec'; +import sinon from 'sinon'; +import { UrlExample } from '../../__test__/examples.js'; +import { fakeLog } from '../../__test__/log.js'; +import { LambdaUrlRequest } from '../request.url.js'; +import { LambdaHttpResponse } from '../response.http.js'; +import { Router } from '../router.js'; + +o.spec('RouterHook', () => { + const fakeContext = {} as Context; + const sandbox = sinon.createSandbox(); + const req = new LambdaUrlRequest(UrlExample, fakeContext, fakeLog); + + o.afterEach(() => sandbox.restore()); + + o.spec('request', () => { + o('should run before every request', async () => { + const r = new Router(); + + const hook = sandbox.stub(); + r.hook('request', hook); + + const res = await r.handle(req); + + o(res.status).equals(404); + o(hook.calledOnce).equals(true); + + const resB = await r.handle(req); + o(resB.status).equals(404); + o(hook.calledTwice).equals(true); + }); + + o('should allow request hooks to make responses', async () => { + const r = new Router(); + r.hook('request', () => { + return new LambdaHttpResponse(200, 'ok'); + }); + + const res = await r.handle(req); + o(res.status).equals(200); + }); + o('should allow request hooks to throw responses', async () => { + const r = new Router(); + r.hook('request', () => { + throw new LambdaHttpResponse(500, 'ok'); + }); + + const res = await r.handle(req); + o(res.status).equals(500); + o(res.statusDescription).equals('ok'); + }); + o('should catch unhandled exceptions', async () => { + const r = new Router(); + r.hook('request', (req) => { + req.path = ''; // Path is readonly + }); + + const res = await r.handle(req); + o(res.status).equals(500); + o(res.statusDescription).equals('Internal Server Error'); + }); + }); + + o.spec('response', () => { + o('should allow overriding of response', async () => { + const r = new Router(); + r.hook('response', (req, res) => { + o(res.status).equals(404); + res.status = 200; + }); + + const res = await r.handle(req); + o(res.status).equals(200); + }); + + o('should allow throwing of errors', async () => { + const r = new Router(); + r.hook('response', () => { + throw new LambdaHttpResponse(400, 'ok'); + }); + + const res = await r.handle(req); + o(res.status).equals(400); + }); + + o('should catch unhandled exceptions', async () => { + const r = new Router(); + r.hook('response', (req) => { + req.path = ''; // Path is readonly + }); + + const res = await r.handle(req); + o(res.status).equals(500); + o(res.statusDescription).equals('Internal Server Error'); + }); + }); +}); diff --git a/src/http/router.ts b/src/http/router.ts index 4556d8e..15d229d 100644 --- a/src/http/router.ts +++ b/src/http/router.ts @@ -1,10 +1,27 @@ -import { execute } from '../function.js'; +import { execute, runFunction } from '../function.js'; import { LambdaHttpRequest, RequestTypes } from './request.http.js'; import { LambdaHttpResponse } from './response.http.js'; -export type Route = ( +export type Route = ( req: LambdaHttpRequest, ) => Promise | LambdaHttpResponse | void; + +export type RouteResponse = ( + req: LambdaHttpRequest, + res: LambdaHttpResponse, +) => Promise | void; + +export type RouteHooks = { + /** Before the request is executed */ + request: Route; + /** Before the response is returned to the lambda */ + response: RouteResponse; +}; + +export type HookRecord = { + [K in keyof T]: T[K][]; +}; + export type HttpMethods = 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT' | 'OPTIONS' | 'ALL'; export class Router { routes: { @@ -15,6 +32,13 @@ export class Router { fn: Route; }[] = []; + hooks: HookRecord = { + /** Hooks to be run before every request */ + request: [], + /** Hooks to be run after every request */ + response: [], + }; + register(method: HttpMethods, path: string, fn: Route): void { // Stolen from https://github.com/kwhitley/itty-router const regex = RegExp( @@ -30,6 +54,16 @@ export class Router { this.routes.push({ path: regex, method, fn }); } + /** + * Attach a hook to the router + * @param name hook to attach too + * @param cb Function to call on hook + */ + hook(name: K, cb: RouteHooks[K]): void { + 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); } @@ -55,18 +89,42 @@ export class Router { return this.register('PUT', path, fn); } + /** After a route has finished processing run the response hooks on the request/response pair */ + async after(req: LambdaHttpRequest, response: LambdaHttpResponse): Promise { + try { + for (const hook of this.hooks.response) await hook(req, response); + } catch (e) { + if (LambdaHttpResponse.is(e)) return e; + req.set('err', e); + return new LambdaHttpResponse(500, 'Internal Server Error'); + } + return response; + } + + /** + * Handle a incoming request + * + * Request flow: hook.request(req) -> requestHandler(req) -> hook.response(req, res) + */ async handle(req: LambdaHttpRequest): Promise { + // On before request + for (const hook of this.hooks.request) { + const res = await runFunction(req, hook); + // 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 ret = await execute(req, r.fn); - if (ret) return ret; + const res = await execute(req, r.fn); + if (res) return this.after(req, res); } } - return new LambdaHttpResponse(404, 'Not found'); + return this.after(req, new LambdaHttpResponse(404, 'Not found')); } }