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
33 changes: 32 additions & 1 deletion src/__test__/wrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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('*', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const hash = process.env.GIT_HASH;
const versionInfo = { version, hash };

/** Run the request catching any errors */
async function runFunction<T extends LambdaRequest, K>(
export async function runFunction<T extends LambdaRequest, K>(
req: T,
fn: (req: T) => K | Promise<K>,
): Promise<K | LambdaHttpResponse> {
Expand Down
98 changes: 98 additions & 0 deletions src/http/__test__/router.hook.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
68 changes: 63 additions & 5 deletions src/http/router.ts
Original file line number Diff line number Diff line change
@@ -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<T extends RequestTypes> = (
export type Route<T extends RequestTypes = RequestTypes> = (
req: LambdaHttpRequest<T>,
) => Promise<LambdaHttpResponse> | LambdaHttpResponse | void;

export type RouteResponse<T extends RequestTypes> = (
req: LambdaHttpRequest<T>,
res: LambdaHttpResponse,
) => Promise<void> | void;

export type RouteHooks = {
/** Before the request is executed */
request: Route<RequestTypes>;
/** Before the response is returned to the lambda */
response: RouteResponse<RequestTypes>;
};

export type HookRecord<T extends RouteHooks> = {
[K in keyof T]: T[K][];
};

export type HttpMethods = 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT' | 'OPTIONS' | 'ALL';
export class Router {
routes: {
Expand All @@ -15,6 +32,13 @@ export class Router {
fn: Route<any>;
}[] = [];

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

register<T extends RequestTypes>(method: HttpMethods, path: string, fn: Route<T>): void {
// Stolen from https://github.com/kwhitley/itty-router
const regex = RegExp(
Expand All @@ -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<K extends keyof RouteHooks>(name: K, cb: RouteHooks[K]): void {
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);
}
Expand All @@ -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<LambdaHttpResponse> {
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<LambdaHttpResponse> {
// 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'));
}
}