diff --git a/package-lock.json b/package-lock.json index 61661771ec..38cfb5871a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "docs/snippets", "layers", "examples/cdk", - "examples/sam" + "examples/sam", + "packages/event-handler" ], "devDependencies": { "@middy/core": "^3.6.2", @@ -301,6 +302,10 @@ "resolved": "packages/commons", "link": true }, + "node_modules/@aws-lambda-powertools/event-handler": { + "resolved": "packages/event-handler", + "link": true + }, "node_modules/@aws-lambda-powertools/idempotency": { "resolved": "packages/idempotency", "link": true @@ -18132,6 +18137,13 @@ "version": "1.14.0", "license": "MIT-0" }, + "packages/event-handler": { + "version": "0.0.1", + "license": "MIT-0", + "devDependencies": { + "@types/aws-lambda": "^8.10.111" + } + }, "packages/idempotency": { "name": "@aws-lambda-powertools/idempotency", "version": "1.14.0", diff --git a/package.json b/package.json index 195223fb8a..a864ac606f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "packages/parameters", "packages/idempotency", "packages/batch", + "packages/event-handler", "packages/testing", "docs/snippets", "layers", diff --git a/packages/event-handler/CHANGELOG.md b/packages/event-handler/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/event-handler/LICENSE-THIRD-PARTY b/packages/event-handler/LICENSE-THIRD-PARTY new file mode 100644 index 0000000000..0adb67e7ef --- /dev/null +++ b/packages/event-handler/LICENSE-THIRD-PARTY @@ -0,0 +1,83 @@ +@aws-lambda-powertools/commons +0.0.2 +license: MIT* +authors: Amazon Web Services + +****************************** + +@types/aws-lambda +8.10.87 + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE + + +****************************** + +lodash +4.17.21 +Copyright OpenJS Foundation and other contributors + +Based on Underscore.js, copyright Jeremy Ashkenas, +DocumentCloud and Investigative Reporters & Editors + +This software consists of voluntary contributions made by many +individuals. For exact contribution history, see the revision history +available at https://github.com/lodash/lodash + +The following license applies to all parts of this software except as +documented below: + +==== + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==== + +Copyright and related rights for sample code are waived via CC0. Sample +code is defined as all source code displayed within the prose of the +documentation. + +CC0: http://creativecommons.org/publicdomain/zero/1.0/ + +==== + +Files located in the node_modules and vendor directories are externally +maintained libraries used by this software which have their own +licenses; we recommend you read them, as their terms may differ from the +terms above. diff --git a/packages/event-handler/README.md b/packages/event-handler/README.md new file mode 100644 index 0000000000..20abe8b12c --- /dev/null +++ b/packages/event-handler/README.md @@ -0,0 +1,85 @@ +# event-handler + +Minimalistic event handler & HTTP router for Serverless applications + +## Simple Example + +```typescript +// Import API Gateway Event handler +import { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import { ApiGatewayResolver } from './ApiGateway'; +import { AsyncFunction, BaseProxyEvent, JSONData } from 'types'; + +// Initialize the event handler +const app = new ApiGatewayResolver(); + +// Define a route +const helloHandler = +async (_event: BaseProxyEvent, _context: Context) : Promise => Promise.resolve({ message: 'Hello World' }); + +// Register Route +app.addRoute('GET', '/v1/hello', helloHandler as AsyncFunction) ; + +// Declare your Lambda handler +// Declare your Lambda handler +exports.handler = ( + _event: APIGatewayProxyEvent, + _context: Context +): Promise => + // Resolve routes + app.resolve(_event, _context) +; +``` + +## Register Route with Decorators + +```typescript +import { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import { ApiGatewayResolver } from './ApiGateway'; +import { BaseProxyEvent, JSONData } from 'types'; + +// Initialize the event handler +const app = new ApiGatewayResolver(); + +// Define a Controller class +export class HelloController{ + + // Register a route + @app.get('/v1/hello') + public hello (_event: BaseProxyEvent, _context: Context) : Promise { + return Promise.resolve({ message: 'Hello World' }); + } + + @app.post('/v1/hello') + public postHello (_event: BaseProxyEvent, _context: Context) : Promise { + return Promise.resolve({ message: 'Resource created' }); + } + +} + +// Declare your Lambda handler +exports.handler = ( + _event: APIGatewayProxyEvent, + _context: Context +): Promise => + // Resolve routes + app.resolve(_event, _context) +; + +``` + +## CORS Support + +```typescript +// Import API Gateway Event handler +import { CORSConfig } from 'types'; +import { ApiGatewayResolver, ProxyEventType } from './ApiGateway'; + +// App with CORS Configurattion +const app = new ApiGatewayResolver( + ProxyEventType.APIGatewayProxyEvent, + new CORSConfig() +); + +``` + diff --git a/packages/event-handler/jest.config.js b/packages/event-handler/jest.config.js new file mode 100644 index 0000000000..34550a8b4d --- /dev/null +++ b/packages/event-handler/jest.config.js @@ -0,0 +1,28 @@ +module.exports = { + displayName: { + name: 'Powertools for AWS Lambda (TypeScript) utility: EVENT-HANDLER', + color: 'yellow', + }, + runner: 'groups', + preset: 'ts-jest', + transform: { + '^.+\\.ts?$': 'ts-jest', + }, + moduleFileExtensions: ['js', 'ts'], + collectCoverageFrom: ['**/src/**/*.ts', '!**/node_modules/**'], + testMatch: ['**/?(*.)+(spec|test).ts'], + roots: ['/src', '/tests'], + testPathIgnorePatterns: ['/node_modules/'], + testEnvironment: 'node', + coveragePathIgnorePatterns: ['/node_modules/', '/types/'], + coverageThreshold: { + global: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + }, + coverageReporters: ['json-summary', 'text', 'lcov'], + setupFiles: [], +}; diff --git a/packages/event-handler/package.json b/packages/event-handler/package.json new file mode 100644 index 0000000000..d879d261d0 --- /dev/null +++ b/packages/event-handler/package.json @@ -0,0 +1,55 @@ +{ + "name": "@aws-lambda-powertools/event-handler", + "version": "0.0.1", + "description": "Minimalistic event handler & http router for the Powertools for AWS Lambda (TypeScript) library.", + "author": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "npm run test:unit", + "test:unit": "jest --group=unit --detectOpenHandles --coverage --verbose", + "test:e2e:nodejs14x": "echo 'Not Implemented'", + "test:e2e:nodejs16x": "echo 'Not Implemented'", + "test:e2e:nodejs18x": "echo 'Not Implemented'", + "test:e2e": "echo 'Not Implemented'", + "watch": "jest --watch", + "build": "tsc --build --force", + "lint": "eslint --ext .ts,.js --no-error-on-unmatched-pattern .", + "lint-fix": "eslint --fix --ext .ts,.js --no-error-on-unmatched-pattern .", + "prebuild": "rimraf ./lib", + "prepack": "node ../../.github/scripts/release_patch_package_json.js ." + }, + "lint-staged": { + "*.{js,ts}": "npm run lint-fix" + }, + "homepage": "https://github.com/aws-powertools/powertools-lambda-typescript/tree/main/packages/event-handler#readme", + "license": "MIT-0", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "files": [ + "lib" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/aws-powertools/powertools-lambda-typescript.git" + }, + "bugs": { + "url": "https://github.com/aws-powertools/powertools-lambda-typescript/issues" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.111" + }, + "keywords": [ + "aws", + "lambda", + "powertools", + "event-handler", + "http-router", + "serverless", + "nodejs" + ] +} diff --git a/packages/event-handler/src/ApiGateway.ts b/packages/event-handler/src/ApiGateway.ts new file mode 100644 index 0000000000..bb6c4a0c3d --- /dev/null +++ b/packages/event-handler/src/ApiGateway.ts @@ -0,0 +1,614 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import zlib from 'node:zlib'; +import { + Response, + Route, + CORSConfig, + JSONData, + Context, + HTTPMethod, + BaseProxyEvent, + Headers, + PathPattern, + ArgsDict, + AsyncFunction, + ResponseInterface, +} from './types'; +import { Context as LambdaContext } from 'aws-lambda'; +import { Middleware, wrapWithMiddlewares } from './middleware'; +import { lookupKeyFromMap } from './utils'; + +enum ProxyEventType { + APIGatewayProxyEvent = 'APIGatewayProxyEvent', + APIGatewayProxyEventV2 = 'APIGatewayProxyEventV2', + ALBEvent = 'ALBEvent', + LambdaFunctionUrlEvent = 'LambdaFunctionUrlEvent', +} + +const _DYNAMIC_ROUTE_PATTERN = /<(\w+)>/g; +const _SAFE_URI = `-._~()\'!*:@,;=`; +const _UNSAFE_URI = '%<> \\[\\]{}|^'; +const _NAMED_GROUP_BOUNDARY_PATTERN = `(?<$1>[${_SAFE_URI}${_UNSAFE_URI}\\w]+)`; +const _ROUTE_REGEX = '^{}$'; + +export { + ApiGatewayResolver, + BaseRouter, + ProxyEventType, + ResponseBuilder, + Router, +}; + +/** + * Standard APIGateway Response builder + */ +class ResponseBuilder { + public constructor(public response: Response, public route?: Route) {} + + /** + * Builds a standard APIGatewayProxyResponseEvent + * + * @param event Incoming Event + * @param cors CORS configuration + * @returns + */ + public build(event: BaseProxyEvent, cors?: CORSConfig): JSONData { + this.route && this._route(event, cors); + + if (this.response.body instanceof Buffer) { + this.response.base64Encoded = true; + this.response.body = this.response.body.toString('base64'); + } + + return { + statusCode: this.response.statusCode, + body: this.response.body, + isBase64Encoded: this.response.base64Encoded || false, + headers: { ...this.response.headers }, + }; + } + + /** + * Sets CORS, Cache-Control & Compress HTTP Headers based on the configuration + * + * @param event Incoming Event + * @param cors CORS configuration + */ + private _route(event: BaseProxyEvent, cors?: CORSConfig): void { + const { headers } = event; + const { cors: enableCORS, cacheControl, compress } = this.route as Route; + + if (enableCORS !== false && cors) { + this.addCORS(cors); + } + if (cacheControl) { + this.addCacheControl(cacheControl); + } + if (compress && headers?.['accept-encoding']?.includes('gzip')) { + this.compress(); + } + } + + /** + * ADD CORS Headers + * + * @param cors CORS Configuration + */ + private addCORS(cors: CORSConfig): void { + const { headers: responseHeaders } = this.response; + + if (responseHeaders) { + for (const [key, value] of Object.entries(cors.headers())) { + responseHeaders[key] = value; + } + } + } + + /** + * ADD Cache-Control Headers + * + * @param cors CORS Configuration + */ + private addCacheControl(cacheControl: string): void { + const { headers: responseHeaders, statusCode } = this.response; + + if (responseHeaders) { + responseHeaders['Cache-Control'] = + statusCode === 200 ? cacheControl : 'no-cache'; + } + } + + /** + * ADD Content-Encoding Headers (for compression) + * + * @param cors CORS Configuration + */ + private compress(): void { + const { headers: responseHeaders, body } = this.response; + + if (responseHeaders) { + responseHeaders['Content-Encoding'] = 'gzip'; + } + if (body) { + this.response.body = zlib.gzipSync(Buffer.from(body)); + } + } +} + +/** + * Base Router + */ +abstract class BaseRouter { + public context: Context = new Map(); + public currentEvent: BaseProxyEvent | undefined; + public lambdaContext?: LambdaContext; + + public addRoute( + method: HTTPMethod, + rule: string, + func: AsyncFunction, + cors?: boolean, + compress?: boolean, + cacheControl?: string, + middlewares?: Middleware[] + ): void { + this.registerRoute( + func, + rule, + method, + cors, + compress, + cacheControl, + middlewares ?? [] + ); + } + + public appendContext(additionalContext?: Context): void { + this.context = new Map([ + ...this.context.entries(), + ...(additionalContext?.entries() || []), + ]); + } + + public clearContext(): void { + this.context?.clear(); + } + + public delete( + rule: string, + middlewares?: Middleware[], + cors?: boolean, + compress?: boolean, + cacheControl?: string + ) { + return ( + target: any, + propertyKey: string, + _descriptor: PropertyDescriptor + ) => { + this.registerRoute( + target[propertyKey], + rule, + 'DELETE', + cors, + compress, + cacheControl, + middlewares ?? [] + ); + }; + } + + public get( + rule: string, + middlewares?: Middleware[], + cors?: boolean, + compress?: boolean, + cacheControl?: string + ) { + return ( + target: any, + propertyKey: string, + _descriptor: PropertyDescriptor + ) => { + this.registerRoute( + target[propertyKey], + rule, + 'GET', + cors, + compress, + cacheControl, + middlewares ?? [] + ); + }; + } + + public patch( + rule: string, + middlewares?: Middleware[], + cors?: boolean, + compress?: boolean, + cacheControl?: string + ) { + return ( + target: any, + propertyKey: string, + _descriptor: PropertyDescriptor + ) => { + this.registerRoute( + target[propertyKey], + rule, + 'PATCH', + cors, + compress, + cacheControl, + middlewares ?? [] + ); + }; + } + + public post( + rule: string, + middlewares?: Middleware[], + cors?: boolean, + compress?: boolean, + cacheControl?: string + ) { + return ( + target: any, + propertyKey: string, + _descriptor: PropertyDescriptor + ) => { + this.registerRoute( + target[propertyKey], + rule, + 'POST', + cors, + compress, + cacheControl, + middlewares ?? [] + ); + }; + } + + public put( + rule: string, + middlewares?: Middleware[], + cors?: boolean, + compress?: boolean, + cacheControl?: string + ) { + return ( + target: any, + propertyKey: string, + _descriptor: PropertyDescriptor + ) => { + this.registerRoute( + target[propertyKey], + rule, + 'PUT', + cors, + compress, + cacheControl, + middlewares ?? [] + ); + }; + } + + public abstract registerRoute( + func: AsyncFunction, + rule: string, + method: HTTPMethod, + cors?: boolean, + compress?: boolean, + cacheControl?: string, + middlewares?: Middleware[] + ): void; + + public route( + rule: string, + method: HTTPMethod, + middlewares?: Middleware[], + cors?: boolean, + compress?: boolean, + cacheControl?: string + ) { + return ( + target: any, + propertyKey: string, + _descriptor: PropertyDescriptor + ) => { + this.registerRoute( + target[propertyKey], + rule, + method, + cors, + compress, + cacheControl, + middlewares ?? [] + ); + }; + } +} + +/** + * Router for APIGatewayroxy Events + */ +class ApiGatewayResolver extends BaseRouter { + public context: Context = new Map(); + public corsEnabled = false; + public corsMethods: HTTPMethod = ['OPTIONS']; + public routeKeys = new Set(); + public routes: Route[] = []; + + public constructor( + public proxyType: ProxyEventType = ProxyEventType.APIGatewayProxyEvent, + public cors?: CORSConfig, + public debug?: boolean, + public stripPrefixes: string[] = [] + ) { + super(); + this.corsEnabled = cors ? true : false; + } + + /** + * Add routes from the router + * + * @param router Event Router + * @param prefix Base HTTP path + */ + public includeRoutes(router: Router, prefix: string): void { + for (const route of router.routes) { + const routeText = + route.rule instanceof RegExp ? route.rule.source : route.rule; + if (prefix) { + const prefixedPath = + prefix === '/' ? routeText : `${prefix}${routeText}`; + route.rule = this.compilePathRegex(prefixedPath); + this.routes.push(route); + this.routeKeys.add(routeText); + } + } + } + + /** + * Standard HTTP 404 Response + * + * @param method HTTP Method + * @returns + */ + public notFoundResponse(method: string): ResponseBuilder { + let headers: Headers = {}; + if (this.cors) { + headers = this.cors.headers(); + if (method === 'OPTIONS') { + headers['Access-Control-Allow-Methods'] = (this.corsMethods as string[]) + .sort() + .join(','); + + return new ResponseBuilder( + new Response(204, undefined, '', (headers = headers)) + ); + } + } + + return new ResponseBuilder( + new Response( + 404, + 'application/json', + JSON.stringify({ statusCode: 404, message: 'Not Found' }), + headers + ) + ); + } + + /** + * Register an HTTP route to the Router + * + * @param func Handler funnction + * @param rule Path pattern + * @param method HTTP method + * @param cors CORS enabled/disabled + * @param compress Compression enabled/disabled + * @param cacheControl Cache-Control configuration + * @param middlewares Middlewares that applies for this route + */ + public registerRoute( + func: AsyncFunction, + rule: string, + method: HTTPMethod, + cors?: boolean, + compress?: boolean, + cacheControl?: string, + middlewares?: Middleware[] + ): void { + const corsEnabled = cors ?? this.corsEnabled; + for (const item of [method].flat()) { + this.routes.push( + new Route( + method, + this.compilePathRegex(rule), + func, + corsEnabled, + compress, + cacheControl, + middlewares ?? [] + ) + ); + this.routeKeys.add(`${method}_${rule}`); + if (corsEnabled) { + (this.corsMethods as string[]).push(item.toUpperCase()); + } + } + } + + /** + * Resolves the HTTP route to invoke for the incoming event and processes it + * + * @param event Incoming Event + * @param context Lambda Context + * @returns Response from route + */ + public async resolve( + event: BaseProxyEvent, + context: LambdaContext + ): Promise { + this.currentEvent = event; + this.lambdaContext = context; + + return (await this._resolve()).build( + this.currentEvent as BaseProxyEvent, + this.cors + ); + } + + private async _resolve(): Promise { + const method = this.currentEvent?.httpMethod?.toUpperCase() as string; + const path = this.removePrefix(this.currentEvent?.path as string); + this.routes.sort((a, b) => + b.rule.toString().localeCompare(a.rule.toString()) + ); + for (const route of this.routes) { + if (!route.method.includes(method)) { + continue; + } + if (route.rule instanceof RegExp) { + const matches = path.match(route.rule); + if (matches) { + return this.callRoute(route, this.currentEvent, this.lambdaContext, { + ...matches.groups, + }); + } + } + } + + return this.notFoundResponse(method); + } + + private async callRoute( + route: Route, + event: BaseProxyEvent | undefined, + context: LambdaContext | undefined, + _args: ArgsDict + ): Promise { + return new ResponseBuilder( + this.toResponse( + await wrapWithMiddlewares( + route.middlewares, + route.func as AsyncFunction, + _args + )(event as BaseProxyEvent, context as LambdaContext, _args) + ), + route + ); + } + + private compilePathRegex( + rule: string, + baseRegex = _ROUTE_REGEX + ): PathPattern { + const ruleRegex = rule.replace( + _DYNAMIC_ROUTE_PATTERN, + _NAMED_GROUP_BOUNDARY_PATTERN + ); + + return new RegExp(baseRegex.replace('{}', ruleRegex)); + } + + private removePrefix(path: string): string { + if (this.stripPrefixes) { + for (const prefix of this.stripPrefixes) { + if (path === prefix) { + return '/'; + } + if (path.startsWith(prefix)) { + return path.slice(prefix.length); + } + } + } + + return path; + } + + private toResponse(result: Response | JSONData): Response { + if (result instanceof Response) { + return result; + } + + if ( + result && + typeof result == 'object' && + 'statusCode' in result && + 'body' in result + ) { + const response = result as unknown as ResponseInterface; + const contentType = + lookupKeyFromMap(response.headers, 'Content-Type') ?? + response.contentType ?? + 'application/json'; + + return new Response( + response.statusCode, + contentType, + response.body, + response.headers + ); + } + + return new Response(200, 'application/json', JSON.stringify(result)); + } +} + +/** + * Simple Router + */ +class Router extends BaseRouter { + public routes: Route[] = []; + + public registerRoute( + func: AsyncFunction, + rule: string, + method: HTTPMethod, + cors?: boolean, + compress?: boolean, + cacheControl?: string, + middlewares?: Middleware[] + ): void { + this.routes.push( + new Route( + method, + rule, + func, + cors, + compress, + cacheControl, + middlewares ?? [] + ) + ); + } + + public route( + method: HTTPMethod, + rule: string, + middlewares?: Middleware[], + cors?: boolean, + compress?: boolean, + cacheControl?: string + ): any { + return ( + _target: any, + _propertyKey: string, + _descriptor: PropertyDescriptor + ) => { + this.registerRoute( + _target[_propertyKey], + rule, + method, + cors, + compress, + cacheControl, + middlewares ?? [] + ); + }; + } +} diff --git a/packages/event-handler/src/helpers/TestServer.ts b/packages/event-handler/src/helpers/TestServer.ts new file mode 100644 index 0000000000..3247dd30af --- /dev/null +++ b/packages/event-handler/src/helpers/TestServer.ts @@ -0,0 +1,214 @@ +import { Context, Handler as AWSHandler } from 'aws-lambda'; +import { Handler } from '../middleware'; +import { + IncomingMessage, + createServer, + ServerResponse, + Server, +} from 'node:http'; +import { + BaseProxyEvent, + MultiValueHeaders, + Response, + Headers, + QueryStringParameters, + MultiValueQueryStringParameters, + ResponseInterface, + ContentType, +} from '../types'; +import { lookupKeyFromMap } from '../utils'; + +const TEST_SERVER_PORT = Number(process.env.TEST_SERVER_PORT) || 4000; +process.env.MODE = 'LOCAL'; + +/** + * A simplistic HTTP test server for local testing + * + * @category Local Testing + */ +class LocalTestServer { + /** AWS Lambda handler function */ + public handlerFn: Handler; + + /** An HTTP Server */ + private server: Server; + + /** instance of the `LocalTestServer` */ + private static server: LocalTestServer; + + private constructor(handlerFn: Handler | AWSHandler) { + this.handlerFn = handlerFn as unknown as Handler; + this.server = createServer(); + this.registerHandler(); + } + + /** Creates a singleton instance of `LocalTestServer` and returns it + * + * @param handlerFn AWS Lambda handler function that the test server routes requests to + * @returns an instance of `LocalTestServer` + */ + public static get(handlerFn: Handler | AWSHandler): LocalTestServer { + if (!this.server) { + this.server = new LocalTestServer(handlerFn); + } + + return this.server; + } + + /** Starts the HTTP server + * + * @param port HTTP server port + */ + public start(port: number = TEST_SERVER_PORT): void { + this.server.listen(port, () => + console.log(`Test server listening on port ${port}`) + ); + } + + /** Creates an AWS Lambda function aligned HTTP request (APIGateway Proxy request) + * + * @param req incoming HTTP request + * @returns a Base proxy event (APIGatewqy Proxy request) + * + * @internal + */ + private async constructRequestEvent( + req: IncomingMessage + ): Promise { + // Extract URL, method, and headers + const { url, method, headers: reqHeaders } = req; + + // Extract path and query parameters + const [path, queryString] = (url as string).split('?'); + const queryStringParameters: QueryStringParameters = {}; + const multiValueQueryStringParameters: MultiValueQueryStringParameters = {}; + if (queryString) { + const qpTokens = queryString.split('&'); + for (const token of qpTokens) { + const [key, value] = token.split('='); + const values = value.split(',').map((s) => s.trim()); + if (values.length > 1) { + multiValueQueryStringParameters[key] = values; + } else { + queryStringParameters[key] = value; + } + } + } + + // Extract headers + const headers: Headers = {}; + const multiValueHeaders: MultiValueHeaders = {}; + for (const [key, value] of Object.entries(reqHeaders)) { + const values = (value as string).split(',').map((s) => s.trim()); + if (values.length > 1) { + multiValueHeaders[key] = values; + } else { + headers[key] = value as string; + } + } + + // Extract body + const bodyChunks: Buffer[] = []; + for await (const chunk of req) { + bodyChunks.push(chunk); + } + const body = Buffer.concat(bodyChunks).toString(); + if (!headers['content-length']) { + headers['content-length'] = String(Buffer.byteLength(body)); + } + + // Construct base proxy event + const event = { + httpMethod: method as string, + path, + headers, + multiValueHeaders, + queryStringParameters, + multiValueQueryStringParameters, + body, + requestContext: {}, + } as BaseProxyEvent; + + return event; + } + + /** Creates an HTTP server response from the AWS Lambda handler's response. + * + * @param handlerResponse response from the Handler + * @param res HTTP response + * @param contentType HTTP content type + * + * @internal + */ + private constructResponse( + handlerResponse: Response, + res: ServerResponse, + contentType?: ContentType + ): void { + res.statusCode = handlerResponse.statusCode; + const cType = contentType || 'application/json'; + + if (handlerResponse.headers) { + for (const [key, value] of Object.entries(handlerResponse.headers)) { + res.setHeader(key, value as string); + } + } + let body = ''; + if (handlerResponse.body) { + if (typeof handlerResponse.body !== 'string') { + body = JSON.stringify(handlerResponse.body); + res.setHeader('content-type', cType); + } else { + body = handlerResponse.body as string; + } + } + res.setHeader('content-length', String(Buffer.byteLength(body))); + res.write(body); + } + + /** Registers the AWS Lambda handler function to the HTTP server + * + */ + private registerHandler(): void { + this.server.on( + 'request', + async (req: IncomingMessage, res: ServerResponse) => { + const event = await this.constructRequestEvent(req); + const handlerResponse = await this.handlerFn(event, {} as Context); + this.constructResponse( + this.toResponse(handlerResponse as Response), + res + ); + res.end(); + } + ); + } + + /** + * Convert to HTTP Response format + * + * @param result result from the handler function + * @param contentType HTTP content type + * @returns standard HTTP response structure for synchronous AWS Lambda function + */ + private toResponse(result: Response, contentType?: ContentType): Response { + if (result instanceof Response) { + return result; + } + + const response = result as ResponseInterface; + const cType = + contentType ?? + lookupKeyFromMap(response.headers, 'Content-Type') ?? + 'application/json'; + + return new Response( + response.statusCode, + cType, + response.body, + response.headers + ); + } +} + +export { LocalTestServer, TEST_SERVER_PORT }; diff --git a/packages/event-handler/src/helpers/index.ts b/packages/event-handler/src/helpers/index.ts new file mode 100644 index 0000000000..062b64e36a --- /dev/null +++ b/packages/event-handler/src/helpers/index.ts @@ -0,0 +1 @@ +export * from './TestServer'; diff --git a/packages/event-handler/src/index.ts b/packages/event-handler/src/index.ts new file mode 100644 index 0000000000..94c6289087 --- /dev/null +++ b/packages/event-handler/src/index.ts @@ -0,0 +1,5 @@ +export * from './ApiGateway'; +export * from './helpers'; +export * from './middleware'; +export * from './types'; +export * from './utils'; diff --git a/packages/event-handler/src/middleware.ts b/packages/event-handler/src/middleware.ts new file mode 100644 index 0000000000..5ad1911deb --- /dev/null +++ b/packages/event-handler/src/middleware.ts @@ -0,0 +1,50 @@ +import { Context } from 'aws-lambda'; +import { ArgsDict, BaseProxyEvent, Response } from './types'; +import { JSONData } from './types'; + +/** HTTP middleware function that wraps the route invocation in a AWS Lambda function */ +type Middleware = ( + event: BaseProxyEvent, + context: Context, + args: ArgsDict, + next: () => Promise +) => Promise; + +/** Model for an AWS Lambda HTTP handler function */ +type Handler = ( + event: BaseProxyEvent, + context: Context, + args?: ArgsDict +) => Promise; + +/** Wraps the AWS Lambda handler function with the provided middlewares. + * + * @remarks + * The middewares are stacked in a classic onion-like pattern, + * + * @typeParam T - the response type of the handler function + * + * @param middlewares middlewares that must be wrapped around the handler + * @param handler the handler function + * @param args arguments for the handler function + * @returns a handler function that is wrapped around the middlewares + * + * @internal + */ +const wrapWithMiddlewares = + ( + middlewares: Middleware[], + handler: Handler, + args: ArgsDict + ): Handler => + async (event: BaseProxyEvent, context: Context): Promise => { + const chain = middlewares.reduceRight( + (next: () => Promise, middleware: Middleware) => () => + middleware(event, context, args, next), + () => handler(event, context, args) + ); + + return await chain(); + }; + +export { Handler, Middleware, wrapWithMiddlewares }; diff --git a/packages/event-handler/src/types/BaseProxyEvent.ts b/packages/event-handler/src/types/BaseProxyEvent.ts new file mode 100644 index 0000000000..deca334f8f --- /dev/null +++ b/packages/event-handler/src/types/BaseProxyEvent.ts @@ -0,0 +1,63 @@ +import { APIGatewayProxyEvent } from 'aws-lambda'; +import { + Headers, + JSONData, + MultiValueHeaders, + QueryStringParameters, + MultiValueQueryStringParameters, +} from './common'; + +/** + * Base model for an HTTP Gateway Proxy event + * + * @category Model + */ +interface HTTPBaseProxyEvent { + /** HTTP URL path */ + path?: string; + + /** JSON stringified Request body */ + body: string | null; + + /** HTTP Headers */ + headers: Headers; + + /** HTTP Multi-value headers */ + multiValueHeaders?: MultiValueHeaders; + + /** HTTP Request body transformed after parsing based on a schema */ + parsedBody?: unknown; + + /** HTTP MEthod */ + httpMethod: string; + + /** base-64 encoded indicator */ + isBase64Encoded: boolean; + + /** HTTP Query parameters */ + queryStringParameters?: QueryStringParameters; + + /** HTTP multi-value Query parameter */ + multiValueQueryStringParameters?: MultiValueQueryStringParameters; +} + +/** Base type for HTTP Proxy event */ +type BaseProxyEvent = HTTPBaseProxyEvent | APIGatewayProxyEvent; + +/** + * Model for a HTTP Proxy event + * + * @category Model + */ +abstract class HTTPProxyEvent implements HTTPBaseProxyEvent { + public body: string | null = null; + public headers: Headers = {}; + public httpMethod = ''; + public isBase64Encoded = false; + public jsonData?: JSONData; + public multiValueHeaders?: MultiValueHeaders; + public multiValueQueryStringParameters?: MultiValueQueryStringParameters; + public queryStringParameters?: QueryStringParameters; +} + +export { BaseProxyEvent, HTTPBaseProxyEvent, HTTPProxyEvent }; diff --git a/packages/event-handler/src/types/CorsConfig.ts b/packages/event-handler/src/types/CorsConfig.ts new file mode 100644 index 0000000000..8b2703dfe5 --- /dev/null +++ b/packages/event-handler/src/types/CorsConfig.ts @@ -0,0 +1,69 @@ +import { Headers } from './common'; + +/** + * CORS Configuration + * + * @category Model + */ +class CORSConfig { + private readonly REQUIRED_HEADERS = [ + 'Authorization', + 'Content-Type', + 'X-Amz-Date', + 'X-Api-Key', + 'X-Amz-Security-Token', + ]; + + /** + * Create CORS Configuration + * @param allowOrigin comma separated list of HTTP origins to allow (`Access-Control-Allow-Origin`). Defaults to "*". + * @param allowHeaders list of headers to allow (`Access-Control-Allow-Headers`) + * @param exposeHeaders list of headers to allow (`Access-Control-Expose-Headers`) + * @param max_age time in seconds until which the response is treated as fresh (`max-age`) + * @param allowCredentials sets the HTTP header `Access-Control-Allow-Credentials` + */ + public constructor( + public allowOrigin: string = '*', + public allowHeaders: string[] = [], + public exposeHeaders: string[] = [], + public max_age?: number, + public allowCredentials: boolean = false + ) { + if (allowHeaders.includes('*')) { + this.allowHeaders = ['*']; + } else { + this.allowHeaders = [ + ...new Set([...this.REQUIRED_HEADERS, ...allowHeaders]), + ]; + } + } + + /** + * Generates the CORS headers based on the CORS configuration + * + * @returns CORS Headers + * + */ + public headers(): Headers { + const headers: Headers = { + 'Access-Control-Allow-Origin': this.allowOrigin, + 'Access-Control-Allow-Headers': this.allowHeaders.join(','), + }; + + if (this.exposeHeaders.length > 0) { + headers['Access-Control-Expose-Headers'] = this.exposeHeaders.join(','); + } + + if (this.max_age) { + headers['Access-Control-Max-Age'] = this.max_age.toString(); + } + + if (this.allowCredentials) { + headers['Access-Control-Allow-Credentials'] = 'true'; + } + + return headers; + } +} + +export { CORSConfig }; diff --git a/packages/event-handler/src/types/Response.ts b/packages/event-handler/src/types/Response.ts new file mode 100644 index 0000000000..88ab97e130 --- /dev/null +++ b/packages/event-handler/src/types/Response.ts @@ -0,0 +1,31 @@ +import { Body, Headers } from './common'; + +/** + * Response model Interface + */ +interface ResponseInterface { + body: Body; + statusCode: number; + headers: Headers; + contentType?: string; +} + +/** + * Standard model for HTTP Proxy response + */ +class Response { + public constructor( + public statusCode: number, + public contentType?: string, + public body?: Body, + public headers: Headers = {}, + public cookies: string[] = [], + public base64Encoded: boolean = false + ) { + if (contentType) { + this.headers['Content-Type'] = contentType; + } + } +} + +export { Body, Response, ResponseInterface }; diff --git a/packages/event-handler/src/types/Route.ts b/packages/event-handler/src/types/Route.ts new file mode 100644 index 0000000000..949f129a95 --- /dev/null +++ b/packages/event-handler/src/types/Route.ts @@ -0,0 +1,27 @@ +import { Middleware } from '../middleware'; +import { PathPattern, HTTPMethod, Path, AsyncFunction } from './common'; + +/** + * HTTP Router model + * + * A `route` defines the HTTP method & URL pattern (an HTTP endpoint) and the associated function that must be called when the event handler resolves the incoming HTTP request. + * Also, it provides endpoint-level additional configuration to enable CORS, compression & setup middlewares. + */ +class Route { + public constructor( + public method: HTTPMethod, + public rule: Path | PathPattern, + public func: AsyncFunction, + public cors = false, + public compress = false, + public cacheControl?: string, + public middlewares: Middleware[] = [] + ) { + if (typeof method === 'string') { + this.method = [method.toUpperCase()]; + } else { + this.method = method.map((m) => m.toUpperCase()); + } + } +} +export { Route }; diff --git a/packages/event-handler/src/types/common.ts b/packages/event-handler/src/types/common.ts new file mode 100644 index 0000000000..ac12d25a93 --- /dev/null +++ b/packages/event-handler/src/types/common.ts @@ -0,0 +1,59 @@ +import { APIGatewayProxyEvent } from 'aws-lambda'; + +type ArgsDict = Record | undefined; + +type Headers = Record; + +type MultiValueHeaders = Record; + +type PathParameters = Record; + +type QueryStringParameters = Record; + +type MultiValueQueryStringParameters = Record; + +type Path = string; + +type PathPattern = RegExp; + +type Body = string | Buffer | undefined; + +type OptionalString = string | undefined | null; + +type JSONData = Record | undefined; + +type ContentType = + | 'text/html' + | 'text/plain' + | 'application/xml' + | 'application/json' + | 'application/xhtml+xml'; + +type Context = Map; + +type HTTPMethod = string | string[]; + +type BaseAPIGatewayProxyEvent = Omit; + +type AsyncFunction = ( + ...args: unknown[] +) => Promise T>>; + +export { + ArgsDict, + AsyncFunction, + BaseAPIGatewayProxyEvent, + Body, + ContentType, + Context, + HTTPMethod, + Headers, + JSONData, + MultiValueHeaders, + MultiValueQueryStringParameters, + OptionalString, + Path, + PathParameters, + PathPattern, + QueryStringParameters, +}; diff --git a/packages/event-handler/src/types/index.ts b/packages/event-handler/src/types/index.ts new file mode 100644 index 0000000000..14baf68b19 --- /dev/null +++ b/packages/event-handler/src/types/index.ts @@ -0,0 +1,5 @@ +export * from './BaseProxyEvent'; +export * from './CorsConfig'; +export * from './Response'; +export * from './Route'; +export * from './common'; diff --git a/packages/event-handler/src/utils.ts b/packages/event-handler/src/utils.ts new file mode 100644 index 0000000000..74b21b1cf1 --- /dev/null +++ b/packages/event-handler/src/utils.ts @@ -0,0 +1,31 @@ +/** Key-Value pairs Type typically used for HTTP Headers, Query parameters, path parameters. */ +type MapType = { [name: string]: string | undefined } | null; + +/** + * Looks up the value for the key from the provided key-value pairs + * + * @param map key-value pair object + * @param lookupKey the key that must be looked up in the key-value pair + * @returns the value for the key + * + * @example + * ```ts + * const contentType = lookupKeyFromMap(response.headers, 'Content-Type') + * ``` + * + * @category General Use + */ +const lookupKeyFromMap = ( + map: MapType, + lookupKey: string +): T | undefined => { + if (!map) return; + const lowercaseLookupKey = lookupKey.toLowerCase(); + for (const [key, value] of Object.entries(map)) { + if (key.toLowerCase() === lowercaseLookupKey) { + return value as T; + } + } +}; + +export { MapType, lookupKeyFromMap }; diff --git a/packages/event-handler/tests/unit/ApiGateway.test.ts b/packages/event-handler/tests/unit/ApiGateway.test.ts new file mode 100644 index 0000000000..5fdfb4ecda --- /dev/null +++ b/packages/event-handler/tests/unit/ApiGateway.test.ts @@ -0,0 +1,580 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { jest } from '@jest/globals'; +import { APIGatewayProxyEvent, Context } from 'aws-lambda'; +/** + * Test Logger class + * + * @group unit/event-handler/class/corsconfig/all + */ +import { + ApiGatewayResolver, + ProxyEventType, + ResponseBuilder, + Router, +} from '../../src/ApiGateway'; +import { Response } from '../../src/types/Response'; +import { AsyncFunction, CORSConfig, JSONData, Route } from '../../src/types'; +import { Middleware } from '../../src/middleware'; + +describe('Class: ApiGateway', () => { + let app: ApiGatewayResolver; + const testFunc: AsyncFunction = ( + ..._args: unknown[] + ): Promise => Promise.resolve(new Response(200)); + + beforeAll(() => { + app = new ApiGatewayResolver(); + }); + + describe.each([ + ['GET', '/', '/', 200], + ['GET', '/single', '/single', 200], + ['GET', '/two/paths', '/two/paths', 200], + ['GET', '/multiple/paths/in/url', '/multiple/paths/in/url', 200], + ['GET', '/test', '/invalid/url', 404], + ['POST', '/single', '/single', 200], + ['PUT', '/single', '/single', 200], + ['PATCH', '/single', '/single', 200], + ['DELETE', '/single', '/single', 200], + ['GET', '/single/', '/single/1234', 200, { single_id: '1234' }], + ['GET', '/single/test', '/single/test', 200], + ['GET', '/single/', '/invalid/1234', 404], + [ + 'GET', + '/single//double/', + '/single/1234/double/5678', + 200, + { single_id: '1234', double_id: '5678' }, + ], + [ + 'GET', + '/single//double/', + '/single/1234/invalid/5678', + 404, + ], + [ + 'GET', + '/single//double/', + '/single/1234/invalid/5678', + 404, + ], + ])( + 'Pattern Match:', + ( + routeMethod: string, + routeRule: string, + testPath: string, + expectedHTTPCode: number, + expectedPathParams?: { [k: string]: string } + ) => { + beforeAll(() => { + app.addRoute(routeMethod, routeRule, testFunc); + }); + describe('Feature: Router URL Pattern matching (Manual)', () => { + test( + expectedHTTPCode == 200 + ? `should resolve method: ${routeMethod} rule:${routeRule}` + : `should not resolve invalid path:${routeRule} testPath: ${testPath}`, + async () => { + const event = { + httpMethod: routeMethod, + path: testPath, + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + app + .resolve(event, {} as Context) + .then((response) => + expect(response?.['statusCode']).toEqual(expectedHTTPCode) + ); + } + ); + }); + + describe('Feature: Router Path-parameter resolving', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spyCallRoute = jest + .spyOn(ApiGatewayResolver.prototype as any, 'callRoute') + .mockImplementation(() => new ResponseBuilder(new Response(200))); + + beforeEach(() => { + spyCallRoute.mockClear(); + }); + + afterAll(() => { + spyCallRoute.mockReset(); + }); + + test(`should resolve path parameters in method: ${routeMethod} rule:${routeRule}`, async () => { + const event = { + httpMethod: routeMethod, + path: testPath, + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + await app.resolve(event, {} as Context); + if (expectedHTTPCode == 200) { + expect(spyCallRoute).toHaveBeenCalled(); + if (expectedPathParams) { + expect([...spyCallRoute.mock.calls[0]][3]).toEqual( + expectedPathParams + ); + } + } + }); + }); + } + ); + + describe.each([ + ['GET', '/', '/', 200], + ['GET', '/single', '/single', 200], + ['GET', '/two/paths', '/two/paths', 200], + ['GET', '/multiple/paths/in/url', '/multiple/paths/in/url', 200], + ['GET', '/test', '/invalid/url', 404], + ['POST', '/single', '/single', 200], + ['PUT', '/single', '/single', 200], + ['PATCH', '/single', '/single', 200], + ['DELETE', '/single', '/single', 200], + ])( + '(Decorator) Pattern Match:', + ( + routeMethod: string, + routeRule: string, + testPath: string, + expectedHTTPCode: number, + expectedPathParams?: { [k: string]: string } + ) => { + const app: ApiGatewayResolver = new ApiGatewayResolver(); + + beforeAll(() => { + // app.addRoute(routeMethod, routeRule, testFunc); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class TestRouter { + @app.route(routeRule, routeMethod) + public test(): void { + 0; + } + } + }); + describe('Feature: Router URL Pattern matching (Decorator)', () => { + test( + expectedHTTPCode == 200 + ? `should resolve method: ${routeMethod} rule:${routeRule}` + : `should not resolve invalid path:${routeRule} testPath: ${testPath}`, + async () => { + const event = { + httpMethod: routeMethod, + path: testPath, + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + app + .resolve(event, {} as Context) + .then((response) => + expect(response?.['statusCode']).toEqual(expectedHTTPCode) + ); + } + ); + }); + + describe('Feature: Router Path-parameter resolving', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spyCallRoute = jest + .spyOn(ApiGatewayResolver.prototype as any, 'callRoute') + .mockImplementation(() => new ResponseBuilder(new Response(200))); + + beforeEach(() => { + spyCallRoute.mockClear(); + }); + + afterAll(() => { + spyCallRoute.mockReset(); + }); + + test(`should resolve path parameters in method: ${routeMethod} rule:${routeRule}`, async () => { + const event = { + httpMethod: routeMethod, + path: testPath, + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + await app.resolve(event, {} as Context); + if (expectedHTTPCode == 200) { + expect(spyCallRoute).toHaveBeenCalled(); + if (expectedPathParams) { + expect([...spyCallRoute.mock.calls[0]][3]).toEqual( + expectedPathParams + ); + } + } + }); + }); + } + ); + + describe('Route Convenient HTTP method decorators test', () => { + const app: ApiGatewayResolver = new ApiGatewayResolver(); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class TestRouter { + @app.delete('/test') + public deleteTest(): Response { + return new Response(200); + } + + @app.get('/test') + public getTest(): Response { + return new Response(200); + } + + @app.patch('/test') + public patchTest(): Response { + return new Response(200); + } + + @app.post('/test') + public postTest(): Response { + return new Response(200); + } + + @app.put('/test') + public putTest(): Response { + return new Response(200); + } + } + + describe.each([ + ['GET', '/test', '/test', 200], + ['POST', '/test', '/test', 200], + ['PUT', '/test', '/test', 200], + ['PATCH', '/test', '/test', 200], + ['DELETE', '/test', '/test', 200], + ])( + '(Decorator) Pattern Match:', + ( + routeMethod: string, + routeRule: string, + testPath: string, + expectedHTTPCode: number + ) => { + test(`should resolve ${routeMethod} configured through decorators`, async () => { + const event = { + httpMethod: routeMethod, + path: testPath, + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + app + .resolve(event, {} as Context) + .then((response) => + expect(response?.['statusCode']).toEqual(expectedHTTPCode) + ); + }); + } + ); + }); + + describe('Feature: Multi-routers resolving', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spyCallRoute = jest + .spyOn(ApiGatewayResolver.prototype as any, 'callRoute') + .mockImplementation(() => new ResponseBuilder(new Response(200))); + let multiRouterApp: ApiGatewayResolver; + const stripPrefixes = ['/base-path']; + beforeEach(() => { + spyCallRoute.mockClear(); + multiRouterApp = new ApiGatewayResolver( + ProxyEventType.APIGatewayProxyEventV2, + new CORSConfig('*', ['test_header']), + false, + stripPrefixes + ); + }); + + afterEach(() => { + spyCallRoute.mockReset(); + }); + + test(`should resolve path when one router is added to BaseRouter`, async () => { + const event = { + httpMethod: 'GET', + path: '/v1/multi/one', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route('GET', '/multi/one', testFunc); + const router = new Router(); + router.registerRoute( + route.func, + route.rule as string, + route.method, + route.cors, + route.compress, + route.cacheControl + ); + + multiRouterApp.includeRoutes(router, '/v1'); + multiRouterApp + .resolve(event, {} as Context) + .then((response) => expect(response?.['statusCode']).toEqual(200)); + }); + + test(`should resolve path when one router is added to BaseRouter with Cors Configuration`, async () => { + const event = { + httpMethod: 'GET', + path: '/v1/multi/one', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route('GET', '/v1/multi/one', testFunc, true); + multiRouterApp.registerRoute( + route.func, + route.rule as string, + route.method, + route.cors, + route.compress, + route.cacheControl + ); + + multiRouterApp + .resolve(event, {} as Context) + .then((response) => expect(response?.['statusCode']).toEqual(200)); + }); + + test(`should resolve any path after stripping prefix`, async () => { + const event = { + httpMethod: 'GET', + path: '/base-path/v1/multi/one', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route('GET', new RegExp('/multi/one'), testFunc); + const router = new Router(); + router.registerRoute( + route.func, + route.rule as string, + route.method, + route.cors, + route.compress, + route.cacheControl + ); + + multiRouterApp.includeRoutes(router, '/v1'); + multiRouterApp + .resolve(event, {} as Context) + .then((response) => expect(response?.['statusCode']).toEqual(200)); + }); + + test(`should resolve base path / after stripping prefix`, async () => { + const event = { + httpMethod: 'GET', + path: '/base-path', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route('GET', '/', testFunc); + const router = new Router(); + router.registerRoute( + route.func, + route.rule as string, + route.method, + route.cors, + route.compress, + route.cacheControl + ); + + multiRouterApp.includeRoutes(router, '/'); + multiRouterApp + .resolve(event, {} as Context) + .then((response) => expect(response?.['statusCode']).toEqual(200)); + }); + + test(`should resolve options method`, async () => { + const event = { + httpMethod: 'OPTIONS', + path: '/base-path', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route('GET', '/', testFunc); + const router = new Router(); + router.registerRoute( + route.func, + route.rule as string, + route.method, + route.cors, + route.compress, + route.cacheControl + ); + + multiRouterApp.includeRoutes(router, '/'); + multiRouterApp + .resolve(event, {} as Context) + .then((response) => expect(response?.['statusCode']).toEqual(204)) + .catch((e) => console.log(`error = ${e}`)); + }); + + test(`should resolve path when multiple router is added to BaseRouter`, async () => { + const route = new Route('GET', '/multi/one', testFunc); + const router_1 = new Router(); + router_1.registerRoute( + route.func, + route.rule as string, + route.method, + route.cors, + route.compress, + route.cacheControl + ); + + const router_2 = new Router(); + router_2.registerRoute( + route.func, + route.rule as string, + route.method, + route.cors, + route.compress, + route.cacheControl + ); + + multiRouterApp.includeRoutes(router_1, '/v1'); + multiRouterApp.includeRoutes(router_2, '/v2'); + + const event = { + httpMethod: 'GET', + path: '/v1/multi/one', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + multiRouterApp + .resolve(event, {} as Context) + .then((response) => expect(response?.['statusCode']).toEqual(200)); + + event.path = '/v2/multi/one'; + multiRouterApp.resolve(event, {} as Context).then((response) => { + expect(response?.['statusCode']).toEqual(200); + }); + }); + }); + + describe('Feature: Middlewares', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spyCallRoute = jest + .spyOn(ApiGatewayResolver.prototype as any, 'callRoute') + .mockImplementation(() => new ResponseBuilder(new Response(200))); + let multiRouterApp: ApiGatewayResolver; + const stripPrefixes = ['/base-path']; + beforeEach(() => { + spyCallRoute.mockClear(); + multiRouterApp = new ApiGatewayResolver( + ProxyEventType.APIGatewayProxyEventV2, + new CORSConfig('*', ['test_header']), + false, + stripPrefixes + ); + }); + + afterEach(() => { + spyCallRoute.mockReset(); + }); + + test(`should resolve path when one router is added to BaseRouter`, async () => { + const event = { + httpMethod: 'GET', + path: '/v1/multi/one', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route('GET', '/multi/one', testFunc); + const router = new Router(); + + const TestMiddleware = + (): Middleware => + async (event, context, args, next): Promise => + await next(); + + router.registerRoute( + route.func, + route.rule as string, + route.method, + route.cors, + route.compress, + route.cacheControl, + [TestMiddleware()] + ); + + multiRouterApp.includeRoutes(router, '/v1'); + multiRouterApp + .resolve(event, {} as Context) + .then((response) => expect(response?.['statusCode']).toEqual(200)); + }); + }); + + describe('Feature: Resolver context', () => { + let app: ApiGatewayResolver; + + beforeAll(() => { + app = new ApiGatewayResolver(); + }); + + test('should be able to add additional context to resolver', () => { + app.clearContext(); + app.appendContext(new Map()); + app.appendContext(new Map([['test_context', 'test_value']])); + app.appendContext(new Map([['test_context', 'test_value']])); + expect(app.context).toBeDefined(); + app.appendContext(new Map([['add_context', 'add_value']])); + app.appendContext(new Map()); + app.appendContext(undefined); + app.clearContext(); + }); + }); +}); diff --git a/packages/event-handler/tests/unit/ResponseBuilder.test.ts b/packages/event-handler/tests/unit/ResponseBuilder.test.ts new file mode 100644 index 0000000000..fda6eec560 --- /dev/null +++ b/packages/event-handler/tests/unit/ResponseBuilder.test.ts @@ -0,0 +1,315 @@ +/** + * ResponseBuilder tests + * + * @group unit/event-handler/class/responsebuilder/all + */ +import zlib from 'node:zlib'; +import { APIGatewayProxyEvent, APIGatewayProxyEventHeaders } from 'aws-lambda'; +import { ResponseBuilder } from '../../src/ApiGateway'; +import { CORSConfig, Response, Route, Headers, AsyncFunction } from '../../src'; + +describe('Class: ResponseBuilder', () => { + const testFunc: AsyncFunction = (_args: unknown): Promise => + Promise.resolve(''); + + const testOrigin = 'test_origin'; + const allowHeaders = ['test_header']; + const cacheControl = 'no-store'; + + describe('Feature: CORS Handling', () => { + test('should provide a basic response if there is no route specific configuration', () => { + const event = { + httpMethod: 'GET', + path: '/test', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const response = new Response(200); + const responseBuilder = new ResponseBuilder(response); + const corsConfig = new CORSConfig(testOrigin, allowHeaders); + const jsonData = responseBuilder.build(event, corsConfig); + expect(jsonData).toBeDefined(); + if (jsonData) { + expect(jsonData['statusCode']).toEqual(200); + expect(jsonData['isBase64Encoded']).toEqual(false); + const headers = jsonData['headers'] as Headers; + expect(headers).toBeDefined(); + if (headers) { + expect(headers['Access-Control-Allow-Origin']).toBeUndefined(); + expect(headers['Access-Control-Allow-Headers']).toBeUndefined(); + expect(headers['Cache-Control']).toBeUndefined(); + expect(headers['Content-Encoding']).toBeUndefined(); + } + } + }); + + test('should add cors headers if the route has CORS configuration', () => { + const event = { + httpMethod: 'GET', + path: '/test', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route('GET', '/test', testFunc, true, false); + const response = new Response(200); + const responseBuilder = new ResponseBuilder(response, route); + const corsConfig = new CORSConfig(testOrigin, allowHeaders); + const jsonData = responseBuilder.build(event, corsConfig); + expect(jsonData).toBeDefined(); + if (jsonData) { + const headers = jsonData['headers'] as Headers; + expect(headers).toBeDefined(); + if (headers) { + expect(headers['Access-Control-Allow-Origin']).toEqual(testOrigin); + expect(headers['Access-Control-Allow-Headers']).toBeDefined(); + } + } + }); + + test('should not cors headers if the route has no CORS configuration', () => { + const event = { + httpMethod: 'GET', + path: '/test', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route('GET', '/test', testFunc, true, false); + const response = new Response(200); + const responseBuilder = new ResponseBuilder(response, route); + const jsonData = responseBuilder.build(event); + expect(jsonData).toBeDefined(); + if (jsonData) { + const headers = jsonData['headers'] as Headers; + expect(headers).toBeDefined(); + if (headers) { + expect(headers['Access-Control-Allow-Origin']).toBeUndefined(); + expect(headers['Access-Control-Allow-Headers']).toBeUndefined(); + } + } + }); + + test('should add cache-control headers for HTTP OK if the route has cache-control enabled', () => { + const event = { + httpMethod: 'GET', + path: '/test', + body: null, + headers: { + 'test-header': 'header-value', + } as APIGatewayProxyEventHeaders, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route( + 'GET', + '/test', + testFunc, + false, + false, + cacheControl + ); + const response = new Response(200); + const responseBuilder = new ResponseBuilder(response, route); + const corsConfig = new CORSConfig(testOrigin, allowHeaders); + const jsonData = responseBuilder.build(event, corsConfig); + expect(jsonData).toBeDefined(); + if (jsonData) { + const headers = jsonData['headers'] as Headers; + expect(headers).toBeDefined(); + if (headers) { + expect(headers['Cache-Control']).toEqual(cacheControl); + } + } + }); + + test('should add no-cache header for non success response', () => { + const event = { + httpMethod: '', + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route( + 'GET', + '/test', + testFunc, + false, + false, + cacheControl + ); + const response = new Response(500); + const responseBuilder = new ResponseBuilder(response, route); + const corsConfig = new CORSConfig(testOrigin, allowHeaders); + const jsonData = responseBuilder.build(event, corsConfig); + expect(jsonData).toBeDefined(); + if (jsonData) { + const headers = jsonData['headers'] as Headers; + expect(headers).toBeDefined(); + if (headers) { + expect(headers['Cache-Control']).toEqual('no-cache'); + } + } + }); + + test('should not add cache-control header if route config has no cache-control', () => { + const event = { + httpMethod: 'GET', + path: '/test', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route('GET', '/test', testFunc, false, false); + const response = new Response(500); + const responseBuilder = new ResponseBuilder(response, route); + const corsConfig = new CORSConfig(testOrigin, allowHeaders); + const jsonData = responseBuilder.build(event, corsConfig); + expect(jsonData).toBeDefined(); + if (jsonData) { + const headers = jsonData['headers'] as Headers; + expect(headers).toBeDefined(); + if (headers) { + expect(headers['Cache-Control']).toBeUndefined(); + } + } + }); + + test('should base64 encode body if the payload is a string', () => { + const event = { + httpMethod: 'GET', + path: '/test', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route('GET', '/test', testFunc); + const stringBody = '{"message":"ok"}'; + const response = new Response(500, 'application.json', stringBody); + const responseBuilder = new ResponseBuilder(response, route); + const corsConfig = new CORSConfig(testOrigin, allowHeaders); + const jsonData = responseBuilder.build(event, corsConfig); + expect(jsonData).toBeDefined(); + if (jsonData) { + expect(jsonData['isBase64Encoded']).toBeDefined(); + expect(jsonData['isBase64Encoded']).toEqual(false); + expect(jsonData['body']).toEqual(stringBody); + } + }); + + test('should not base64 encode body if the payload is not a string type', () => { + const event = { + httpMethod: 'GET', + path: '/test', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route('GET', '/test', testFunc); + const stringBody = '{"message":"ok"}'; + const response = new Response( + 500, + 'application.json', + JSON.parse(stringBody) + ); + const responseBuilder = new ResponseBuilder(response, route); + const corsConfig = new CORSConfig(testOrigin, allowHeaders); + const jsonData = responseBuilder.build(event, corsConfig); + expect(jsonData).toBeDefined(); + if (jsonData) { + expect(jsonData['isBase64Encoded']).toBeDefined(); + expect(jsonData['isBase64Encoded']).toEqual(false); + } + }); + + test('should compress body if the compress enabled in route config and accept-encoding is gzip', () => { + const event = { + httpMethod: 'GET', + path: '/test', + body: 'hello', + headers: { + 'accept-encoding': 'gzip', + } as Headers, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route('GET', '/test', testFunc, false, true); + const stringBody = '{"message":"ok"}'; + const response = new Response(200, 'application.json', stringBody); + const responseBuilder = new ResponseBuilder(response, route); + const corsConfig = new CORSConfig(testOrigin, allowHeaders); + const jsonData = responseBuilder.build(event, corsConfig); + expect(jsonData).toBeDefined(); + if (jsonData) { + expect(jsonData['isBase64Encoded']).toBeDefined(); + expect(jsonData['isBase64Encoded']).toEqual(true); + const headers = jsonData['headers'] as Headers; + expect(headers).toBeDefined(); + if (headers) { + expect(headers['Content-Encoding']).toEqual('gzip'); + } + expect( + zlib + .gunzipSync(Buffer.from(jsonData['body'] as string, 'base64')) + .toString('utf8') + ).toEqual(stringBody); + } + }); + + test('should not compress body if the compress enabled in route config and accept-encoding is not gzip', () => { + const event = { + httpMethod: 'GET', + path: '/test', + body: null, + headers: { + 'accept-encoding': 'br', + } as Headers, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const route = new Route('GET', '/test', testFunc, false, true); + const stringBody = '{"message":"ok"}'; + const response = new Response(200, 'application.json', stringBody); + const responseBuilder = new ResponseBuilder(response, route); + const corsConfig = new CORSConfig(testOrigin, allowHeaders); + const jsonData = responseBuilder.build(event, corsConfig); + expect(jsonData).toBeDefined(); + if (jsonData) { + expect(jsonData['isBase64Encoded']).toBeDefined(); + expect(jsonData['isBase64Encoded']).toEqual(false); + const headers = jsonData['headers'] as Headers; + expect(headers).toBeDefined(); + if (headers) { + expect(headers['Content-Encoding']).toBeUndefined(); + } + expect(jsonData['body']).toEqual(stringBody); + } + }); + }); +}); diff --git a/packages/event-handler/tests/unit/Router.test.ts b/packages/event-handler/tests/unit/Router.test.ts new file mode 100644 index 0000000000..5f3702a568 --- /dev/null +++ b/packages/event-handler/tests/unit/Router.test.ts @@ -0,0 +1,47 @@ +import { Router } from '../../src/ApiGateway'; +import { AsyncFunction, Route } from '../../src/types'; + +/** + * ResponseBuilder tests + * + * @group unit/event-handler/class/router/all + */ +describe('Class: Router', () => { + describe('Feature: Base routing', () => { + let router: Router; + + beforeEach(() => { + router = new Router(); + }); + const testFunc: AsyncFunction = (_args: unknown): Promise => + Promise.resolve(''); + test('should add a route to the routes list when registering a route declaratively', () => { + const route = new Route('GET', '/v1/test', testFunc); + router.registerRoute( + route.func, + route.rule as string, + route.method, + route.cors, + route.compress, + route.cacheControl + ); + expect(router.routes).toBeDefined(); + expect(router.routes.length).toBeGreaterThan(0); + expect(router.routes).toEqual(expect.arrayContaining([route])); + }); + + test('should add a route to the routes list when registering a route via decorators', () => { + class TestRouter { + @router.route('GET', '/v1/test') + public testFunc(): Promise { + return Promise.resolve(''); + } + } + const testRouter = new TestRouter(); + const route = new Route('GET', '/v1/test', testRouter.testFunc); + expect(router.routes).toBeDefined(); + expect(router.routes.length).toBeGreaterThan(0); + expect(router.routes).toEqual(expect.arrayContaining([route])); + }); + }); +}); diff --git a/packages/event-handler/tests/unit/Utils.test.ts b/packages/event-handler/tests/unit/Utils.test.ts new file mode 100644 index 0000000000..45f1b3a40a --- /dev/null +++ b/packages/event-handler/tests/unit/Utils.test.ts @@ -0,0 +1,51 @@ +import { lookupKeyFromMap } from '../../src/utils'; +import { BaseProxyEvent } from '../../src/types'; + +/** + * Utils tests + * + * @group unit/event-handler/class/utils/all + */ +describe('Class: Utils', () => { + describe('Feature: Utils Testing', () => { + test('should lookup headers from Incoming event if available', () => { + const event = { + httpMethod: 'GET', + path: '/v1/multi/one', + body: 'OK', + headers: { + 'x-test': 'x-test-value', + }, + isBase64Encoded: true, + queryStringParameters: { + 'test-query': 'query-value', + }, + pathParameters: {}, + multiValueHeaders: {}, + multiValueQueryStringParameters: {}, + stageVariables: {}, + resource: 'dummy', + } as unknown as BaseProxyEvent; + + const result = lookupKeyFromMap(event.headers, 'x-test'); + expect(event.headers).toBeDefined(); + expect(result).toBeDefined(); + expect(result).toEqual('x-test-value'); + }); + + test('lookup should return undefined if header is unavailable in Incoming event', () => { + const event = { + httpMethod: 'GET', + path: '/v1/multi/one', + body: 'OK', + headers: {}, + isBase64Encoded: true, + queryStringParameters: {}, + multiValueQueryStringParameters: {}, + } as BaseProxyEvent; + + const result = lookupKeyFromMap(event.headers, 'x-test'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/event-handler/tests/unit/types/BaseProxyEvent.test.ts b/packages/event-handler/tests/unit/types/BaseProxyEvent.test.ts new file mode 100644 index 0000000000..9a4b4392ac --- /dev/null +++ b/packages/event-handler/tests/unit/types/BaseProxyEvent.test.ts @@ -0,0 +1,30 @@ +/** + * Test Logger class + * + * @group unit/event-handler/types/all + */ + +import { BaseAPIGatewayProxyEvent } from '../../../src/types'; + +describe('Class: BaseProxyEvent', () => { + describe('Feature: HTTPProxyEvent', () => { + test('should be able to cast APIGatewayProxyEvent to base event', () => { + const event = { + resource: '/v1/multi/one', + httpMethod: 'GET', + path: '/v1/multi/one', + body: 'null', + headers: { + 'test-header': 'test-value', + }, + isBase64Encoded: false, + multiValueHeaders: {}, + pathParameters: null, + stageVariables: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as BaseAPIGatewayProxyEvent; + expect(event).toBeDefined(); + }); + }); +}); diff --git a/packages/event-handler/tests/unit/types/CorsConfig.test.ts b/packages/event-handler/tests/unit/types/CorsConfig.test.ts new file mode 100644 index 0000000000..824295d454 --- /dev/null +++ b/packages/event-handler/tests/unit/types/CorsConfig.test.ts @@ -0,0 +1,110 @@ +/** + * Test Logger class + * + * @group unit/event-handler/types/all + */ + +import { CORSConfig } from '../../../src/types/CorsConfig'; + +describe('Class: CORSConfig', () => { + describe('Feature: CORS Config - Headers', () => { + test('should add Access-Control-Allow-Origin: * if allow origin is not configured', () => { + const corsHeaders = new CORSConfig().headers(); + expect(corsHeaders).toBeDefined(); + expect(corsHeaders['Access-Control-Allow-Origin']).toBeDefined(); + expect(corsHeaders['Access-Control-Allow-Origin']).toEqual('*'); + }); + + test('should add Access-Control-Allow-Origin with origins if allow origin is configured', () => { + const allowOrigin = 'xyz.domain.com, abc.domain.com'; + const corsHeaders = new CORSConfig(allowOrigin).headers(); + expect(corsHeaders).toBeDefined(); + expect(corsHeaders['Access-Control-Allow-Origin']).toBeDefined(); + expect(corsHeaders['Access-Control-Allow-Origin']).toEqual(allowOrigin); + }); + + test('should add default Access-Control-Allow-Headers if allow headers is not configured', () => { + const defaultAllowHeaders = + 'Authorization,Content-Type,X-Amz-Date,X-Api-Key,X-Amz-Security-Token'; + const corsHeaders = new CORSConfig().headers(); + expect(corsHeaders).toBeDefined(); + expect(corsHeaders['Access-Control-Allow-Headers']).toBeDefined(); + expect(corsHeaders['Access-Control-Allow-Headers']).toEqual( + defaultAllowHeaders + ); + }); + + test('should include Access-Control-Allow-Headers with default allow headers if allow headers is configured', () => { + const defaultAllowHeaders = + 'Authorization,Content-Type,X-Amz-Date,X-Api-Key,X-Amz-Security-Token'; + const additionalAllowHeaders = 'test-header,mock-header'; + const additionalAllowHeadersSet = additionalAllowHeaders.split(','); + + const corsHeaders = new CORSConfig( + '*', + additionalAllowHeadersSet + ).headers(); + expect(corsHeaders).toBeDefined(); + expect(corsHeaders['Access-Control-Allow-Headers']).toBeDefined(); + expect(corsHeaders['Access-Control-Allow-Headers']).toEqual( + defaultAllowHeaders + ',' + additionalAllowHeaders + ); + }); + + test('should include Access-Control-Allow-Headers as only "*" if allow headers includes "*"', () => { + const additionalAllowHeaders = '*,test-header,mock-header'; + const additionalAllowHeadersSet = additionalAllowHeaders.split(','); + const corsHeaders = new CORSConfig( + '*', + additionalAllowHeadersSet + ).headers(); + expect(corsHeaders).toBeDefined(); + expect(corsHeaders['Access-Control-Allow-Headers']).toBeDefined(); + expect(corsHeaders['Access-Control-Allow-Headers']).toEqual('*'); + }); + + test('should not include Access-Control-Expose-Headers if expose headers is not configured', () => { + const corsHeaders = new CORSConfig().headers(); + expect(corsHeaders).toBeDefined(); + expect(corsHeaders['Access-Control-Expose-Headers']).toBeUndefined(); + }); + + test('should include Access-Control-Expose-Headers if expose headers is configured', () => { + const exposeHeaders = 'test_header, mock_header'; + const exposeHeadersSet = exposeHeaders.split(','); + const corsHeaders = new CORSConfig('*', [], exposeHeadersSet).headers(); + expect(corsHeaders).toBeDefined(); + expect(corsHeaders['Access-Control-Expose-Headers']).toBeDefined(); + expect(corsHeaders['Access-Control-Expose-Headers']).toEqual( + exposeHeaders + ); + }); + + test('should not include Access-Control-Max-Age if Max Age is not configured', () => { + const corsHeaders = new CORSConfig().headers(); + expect(corsHeaders).toBeDefined(); + expect(corsHeaders['Access-Control-Max-Age']).toBeUndefined(); + }); + + test('should include Access-Control-Max-Age if Max Age is configured', () => { + const maxAge = 5; + const corsHeaders = new CORSConfig('*', [], [], maxAge).headers(); + expect(corsHeaders).toBeDefined(); + expect(corsHeaders['Access-Control-Max-Age']).toBeDefined(); + expect(corsHeaders['Access-Control-Max-Age']).toEqual(maxAge.toString()); + }); + + test('should not include Access-Control-Allow-Credentials if allow credentials is not configured', () => { + const corsHeaders = new CORSConfig().headers(); + expect(corsHeaders).toBeDefined(); + expect(corsHeaders['Access-Control-Allow-Credentials']).toBeUndefined(); + }); + + test('should include Access-Control-Allow-Credentials if allow credentials is configured', () => { + const corsHeaders = new CORSConfig('*', [], [], 0, true).headers(); + expect(corsHeaders).toBeDefined(); + expect(corsHeaders['Access-Control-Allow-Credentials']).toBeDefined(); + expect(corsHeaders['Access-Control-Allow-Credentials']).toEqual('true'); + }); + }); +}); diff --git a/packages/event-handler/tsconfig.json b/packages/event-handler/tsconfig.json new file mode 100644 index 0000000000..1cb9d72773 --- /dev/null +++ b/packages/event-handler/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./src", + }, + "include": [ + "./src/**/*" + ], +} \ No newline at end of file diff --git a/packages/event-handler/typedoc.json b/packages/event-handler/typedoc.json new file mode 100644 index 0000000000..3a1fad0dcf --- /dev/null +++ b/packages/event-handler/typedoc.json @@ -0,0 +1,8 @@ +{ + "extends": ["../../typedoc.base.json"], + "entryPoints": [ + "./src/index.ts", + "./src/types" + ], + "readme": "README.md" +} \ No newline at end of file