Skip to content

europace/thirty

Repository files navigation


thirty

A middleware engine for AWS Lambda, that makes Lambda Functions type-safe, easy to develop and test.



Installation

npm install thirty

Getting started

import { APIGatewayProxyEvent } from 'aws-lambda';
import { compose, eventType } from 'thirty/core';
import { parseJson } from 'thirty/parseJson';
import { serializeJson } from 'thirty/serializeJson';
import { verifyJwt, tokenFromHeaderFactory } from 'thirty/verifyJwt';
import { registerHttpErrorHandler } from 'thirty/registerHttpErrorHandler';
import { inject } from 'thirty/inject';
import { APIGatewayProxyResult } from 'thrirty/types/APIGatewayProxyResult';

export const handler = compose(
  types<APIGatewayProxyEvent, Promise<APIGatewayProxyResult>>(),
  inject({
    authService: authServiceFactory,
    userService: userServiceFactory,
  }),
  registerHttpErrorHandler(),
  parseJson(),
  serializeJson(),
)(async event => {
  const { userService } = event.deps;
  const user = await userService.createUser(event.jsonBody);
  return {
    statusCode: 201,
    body: user,
  };
});

Testing

The composed handler function exposes a reference to the actual handler via the actual property:

// handler.spec.ts
import { handler } from './handler';

it('should return created user', async () => {
  const user = {
    /*...*/
  };
  const eventMock = {
    deps: { userService: userServiceMock /* ..*/ },
    /* ..*/
  };
  const { statusCode, body } = await handler.actual(eventMock);
  expect(statusCode).toBe(201);
  expect(body).toEqual(user);
});

This makes it possible to easily unit test the business code without retesting middleware-functionality again.

compose

compose is a common implementation of Function_composition and the heart of thirty.

On top of that compose provides typings so that the event type, which is extended by middlewares, can be inferred.

export const handler = compose(
  types<{ inputA: number; inputB: number }, string>(),
  serializeJson(),
)(async event => {
  return event.inputA + event.inputB;
});

It also exposes a reference to the argument of the composed function:

const actual = async () => {};
export const handler = compose()(actual);
// ...
handler.actual === actual; // true

Middlewares

inject

inject is a middleware that provides lightweight dependency injection.

In order to create a dependency injection container, just define an object, where its properties refer to factory methods.

import { inject } from 'thirty/inject';

export const handler = compose(
  eventType<APIGatewayProxyEvent>(),
  inject({
    authService: authServiceFactory,
    userService: userServiceFactory,
  }),
)(async event => {
  const { userService } = event.deps;
  // ...
});

Each factory gets access all dependencies defined in the container:

export type AuthServiceDeps = { userService: UserService };
export type AuthService = ReturnType<typeof authServiceFactory>;

export const authServiceFactory = ({ userService }: AuthServiceDeps) => ({
  authenticate() {
    const user = userService.getUser();
    // ...
  },
});

This makes it easy to mock and test the actual handler:

// handler.spec.ts
it('should return created user', async () => {
  const eventMock = {
    deps: { authService: authServiceMock, userService: userServiceMock },
    /* ..*/
  };
  const result = await handler.actual(eventMock);
  // assertion goes here
});

doNotWaitForEmptyEventLoop

Sets context.callbackWaitsForEmptyEventLoop to false.

From official documentation:

callbackWaitsForEmptyEventLoop – Set to false to send the response right away when the callback runs, instead of waiting for the Node.js event loop to be empty. If this is false, any outstanding events continue to run during the next invocation.

const handler = compose(
  types<APIGatewayEvent, Promise<APIGatewayProxyResult>>(), 
  doNotWaitForEmptyEventLoop(),
)(async event => {

});

parseJson

parseJson is a middleware that parses the request body and extends the event object by a jsonBody object:

import { compose, types, of } from 'thirty/core';
import { parseJson } from 'thirty/parseJson';
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

type SomeDto = { description: string };

export const handler = compose(
  types<APIGatewayProxyEvent, Promise<APIGatewayProxyResult>>(),
  parseJson(of<SomeDto>),
)(async event => {
  const { description } = event.jsonBody;
    
  return {
    statusCode: 200,
    body: JSON.stringify({ id: uuid(), description }),
  };
});

serializeJson

Before that middleware you had to serialize your response body's manually and parse it back again in your tests in order to assert response bodys - especially partially.

type User = {id: string; name: string};
const handler = compose(
  types<APIGatewayEvent, Promise<APIGatewayProxyResult>>(),
)(async event => {
  return {
    statusCode: 200,
    body: JSON.stringify({id: 'USER_1', name: 'Marty'} as User),
  };
});

const response = await handler.actual({...});
expect(response.statusCode).toEqual(200);
expect(JSON.parse(response.body)).toEqual(expect.objectContaining({
     id: 'USER_1'
  }));

But serializeJson makes type-safe and testing less verbose:

const handler = compose(
  types<APIGatewayEvent, Promise<APIGatewayProxyResult>>(),
  serializeJson(of<User>),
)(async event => {
  return {
    statusCode: 200,
    body: {id: 'USER_1', name: 'Marty'},
  };
});

const response = await handler.actual({...});
expect(response).toEqual({
   statusCode: 200,
   body: expect.objectContaining({ id: 'USER_1' })
});

registerHttpErrorHandler

registerHttpErrorHandler is a middleware that wraps the actual handler, catches all errors and creates an error response:

import { registerHttpErrorHandler } from 'thirty/registerHttpErrorHandler';
import { BadRequestError } from 'thirty/errors';

