Skip to content

Commit

Permalink
SLSCMN-4 middy middleware (#2)
Browse files Browse the repository at this point in the history
* SLSCMN-4 initial middleware

* SLSCMN-4 joi validator tests

* SLSCMN-4 do not package tests

* SLSCMN-4 middleware logger option

* SLSCMN-4 error  handler tests

* SLSCMN-4 joi validator tests
  • Loading branch information
mwarman authored Nov 14, 2023
1 parent 57228e2 commit 2ed940d
Show file tree
Hide file tree
Showing 6 changed files with 430 additions and 1 deletion.
5 changes: 4 additions & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ export default [
peerDepsExternal(),
resolve(),
commonjs(),
typescript({ tsconfig: './tsconfig.json', exclude: ['jest.config.ts', '**/*.test.ts'] }),
typescript({
tsconfig: './tsconfig.json',
exclude: ['jest.config.ts', '**/*.test.ts', '**/__tests__/**/*', '**/__fixtures__/**/*'],
}),
terser(),
],
external: [],
Expand Down
22 changes: 22 additions & 0 deletions src/__fixtures__/middy.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Request } from '@middy/core';

export const requestFixture: Request = {
context: {
callbackWaitsForEmptyEventLoop: false,
functionName: 'functionName',
functionVersion: 'functionVersion',
invokedFunctionArn: 'invokedFunctionArn',
memoryLimitInMB: 'memoryLimitInMB',
awsRequestId: 'awsRequestId',
logGroupName: 'logGroupName',
logStreamName: 'logStreamName',
getRemainingTimeInMillis: () => 1,
done: () => 1,
fail: () => 1,
succeed: () => 1,
},
event: {},
response: {},
error: null,
internal: {},
};
140 changes: 140 additions & 0 deletions src/middlewares/error-handler-http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { Request } from '@middy/core';
import { jest } from '@jest/globals';

import { HttpError, ServiceError } from '../errors';

import { httpErrorHandler } from './error-handler-http';
import { requestFixture } from '../__fixtures__/middy.fixture';

describe('HttpErrorHandler', () => {
it('should create the middleware', () => {
const middleware = httpErrorHandler();

expect(middleware.before).not.toBeDefined();
expect(middleware.after).not.toBeDefined();
expect(middleware.onError).toBeDefined();
});

it('should do nothing when response has been written', () => {
const response = JSON.stringify({ foo: 'bar' });
const request: Request = {
...requestFixture,
response,
};

const middleware = httpErrorHandler();

expect(middleware.onError).toBeDefined();

middleware.onError?.(request);

expect(request.response).toBe(response);
});

it('should do nothing when no error', () => {
const request: Request = {
...requestFixture,
};
delete request.response;

const middleware = httpErrorHandler();

expect(middleware.onError).toBeDefined();

middleware.onError?.(request);

expect(request.response).not.toBeDefined();
});

it('should write log when logger provided', () => {
type loggerFn = (msg: string) => void;
const mockLogger = jest.fn<loggerFn>();
const request: Request = {
...requestFixture,
error: new HttpError('message', 404),
};
delete request.response;

const middleware = httpErrorHandler({
defaultMessage: 'message',
defaultStatusCode: 500,
logger: mockLogger,
});

expect(middleware.onError).toBeDefined();

middleware.onError?.(request);

expect(mockLogger).toHaveBeenCalled();
});

it('should handle HttpError', () => {
const expectedResponse = {
body: JSON.stringify({ name: 'HttpError', message: 'message', code: 500, statusCode: 500 }),
statusCode: 500,
};
const request: Request = {
...requestFixture,
error: new HttpError('message', 500),
};
delete request.response;

const middleware = httpErrorHandler();

expect(middleware.onError).toBeDefined();

middleware.onError?.(request);

expect(request.response).toStrictEqual(expectedResponse);
});

it('should handle ServiceError', () => {
const expectedResponse = {
body: JSON.stringify({
name: 'ServiceError',
message: 'message',
code: 500,
statusCode: 500,
}),
statusCode: 500,
};
const request: Request = {
...requestFixture,
error: new ServiceError('message'),
};
delete request.response;

const middleware = httpErrorHandler();

expect(middleware.onError).toBeDefined();

middleware.onError?.(request);

expect(request.response).toStrictEqual(expectedResponse);
});

it('should handle Error', () => {
const expectedResponse = {
body: JSON.stringify({
name: 'Error',
message: 'message',
code: 500,
statusCode: 500,
}),
statusCode: 500,
};
const request: Request = {
...requestFixture,
error: new Error('message'),
};
delete request.response;

const middleware = httpErrorHandler();

expect(middleware.onError).toBeDefined();

middleware.onError?.(request);

expect(request.response).toStrictEqual(expectedResponse);
});
});
118 changes: 118 additions & 0 deletions src/middlewares/error-handler-http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import middy, { MiddlewareObj } from '@middy/core';
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

