Skip to content
2 changes: 2 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const PROVIDER = process.env.PROVIDER;
export const TRACING_ENABLED = process.env.TRACING_ENABLED === 'true' && process.env.IS_OFFLINE !== 'true';
8 changes: 7 additions & 1 deletion lib/default-error-transformer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ describe('default-error-transformer', () => {
const error: HttpError = new HttpError(201, 'message');

expect(defaultErrorTransformer(error, { showStackTrace: false })).toEqual({
success: false,
statusCode: 201,
body: '{"status":201,"name":"Created","message":"message","details":[]}'
body: {
status: 201,
name: 'Created',
message: 'message',
details: []
},
});
});
});
9 changes: 5 additions & 4 deletions lib/default-error-transformer.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
import { getStatusText } from 'http-status-codes';
import { APIGatewayProxyResult } from 'aws-lambda';
import { HttpError } from './http-error';
import { IError, IProviderResponse } from './models';

export function defaultErrorTransformer(error: HttpError,
options: { showStackTrace?: boolean }): APIGatewayProxyResult {
options: { showStackTrace?: boolean }): IProviderResponse<IError> {

const stackTraceDetails = options.showStackTrace ? [{
name: 'Unexpected Error',
message: error.stack
}] : [];

return {
success: false,
statusCode: error.statusCode,
body: JSON.stringify({
body: {
status: error.statusCode,
name: getStatusText(error.statusCode),
message: error.message,
details: [
...error.details,
...stackTraceDetails
]
})
}
};
}
75 changes: 75 additions & 0 deletions lib/handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,4 +190,79 @@ describe('handler', () => {
expect(result.statusCode).toEqual(400);
expect(JSON.parse(result.body).message).toEqual('Invalid response');
});

it('should select the correct provider', async () => {
// tslint:disable-next-line: variable-name
const AWSProvider = jest.fn();
jest.mock('./providers/aws/provider', () => ({ AWSProvider }));
// tslint:disable-next-line: variable-name
const AzureProvider = jest.fn();
jest.mock('./providers/azure/provider', () => ({ AzureProvider }));
// tslint:disable-next-line: variable-name
const GoogleProvider = jest.fn();
jest.mock('./providers/google/provider', () => ({ GoogleProvider }));

// Default
jest.clearAllMocks();
jest.resetModules();
(await import('./handler')).handler({}, async () => Result.Ok(OK));
expect(AWSProvider).toHaveBeenCalledTimes(1);
expect(AzureProvider).not.toHaveBeenCalled();
expect(GoogleProvider).not.toHaveBeenCalled();

// AWS
jest.clearAllMocks();
jest.resetModules();
process.env.PROVIDER = 'aws';
(await import('./handler')).handler({}, async () => Result.Ok(OK));
expect(AWSProvider).toHaveBeenCalledTimes(1);
expect(AzureProvider).not.toHaveBeenCalled();
expect(GoogleProvider).not.toHaveBeenCalled();

// Azure
jest.clearAllMocks();
jest.resetModules();
process.env.PROVIDER = 'azure';
(await import('./handler')).handler({}, async () => Result.Ok(OK));
expect(AWSProvider).not.toHaveBeenCalled();
expect(AzureProvider).toHaveBeenCalledTimes(1);
expect(GoogleProvider).not.toHaveBeenCalled();

// Google
jest.clearAllMocks();
jest.resetModules();
process.env.PROVIDER = 'google';
(await import('./handler')).handler({}, async () => Result.Ok(OK));
expect(AWSProvider).not.toHaveBeenCalled();
expect(AzureProvider).not.toHaveBeenCalled();
expect(GoogleProvider).toHaveBeenCalledTimes(1);

delete process.env.PROVIDER;
});

