Skip to content

Commit

Permalink
Merge pull request #11 from santiperone/release/1.1.0
Browse files Browse the repository at this point in the history
Release/1.1.0
  • Loading branch information
santiperone authored Sep 2, 2023
2 parents 726998d + abcccf0 commit 9e7af2a
Show file tree
Hide file tree
Showing 8 changed files with 140 additions and 66 deletions.
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
## [1.0.5] - 2023-07-06
## [1.1.0] - 2023-09-01
- Changed homepage and added docs.
- Fixed Readme.
- Added console.log as default logger.
- Added UnsupportedMediaType error.
- Changes Headers parsing implementation to provide a cleaner API.
- Added check to only parse Body when Content-Type is application/json.
- Improved support for CORS settings.
- Added ability to send custom headers in the response.

## [1.0.5] - 2023-08-21
- Changed homepage and added docs.
- Fixed Readme.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "holp",
"version": "1.0.5",
"version": "1.1.0",
"description": "HOF approach for AWS Lambda proxy integrations",
"repository": {
"type": "git",
Expand Down
10 changes: 10 additions & 0 deletions src/errors/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ export class ConflictError extends HTTPError {
}
}

export class UnsupportedMediaTypeError extends HTTPError {
constructor(message: string, options?: CustomHttpErrorOptions) {
super(message, {
code: 'UNSUPPORTED_MEDIA_TYPE',
...options,
statusCode: 415,
});
}
}