export const handler = compose(
  eventType<{ someType: string }>(),
  registerHttpErrorHandler({
    logger: console,
    backlist: [{ statusCode: 401, message: 'Alternative message' }],
  }),
)(async event => {
  throw new BadRequestError('Parameter x missing');
});

The above example would create an error response that would look like:

{
  "statusCode": 400,
  "headers": {
    "Content-Type": "application/json"
  },
  "body": "{\"error\":\"Parameter x missing\"}"
}

sanitizeHeaders

sanitizeHeaders is a middleware that lower cases all header properties and stores them in a new event.sanitizedHeaders object. This is necessary because the header properties in event.headers aren't consolidated. Which means they are deserialized as set in the header request.

import { sanitizeHeaders } from 'thirty/sanitizeHeaders';

export const handler = compose(
  eventType<{ someType: string }>(),
  sanitizeHeaders(),
)(async event => {
  event.sanitizedHeaders;
});

handleCors

handleCors is a middleware that creates a preflight response to OPTIONS requests and adds CORS headers to any other request.

Requires sanitizeHeaders middleware

import { sanitizeHeaders } from 'thirty/sanitizeHeaders';
import { handleCors } from 'thirty/handleCors';

export const handler = compose(
  eventType<APIGatewayProxyEvent>(),
  sanitizeHeaders(),
  handleCors(),
)(async event => {
  // ...
});

decodeParameters

decodeParameters is a middleware that decodes all parameter values with decodeURIComponent and stores them in event.decodedPathParameters,event.decodedQueryParameters, event.decodedMultiValueQueryParameters.

import { decodeParameters } from 'thirty/decodeParameters';

export const handler = compose(
  eventType<{ someType: string }>(),
  decodeParameters(),
)(async event => {
  event.decodeParameters;
  event.decodedQueryParameters;
  event.decodedMultiValueQueryParameters;
});

verifyJwt

verifyJwt is a authentication middleware, which extends the event object by a user object and throws an UnauthorizedError if the client is not authorized. Under the hood it uses the jsonwebtoken library.

import { verifyJwt } from 'thirty/verifyJwt';

export const handler = compose(
  eventType<{ someType: string }>(),
  verifyJwt({
    getToken: event => event.headers.Authorization.split(' ')[1],
    getSecretOrPublic: ({ deps, event, decodedJwt }) => someSecretOrPublic,
  }),
)(async event => {
  event.user;
});

thirty/verifyJwt already provides factory functions to retrieve the token from headers or cookie:

  • tokenFromHeaderFactory expects a header name (default is 'Authorization').

    Requires sanitizeHeaders middleware

    import { tokenFromHeaderFactory } from 'thirty/verifyJwt';
    
    {
      getToken: tokenFromHeaderFactory();
    }
  • tokenFromCookieFactory requires parseCookie middleware and expects a key for cookie entry (default is 'authentication').

    import { tokenFromCookieFactory } from 'thirty/verifyJwt';
    
    {
      getToken: tokenFromCookieFactory();
    }

Options API

  • getToken - Function that expects the token that should be validated.
  • getSecretOrPublic - Secret or public key provider for verifying token.
  • All options that can be passed to jsonwebtoken's verify

verifyXsrfToken

verifyXsrfToken is a middleware that checks the XSRF Token provided in the request headers. It uses the csrf library.

Requires sanitizeHeaders middlware

import { verifyXsrfToken } from 'thirty/verifyXsrfToken';

export const handler = compose(
  eventType<{ someType: string }>(),
  verifyXsrfToken({
    getSecret: ({ event }) => secret,
  }),
)(async event => {
  // ...
});

parseCookie

parseCookie is a middleware that parses the event cookie header and extends the event object by a cookie object:

import { parseCookie } from 'thirty/parseCookie';

export const handler = compose(
  eventType<{ someType: string }>(),
  parseCookie(),
)(async event => {
  event.cookie;
});

forEachSqsRecord

Consider the following setup not using that middleware:

type SomeMesssage = {id: string; text: string};
const handler = compose(
  types<SQSEvent, Promise<SQSBatchResponse>>(),
)(async event => {
  return {
    batchItemFailures: (
      await Promise.all(
        event.Records.map((record) => {
          try {
            const message: SomeMessage = JSON.parse(record.body);
            // process message
          } catch (e) {
            return {
              itemIdentifier: record.messageId,
            };
          }
        }),
      )
    ).filter((maybeItemFailure): maybeItemFailure is SQSBatchItemFailure => !!maybeItemFailure),
  };
});

You have to do a lot of boilerplate code, which makes the actual business code of processing one message hard to read. forEachSqsRecord lets you process one message without any of that boilerplate:

const handler = compose(
  types<SQSEvent, Promise<SQSBatchResponse>>(),
  forEachSqsRecord({
    batchItemFailures: true,
    bodyType: of<SomeMessage>,
  })
)(async event => {
  const message = event.record.body;
  // process message
});

Use sequential set to true in order to iterate over the records in order. If one record fails to be processed, the processing of any upcoming records will be stopped, stopped too. If batchItemFailures is also set to true, all unprocessed records will be added to list of batchItemFailures.

Publish

In order to publish a new version to npm, create a new release on github.

  1. Create a tag. The tag needs to follow semver (Don't prefix the version number with "v" as suggested by github). e.g. 1.7.0
  2. Define a release title
  3. Generate release notes by clicking "Generate release notes"
  4. Click "Publish release"

ℹ️ The package will automatically bundled and published to npm via the publish.yml workflow.