it('should call trace when tracing is enabled', async () => {
const trace = jest.fn();
// tslint:disable-next-line: max-classes-per-file
class AWSProvider {
public transformRequest = jest.fn(() => ({}));
public transformResponse = jest.fn();
public trace = trace;
}
jest.mock('./providers/aws/provider', () => ({ AWSProvider }));

// Without tracing
jest.resetModules();
const handle = (await import('./handler')).handler({ traceName: 'test' }, async () => Result.Ok(OK));
await handle();
expect(trace).not.toHaveBeenCalled();

// With tracing
jest.resetModules();
process.env.TRACING_ENABLED = 'true';
const tracedHandle = (await import('./handler')).handler({ traceName: 'test' }, async () => Result.Ok(OK));
await tracedHandle();
expect(trace).toHaveBeenCalledTimes(1);

delete process.env.TRACING_ENABLED;
});
});
179 changes: 116 additions & 63 deletions lib/handler.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { defaultLogger } from './console-logger';
import { HandlerOptions, ProxyEvent, ResultResponse, Dictionary, IErrorDetail } from './models';
import { APIGatewayProxyHandler, APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import {
HandlerOptions,
Dictionary,
IOk,
GenericHandlerOptions,
Handler,
GenericProviderHandler,
IProviderRequest
} from './models';
import { PROVIDER, TRACING_ENABLED } from './constants';
import { convertAndValidate } from './convert-and-validate';
import { INTERNAL_SERVER_ERROR } from 'http-status-codes';
import { convertToJson } from './convert-to-json';
import { defaultErrorTransformer } from './default-error-transformer';
import { HttpError } from './http-error';
import { Provider } from './providers';
import winston from 'winston';

const ERROR_MESSAGES = {
INVALID_BODY: 'Invalid body',
Expand All @@ -22,83 +31,127 @@ export function handler<
T4 = Dictionary | null,
TResponse = unknown>(
options: HandlerOptions<T1, T2, T3, T4, TResponse>,
eventHandler: (event: ProxyEvent<T1, T2, T3, T4>) => ResultResponse<TResponse>): APIGatewayProxyHandler {
eventHandler: Handler<T1, T2, T3, T4, TResponse>): GenericProviderHandler {
// TODO: add support for tracing the transformation and validation as separate segments.
// TODO: add support for generating Swagger documentation based on the validation.

const errorTransformer = options.errorTransformer || defaultErrorTransformer;
const logger = options.logger || defaultLogger;
const provider = selectProvider(options, logger);

return async (proxyEvent: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {

return async (...providerParams: any[]): Promise<any> => {
try {
// Transform and validate the request.
const request = provider.transformRequest(...providerParams);
const validatedRequest = await validateRequest(options, request);
const event = {
...validatedRequest,
body: validatedRequest.body as T1,
queryParameters: validatedRequest.queryParameters as T2,
pathParameters: validatedRequest.pathParameters as T3,
headers: validatedRequest.headers as T4,
};

// Execute the handler with optional tracing.
let response: HttpError | IOk<TResponse>;
if (TRACING_ENABLED && options.traceName) {
response = await provider.trace(eventHandler, event);
} else {
response = await eventHandler(event);
}

const pathParameters = options.pathParameters ?
await convertAndValidate(
proxyEvent.pathParameters || {},
options.pathParameters,
ERROR_MESSAGES.INVALID_PATH_PARAMETERS
) : proxyEvent.pathParameters;

const queryParameters = options.queryParameters ?
await convertAndValidate(
proxyEvent.queryStringParameters || {},
options.queryParameters,
ERROR_MESSAGES.INVALID_QUERY_PARAMETERS
) : proxyEvent.queryStringParameters;

const headers = options.headers ?
await convertAndValidate(
proxyEvent.headers,
options.headers,
ERROR_MESSAGES.INVALID_HEADERS
) : proxyEvent.headers;

const body = options.body ?
await convertAndValidate(
convertToJson(proxyEvent.body, logger),
options.body,
ERROR_MESSAGES.INVALID_BODY
) : proxyEvent.body;

const result = await eventHandler({
body: body as T1,
queryParameters: queryParameters as T2,
pathParameters: pathParameters as T3,
headers: headers as T4,
httpMethod: proxyEvent.httpMethod,
path: proxyEvent.path,
context: proxyEvent.requestContext
});

if (result.success) {

const validatedBody = options.response ?
await convertAndValidate(
result.body,
options.response,
ERROR_MESSAGES.INVALID_RESPONSE
) : result.body;

return {
statusCode: result.statusCode,
body: validatedBody ? JSON.stringify(validatedBody) : '',
headers: result.headers
};
if (response.success) {
// Validate and send the success response.
const validatedResponse = await validateResponse(options, response);
return provider.transformResponse(validatedResponse, ...providerParams);
}

return errorTransformer(result, options);
// Send the error response.
return provider.transformResponse(errorTransformer(response, options), ...providerParams);
} catch (error) {

if (error instanceof HttpError) {
return errorTransformer(error, options);
return provider.transformResponse(errorTransformer(error, options), ...providerParams);
}

// Log unexpected errors
logger.error(error);

return errorTransformer(new HttpError(
return provider.transformResponse(errorTransformer(new HttpError(
INTERNAL_SERVER_ERROR,
error.message
), options);
), options), ...providerParams);
}
};
}

/**
* Select the provider to use based on the PROVIDER environment variable.
* Will select AWS by default if none is provided.
* @param {GenericHandlerOptions} options - The options to use for the provider instance.
* @param {winston.Logger} logger - The logger to use for the provider instance.
*/
function selectProvider(options: GenericHandlerOptions, logger: winston.Logger): Provider {
switch (PROVIDER) {
default:
case 'aws':
return new (require('./providers/aws')).AWSProvider(options, logger);
case 'azure':
return new (require('./providers/azure')).AzureProvider(options, logger);
case 'google':
return new (require('./providers/google')).GoogleProvider(options, logger);
}
}

/**
* Validate and convert an incoming request based on the provided handler options.
* @param {GenericHandlerOptions} options - The options to use for validating.
* @param {IProviderRequest} request - The data to validate.
* @returns {IProviderRequest} - The validated and converted data.
*/
async function validateRequest(options: GenericHandlerOptions, request: IProviderRequest): Promise<IProviderRequest> {
return {
...request,
pathParameters: options.pathParameters ?
await convertAndValidate(
request.pathParameters || {},
options.pathParameters,
ERROR_MESSAGES.INVALID_PATH_PARAMETERS
) : request.pathParameters,
queryParameters: options.queryParameters ?
await convertAndValidate(
request.queryParameters || {},
options.queryParameters,
ERROR_MESSAGES.INVALID_QUERY_PARAMETERS
) : request.queryParameters,
headers: options.headers ?
await convertAndValidate(
request.headers,
options.headers,
ERROR_MESSAGES.INVALID_HEADERS
) : request.headers,
body: options.body ?
await convertAndValidate(
request.body,
options.body,
ERROR_MESSAGES.INVALID_BODY
) : request.body,
};
}

/**
* Validate and convert a response based on the provided handler options.
* @param {GenericHandlerOptions} options - The options to use for validating.
* @param {IOk<T>} response - The data to validate.
* @returns {IOk<T>} - The validated and converted data.
*/
async function validateResponse<T>(options: GenericHandlerOptions, response: IOk<T>): Promise<IOk<T>> {
return {
...response,
body: options.response ?
await convertAndValidate(
response.body,
options.response,
ERROR_MESSAGES.INVALID_RESPONSE
) : response.body,
};
}
2 changes: 1 addition & 1 deletion lib/http-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ export class HttpError extends Error implements IHttpError {
this.success = false;
this.details = details;
}
}
}
Loading