export class InternalServerError extends HTTPError {
constructor(message: string, options?: CustomHttpErrorOptions) {
super(message, { code: 'INTERNAL_SERVER_ERROR', ...options, statusCode: 500 });
Expand Down
30 changes: 22 additions & 8 deletions src/proxies/withAPIGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
APIGatewayProxyEventQueryStringParameters,
Context,
} from 'aws-lambda';
import { parseEvent } from '../utils/APIGatewayRequest';
import {HolpHeaders, parseEvent} from '../utils/APIGatewayRequest';
import {
Logger,
APIGatewayResponse,
Expand All @@ -14,41 +14,55 @@ import {
} from '../utils/APIGatewayResponse';

export interface APIGatewayParsedEvent
extends Omit<APIGatewayProxyEvent, 'body'> {
extends Omit<APIGatewayProxyEvent, 'body' | 'headers'> {
body: unknown;
headers: HolpHeaders;
queryStringParameters: APIGatewayProxyEventQueryStringParameters;
pathParameters: APIGatewayProxyEventPathParameters;
rawHeaders: APIGatewayProxyEventHeaders;
}

type Handler<TEvent> = (
event: TEvent,
context: Context
context: Context,
) => Promise<APIGatewayResponse>;

interface CorsSettings {
origin?: string;
allowCredentials?: boolean;
}
export interface withAPIGatewayOptions {
logger?: Logger;
cors?: boolean;
cors?: CorsSettings;
}

const defaultLogger: Logger = {
debug: console.log,
info: console.log,
warn: console.log,
error: console.log,
};

/**
* Higher Order Function that wraps your lambda handler code running
* through API Gateway and handles parameter parsing and try/catch logic using
* the response and error factories.
*
* @param handler - Your lambda handler function
* @param options - Options for the wrapper
* @param options.logger - A logger instance
* @param options.cors - Whether to add CORS headers to the response
* @param options.logger - A logger instance (console.log by default)
* @param options.cors - CORS configuration.
* @returns A lambda handler function
*/
export function withAPIGateway<TEvent extends APIGatewayParsedEvent>(
handler: Handler<TEvent>,
options: withAPIGatewayOptions = {}
options: withAPIGatewayOptions = {logger: defaultLogger},
) {
return async function (event: APIGatewayProxyEvent, context: Context) {
try {
options.logger?.debug(event, 'START');
if (process.env.DEBUG === 'true') {
options.logger?.debug({...event, msg: '[INCOMING_EVENT]'});
}
const parsedEvent = parseEvent<TEvent>(event);
const response = await handler(parsedEvent, context);
return responseFactory(response, options);
Expand Down
54 changes: 35 additions & 19 deletions src/utils/APIGatewayRequest.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,46 @@
import { APIGatewayProxyEvent, APIGatewayProxyEventHeaders } from 'aws-lambda';
import { APIGatewayParsedEvent } from '../proxies';
import {APIGatewayProxyEvent, APIGatewayProxyEventHeaders} from 'aws-lambda';
import {APIGatewayParsedEvent} from '../proxies';
import {UnsupportedMediaTypeError} from '../errors';

function caseInsensitiveHeaders(target: APIGatewayProxyEventHeaders) {
const proxy = new Proxy(
{},
{
get: (obj: APIGatewayProxyEventHeaders, key: string) =>
obj[key.toLowerCase()],
set: (obj: APIGatewayProxyEventHeaders, key: string, value: string) => {
obj[key.toLowerCase()] = value;
return true;
},
}
);
return Object.assign(proxy, target);
export class HolpHeaders {
constructor(private headers: APIGatewayProxyEventHeaders) {
this.headers = Object.fromEntries(
Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]),
);
}
get(key: string) {
return this.headers[key.toLowerCase()];
}
[Symbol.for('nodejs.util.inspect.custom')]() {
return this.headers;
}
}

const jsonMimePattern = /^application\/(.+\+)?json($|;.+)/;

export const parseEvent = <TEvent extends APIGatewayParsedEvent>(
event: APIGatewayProxyEvent
event: APIGatewayProxyEvent,
): TEvent => {
const rawHeaders = event.headers || {};
const headers = new HolpHeaders(rawHeaders);
const contentType = headers.get('Content-Type');
let body = event.body;
if (jsonMimePattern.test(contentType ?? '')) {
try {
const data =
body && event.isBase64Encoded
? Buffer.from(body, 'base64').toString()
: body;
body = JSON.parse(data ?? '{}');
} catch (error) {
throw new UnsupportedMediaTypeError('Invalid or malformed JSON');
}
}

const parsedEvent = {
...event,
rawHeaders,
headers: caseInsensitiveHeaders(rawHeaders),
body: event.body ? JSON.parse(event.body) : {},
headers,
body,
queryStringParameters: event.queryStringParameters || {},
pathParameters: event.pathParameters || {},
} as TEvent;
Expand Down
49 changes: 33 additions & 16 deletions src/utils/APIGatewayResponse.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// TODO: find a more suitable logging library.
import { APIGatewayProxyResult } from 'aws-lambda';
import { HTTPError, InternalServerError } from '../errors';
import { withAPIGatewayOptions } from '../proxies';
import {APIGatewayProxyResult} from 'aws-lambda';
import {HTTPError, InternalServerError} from '../errors';
import {withAPIGatewayOptions} from '../proxies';

type Body = object | undefined | null;

Expand All @@ -22,47 +22,64 @@ export interface Logger {
}
export interface APIGatewayResponse {
body?: Body;
headers?: APIGatewayProxyResult['headers'];
statusCode?: number;
isBase64Encoded?: boolean;
}

/**
* Generate a response for API Gateway Proxy Integration.
*/
export function responseFactory(
res: APIGatewayResponse,
options: withAPIGatewayOptions = {}
options: withAPIGatewayOptions = {},
): APIGatewayProxyResult {
const logger = options.logger;
const body = res.body !== undefined ? res.body : res;
const statusCode = res.statusCode;
const headers: Record<string, string | number | boolean> = {
'Content-Type': 'application/json',
...res.headers,
};
if (options.cors?.origin || process.env.CORS_ORIGIN) {
headers['Access-Control-Allow-Origin'] =
options.cors?.origin || (process.env.CORS_ORIGIN as string); // This won't be undefined because of the if statement above.
}
if (options.cors) {
if (options.cors?.allowCredentials) {
headers['Access-Control-Allow-Credentials'] =
options.cors.allowCredentials;
}
}

const response = {
statusCode: statusCode ? statusCode : isEmpty(body) ? 204 : 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
},
headers,
body,
isBase64Encoded: false,
isBase64Encoded: res.isBase64Encoded,
};

if (Number(response.statusCode) < 400) {
logger?.info({
msg: '[RESPONSE]',
response: { ...response, body: '****' },
response: {...response, body: '****'},
});
} else {
logger?.error({ msg: '[RESPONSE_ERROR]', response });
logger?.error({msg: '[RESPONSE_ERROR]', response});
}

return { ...response, body: JSON.stringify(body) };
return {
...response,
body: typeof body === 'string' ? body : JSON.stringify(body),
};
}

/**
* Generate an error response for API Gateway Proxy Integration.
*/
export function errorFactory(
error: unknown,
options: withAPIGatewayOptions = {}
options: withAPIGatewayOptions = {},
): APIGatewayProxyResult {
const logger = options.logger;
logger?.error(error as Error);
Expand All @@ -72,8 +89,8 @@ export function errorFactory(
: new InternalServerError('Unidentified server error', {
cause: error,
});
const { message, code, statusCode } = responseError;
const body = { error: { message, code } };
const {message, code, statusCode} = responseError;
const body = {error: {message, code}};

return responseFactory({ body, statusCode }, { logger });
return responseFactory({body, statusCode}, {logger});
}
45 changes: 26 additions & 19 deletions tests/library.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { BadRequestError } from '../src/errors/http';
import { responseFactory, errorFactory } from '../src/utils/APIGatewayResponse';
import { withAPIGateway } from '../src';
import { fromPartial } from '@total-typescript/shoehorn';
import {BadRequestError} from '../src/errors/http';
import {responseFactory, errorFactory} from '../src/utils/APIGatewayResponse';
import {withAPIGateway} from '../src';
import {fromPartial} from '@total-typescript/shoehorn';

process.env.DEBUG = 'true';

const mockLogger = {
debug: jest.fn(),
Expand All @@ -12,7 +14,7 @@ const mockLogger = {

describe('responseFactory', () => {
it('should create a 204 response for empty body', () => {
const content = { body: {} };
const content = {body: {}};
const response = responseFactory(content);
expect(response.statusCode).toBe(204);
expect(response.body).toBeDefined();
Expand All @@ -26,7 +28,7 @@ describe('responseFactory', () => {
});

it('should create a 204 response for null body', () => {
const content = { body: null };
const content = {body: null};
const response = responseFactory(content);
expect(response.statusCode).toBe(204);
expect(response.body).toBeDefined();
Expand All @@ -50,33 +52,38 @@ describe('responseFactory', () => {
describe('withAPIGateway', () => {
it('should return a 200 response', async () => {
const handler = async () => {
return { body: { message: 'success' } };
return {body: {message: 'success'}};
};
const proxy = withAPIGateway(handler);
const response = await proxy(fromPartial({}), fromPartial({}));
expect(response.statusCode).toBe(200);
expect(response.body).toBeDefined();
expect(JSON.parse(response.body)).toEqual({ message: 'success' });
expect(JSON.parse(response.body)).toEqual({message: 'success'});
});

it('should parse request body', async () => {
const rawBody = '{"id": "123"}';
const handler = jest.fn();
const proxy = withAPIGateway(handler);
await proxy(fromPartial({ body: rawBody }), fromPartial({}));
expect(handler.mock.calls[0][0].body).toStrictEqual({ id: '123' });
await proxy(
fromPartial({
body: rawBody,
headers: {'Content-type': 'application/json'},
}),
fromPartial({}),
);
expect(handler.mock.calls[0][0].body).toStrictEqual({id: '123'});
});

it('should proxy request headers', async () => {
it('should make request headers case insensitive', async () => {
const rawHeaders = {
Authorization: 'Bearer 123',
authorization: 'Bearer 123',
};
const handler = jest.fn();
const proxy = withAPIGateway(handler);
await proxy(fromPartial({ headers: rawHeaders }), fromPartial({}));
expect(handler.mock.calls[0][0].rawHeaders).toEqual(rawHeaders);
expect(handler.mock.calls[0][0].headers.authorization).toEqual(
rawHeaders.Authorization,
await proxy(fromPartial({headers: rawHeaders}), fromPartial({}));
expect(handler.mock.calls[0][0].headers.get('Authorization')).toEqual(
rawHeaders.authorization,
);
});

Expand All @@ -94,9 +101,9 @@ describe('withAPIGateway', () => {

it('should generate success logs', async () => {
const handler = async () => {
return { body: { message: 'success' } };
return {body: {message: 'success'}};
};
const proxy = withAPIGateway(handler, { logger: mockLogger });
const proxy = withAPIGateway(handler, {logger: mockLogger});
await proxy(fromPartial({}), fromPartial({}));
expect(mockLogger.debug).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalled();
Expand All @@ -106,7 +113,7 @@ describe('withAPIGateway', () => {
const handler = async () => {
throw new Error('any error');
};
const proxy = withAPIGateway(handler, { logger: mockLogger });
const proxy = withAPIGateway(handler, {logger: mockLogger});
await proxy(fromPartial({}), fromPartial({}));
expect(mockLogger.debug).toHaveBeenCalled();
expect(mockLogger.error).toHaveBeenCalled();
Expand Down

0 comments on commit 9e7af2a

Please sign in to comment.