diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..281bb33 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +coverage +lib +node_modules +tests/resources/* \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..aaa8fbe --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,68 @@ +module.exports = { + env: { + browser: false, + es2020: true, + jest: true, + node: true, + }, + extends: [ 'prettier', 'plugin:@typescript-eslint/eslint-recommended', 'plugin:@typescript-eslint/recommended' ], + parser: '@typescript-eslint/parser', + plugins: [ 'prettier' , '@typescript-eslint', 'sort-exports' ], + settings: { + 'import/resolver': { + node: {}, + typescript: { + project: './tsconfig.es.json', + alwaysTryTypes: true, + }, + }, + }, + rules: { + 'sort-exports/sort-exports': [ 'error', { 'sortDir': 'asc' } ], + '@typescript-eslint/ban-ts-ignore': ['off'], + '@typescript-eslint/camelcase': ['off'], + '@typescript-eslint/explicit-function-return-type': [ 'error', { allowExpressions: true } ], + '@typescript-eslint/explicit-member-accessibility': 'error', + '@typescript-eslint/indent': [ 'error', 2, { SwitchCase: 1 } ], + '@typescript-eslint/interface-name-prefix': ['off'], + '@typescript-eslint/member-delimiter-style': [ 'error', { multiline: { delimiter: 'none' } } ], + '@typescript-eslint/member-ordering': [ + 'error', + { + default: { + memberTypes: [ + 'signature', + 'public-field', // = ["public-static-field", "public-instance-field"] + 'protected-field', // = ["protected-static-field", "protected-instance-field"] + 'private-field', // = ["private-static-field", "private-instance-field"] + 'constructor', + 'public-method', // = ["public-static-method", "public-instance-method"] + 'protected-method', // = ["protected-static-method", "protected-instance-method"] + 'private-method', // = ["private-static-method", "private-instance-method"] + ], + order: 'alphabetically', + }, + }, + ], + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-inferrable-types': ['off'], + '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: 'TestRouter' } ], + '@typescript-eslint/no-use-before-define': ['off'], + '@typescript-eslint/semi': [ 'error', 'always' ], + 'array-bracket-spacing': [ 'error', 'always', { singleValue: false } ], + 'arrow-body-style': [ 'error', 'as-needed' ], + 'computed-property-spacing': [ 'error', 'never' ], + 'func-style': [ 'warn', 'expression' ], + indent: [ 'error', 2, { SwitchCase: 1 } ], + 'keyword-spacing': 'error', + 'newline-before-return': 2, + 'no-console': 0, + 'no-multi-spaces': [ 'error', { ignoreEOLComments: false } ], + 'no-multiple-empty-lines': [ 'error', { max: 1, maxBOF: 0 } ], + 'no-throw-literal': 'error', + 'object-curly-spacing': [ 'error', 'always' ], + 'prefer-arrow-callback': 'error', + quotes: [ 'error', 'single', { allowTemplateLiterals: true } ], + semi: [ 'error', 'always' ], + }, +}; diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..3691dc6 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,28 @@ +name: Build + +on: + push: + branches: + - "**" + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies and build + run: | + npm install + npm run build + + - name: Run tests + run: | + npm run test diff --git a/.gitignore b/.gitignore index c2bbf4f..6af85b6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # IntelliJ /.idea *.iml +/lib # Visual Studio Code /.vscode diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..d055233 --- /dev/null +++ b/.npmignore @@ -0,0 +1,21 @@ +src +tests +jest.config.js +tsconfig.json +.vscode +.github +.gradle +node_modules +scripts +.idea +.vscode +coverage +tslint.json +tsconfig.json +MakeFile +jest.config.js +.npmignore +.eslintignore +.huskyrc.js +.eslintrc.json +examples \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS index 31328ba..842da59 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1,2 @@ # Global owners -* [ @Evernorth/team-name or each @username ] \ No newline at end of file +* @karthikeyanjp \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f84045..e358232 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,18 +1,21 @@ -# Guidance on how to contribute +# Contributing -Include information that will help a contributor understand expectations and be successful, such as the examples below. -> Want to see a good real world example? Take a look at the [CONTRIBUTING.adoc](https://github.com/spring-projects/spring-boot/blob/main/CONTRIBUTING.adoc) for spring-boot. +Thanks for your interest in contributing to this project! -* Where to ask Questions? Please make sure it is SEARCHABLE -* Who are the TC’s (Trusted Committers)? -* How to file an Issue -* How to file a PR -* Dependencies -* Style Guide / Coding standards -* Developer Environment -* Branching/ versioning -* Features -* Testing -* Roadmap -* Calendar -* Links to other documentation \ No newline at end of file +We welcome any kind of contribution including: + +- Documentation +- Examples +- New features and feature requests +- Bug fixes + +Please open Pull Requests for small changes. + +For larger changes please submit an issue with additional details. + +This issue should outline the following: + +- The use case that your changes are applicable to. +- Steps to reproduce the issue(s) if applicable. +- Detailed description of what your changes would entail. +- Alternative solutions or approaches if applicable. \ No newline at end of file diff --git a/INSTALL.md b/INSTALL.md index a7aa523..85e3dfe 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,3 +1,46 @@ # Installation instructions -Detailed instructions on how to install, configure, and get the project running. If the instructions are minimal, they can reside in the Readme Installation section. \ No newline at end of file +follow these steps to install and build the library. + +## Prerequisites + +Ensure you have the following installed on your system: + +- **Node.js** (version >= 18) +- **npm** (Node Package Manager) + +## Installation + +1. Clone the repository: + + ```shell + git clone https://github.com/Evernorth/aws-lambda-ts-event-handler.git + cd aws-lambda-ts-event-handler + + ``` + +2. Install dependencies: + ```shell + npm install + ``` + +## Build + +To build the project, run: +`shell + npm run build + ` +This will compile the TypeScript files into JavaScript and output them to the lib directory. + +## Additional Scripts + +- Watch for changes and rebuild automatically: + + ```shell + npm run watch:build + ``` + +- Run Tests + ```shell + npm test + ``` diff --git a/LICENSE b/LICENSE index e8b0743..37521aa 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright © [ Year or Years Range of the Work ] Evernorth Strategic Development, Inc. + Copyright © 2025 Evernorth Strategic Development, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/NOTICE b/NOTICE index f0d59a6..3a1afcf 100644 --- a/NOTICE +++ b/NOTICE @@ -1,6 +1,6 @@ -[ Project Repo Name ] +[ aws-lambda-ts-lambda-handler ] -Copyright (c) [ Year or Years Range of the Work ] Evernorth Strategic Development, Inc. +Copyright (c) 2025 Evernorth Strategic Development, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 652d16f..98a3565 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,209 @@ -#### Github.com/Evernorth OpenSource Project Template Instructions - -1. Clone the Github.com/Evernorth project repo that was created for your approved Open Source contribution by an Org admin. -2. Copy all the *.MD from this repository to your new project. -3. Update the README, replacing the contents below as prescribed. -4. Update CODEOWNERS replacing "\[ @Evernorth/team-name or each @username \]" with the expected value. -5. Update the LICENSE and NOTICE replacing "\[ Year or Years Range of the Work \]" with the expected value. -6. Update NOTICE replacing "\[ Project Repo Name \]" with the expected value. -7. Delete these instructions and everything up to the _Project Title_ from the README. -8. Write some great software and tell people about it. - -> Keep the README fresh! It's the first thing people see and will make the initial impression. - -> Want to see a good real world example? Take a look at the [README](https://github.com/spring-projects/spring-boot/blob/main/README.adoc) for spring-boot. - ----- - # aws-lambda-ts-event-handler -**Description**: Put a meaningful, short, plain-language description of what -this project is trying to accomplish and why it matters. -Describe the problem(s) this project solves. -Describe how this software can improve the lives of its audience. +**Description**: -Other things to include: +Minimalistic event handler & HTTP router for Serverless applications. - - **Technology stack**: Indicate the technological nature of the software, including primary programming language(s) and whether the software is intended as standalone or as a module in a framework or other ecosystem. - - **Status**: Alpha, Beta, 1.1, etc. It's OK to write a sentence, too. The goal is to let interested people know where this project is at. This is also a good place to link to the [CHANGELOG](CHANGELOG.md). - - **Links to production or demo instances** - - Describe what sets this apart from related-projects. Linking to another doc or page is OK if this can't be expressed in a sentence or two. +`aws-lambda-ts-event-handler` is a lightweight and focused Typescript library that brings elegant HTTP routing to AWS Lambda functions - without the overhead of traditional web frameworks. +Designed specifically for serverless workloads on AWS, this library enables developers to define clean and type-safe API routes using Typescript decorations. +**Features** -**Screenshot**: If the software has visual components, consider placing a screenshot after the description; +- **Minimal & Efficient**: Tailored for AWS Lambda to keep cold start times low and performant. +- **Typescript Decorators**: Intuitive route defintions using modern decorator syntax. +- **Built-in CORS Support**: Easily enable and configure CORS to your APIs. +- **Local HTTP Test Server**: Simulate and test routes locally without deploying to AWS. +**Why Use This?** +While robust frameworks like Express and Koa offer powerful tooling, they are often optimized for traditional server environments. `aws-lambda-ts-event-handler` focuses on the specific needs of Lambda-based applications, providing just the right level of abstraction to build scalabale serverless APIs -cleanly and efficiently. ## Dependencies -Describe any dependencies that must be installed for this software to work. -This includes programming languages, databases or other storage mechanisms, build tools, frameworks, and so forth. -If specific versions of other software are required, or known not to work, call that out. - -## Building from Source - -Detailed instructions on how to build the project from source. Also note where to get pre-built distribution, if building is not required. +See the [package.json](./package.json) file. ## Installation -Detailed instructions on how to install, configure, and get the project running. -This should be frequently tested to ensure reliability. Alternatively, link to -a separate [INSTALL](INSTALL.md) document. - -## Configuration - -If the software is configurable, describe it in detail, either here or in other documentation to which you link. - -## Usage - -Show users how to use the software. -Be specific. -Use appropriate formatting when showing code snippets. - -## How to test the software - -If the software includes automated tests, detail how to run those tests. - -## Known issues +To add this library to your project, run -Document any known significant shortcomings with the software. +```shell +npm install --save @evernorth/aws-lambda-ts-event-handler +``` -## Getting help +Install dev dependencies for AWS Lambda -Instruct users how to get help with this software; this might include links to an issue tracker, wiki, mailing list, etc. +```shell +npm install --save-dev aws-lambda @types/node @types/aws-lambda +``` -**Example** - -If you have questions, concerns, bug reports, etc, please file an issue in this repository's Issue Tracker. - -## Getting involved - -This section should detail why people should get involved and describe key areas you are -currently focusing on; e.g., trying to get feedback on features, fixing certain bugs, building -important pieces, etc. +## Usage -General instructions on _how_ to contribute should be stated with a link to [CONTRIBUTING](CONTRIBUTING.md). +### Simple Example + +Create a `app.ts` file + +```typescript +// Import API Gateway Event handler +import { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import { + ApiGatewayResolver, + AsyncFunction, + BaseProxyEvent, + JSONData, +} from '@evernorth/aws-lambda-ts-event-handler'; + +// 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 +exports.handler = ( + _event: APIGatewayProxyEvent, + _context: Context, +): Promise => { + // Resolve routes + return app.resolve(_event, _context); +}; + +// Declare your Lambda handler +if (require.main === module) { + LocalTestServer.getInstance(handler as Handler).start(); +} else { + module.exports.handler = handler; +} +``` + +Run the application + +```shell +ts-node app.ts +``` + +The package includes a test server (`LocalTestServer`) for local testing. + +You should see a message + +```shell +Test server listening on port 4000 +``` + +Test the service + +```shell +curl http://localhost:4000/v1/hello +{"message":"Hello World"} +``` + +### Register Route with Decorators + +```typescript +import { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import { + ApiGatewayResolver, + BaseProxyEvent, + JSONData, + Handler, + LocalTestServer, +} from '@evernorth/aws-lambda-ts-event-handler'; + +// 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' }); + } +} + +const handler = ( + _event: APIGatewayProxyEvent, + _context: Context, +): Promise => { + // Resolve routes + return app.resolve(_event, _context); +}; + +// Declare your Lambda handler +if (require.main === module) { + LocalTestServer.getInstance(handler as Handler).start(); +} else { + module.exports.handler = handler; +} +``` + +### 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(), +); +``` + +adds standard CORS headers to the response + +```shell +➜ curl http://localhost:4000/v1/hello -v + +* Host localhost:4000 was resolved. +* IPv6: ::1 +* IPv4: 127.0.0.1 +* Trying [::1]:4000... +* Connected to localhost (::1) port 4000 +> GET /v1/hello HTTP/1.1 +> Host: localhost:4000 +> User-Agent: curl/8.7.1 +> Accept: */* +> +* Request completely sent off +< HTTP/1.1 200 OK +< Content-Type: application/json +< Access-Control-Allow-Origin: * # For security, it is recommended to specify specific allow-listed domains. +< Access-Control-Allow-Headers: Authorization,Content-Type,X-Amz-Date,X-Api-Key,X-Amz-Security-Token +< content-length: 25 +< Date: Mon, 10 Mar 2025 20:47:29 GMT +< Connection: keep-alive +< Keep-Alive: timeout=5 +< +* Connection #0 to host localhost left intact +{"message":"Hello World"}% +``` + +--- + +## Support + +If you have questions, concerns, bug reports, etc. See [CONTRIBUTING](CONTRIBUTING.md). ## License -{ Project Title } is Open Source software released under the [Apache 2.0 license](https://www.apache.org/licenses/LICENSE-2.0.html). - ----- -## Credits and references +aws-lambda-ts-event-handler is Open Source software released under the [Apache 2.0 license](https://www.apache.org/licenses/LICENSE-2.0.html). -1. Projects that inspired you -2. Related projects -3. Books, papers, talks, or other sources that have meaningful impact or influence on this project +--- +### Original Contributors +1. Karthikeyan Perumal, Evernorth diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..da12e68 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,48 @@ +module.exports = { + displayName: { + name: 'Evernorth Typescript Library: aws-lambda-ts-event-handler', + color: 'yellow', + }, + runner: 'groups', + preset: 'ts-jest', + transform: { + '^.+\\.ts?$': 'ts-jest', + }, + moduleFileExtensions: ['js', 'ts'], + collectCoverage: true, + collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**'], + testMatch: ['**/?(*.)+(spec|test).ts'], + roots: ['src', 'tests'], + testPathIgnorePatterns: ['/node_modules/'], + testEnvironment: 'node', + coveragePathIgnorePatterns: [ + '/node_modules/', + 'src/helpers/TestServer.ts', + // '/types/', + ], + coverageThreshold: { + global: { + statements: 85, + branches: 85, + functions: 85, + lines: 85, + }, + }, + coverageReporters: ['json-summary', 'text', 'lcov'], + // 'setupFiles': [ + // '/tests/helpers/populateEnvironmentVariables.ts' + // ] + + // Fix for GitHub Actions compatibility issue + resolver: undefined, + // Ensure consistent module resolution in different environments + moduleDirectories: ['node_modules'], + // Improve error reporting + verbose: true, + // Add GitHub Actions reporter when running in GitHub Actions + reporters: [ + 'default', + process.env.GITHUB_ACTIONS === 'true' ? 'github-actions' : null, + process.env.GITHUB_ACTIONS === 'true' ? 'summary' : null, + ].filter(Boolean), +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..9cbce65 --- /dev/null +++ b/package.json @@ -0,0 +1,84 @@ +{ + "name": "@evernorth/aws-lambda-ts-event-handler", + "version": "0.0.1", + "description": "Minimalistic event handler & http router for Serverless applications", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "typedocMain": "src/index.ts", + "repository": "https://github.com/Evernorth/aws-lambda-ts-event-handler", + "license": "MIT", + "keywords": [], + "scripts": { + "init-environment": "husky install", + "build": "run-p build:*", + "build:main": "tsc -p tsconfig.json", + "fix": "run-s fix:*", + "fix:prettier": "prettier \"src/**/*.ts\" --write", + "watch": "jest --watch --group=unit", + "test": "npm run test:unit", + "test:unit": "jest --group=unit --detectOpenHandles --coverage --verbose --no-cache", + "test:e2e": "jest --group=e2e", + "test:prettier": "prettier \"src/**/*.ts\" --list-different", + "check-cli": "run-s test diff-integration-tests check-integration-tests", + "check-integration-tests": "run-s check-integration-test:*", + "diff-integration-tests": "mkdir -p diff && rm -rf diff/test && cp -r test diff/test && rm -rf diff/test/test-*/.git && cd diff && git init --quiet && git add -A && git commit --quiet --no-verify --allow-empty -m 'WIP' && echo '\\n\\nCommitted most recent integration test output in the \"diff\" directory. Review the changes with \"cd diff && git diff HEAD\" or your preferred git diff viewer.'", + "watch:build": "tsc -p tsconfig.json -w", + "watch:test": "nyc --silent ava --watch", + "cov": "run-s build test:unit cov:html cov:lcov && open-cli coverage/index.html", + "cov:html": "nyc report --reporter=html", + "cov:lcov": "nyc report --reporter=lcov", + "cov:send": "run-s cov:lcov && codecov", + "cov:check": "nyc report && nyc check-coverage --lines 100 --functions 100 --branches 100", + "doc": "run-s doc:html && open-cli build/docs/index.html", + "doc:html": "typedoc src/ --exclude **/*.spec.ts --out build/docs", + "doc:json": "typedoc src/ --exclude **/*.spec.ts --json build/docs/typedoc.json", + "doc:publish": "gh-pages -m \"[ci skip] Updates\" -d build/docs", + "version": "standard-version", + "reset-hard": "git clean -dfx && git reset --hard && npm i", + "prepare-release": "run-s reset-hard test cov:check doc:html version doc:publish" + }, + "engines": { + "node": ">=12" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.152", + "@types/jest": "^30.0.0", + "@types/node": "^24.1.0", + "@types/uuid": "^10.0.0", + "@typescript-eslint/eslint-plugin": "^8.38.0", + "@typescript-eslint/parser": "^8.38.0", + "ava": "^6.4.1", + "codecov": "^3.8.3", + "cspell": "^9.2.0", + "cz-conventional-changelog": "^3.3.0", + "eslint": "^9.31.0", + "eslint-config-prettier": "^10.1.8", + "eslint-import-resolver-node": "^0.3.9", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-prettier": "^5.5.3", + "eslint-plugin-sort-exports": "^0.9.1", + "jest": "^30.0.5", + "jest-runner-groups": "^2.2.0", + "npm-run-all": "^4.1.5", + "prettier": "^3.6.2", + "prettier-eslint": "^16.4.2", + "promptly": "^3.2.0", + "proxy-agent": "^6.5.0", + "ts-jest": "^29.4.0", + "ts-node": "^10.9.2", + "typedoc": "^0.28.7", + "typedoc-plugin-missing-exports": "^4.0.0", + "typescript": "^5.8.3" + }, + "files": [ + "lib/**/*" + ], + "prettier": { + "singleQuote": true, + "bracketSpacing": true + }, + "dependencies": { + "uuid": "^11.1.0" + } +} diff --git a/src/ApiGatewayEventRouter.ts b/src/ApiGatewayEventRouter.ts new file mode 100644 index 0000000..a78ecd1 --- /dev/null +++ b/src/ApiGatewayEventRouter.ts @@ -0,0 +1,623 @@ +/* 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'; +import { + MIME_TYPE, + ProblemDocument, + ProblemTypes, +} from './types/http-problem-details'; + +enum ProxyEventType { + APIGatewayProxyEvent = 'APIGatewayProxyEvent', + APIGatewayProxyEventV2 = 'APIGatewayProxyEventV2', + ALBEvent = 'ALBEvent', + LambdaFunctionUrlEvent = 'LambdaFunctionUrlEvent', +} + +const DYNAMIC_ROUTE_PATTERN: RegExp = /<(\w+)>/g; +const SAFE_URI: string = "-._~()'!*:@,;="; +const UNSAFE_URI: string = '%<> \\[\\]{}|^'; +const NAMED_GROUP_BOUNDARY_PATTERN: string = `(?<$1>[${SAFE_URI}${UNSAFE_URI}\\w]+)`; +const ROUTE_REGEX: string = '^{}$'; + +export { + ApiGatewayResolver, + BaseRouter, + ProxyEventType, + ResponseBuilder, + Router, +}; + +/** + * Standard APIGateway Response builder + */ +class ResponseBuilder { + constructor( + public response: Response, + public route?: Route, + ) {} + + /** + * Builds a standard APIGatewayProxyResponseEvent + * + * @param event Incoming Event + * @param cors CORS configuration + * @returns JSONData + */ + 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 cacheControl Cache-Control 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) + */ + private compress(): void { + const { headers: responseHeaders, body } = this.response; + + if (responseHeaders) { + responseHeaders['Content-Encoding'] = 'gzip'; + } + if (body) { + this.response.body = zlib.gzipSync( + Buffer.isBuffer(body) ? body : Buffer.from(body), + ); + } + } +} + +/** + * Base Router + */ +abstract class BaseRouter { + public context: Context = new Map(); + public currentEvent?: BaseProxyEvent; + 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 APIGateway Proxy Events + */ +class ApiGatewayResolver extends BaseRouter { + public context: Context = new Map(); + public corsEnabled = false; + public corsMethods: HTTPMethod = ['OPTIONS']; + public routeKeys = new Set(); + public routes: Route[] = []; + + 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 ResponseBuilder + */ + 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)); + } + } + + // IETF RFC 9457 compliant error response + const notFoundProblemDocument = ProblemDocument.fromType( + ProblemTypes.notFound, + 'No route found for the HTTP path', + ); + + return new ResponseBuilder( + new Response( + 404, + MIME_TYPE, + JSON.stringify(notFoundProblemDocument), + headers, + ), + ); + } + + /** + * Register an HTTP route to the Router + * + * @param func Handler function + * @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/src/helpers/TestServer.ts b/src/helpers/TestServer.ts new file mode 100644 index 0000000..d76e5b9 --- /dev/null +++ b/src/helpers/TestServer.ts @@ -0,0 +1,252 @@ +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'; +import { + MIME_TYPE, + ProblemDocument, + ProblemTypes, +} from '../types/http-problem-details'; + +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 instance: 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 getInstance(handlerFn: Handler | AWSHandler): LocalTestServer { + if (!this.instance) { + this.instance = new LocalTestServer(handlerFn); + } + + return this.instance; + } + + /** + * 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 (APIGateway Proxy request) + * + * @internal + */ + private async constructRequestEvent( + req: IncomingMessage, + ): Promise { + try { + const { url, method, headers: reqHeaders } = req; + const [path, queryString] = (url as string).split('?'); + const queryStringParameters: QueryStringParameters = {}; + const multiValueQueryStringParameters: MultiValueQueryStringParameters = + {}; + + if (queryString) { + queryString.split('&').forEach((token) => { + const [key, value] = token.split('='); + const values = value.split(',').map((s) => s.trim()); + if (values.length > 1) { + multiValueQueryStringParameters[key] = values; + } else { + queryStringParameters[key] = value; + } + }); + } + + const headers: Headers = {}; + const multiValueHeaders: MultiValueHeaders = {}; + Object.entries(reqHeaders).forEach(([key, value]) => { + const values = (value as string).split(',').map((s) => s.trim()); + if (values.length > 1) { + multiValueHeaders[key] = values; + } else { + headers[key] = value as string; + } + }); + + 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)); + } + + return { + httpMethod: method as string, + path, + headers, + multiValueHeaders, + queryStringParameters, + multiValueQueryStringParameters, + body, + requestContext: {}, + } as BaseProxyEvent; + } catch (error) { + console.error('Error constructing request event:', error); + throw new Error('Failed to construct request 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 { + try { + res.statusCode = handlerResponse.statusCode; + const cType = contentType || 'application/json'; + + if (handlerResponse.headers) { + Object.entries(handlerResponse.headers).forEach(([key, value]) => { + 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); + } catch (error) { + console.error('Error constructing response:', error); + + // IETF RFC 9457 compliant error response + const internalServerErrorProblemDocument = ProblemDocument.fromType( + ProblemTypes.internalServerError, + `Error constructing response: ${error}`, + ); + + res.statusCode = 500; + res.setHeader('content-type', MIME_TYPE); + res.write(JSON.stringify(internalServerErrorProblemDocument)); + } finally { + res.end(); + } + } + + /** + * Registers the AWS Lambda handler function to the HTTP server + */ + private registerHandler(): void { + this.server.on( + 'request', + async (req: IncomingMessage, res: ServerResponse) => { + try { + const event = await this.constructRequestEvent(req); + const handlerResponse = await this.handlerFn(event, {} as Context); + this.constructResponse( + this.toResponse(handlerResponse as Response), + res, + ); + } catch (error) { + console.error('Error handling request:', error); + + // IETF RFC 9457 compliant error response + const internalServerErrorProblemDocument = ProblemDocument.fromType( + ProblemTypes.internalServerError, + `Error handling request: ${error}`, + ); + res.statusCode = 500; + res.setHeader('Content-Type', MIME_TYPE); + res.write(JSON.stringify(internalServerErrorProblemDocument)); + 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/src/helpers/index.ts b/src/helpers/index.ts new file mode 100644 index 0000000..062b64e --- /dev/null +++ b/src/helpers/index.ts @@ -0,0 +1 @@ +export * from './TestServer'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..702a58d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +export * from './ApiGatewayEventRouter'; +export * from './helpers'; +export * from './middleware'; +export * from './types'; +export * from './utils'; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..298d23d --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,63 @@ +import { Context } from 'aws-lambda'; +import { ArgsDict, BaseProxyEvent, JSONData, Response } from './types'; + +/** + * HTTP middleware function that wraps the route invocation in an AWS Lambda function. + * + * @typeParam T - The response type of the middleware function. + */ +type Middleware = ( + event: BaseProxyEvent, + context: Context, + args: ArgsDict, + next: () => Promise, +) => Promise; + +/** + * Model for an AWS Lambda HTTP handler function. + * + * @typeParam T - The response type of the handler function. + */ +type Handler = ( + event: BaseProxyEvent, + context: Context, + args?: ArgsDict, +) => Promise; + +/** + * Wraps the AWS Lambda handler function with the provided middlewares. + * + * @remarks + * The middlewares 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. + * + * @throws Will throw an error if the middleware or handler function fails. + * + * @template T - The response type of the handler function. + */ +const wrapWithMiddlewares = + ( + middlewares: Middleware[], + handler: Handler, + _args?: ArgsDict, + ): Handler => + async ( + event: BaseProxyEvent, + context: Context, + args?: ArgsDict, + ): Promise => { + const chain = middlewares.reduceRight( + (next: () => Promise, middleware: Middleware) => () => + middleware(event, context, args, next), + () => handler(event, context, args || {}), + ); + return chain(); + }; + +export { Handler, Middleware, wrapWithMiddlewares }; diff --git a/src/types/BaseProxyEvent.ts b/src/types/BaseProxyEvent.ts new file mode 100644 index 0000000..85824c6 --- /dev/null +++ b/src/types/BaseProxyEvent.ts @@ -0,0 +1,80 @@ +import { APIGatewayProxyEvent } from 'aws-lambda'; +import { + Headers, + MultiValueHeaders, + MultiValueQueryStringParameters, + QueryStringParameters, +} 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; + +/** + * Abstract class representing a HTTP Proxy event + * + * @category Model + */ +abstract class HTTPProxyEvent implements HTTPBaseProxyEvent { + public path?: string; + public body: string | null = null; + public headers: Headers = {}; + public multiValueHeaders?: MultiValueHeaders; + public parsedBody?: unknown; + public httpMethod: string = ''; + public isBase64Encoded: boolean = false; + public queryStringParameters?: QueryStringParameters; + public multiValueQueryStringParameters?: MultiValueQueryStringParameters; + + constructor(event: Partial) { + Object.assign(this, event); + } + + /** + * Validates the HTTP Proxy event + * @throws Error if validation fails + */ + validate(): void { + if (!this.httpMethod) { + throw new Error('HTTP method is required'); + } + if (!this.headers) { + throw new Error('HTTP headers are required'); + } + } +} + +export { BaseProxyEvent, HTTPBaseProxyEvent, HTTPProxyEvent }; diff --git a/src/types/CorsConfig.ts b/src/types/CorsConfig.ts new file mode 100644 index 0000000..a67e691 --- /dev/null +++ b/src/types/CorsConfig.ts @@ -0,0 +1,75 @@ +import { Headers } from './common'; + +/** + * CORS Configuration + * + * @category Model + */ +class CORSConfig { + private static readonly REQUIRED_HEADERS: string[] = [ + 'Authorization', + 'Content-Type', + 'X-Amz-Date', + 'X-Api-Key', + 'X-Amz-Security-Token', + ]; + + public allowOrigin: string; + public allowHeaders: string[]; + public exposeHeaders: string[]; + public maxAge?: number; + public allowCredentials: boolean; + + /** + * Constructs a new instance of the CorsConfig class. + * + * @param allowOrigin - Specifies the allowed origin for CORS requests. Defaults to '*'. + * For security, it is recommended to specify specific allow-listed domains. + * @param allowHeaders - An array of allowed HTTP headers for CORS requests. Defaults to an empty array. + * @param exposeHeaders - An array of HTTP headers that are safe to expose to the browser. Defaults to an empty array. + * @param maxAge - The maximum time (in seconds) that the results of a preflight request can be cached. Optional. + * @param allowCredentials - Indicates whether credentials (cookies, authorization headers, etc.) are allowed in CORS requests. Defaults to false. + */ + constructor( + allowOrigin: string = '*', + allowHeaders: string[] = [], + exposeHeaders: string[] = [], + maxAge?: number, + allowCredentials: boolean = false, + ) { + this.allowOrigin = allowOrigin; + this.allowHeaders = this.initializeAllowHeaders(allowHeaders); + this.exposeHeaders = exposeHeaders; + this.maxAge = maxAge; + this.allowCredentials = allowCredentials; + } + + private initializeAllowHeaders(allowHeaders: string[]): string[] { + return allowHeaders.includes('*') + ? ['*'] + : [...new Set([...CORSConfig.REQUIRED_HEADERS, ...allowHeaders])]; + } + + 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.maxAge !== undefined) { + headers['Access-Control-Max-Age'] = this.maxAge.toString(); + } + + if (this.allowCredentials) { + headers['Access-Control-Allow-Credentials'] = 'true'; + } + + return headers; + } +} + +export { CORSConfig }; diff --git a/src/types/Response.ts b/src/types/Response.ts new file mode 100644 index 0000000..1d5a1f2 --- /dev/null +++ b/src/types/Response.ts @@ -0,0 +1,31 @@ +import { Body, Headers } from './common'; // Ensure that Body and Headers are correctly exported 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/src/types/Route.ts b/src/types/Route.ts new file mode 100644 index 0000000..f5192d7 --- /dev/null +++ b/src/types/Route.ts @@ -0,0 +1,49 @@ +import { Middleware } from '../middleware'; +import { PathPattern, HTTPMethod, Path, AsyncFunction } from './common'; + +/** + * Represents an HTTP route with method, URL pattern, handler function, and additional configurations. + */ +class Route { + public method: HTTPMethod; + public rule: Path | PathPattern; + public func: AsyncFunction; + public cors: boolean; + public compress: boolean; + public cacheControl?: string; + public middlewares: Middleware[]; + + /** + * Constructs a new Route instance. + * + * @param method - The HTTP method(s) for the route. + * @param rule - The URL pattern or path for the route. + * @param func - The handler function to be called for the route. + * @param cors - Whether to enable CORS for the route. + * @param compress - Whether to enable compression for the route. + * @param cacheControl - Cache control settings for the route. + * @param middlewares - Array of middlewares to be applied to the route. + * @throws {TypeError} If the method is not a string or an array of strings. + */ + constructor( + method: HTTPMethod, + rule: Path | PathPattern, + func: AsyncFunction, + cors = false, + compress = false, + cacheControl?: string, + middlewares: Middleware[] = [], + ) { + this.method = (Array.isArray(method) ? method : [method]).map((m) => + m.toUpperCase(), + ); + this.rule = rule; + this.func = func; + this.cors = cors; + this.compress = compress; + this.cacheControl = cacheControl; + this.middlewares = middlewares; + } +} + +export { Route }; diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 0000000..0a4bde0 --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,106 @@ +import { APIGatewayProxyEvent } from 'aws-lambda'; + +/** + * Dictionary of arguments. + */ +type ArgsDict = Record | undefined; + +/** + * HTTP headers. + */ +type Headers = Record; + +/** + * HTTP multi-value headers. + */ +type MultiValueHeaders = Record; + +/** + * Path parameters. + */ +type PathParameters = Record; + +/** + * Query string parameters. + */ +type QueryStringParameters = Record; + +/** + * Multi-value query string parameters. + */ +type MultiValueQueryStringParameters = Record; + +/** + * URL path. + */ +type Path = string; + +/** + * URL path pattern as a regular expression. + */ +type PathPattern = RegExp; + +/** + * HTTP request body. + */ +type Body = string | Buffer | undefined; + +/** + * Optional string type. + */ +type OptionalString = string | undefined | null; + +/** + * JSON data. + */ +type JSONData = Record | undefined; + +/** + * Supported content types. + */ +type ContentType = + | 'text/html' + | 'text/plain' + | 'application/xml' + | 'application/json' + | 'application/xhtml+xml'; + +/** + * Context map. + */ +type Context = Map; + +/** + * HTTP method type. + */ +type HTTPMethod = string | string[]; + +/** + * Base API Gateway Proxy Event without request context. + */ +type BaseAPIGatewayProxyEvent = Omit; + +/** + * Asynchronous function type. + * @template T - Return type of the function. + */ +type AsyncFunction = (...args: unknown[]) => Promise; + +export { + ArgsDict, + AsyncFunction, + BaseAPIGatewayProxyEvent, + Body, + ContentType, + Context, + HTTPMethod, + Headers, + JSONData, + MultiValueHeaders, + MultiValueQueryStringParameters, + OptionalString, + Path, + PathParameters, + PathPattern, + QueryStringParameters, +}; diff --git a/src/types/http-problem-details.ts b/src/types/http-problem-details.ts new file mode 100644 index 0000000..332da38 --- /dev/null +++ b/src/types/http-problem-details.ts @@ -0,0 +1,186 @@ +import { v4 as uuidv4 } from 'uuid'; +/** + * Constants for Problem Details + */ +export const MIME_TYPE = 'application/problem+json'; +export const UUID_PREFIX = 'urn:uuid:'; + +/** + * Problem type definition as per RFC 9457 + */ +export interface ProblemType { + readonly code: number; + readonly urn: string; + readonly title: string; +} + +/** + * Problem document as per RFC 9457 + */ +export class ProblemDocument { + type?: string; + status: number; + title?: string; + detail?: string; + instance: string; + created: string; + extensions?: Record; + + /** + * Create a new problem document + */ + constructor(options: { + type?: string; + status: number; + title?: string; + detail?: string; + instance?: string; + extensions?: Record; + }) { + this.type = options.type; + this.status = options.status; + this.title = options.title; + this.detail = options.detail; + this.instance = options.instance || `${UUID_PREFIX}${uuidv4()}`; + this.created = new Date().toISOString(); + this.extensions = options.extensions; + } + + /** + * Create a problem document from a problem type + */ + static fromType( + problemType: ProblemType, + detail?: string, + extensions?: Record, + ): ProblemDocument { + return new ProblemDocument({ + type: problemType.urn, + status: problemType.code, + title: problemType.title, + detail, + extensions, + }); + } +} + +/** + * Standard HTTP problem types + */ +export const ProblemTypes = { + badRequest: { + code: 400, + urn: 'urn:problems:bad-request', + title: 'Request could not be processed because it is invalid.', + } as ProblemType, + + unauthorized: { + code: 401, + urn: 'urn:problems:unauthorized', + title: 'Authentication required.', + } as ProblemType, + + forbidden: { + code: 403, + urn: 'urn:problems:forbidden', + title: 'User is not authorized to perform the requested operation.', + } as ProblemType, + + notFound: { + code: 404, + urn: 'urn:problems:not-found', + title: 'The specified resource could not be found.', + } as ProblemType, + + methodNotAllowed: { + code: 405, + urn: 'urn:problems:method-not-allowed', + title: 'The specified method is not allowed.', + } as ProblemType, + + conflict: { + code: 409, + urn: 'urn:problems:conflict', + title: + 'Request could not be completed due to a conflict with the current state of the resource.', + } as ProblemType, + + tooManyRequests: { + code: 429, + urn: 'urn:problems:too-many-requests', + title: 'User has sent too many requests.', + } as ProblemType, + + internalServerError: { + code: 500, + urn: 'urn:problems:internal-server-error', + title: 'An unexpected error occurred.', + } as ProblemType, + + badGateway: { + code: 502, + urn: 'urn:problems:bad-gateway', + title: 'Invalid response from upstream server.', + } as ProblemType, + + serviceUnavailable: { + code: 503, + urn: 'urn:problems:service-unavailable', + title: 'Service is temporarily unavailable.', + } as ProblemType, + + gatewayTimeout: { + code: 504, + urn: 'urn:problems:gateway-timeout', + title: 'Timeout invoking upstream server.', + } as ProblemType, +}; + +/** + * Helper for creating problem documents + */ +export const Problems = { + badRequest: (detail?: string, extensions?: Record) => + ProblemDocument.fromType(ProblemTypes.badRequest, detail, extensions), + + unauthorized: (detail?: string, extensions?: Record) => + ProblemDocument.fromType(ProblemTypes.unauthorized, detail, extensions), + + forbidden: (detail?: string, extensions?: Record) => + ProblemDocument.fromType(ProblemTypes.forbidden, detail, extensions), + + notFound: (detail?: string, extensions?: Record) => + ProblemDocument.fromType(ProblemTypes.notFound, detail, extensions), + + methodNotAllowed: (detail?: string, extensions?: Record) => + ProblemDocument.fromType(ProblemTypes.methodNotAllowed, detail, extensions), + + conflict: (detail?: string, extensions?: Record) => + ProblemDocument.fromType(ProblemTypes.conflict, detail, extensions), + + tooManyRequests: (detail?: string, extensions?: Record) => + ProblemDocument.fromType(ProblemTypes.tooManyRequests, detail, extensions), + + internalServerError: ( + detail?: string, + extensions?: Record, + ) => + ProblemDocument.fromType( + ProblemTypes.internalServerError, + detail, + extensions, + ), + + badGateway: (detail?: string, extensions?: Record) => + ProblemDocument.fromType(ProblemTypes.badGateway, detail, extensions), + + serviceUnavailable: (detail?: string, extensions?: Record) => + ProblemDocument.fromType( + ProblemTypes.serviceUnavailable, + detail, + extensions, + ), + + gatewayTimeout: (detail?: string, extensions?: Record) => + ProblemDocument.fromType(ProblemTypes.gatewayTimeout, detail, extensions), +}; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..14baf68 --- /dev/null +++ b/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/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..e3cb252 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,41 @@ +/** + * Utility types and functions for handling key-value pairs. + * Typically used for HTTP Headers, Query parameters, and path parameters. + * + * @module utils + */ + +/** 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 in a case-insensitive manner. + * + * @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, or undefined if the key is not found. + * @throws Will throw an error if the lookupKey is not a string. + * + * @example + * ```ts + * const contentType = lookupKeyFromMap(response.headers, 'Content-Type'); + * ``` + */ +const lookupKeyFromMap = ( + map: MapType, + lookupKey: string, +): T | undefined => { + if (typeof lookupKey !== 'string') { + throw new Error('lookupKey must be a string'); + } + + if (!map) return undefined; + + const lowercaseLookupKey = lookupKey.toLowerCase(); + + return Object.entries(map).find( + ([key]) => key.toLowerCase() === lowercaseLookupKey, + )?.[1] as T | undefined; +}; + +export { MapType, lookupKeyFromMap }; diff --git a/tests/unit/ApiGateway.test.ts b/tests/unit/ApiGateway.test.ts new file mode 100644 index 0000000..cee7f1c --- /dev/null +++ b/tests/unit/ApiGateway.test.ts @@ -0,0 +1,439 @@ +/** + * Test ApiGateway Handler + * + * @group unit/types/all + */ +import { jest } from '@jest/globals'; +import { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import { + ApiGatewayResolver, + ProxyEventType, + ResponseBuilder, + Router, +} from '../../src/ApiGatewayEventRouter'; +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 = (): Promise => + Promise.resolve(new Response(200)); + + beforeAll(() => { + app = new ApiGatewayResolver(); + }); + + const testCases: [ + string, + string, + string, + number, + { [key: string]: string }?, + ][] = [ + ['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, + ], + ]; + + describe.each(testCases)( + 'Pattern Match:', + ( + routeMethod: string, + routeRule: string, + testPath: string, + expectedHTTPCode: number, + expectedPathParams?: { [key: string]: string }, + ) => { + beforeAll(() => { + app.addRoute(routeMethod, routeRule, testFunc); + }); + + test(`should resolve method: ${routeMethod} rule:${routeRule}`, async () => { + const event = { + httpMethod: routeMethod, + path: testPath, + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const response = await app.resolve(event, {} as Context); + expect(response?.statusCode).toEqual(expectedHTTPCode); + }); + + 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; + + const spyCallRoute = jest + .spyOn(ApiGatewayResolver.prototype as any, 'callRoute') + .mockImplementation(() => new ResponseBuilder(new Response(200))); + + await app.resolve(event, {} as Context); + if (expectedHTTPCode === 200 && expectedPathParams) { + expect(spyCallRoute).toHaveBeenCalled(); + expect(spyCallRoute.mock.calls[0][3]).toEqual(expectedPathParams); + } + + spyCallRoute.mockRestore(); + }); + }, + ); + + describe.each(testCases)( + '(Decorator) Pattern Match:', + ( + routeMethod, + routeRule, + testPath, + expectedHTTPCode, + expectedPathParams, + ) => { + const app: ApiGatewayResolver = new ApiGatewayResolver(); + + beforeAll(() => { + class TestRouter { + @app.route(routeRule, routeMethod) + public test(): void {} + } + new TestRouter(); + }); + + test(`should resolve method: ${routeMethod} rule:${routeRule}`, async () => { + const event = { + httpMethod: routeMethod, + path: testPath, + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const response = await app.resolve(event, {} as Context); + expect(response?.statusCode).toEqual(expectedHTTPCode); + }); + + 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; + + const spyCallRoute = jest + .spyOn(ApiGatewayResolver.prototype as any, 'callRoute') + .mockImplementation(() => new ResponseBuilder(new Response(200))); + + await app.resolve(event, {} as Context); + if (expectedHTTPCode === 200 && expectedPathParams) { + expect(spyCallRoute).toHaveBeenCalled(); + expect(spyCallRoute.mock.calls[0][3]).toEqual(expectedPathParams); + } + + spyCallRoute.mockRestore(); + }); + }, + ); + + describe('Route Convenient HTTP method decorators test', () => { + const app: ApiGatewayResolver = new ApiGatewayResolver(); + + 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); + } + } + new TestRouter(); + + const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; + + describe.each(methods)('(Decorator) Pattern Match:', (routeMethod) => { + test(`should resolve ${routeMethod} configured through decorators`, async () => { + const event = { + httpMethod: routeMethod, + path: '/test', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + const response = await app.resolve(event, {} as Context); + expect(response?.statusCode).toEqual(200); + }); + }); + }); + + describe('Feature: Multi-routers resolving', () => { + let multiRouterApp: ApiGatewayResolver; + const stripPrefixes = ['/base-path']; + + beforeEach(() => { + multiRouterApp = new ApiGatewayResolver( + ProxyEventType.APIGatewayProxyEventV2, + new CORSConfig('*', ['test_header']), + false, + stripPrefixes, + ); + }); + + 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); + + multiRouterApp.includeRoutes(router, '/v1'); + const response = await multiRouterApp.resolve(event, {} as Context); + 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, + ); + + const response = await multiRouterApp.resolve(event, {} as Context); + 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); + + multiRouterApp.includeRoutes(router, '/v1'); + const response = await multiRouterApp.resolve(event, {} as Context); + 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); + + multiRouterApp.includeRoutes(router, '/'); + const response = await multiRouterApp.resolve(event, {} as Context); + 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); + + multiRouterApp.includeRoutes(router, '/'); + const response = await multiRouterApp.resolve(event, {} as Context); + expect(response?.statusCode).toEqual(204); + }); + + test('should resolve path when multiple routers are added to BaseRouter', async () => { + const route = new Route('GET', '/multi/one', testFunc); + const router1 = new Router(); + router1.registerRoute(route.func, route.rule as string, route.method); + + const router2 = new Router(); + router2.registerRoute(route.func, route.rule as string, route.method); + + multiRouterApp.includeRoutes(router1, '/v1'); + multiRouterApp.includeRoutes(router2, '/v2'); + + let event = { + httpMethod: 'GET', + path: '/v1/multi/one', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + + let response = await multiRouterApp.resolve(event, {} as Context); + expect(response?.statusCode).toEqual(200); + + event.path = '/v2/multi/one'; + response = await multiRouterApp.resolve(event, {} as Context); + expect(response?.statusCode).toEqual(200); + }); + }); + + describe('Feature: Middlewares', () => { + let multiRouterApp: ApiGatewayResolver; + const stripPrefixes = ['/base-path']; + + beforeEach(() => { + multiRouterApp = new ApiGatewayResolver( + ProxyEventType.APIGatewayProxyEventV2, + new CORSConfig('*', ['test_header']), + false, + stripPrefixes, + ); + }); + + 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) => + await next(); + + router.registerRoute( + route.func, + route.rule as string, + route.method, + route.cors, + route.compress, + route.cacheControl, + [TestMiddleware()], + ); + + multiRouterApp.includeRoutes(router, '/v1'); + const response = await multiRouterApp.resolve(event, {} as Context); + 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/tests/unit/ResponseBuilder.test.ts b/tests/unit/ResponseBuilder.test.ts new file mode 100644 index 0000000..28cbfc5 --- /dev/null +++ b/tests/unit/ResponseBuilder.test.ts @@ -0,0 +1,315 @@ +/** + * ResponseBuilder tests + * + * @group unit/responsebuilder/all + */ +import zlib from 'node:zlib'; +import { APIGatewayProxyEvent, APIGatewayProxyEventHeaders } from 'aws-lambda'; +import { ResponseBuilder } from '../../src/ApiGatewayEventRouter'; +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/tests/unit/Router.test.ts b/tests/unit/Router.test.ts new file mode 100644 index 0000000..e9311a4 --- /dev/null +++ b/tests/unit/Router.test.ts @@ -0,0 +1,43 @@ +import { Router } from '../../src/ApiGatewayEventRouter'; +import { AsyncFunction } from '../../src/types'; + +/** + * Router tests + * + * @group unit/router/all + */ +describe('Class: Router', () => { + describe('Feature: Base routing', () => { + let router: Router; + + beforeEach(() => { + router = new Router(); + }); + + const testFunc: AsyncFunction = (): Promise => + Promise.resolve(''); + + test('should register route declaratively', () => { + router.registerRoute(testFunc, '/v1/test', 'GET'); + + expect(router.routes).toHaveLength(1); + expect(router.routes[0].method).toContain('GET'); + expect(router.routes[0].rule).toBe('/v1/test'); + }); + + test('should register route via decorators', () => { + // @ts-ignore + class TestRouter { + // @ts-ignore + @router.route('GET', '/v1/test') + public testFunc(): Promise { + return Promise.resolve(''); + } + } + + expect(router.routes).toHaveLength(1); + expect(router.routes[0].method).toContain('GET'); + expect(router.routes[0].rule).toBe('/v1/test'); + }); + }); +}); diff --git a/tests/unit/Utils.test.ts b/tests/unit/Utils.test.ts new file mode 100644 index 0000000..5925c1d --- /dev/null +++ b/tests/unit/Utils.test.ts @@ -0,0 +1,53 @@ +import { BaseProxyEvent } from '../../src/types'; +import { lookupKeyFromMap } from '../../src/utils'; + +/** + * Utils tests + * + * @group unit/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/tests/unit/middleware.test.ts b/tests/unit/middleware.test.ts new file mode 100644 index 0000000..03f92ad --- /dev/null +++ b/tests/unit/middleware.test.ts @@ -0,0 +1,98 @@ +/** + * Test Middleware + * + * @group unit/types/all + */ +import { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import { Handler, Middleware, wrapWithMiddlewares } from '../../src/middleware'; +import { ArgsDict, BaseProxyEvent, JSONData } from '../../src/types'; + +describe('wrapWithMiddlewares', () => { + const mockEvent: BaseProxyEvent = { + httpMethod: 'GET', + path: '/test', + body: null, + headers: {}, + isBase64Encoded: false, + queryStringParameters: null, + multiValueQueryStringParameters: null, + } as APIGatewayProxyEvent; + const mockContext: Context = { + awsRequestId: '123', + functionName: 'test', + invokedFunctionArn: 'arn', + logGroupName: 'group', + logStreamName: 'stream', + memoryLimitInMB: '128', + } as Context; + const mockArgs: ArgsDict = {}; + + const mockHandler: Handler = jest.fn( + async (_event, _context, _args) => { + return { message: 'handler response' }; + }, + ); + + const mockMiddleware1: Middleware = jest.fn( + async (_event, _context, _args, next) => { + const result = await next(); + return { ...result, middleware1: true }; + }, + ); + + const mockMiddleware2: Middleware = jest.fn( + async (_event, _context, _args, next) => { + const result = await next(); + return { ...result, middleware2: true }; + }, + ); + + it('should call handler without middlewares', async () => { + const wrappedHandler = wrapWithMiddlewares([], mockHandler); + const response = await wrappedHandler(mockEvent, mockContext, mockArgs); + + expect(mockHandler).toHaveBeenCalledWith(mockEvent, mockContext, mockArgs); + expect(response).toEqual({ message: 'handler response' }); + }); + + it('should call handler with middlewares', async () => { + const wrappedHandler = wrapWithMiddlewares( + [mockMiddleware1, mockMiddleware2], + mockHandler, + ); + const response = await wrappedHandler(mockEvent, mockContext, mockArgs); + + expect(mockHandler).toHaveBeenCalledWith(mockEvent, mockContext, mockArgs); + expect(mockMiddleware1).toHaveBeenCalledWith( + mockEvent, + mockContext, + mockArgs, + expect.any(Function), + ); + expect(mockMiddleware2).toHaveBeenCalledWith( + mockEvent, + mockContext, + mockArgs, + expect.any(Function), + ); + expect(response).toEqual({ + message: 'handler response', + middleware1: true, + middleware2: true, + }); + }); + + it('should handle middleware errors', async () => { + const errorMiddleware: Middleware = jest.fn( + async (_event, _context, _args, _next) => { + throw new Error('middleware error'); + }, + ); + + const wrappedHandler = wrapWithMiddlewares([errorMiddleware], mockHandler); + + await expect( + wrappedHandler(mockEvent, mockContext, mockArgs), + ).rejects.toThrow('middleware error'); + }); +}); diff --git a/tests/unit/types/BaseProxyEvent.test.ts b/tests/unit/types/BaseProxyEvent.test.ts new file mode 100644 index 0000000..da1ac03 --- /dev/null +++ b/tests/unit/types/BaseProxyEvent.test.ts @@ -0,0 +1,97 @@ +/** + * Test Logger class + * + * @group unit/types/all + */ + +import { BaseAPIGatewayProxyEvent, HTTPProxyEvent } 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(); + expect(event.resource).toBe('/v1/multi/one'); + expect(event.httpMethod).toBe('GET'); + expect(event.path).toBe('/v1/multi/one'); + expect(event.body).toBe('null'); + expect(event.headers['test-header']).toBe('test-value'); + expect(event.isBase64Encoded).toBe(false); + expect(event.multiValueHeaders).toEqual({}); + expect(event.pathParameters).toBeNull(); + expect(event.stageVariables).toBeNull(); + expect(event.queryStringParameters).toBeNull(); + expect(event.multiValueQueryStringParameters).toBeNull(); + }); + + test('should handle missing optional fields', () => { + const event = { + resource: '/v1/multi/one', + httpMethod: 'POST', + path: '/v1/multi/one', + body: 'null', + headers: { + 'test-header': 'test-value', + }, + isBase64Encoded: false, + } as unknown as BaseAPIGatewayProxyEvent; + expect(event).toBeDefined(); + expect(event.resource).toBe('/v1/multi/one'); + expect(event.httpMethod).toBe('POST'); + expect(event.path).toBe('/v1/multi/one'); + expect(event.body).toBe('null'); + expect(event.headers['test-header']).toBe('test-value'); + expect(event.isBase64Encoded).toBe(false); + expect(event.multiValueHeaders).toBeUndefined(); + expect(event.pathParameters).toBeUndefined(); + expect(event.stageVariables).toBeUndefined(); + expect(event.queryStringParameters).toBeUndefined(); + expect(event.multiValueQueryStringParameters).toBeUndefined(); + }); + + test('should validate HTTPProxyEvent with all required fields', () => { + const event = { + httpMethod: 'PUT', + body: 'test-body', + headers: { + 'Content-Type': 'application/json', + }, + isBase64Encoded: false, + validate: HTTPProxyEvent.prototype.validate, + } as HTTPProxyEvent; + expect(() => event.validate()).not.toThrow(); + }); + + const event1 = { + body: 'test-body', + headers: { + 'Content-Type': 'application/json', + }, + isBase64Encoded: false, + validate: HTTPProxyEvent.prototype.validate, + } as unknown as HTTPProxyEvent; + expect(() => event1.validate()).toThrow('HTTP method is required'); + + const event2 = { + httpMethod: 'DELETE', + body: 'test-body', + isBase64Encoded: false, + validate: HTTPProxyEvent.prototype.validate, + } as HTTPProxyEvent; + expect(() => event2.validate()).toThrow('HTTP headers are required'); + }); +}); diff --git a/tests/unit/types/CorsConfig.test.ts b/tests/unit/types/CorsConfig.test.ts new file mode 100644 index 0000000..f08d2d5 --- /dev/null +++ b/tests/unit/types/CorsConfig.test.ts @@ -0,0 +1,125 @@ +/** + * Test Logger class + * + * @group unit/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/tests/unit/types/http-problem-details.test.ts b/tests/unit/types/http-problem-details.test.ts new file mode 100644 index 0000000..2efe5ff --- /dev/null +++ b/tests/unit/types/http-problem-details.test.ts @@ -0,0 +1,266 @@ +/** + * Test HTTP Problem Details + * + * @group unit/types/all + */ + +import { v4 as uuidv4 } from 'uuid'; +import { + MIME_TYPE, + UUID_PREFIX, + ProblemType, + ProblemDocument, + ProblemTypes, + Problems, +} from '../../../src/types/http-problem-details'; + +// Mock uuid for consistent testing +jest.mock('uuid'); + +describe('Module: http-problem-details', () => { + // Setup for consistent UUID + beforeEach(() => { + (uuidv4 as jest.Mock).mockReturnValue('test-uuid-value'); + }); + + describe('Constants', () => { + test('should export MIME_TYPE constant', () => { + expect(MIME_TYPE).toBeDefined(); + expect(MIME_TYPE).toEqual('application/problem+json'); + }); + + test('should export UUID_PREFIX constant', () => { + expect(UUID_PREFIX).toBeDefined(); + expect(UUID_PREFIX).toEqual('urn:uuid:'); + }); + }); + + describe('ProblemTypes', () => { + test('should define all standard HTTP problem types', () => { + expect(ProblemTypes.badRequest).toBeDefined(); + expect(ProblemTypes.unauthorized).toBeDefined(); + expect(ProblemTypes.forbidden).toBeDefined(); + expect(ProblemTypes.notFound).toBeDefined(); + expect(ProblemTypes.methodNotAllowed).toBeDefined(); + expect(ProblemTypes.conflict).toBeDefined(); + expect(ProblemTypes.tooManyRequests).toBeDefined(); + expect(ProblemTypes.internalServerError).toBeDefined(); + expect(ProblemTypes.badGateway).toBeDefined(); + expect(ProblemTypes.serviceUnavailable).toBeDefined(); + expect(ProblemTypes.gatewayTimeout).toBeDefined(); + }); + + test('should have correct properties for each problem type', () => { + // Test a few representative types + expect(ProblemTypes.badRequest.code).toEqual(400); + expect(ProblemTypes.badRequest.urn).toEqual('urn:problems:bad-request'); + expect(ProblemTypes.badRequest.title).toEqual( + 'Request could not be processed because it is invalid.', + ); + + expect(ProblemTypes.notFound.code).toEqual(404); + expect(ProblemTypes.notFound.urn).toEqual('urn:problems:not-found'); + expect(ProblemTypes.notFound.title).toEqual( + 'The specified resource could not be found.', + ); + + expect(ProblemTypes.internalServerError.code).toEqual(500); + expect(ProblemTypes.internalServerError.urn).toEqual( + 'urn:problems:internal-server-error', + ); + expect(ProblemTypes.internalServerError.title).toEqual( + 'An unexpected error occurred.', + ); + }); + }); + + describe('Class: ProblemDocument', () => { + test('should create a problem document with provided values', () => { + const now = new Date(); + // Save the original Date constructor + const OriginalDate = global.Date; + // Mock Date to return a fixed value + global.Date = jest.fn(() => now) as any; + global.Date.UTC = OriginalDate.UTC; + global.Date.parse = OriginalDate.parse; + global.Date.now = OriginalDate.now; + + const problemDoc = new ProblemDocument({ + type: 'test:type', + status: 418, + title: "I'm a teapot", + detail: 'Cannot brew coffee in a teapot', + instance: 'test:instance', + extensions: { key: 'value' }, + }); + + expect(problemDoc.type).toEqual('test:type'); + expect(problemDoc.status).toEqual(418); + expect(problemDoc.title).toEqual("I'm a teapot"); + expect(problemDoc.detail).toEqual('Cannot brew coffee in a teapot'); + expect(problemDoc.instance).toEqual('test:instance'); + expect(problemDoc.created).toEqual(now.toISOString()); + expect(problemDoc.extensions).toEqual({ key: 'value' }); + + // Restore the original Date constructor + global.Date = OriginalDate; + }); + + test('should generate instance if not provided', () => { + const problemDoc = new ProblemDocument({ + status: 500, + }); + + expect(problemDoc.instance).toEqual(`${UUID_PREFIX}test-uuid-value`); + }); + + test('should create a problem document from a problem type', () => { + const now = new Date(); + // Save the original Date constructor + const OriginalDate = global.Date; + // Mock Date to return a fixed value + global.Date = jest.fn(() => now) as any; + global.Date.UTC = OriginalDate.UTC; + global.Date.parse = OriginalDate.parse; + global.Date.now = OriginalDate.now; + + const problemType: ProblemType = { + code: 418, + urn: 'test:type', + title: 'Test Title', + }; + + const detail = 'Test detail'; + const extensions = { key: 'value' }; + + const problemDoc = ProblemDocument.fromType( + problemType, + detail, + extensions, + ); + + expect(problemDoc.type).toEqual(problemType.urn); + expect(problemDoc.status).toEqual(problemType.code); + expect(problemDoc.title).toEqual(problemType.title); + expect(problemDoc.detail).toEqual(detail); + expect(problemDoc.instance).toEqual(`${UUID_PREFIX}test-uuid-value`); + expect(problemDoc.created).toEqual(now.toISOString()); + expect(problemDoc.extensions).toEqual(extensions); + + // Restore the original Date constructor + global.Date = OriginalDate; + }); + }); + + describe('Problems Helper', () => { + test('should create a badRequest problem document', () => { + const detail = 'Invalid input'; + const extensions = { fields: ['name', 'email'] }; + const problem = Problems.badRequest(detail, extensions); + + expect(problem.type).toEqual(ProblemTypes.badRequest.urn); + expect(problem.status).toEqual(ProblemTypes.badRequest.code); + expect(problem.title).toEqual(ProblemTypes.badRequest.title); + expect(problem.detail).toEqual(detail); + expect(problem.extensions).toEqual(extensions); + }); + + test('should create an unauthorized problem document', () => { + const detail = 'Authentication required'; + const problem = Problems.unauthorized(detail); + + expect(problem.type).toEqual(ProblemTypes.unauthorized.urn); + expect(problem.status).toEqual(ProblemTypes.unauthorized.code); + expect(problem.title).toEqual(ProblemTypes.unauthorized.title); + expect(problem.detail).toEqual(detail); + }); + + test('should create a forbidden problem document', () => { + const detail = 'Access denied'; + const problem = Problems.forbidden(detail); + + expect(problem.type).toEqual(ProblemTypes.forbidden.urn); + expect(problem.status).toEqual(ProblemTypes.forbidden.code); + expect(problem.title).toEqual(ProblemTypes.forbidden.title); + expect(problem.detail).toEqual(detail); + }); + + test('should create a notFound problem document', () => { + const detail = 'Resource not found'; + const problem = Problems.notFound(detail); + + expect(problem.type).toEqual(ProblemTypes.notFound.urn); + expect(problem.status).toEqual(ProblemTypes.notFound.code); + expect(problem.title).toEqual(ProblemTypes.notFound.title); + expect(problem.detail).toEqual(detail); + }); + + test('should create a methodNotAllowed problem document', () => { + const detail = 'Method not allowed'; + const problem = Problems.methodNotAllowed(detail); + + expect(problem.type).toEqual(ProblemTypes.methodNotAllowed.urn); + expect(problem.status).toEqual(ProblemTypes.methodNotAllowed.code); + expect(problem.title).toEqual(ProblemTypes.methodNotAllowed.title); + expect(problem.detail).toEqual(detail); + }); + + test('should create a conflict problem document', () => { + const detail = 'Resource already exists'; + const problem = Problems.conflict(detail); + + expect(problem.type).toEqual(ProblemTypes.conflict.urn); + expect(problem.status).toEqual(ProblemTypes.conflict.code); + expect(problem.title).toEqual(ProblemTypes.conflict.title); + expect(problem.detail).toEqual(detail); + }); + + test('should create a tooManyRequests problem document', () => { + const detail = 'Rate limit exceeded'; + const problem = Problems.tooManyRequests(detail); + + expect(problem.type).toEqual(ProblemTypes.tooManyRequests.urn); + expect(problem.status).toEqual(ProblemTypes.tooManyRequests.code); + expect(problem.title).toEqual(ProblemTypes.tooManyRequests.title); + expect(problem.detail).toEqual(detail); + }); + + test('should create an internalServerError problem document', () => { + const problem = Problems.internalServerError(); + + expect(problem.type).toEqual(ProblemTypes.internalServerError.urn); + expect(problem.status).toEqual(ProblemTypes.internalServerError.code); + expect(problem.title).toEqual(ProblemTypes.internalServerError.title); + }); + + test('should create a badGateway problem document', () => { + const detail = 'Invalid upstream response'; + const problem = Problems.badGateway(detail); + + expect(problem.type).toEqual(ProblemTypes.badGateway.urn); + expect(problem.status).toEqual(ProblemTypes.badGateway.code); + expect(problem.title).toEqual(ProblemTypes.badGateway.title); + expect(problem.detail).toEqual(detail); + }); + + test('should create a serviceUnavailable problem document', () => { + const detail = 'Service is down for maintenance'; + const problem = Problems.serviceUnavailable(detail); + + expect(problem.type).toEqual(ProblemTypes.serviceUnavailable.urn); + expect(problem.status).toEqual(ProblemTypes.serviceUnavailable.code); + expect(problem.title).toEqual(ProblemTypes.serviceUnavailable.title); + expect(problem.detail).toEqual(detail); + }); + + test('should create a gatewayTimeout problem document', () => { + const detail = 'Upstream server timed out'; + const problem = Problems.gatewayTimeout(detail); + + expect(problem.type).toEqual(ProblemTypes.gatewayTimeout.urn); + expect(problem.status).toEqual(ProblemTypes.gatewayTimeout.code); + expect(problem.title).toEqual(ProblemTypes.gatewayTimeout.title); + expect(problem.detail).toEqual(detail); + }); + }); +}); diff --git a/tsconfig-dev.json b/tsconfig-dev.json new file mode 100644 index 0000000..6f76685 --- /dev/null +++ b/tsconfig-dev.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declarationMap": true, + "esModuleInterop": false + }, + "include": [ "src/**/*", "examples/**/*", "**/tests/**/*" ], + "types": [ + "jest" + ] +} \ No newline at end of file diff --git a/tsconfig.es.json b/tsconfig.es.json new file mode 100644 index 0000000..6f76685 --- /dev/null +++ b/tsconfig.es.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declarationMap": true, + "esModuleInterop": false + }, + "include": [ "src/**/*", "examples/**/*", "**/tests/**/*" ], + "types": [ + "jest" + ] +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4694eb7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + "target": "es2020", // Modern target for better performance and features + "module": "commonjs", // CommonJS for compatibility + "lib": ["ES2020"], // Include modern JavaScript features + "strict": true, // Enable all strict type-checking options + "noImplicitAny": true, // Disallow implicit 'any' types + "strictNullChecks": true, // Ensure strict null checks + "strictFunctionTypes": true, // Ensure strict function types + "strictBindCallApply": true, // Ensure strict bind, call, and apply methods + "strictPropertyInitialization": true, // Ensure strict property initialization + "noImplicitThis": true, // Disallow 'this' of type 'any' + "alwaysStrict": true, // Parse in strict mode and emit "use strict" + "noUnusedLocals": true, // Report errors on unused locals + "noUnusedParameters": true, // Report errors on unused parameters + "noImplicitReturns": true, // Report error when not all code paths in function return a value + "noFallthroughCasesInSwitch": true, // Report errors for fallthrough cases in switch statements + "esModuleInterop": true, // Enable interoperability between CommonJS and ES Modules + "forceConsistentCasingInFileNames": true, // Ensure consistent casing in file names + "skipLibCheck": true, // Skip type checking of declaration files + "moduleResolution": "node", // Use Node.js module resolution + "resolveJsonModule": true, // Include modules imported with .json extension + "experimentalDecorators": true, // Enable experimental support for decorators + "emitDecoratorMetadata": true, // Emit design-type metadata for decorated declarations + "declaration": true, // Generate .d.ts files + "declarationMap": true, // Create sourcemaps for d.ts files + "outDir": "lib", // Redirect output structure to the directory + "baseUrl": "src", // Base directory to resolve non-relative module names + "rootDirs": ["src"], // List of root folders whose combined content represents the structure of the project + "pretty": true, // Enable color and formatting in TypeScript's output + "inlineSourceMap": true, // Emit a single file with source maps instead of having a separate file + "incremental": true, // Enable incremental compilation + "tsBuildInfoFile": "lib/.tsbuildinfo" // Specify the folder for .tsbuildinfo incremental compilation files + }, + "include": ["src/**/*"], // Include all files in the src directory + "exclude": ["node_modules", "lib"], // Exclude node_modules and output directory + "watchOptions": { + "watchFile": "useFsEvents", // Use file system events for watching files + "watchDirectory": "useFsEvents", // Use file system events for watching directories + "fallbackPolling": "dynamicPriority" // Use dynamic priority polling as a fallback + }, + "types": ["node"] // Include type definitions for Node.js +} \ No newline at end of file diff --git a/typedoc.js b/typedoc.js new file mode 100644 index 0000000..6d15f6c --- /dev/null +++ b/typedoc.js @@ -0,0 +1,9 @@ +module.exports = { + out: 'api', + exclude: [ '**/node_modules/**', '**/*.test.ts', '**/*.json' ], + name: 'digital-event-handler', + excludePrivate: true, + entryPointStrategy: 'resolve', + readme: './README.md', + tsconfig: './tsconfig.json' +}; \ No newline at end of file