import { ServiceError } from '../errors/service.error';
import { HttpError } from '../errors/http.error';

export type HttpErrorHandlerOptions = {
defaultMessage: string;
defaultStatusCode: number;
logger?(message: string): void;
};

const DEFAULT_OPTIONS: HttpErrorHandlerOptions = {
defaultMessage: 'Unhandled error',
defaultStatusCode: 500,
};

export const httpErrorHandler = (
opts?: HttpErrorHandlerOptions,
): MiddlewareObj<APIGatewayProxyEvent, APIGatewayProxyResult> => {
/**
* Merged options.
*/
const options = {
...DEFAULT_OPTIONS,
...opts,
};

/**
* Write a message to the application log.
* @param message string - The message to write.
*/
const log = (message: string): void => {
if (options.logger) {
options.logger(message);
}
};

/**
* `ServiceError` type guard.
* @param error An Error.
* @returns Indicates if `error` is of type `ServiceError`.
*/
const isServiceError = (error: Error | ServiceError): error is ServiceError => {
return error instanceof ServiceError;
};

/**
* `HttpError` type guard.
* @param error An Error.
* @returns Indicates if `error` is of type `HttpError`.
*/
const isHttpError = (error: Error | HttpError): error is HttpError => {
return error instanceof HttpError;
};

/**
* Format response for unhandled error.
* @param request - Middy request context.
*/
const onError: middy.MiddlewareFn<APIGatewayProxyEvent, APIGatewayProxyResult> = (
request,
): void => {
if (request.response !== undefined) {
// if response already written; do nothing
return;
}

if (!request.error) {
// no error; do nothing
return;
}

const { error } = request;

if (isHttpError(error)) {
// type HttpError
log(`middleware::error-handler::HttpError ${error}`);
request.response = {
statusCode: error.statusCode || options.defaultStatusCode,
body: JSON.stringify({
name: error.name,
message: error.message || options.defaultMessage,
code: error.statusCode || options.defaultStatusCode,
statusCode: error.statusCode || options.defaultStatusCode,
}),
};
} else if (isServiceError(error)) {
// type ServiceError
log(`middleware::error-handler::ServiceError ${error}`);
request.response = {
statusCode: error.statusCode || options.defaultStatusCode,
body: JSON.stringify({
name: error.name,
message: error.message || options.defaultMessage,
code: error.code || options.defaultStatusCode,
statusCode: error.statusCode || options.defaultStatusCode,
}),
};
} else {
// any other type of Error
log(`middleware::error-handler::Error ${error}`);
request.response = {
statusCode: options.defaultStatusCode,
body: JSON.stringify({
name: error.name,
message: error.message || options.defaultMessage,
code: options.defaultStatusCode,
statusCode: options.defaultStatusCode,
}),
};
}
};

return {
onError,
};
};
78 changes: 78 additions & 0 deletions src/middlewares/validator-joi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { jest } from '@jest/globals';
import { Request } from '@middy/core';
import * as Joi from 'joi';
import { BadRequestError } from '../errors/bad-request.error';

import { validator } from './validator-joi';
import { requestFixture } from '../__fixtures__/middy.fixture';

describe('JoiValidator', () => {
const schema = Joi.object({
s: Joi.string().required(),
b: Joi.boolean().default(false),
n: Joi.number().default(1),
});

it('should create the middleware', () => {
const middleware = validator({ eventSchema: schema });

expect(middleware.before).toBeDefined();
});

it('should write log when logger provided', () => {
type loggerFn = (msg: string) => void;
const mockLogger = jest.fn<loggerFn>();

const request: Request = {
...requestFixture,
event: {
s: 'string',
b: 'true',
n: '100',
},
};
const middleware = validator({ eventSchema: schema, logger: mockLogger });

middleware.before?.(request);

expect(mockLogger).toHaveBeenCalled();
});

it('should validate successfully', () => {
const request: Request = {
...requestFixture,
event: {
s: 'string',
b: 'true',
n: '100',
},
};
const middleware = validator({ eventSchema: schema });

expect(middleware.before).toBeDefined();

if (middleware.before) {
middleware.before(request);

expect(request.event).toBeDefined();
expect(request.event.s).toBe('string');
expect(request.event.b).toBe(true);
expect(request.event.n).toBe(100);
}
});

it('should throw error on validation failure', () => {
const validate = () => {
const request: Request = {
...requestFixture,
event: {
s: 1,
},
};
const middleware = validator({ eventSchema: schema });
middleware.before && middleware.before(request);
};

expect(validate).toThrow(BadRequestError);
});
});
Loading

0 comments on commit 2ed940d

Please sign in to comment.