├── .eslintrc.js ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .nvmrc ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .yarnclean ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── docs ├── .nojekyll ├── assets │ ├── highlight.css │ ├── main.js │ ├── search.js │ └── style.css ├── classes │ ├── ApiApp.html │ ├── ApiError.html │ ├── ApiLambdaApp.html │ ├── ApiRequest.html │ ├── ApiResponse.html │ ├── AppConfig.html │ ├── BasicAuth.html │ ├── BasicAuthFilter.html │ ├── Controller.html │ ├── ErrorInterceptor.html │ ├── LogFactory.html │ ├── MiddlewareRegistry.html │ ├── OpenApiConfig.html │ ├── Principal.html │ ├── RequestBuilder.html │ ├── Server.html │ └── ServerLoggerConfig.html ├── enums │ └── LogLevel.html ├── functions │ ├── DELETE.html │ ├── GET.html │ ├── PATCH.html │ ├── POST.html │ ├── PUT.html │ ├── api.html │ ├── apiController.html │ ├── apiIgnore.html │ ├── apiIgnoreController.html │ ├── apiOperation.html │ ├── apiRequest-1.html │ ├── apiResponse-1.html │ ├── apiSecurity.html │ ├── body.html │ ├── consumes.html │ ├── controllerConsumes.html │ ├── controllerErrorInterceptor.html │ ├── controllerNoAuth.html │ ├── controllerProduces.html │ ├── controllerRolesAllowed.html │ ├── errorInterceptor-1.html │ ├── header.html │ ├── noAuth.html │ ├── pathParam.html │ ├── principal-1.html │ ├── produces.html │ ├── queryParam.html │ ├── rawBody.html │ ├── request.html │ ├── response.html │ ├── rolesAllowed.html │ └── timed.html ├── index.html ├── interfaces │ ├── IAuthFilter.html │ ├── IAuthorizer.html │ ├── IDictionary.html │ └── ILogger.html ├── modules.html └── types │ ├── JsonPatch.html │ └── LogFormat.html ├── package.json ├── scripts └── runTests.sh ├── src ├── ApiApp.ts ├── ApiLambdaApp.ts ├── api │ ├── Controller.ts │ ├── Endpoint.ts │ ├── MiddlewareRegistry.ts │ ├── Server.ts │ ├── decorator │ │ ├── apiController.ts │ │ ├── context │ │ │ ├── consumes.ts │ │ │ ├── parameters │ │ │ │ ├── body.ts │ │ │ │ ├── header.ts │ │ │ │ ├── pathParam.ts │ │ │ │ ├── principal.ts │ │ │ │ ├── queryParam.ts │ │ │ │ ├── rawBody.ts │ │ │ │ ├── request.ts │ │ │ │ └── response.ts │ │ │ └── produces.ts │ │ ├── endpoints.ts │ │ ├── error │ │ │ └── errorInterceptor.ts │ │ ├── open-api │ │ │ ├── api.ts │ │ │ ├── apiIgnore.ts │ │ │ ├── apiIgnoreController.ts │ │ │ ├── apiOperation.ts │ │ │ ├── apiRequest.ts │ │ │ ├── apiResponse.ts │ │ │ └── apiSecurity.ts │ │ └── security │ │ │ ├── noAuth.ts │ │ │ └── rolesAllowed.ts │ ├── error │ │ └── ErrorInterceptor.ts │ ├── open-api │ │ └── OpenApiGenerator.ts │ ├── parameters │ │ ├── BaseParameterExtractor.ts │ │ ├── BodyParameterExtractor.ts │ │ ├── HeaderParameterExtractor.ts │ │ ├── IParameterExtractor.ts │ │ ├── PathParameterExtractor.ts │ │ ├── QueryParameterExtractor.ts │ │ ├── RawBodyParameterExtractor.ts │ │ ├── RequestParameterExtractor.ts │ │ ├── ResponseParameterExtractor.ts │ │ └── UserParameterExtractor.ts │ ├── reflection │ │ ├── ControllerLoader.ts │ │ └── DecoratorRegistry.ts │ └── security │ │ ├── BasicAuthFilter.ts │ │ ├── IAuthFilter.ts │ │ └── IAuthorizer.ts ├── model │ ├── ApiError.ts │ ├── ApiRequest.ts │ ├── ApiResponse.ts │ ├── AppConfig.ts │ ├── JsonPatch.ts │ ├── OpenApiConfig.ts │ ├── logging │ │ ├── LogLevel.ts │ │ └── ServerLoggerConfig.ts │ ├── open-api │ │ ├── ApiBody.ts │ │ ├── ApiBodyInfo.ts │ │ ├── ApiOperation.ts │ │ ├── ApiOperationInfo.ts │ │ ├── ApiParam.ts │ │ └── AuthFilterInfo.ts │ ├── reflection │ │ ├── ControllerInfo.ts │ │ └── EndpointInfo.ts │ └── security │ │ ├── AuthResult.ts │ │ ├── BasicAuth.ts │ │ └── Principal.ts ├── ts-lambda-api.ts └── util │ ├── Environment.ts │ ├── IDictionary.ts │ ├── RequestBuilder.ts │ ├── jsonUtils.ts │ ├── logging │ ├── ConsoleLogger.ts │ ├── ILogger.ts │ └── LogFactory.ts │ └── timed.ts ├── tests ├── banner.txt ├── src │ ├── ApiAcceptanceTests.ts │ ├── ApiLambdaAppTests.ts │ ├── AuthFilterTests.ts │ ├── AuthorizerTests.ts │ ├── ErrorInterceptorTests.ts │ ├── LogFactoryTests.ts │ ├── OpenApiTests.ts │ ├── TestBase.ts │ ├── test-components │ │ ├── TestAuthFilter.ts │ │ ├── TestAuthorizer.ts │ │ ├── TestCustomAuthFilter.ts │ │ ├── TestDecoratorErrorInterceptor.ts │ │ ├── TestErrorInterceptor.ts │ │ └── model │ │ │ ├── ApiError.ts │ │ │ ├── ArrayOfPrimitivesExample.ts │ │ │ ├── ConstructorOnlyModel.ts │ │ │ ├── EdgeCaseModel.ts │ │ │ ├── Location.ts │ │ │ ├── NullFieldsModel.ts │ │ │ ├── People.ts │ │ │ ├── Person.ts │ │ │ ├── PrimitiveExample.ts │ │ │ ├── ResponseWithValue.ts │ │ │ └── TestUser.ts │ └── test-controllers │ │ ├── ConsumesTestController.ts │ │ ├── ControllerErrorDecoratorTestController.ts │ │ ├── EdgeCaseController.ts │ │ ├── ErrorDecoratorTestController.ts │ │ ├── ImplicitRoutesController.ts │ │ ├── MethodTestsController.ts │ │ ├── NoAuthController.ts │ │ ├── NoRootPathController.ts │ │ ├── OpenApiTestController.ts │ │ ├── OpenApiTestIgnoredController.ts │ │ ├── OpenApiTestIgnoredEndpointController.ts │ │ ├── RestrictedTestController.ts │ │ └── TestController.ts ├── test.pdf └── tsconfig.json ├── ts-lambda-api.code-workspace ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "project": "tsconfig.json", 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "eslint-plugin-import", 17 | "eslint-plugin-jsdoc", 18 | "eslint-plugin-prefer-arrow", 19 | "@typescript-eslint" 20 | ], 21 | "root": true, 22 | "rules": { 23 | "@typescript-eslint/adjacent-overload-signatures": "error", 24 | "@typescript-eslint/array-type": "off", 25 | "@typescript-eslint/ban-types": "off", 26 | "@typescript-eslint/consistent-type-assertions": "error", 27 | "@typescript-eslint/dot-notation": "error", 28 | "@typescript-eslint/explicit-function-return-type": "off", 29 | "@typescript-eslint/explicit-module-boundary-types": "off", 30 | "@typescript-eslint/member-delimiter-style": [ 31 | "off", 32 | { 33 | "multiline": { 34 | "delimiter": "none", 35 | "requireLast": true 36 | }, 37 | "singleline": { 38 | "delimiter": "semi", 39 | "requireLast": false 40 | } 41 | } 42 | ], 43 | "@typescript-eslint/member-ordering": "off", 44 | "@typescript-eslint/naming-convention": [ 45 | "off", 46 | { 47 | "selector": "variable", 48 | "format": [ 49 | "camelCase", 50 | "UPPER_CASE" 51 | ], 52 | "leadingUnderscore": "forbid", 53 | "trailingUnderscore": "forbid" 54 | } 55 | ], 56 | "@typescript-eslint/no-empty-function": "error", 57 | "@typescript-eslint/no-empty-interface": "error", 58 | "@typescript-eslint/no-explicit-any": "off", 59 | "@typescript-eslint/no-misused-new": "error", 60 | "@typescript-eslint/no-namespace": "error", 61 | "@typescript-eslint/no-parameter-properties": "off", 62 | "@typescript-eslint/no-shadow": [ 63 | "error", 64 | { 65 | "hoist": "all" 66 | } 67 | ], 68 | "@typescript-eslint/no-unused-expressions": "error", 69 | "@typescript-eslint/no-use-before-define": "off", 70 | "@typescript-eslint/no-unsafe-argument": "off", 71 | "@typescript-eslint/no-unsafe-assignment": "off", 72 | "@typescript-eslint/no-unsafe-call": "off", 73 | "@typescript-eslint/no-unsafe-member-access": "off", 74 | "@typescript-eslint/no-unsafe-return": "off", 75 | "@typescript-eslint/no-var-requires": "error", 76 | "@typescript-eslint/prefer-for-of": "error", 77 | "@typescript-eslint/prefer-function-type": "error", 78 | "@typescript-eslint/prefer-namespace-keyword": "error", 79 | "@typescript-eslint/semi": [ 80 | "off", 81 | null 82 | ], 83 | "@typescript-eslint/triple-slash-reference": [ 84 | "error", 85 | { 86 | "path": "always", 87 | "types": "prefer-import", 88 | "lib": "always" 89 | } 90 | ], 91 | "@typescript-eslint/typedef": "off", 92 | "@typescript-eslint/unified-signatures": "error", 93 | "arrow-parens": [ 94 | "off", 95 | "always" 96 | ], 97 | "comma-dangle": "off", 98 | "complexity": "off", 99 | "constructor-super": "error", 100 | "curly": "error", 101 | "dot-notation": "off", 102 | "eqeqeq": [ 103 | "error", 104 | "smart" 105 | ], 106 | "guard-for-in": "error", 107 | "id-denylist": "off", 108 | "id-match": "off", 109 | "import/order": [ 110 | "off", 111 | { 112 | "alphabetize": { 113 | "caseInsensitive": true, 114 | "order": "asc" 115 | }, 116 | "newlines-between": "ignore", 117 | "groups": [ 118 | [ 119 | "builtin", 120 | "external", 121 | "internal", 122 | "unknown", 123 | "object", 124 | "type" 125 | ], 126 | "parent", 127 | [ 128 | "sibling", 129 | "index" 130 | ] 131 | ], 132 | "distinctGroup": false, 133 | "pathGroupsExcludedImportTypes": [], 134 | "pathGroups": [ 135 | { 136 | "pattern": "./", 137 | "patternOptions": { 138 | "nocomment": true, 139 | "dot": true 140 | }, 141 | "group": "sibling", 142 | "position": "before" 143 | }, 144 | { 145 | "pattern": ".", 146 | "patternOptions": { 147 | "nocomment": true, 148 | "dot": true 149 | }, 150 | "group": "sibling", 151 | "position": "before" 152 | }, 153 | { 154 | "pattern": "..", 155 | "patternOptions": { 156 | "nocomment": true, 157 | "dot": true 158 | }, 159 | "group": "parent", 160 | "position": "before" 161 | }, 162 | { 163 | "pattern": "../", 164 | "patternOptions": { 165 | "nocomment": true, 166 | "dot": true 167 | }, 168 | "group": "parent", 169 | "position": "before" 170 | } 171 | ] 172 | } 173 | ], 174 | "jsdoc/check-alignment": "error", 175 | "jsdoc/check-indentation": "off", 176 | "jsdoc/tag-lines": [ 177 | "off", 178 | "any", 179 | { 180 | "startLines": 1 181 | } 182 | ], 183 | "max-classes-per-file": [ 184 | "error", 185 | 1 186 | ], 187 | "new-parens": "error", 188 | "no-bitwise": "error", 189 | "no-caller": "error", 190 | "no-cond-assign": "error", 191 | "no-console": "off", 192 | "no-debugger": "error", 193 | "no-empty": "error", 194 | "no-empty-function": "off", 195 | "no-eval": "error", 196 | "no-fallthrough": "off", 197 | "no-invalid-this": "off", 198 | "no-new-wrappers": "error", 199 | "no-shadow": "off", 200 | "no-throw-literal": "error", 201 | "no-trailing-spaces": "error", 202 | "no-undef-init": "error", 203 | "no-underscore-dangle": "off", 204 | "no-unsafe-finally": "error", 205 | "no-unused-expressions": "off", 206 | "no-unused-labels": "error", 207 | "no-use-before-define": "off", 208 | "no-var": "error", 209 | "object-shorthand": "error", 210 | "one-var": [ 211 | "error", 212 | "never" 213 | ], 214 | "prefer-arrow/prefer-arrow-functions": "off", 215 | "prefer-const": "off", 216 | "radix": "error", 217 | "semi": "off", 218 | "spaced-comment": [ 219 | "error", 220 | "always", 221 | { 222 | "markers": [ 223 | "/" 224 | ] 225 | } 226 | ], 227 | "use-isnan": "error", 228 | "valid-typeof": "off" 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | pull_request: {} 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Install Node.JS Version 16 | shell: bash -l -eo pipefail {0} 17 | run: nvm install 18 | 19 | - name: Test 20 | shell: bash -l -eo pipefail {0} 21 | run: nvm use && yarn test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | .env.test 60 | 61 | # parcel-bundler cache (https://parceljs.org/) 62 | .cache 63 | 64 | # next.js build output 65 | .next 66 | 67 | # nuxt.js build output 68 | .nuxt 69 | 70 | # vuepress build output 71 | .vuepress/dist 72 | 73 | # Serverless directories 74 | .serverless/ 75 | 76 | # FuseBox cache 77 | .fusebox/ 78 | 79 | # DynamoDB Local files 80 | .dynamodb/ 81 | 82 | dist 83 | tests/js 84 | 85 | .test_results 86 | 87 | package-lock.json 88 | 89 | .DS_Store 90 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Run Tests", 8 | "preLaunchTask": "npm: build-all", 9 | "program": "${workspaceFolder}/node_modules/.bin/alsatian", 10 | "cwd": "${workspaceFolder}", 11 | "outputCapture": "std", 12 | "console": "integratedTerminal", 13 | "args": [ 14 | "--tap", 15 | "./tests/js/**/*Tests.js" 16 | ], 17 | // the below settings are required, otherwise debugging is flaky 18 | // "protocol": "inspector", 19 | "sourceMaps": true, 20 | "outFiles": [ 21 | "dist/**/*.js", 22 | "tests/js/**/*.js" 23 | ], 24 | "env": { 25 | "TLA_UNDER_TEST": "1" 26 | } 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Dropwizard", 4 | "Healthcheck", 5 | "Inversify", 6 | "openapi", 7 | "presigner", 8 | "serialise", 9 | "serialised", 10 | "sprintf", 11 | "xmljs" 12 | ] 13 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "build", 7 | "problemMatcher": [] 8 | }, 9 | { 10 | "type": "npm", 11 | "script": "build-tests", 12 | "problemMatcher": [] 13 | }, 14 | { 15 | "type": "npm", 16 | "script": "build-all", 17 | "problemMatcher": [] 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /.yarnclean: -------------------------------------------------------------------------------- 1 | # test directories 2 | __tests__ 3 | test 4 | tests 5 | powered-test 6 | 7 | # asset directories 8 | docs 9 | doc 10 | website 11 | images 12 | assets 13 | 14 | # examples 15 | example 16 | examples 17 | 18 | # code coverage directories 19 | coverage 20 | .nyc_output 21 | 22 | # build scripts 23 | Makefile 24 | Gulpfile.js 25 | Gruntfile.js 26 | 27 | # configs 28 | appveyor.yml 29 | circle.yml 30 | codeship-services.yml 31 | codeship-steps.yml 32 | wercker.yml 33 | .tern-project 34 | .gitattributes 35 | .editorconfig 36 | .*ignore 37 | .eslintrc 38 | .jshintrc 39 | .flowconfig 40 | .documentup.json 41 | .yarn-metadata.json 42 | .travis.yml 43 | 44 | # misc 45 | *.md 46 | 47 | # custom 48 | .istanbul.yml 49 | .eslintrc.json 50 | 51 | AUTHORS 52 | LICENSE 53 | *.markdown 54 | *.txt 55 | 56 | # below modules are broken by the above rules, exclude them from cleaning 57 | !nyc/**/* 58 | !typedoc-default-themes/**/* 59 | !xunit-viewer/**/* 60 | !istanbul-reports/**/* 61 | !yaml/**/* 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Matthew Snoddy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | junit2html = "==30.1.3" 10 | 11 | [requires] 12 | python_version = "3" 13 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "0a2f9be7e34439dfde388467825291f3e6729ee5b5e360b97bde64f4412cc960" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "jinja2": { 20 | "hashes": [ 21 | "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", 22 | "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" 23 | ], 24 | "markers": "python_version >= '3.7'", 25 | "version": "==3.1.2" 26 | }, 27 | "junit2html": { 28 | "hashes": [ 29 | "sha256:842e833917c60b5a4a6a2f7ffebd069e6c572ced82f66b12e395c7ab3dceae6b" 30 | ], 31 | "index": "pypi", 32 | "version": "==30.1.3" 33 | }, 34 | "markupsafe": { 35 | "hashes": [ 36 | "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003", 37 | "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88", 38 | "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5", 39 | "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7", 40 | "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a", 41 | "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603", 42 | "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1", 43 | "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135", 44 | "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247", 45 | "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6", 46 | "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601", 47 | "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77", 48 | "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02", 49 | "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e", 50 | "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63", 51 | "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f", 52 | "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980", 53 | "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b", 54 | "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812", 55 | "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff", 56 | "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96", 57 | "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1", 58 | "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925", 59 | "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a", 60 | "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6", 61 | "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e", 62 | "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f", 63 | "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4", 64 | "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f", 65 | "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3", 66 | "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c", 67 | "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a", 68 | "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417", 69 | "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a", 70 | "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a", 71 | "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37", 72 | "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452", 73 | "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933", 74 | "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a", 75 | "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7" 76 | ], 77 | "markers": "python_version >= '3.7'", 78 | "version": "==2.1.1" 79 | } 80 | }, 81 | "develop": {} 82 | } 83 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /docs/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-hl-0: #795E26; 3 | --dark-hl-0: #DCDCAA; 4 | --light-hl-1: #000000; 5 | --dark-hl-1: #D4D4D4; 6 | --light-hl-2: #A31515; 7 | --dark-hl-2: #CE9178; 8 | --light-hl-3: #0000FF; 9 | --dark-hl-3: #569CD6; 10 | --light-hl-4: #0451A5; 11 | --dark-hl-4: #9CDCFE; 12 | --light-hl-5: #AF00DB; 13 | --dark-hl-5: #C586C0; 14 | --light-hl-6: #001080; 15 | --dark-hl-6: #9CDCFE; 16 | --light-hl-7: #0070C1; 17 | --dark-hl-7: #4FC1FF; 18 | --light-hl-8: #008000; 19 | --dark-hl-8: #6A9955; 20 | --light-hl-9: #267F99; 21 | --dark-hl-9: #4EC9B0; 22 | --light-hl-10: #098658; 23 | --dark-hl-10: #B5CEA8; 24 | --light-hl-11: #000000; 25 | --dark-hl-11: #C8C8C8; 26 | --light-hl-12: #811F3F; 27 | --dark-hl-12: #D16969; 28 | --light-code-background: #FFFFFF; 29 | --dark-code-background: #1E1E1E; 30 | } 31 | 32 | @media (prefers-color-scheme: light) { :root { 33 | --hl-0: var(--light-hl-0); 34 | --hl-1: var(--light-hl-1); 35 | --hl-2: var(--light-hl-2); 36 | --hl-3: var(--light-hl-3); 37 | --hl-4: var(--light-hl-4); 38 | --hl-5: var(--light-hl-5); 39 | --hl-6: var(--light-hl-6); 40 | --hl-7: var(--light-hl-7); 41 | --hl-8: var(--light-hl-8); 42 | --hl-9: var(--light-hl-9); 43 | --hl-10: var(--light-hl-10); 44 | --hl-11: var(--light-hl-11); 45 | --hl-12: var(--light-hl-12); 46 | --code-background: var(--light-code-background); 47 | } } 48 | 49 | @media (prefers-color-scheme: dark) { :root { 50 | --hl-0: var(--dark-hl-0); 51 | --hl-1: var(--dark-hl-1); 52 | --hl-2: var(--dark-hl-2); 53 | --hl-3: var(--dark-hl-3); 54 | --hl-4: var(--dark-hl-4); 55 | --hl-5: var(--dark-hl-5); 56 | --hl-6: var(--dark-hl-6); 57 | --hl-7: var(--dark-hl-7); 58 | --hl-8: var(--dark-hl-8); 59 | --hl-9: var(--dark-hl-9); 60 | --hl-10: var(--dark-hl-10); 61 | --hl-11: var(--dark-hl-11); 62 | --hl-12: var(--dark-hl-12); 63 | --code-background: var(--dark-code-background); 64 | } } 65 | 66 | :root[data-theme='light'] { 67 | --hl-0: var(--light-hl-0); 68 | --hl-1: var(--light-hl-1); 69 | --hl-2: var(--light-hl-2); 70 | --hl-3: var(--light-hl-3); 71 | --hl-4: var(--light-hl-4); 72 | --hl-5: var(--light-hl-5); 73 | --hl-6: var(--light-hl-6); 74 | --hl-7: var(--light-hl-7); 75 | --hl-8: var(--light-hl-8); 76 | --hl-9: var(--light-hl-9); 77 | --hl-10: var(--light-hl-10); 78 | --hl-11: var(--light-hl-11); 79 | --hl-12: var(--light-hl-12); 80 | --code-background: var(--light-code-background); 81 | } 82 | 83 | :root[data-theme='dark'] { 84 | --hl-0: var(--dark-hl-0); 85 | --hl-1: var(--dark-hl-1); 86 | --hl-2: var(--dark-hl-2); 87 | --hl-3: var(--dark-hl-3); 88 | --hl-4: var(--dark-hl-4); 89 | --hl-5: var(--dark-hl-5); 90 | --hl-6: var(--dark-hl-6); 91 | --hl-7: var(--dark-hl-7); 92 | --hl-8: var(--dark-hl-8); 93 | --hl-9: var(--dark-hl-9); 94 | --hl-10: var(--dark-hl-10); 95 | --hl-11: var(--dark-hl-11); 96 | --hl-12: var(--dark-hl-12); 97 | --code-background: var(--dark-code-background); 98 | } 99 | 100 | .hl-0 { color: var(--hl-0); } 101 | .hl-1 { color: var(--hl-1); } 102 | .hl-2 { color: var(--hl-2); } 103 | .hl-3 { color: var(--hl-3); } 104 | .hl-4 { color: var(--hl-4); } 105 | .hl-5 { color: var(--hl-5); } 106 | .hl-6 { color: var(--hl-6); } 107 | .hl-7 { color: var(--hl-7); } 108 | .hl-8 { color: var(--hl-8); } 109 | .hl-9 { color: var(--hl-9); } 110 | .hl-10 { color: var(--hl-10); } 111 | .hl-11 { color: var(--hl-11); } 112 | .hl-12 { color: var(--hl-12); } 113 | pre, code { background: var(--code-background); } 114 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-lambda-api", 3 | "description": "Build REST API's using Typescript & AWS Lambda. Support for decorator based routing and dependecy injection using InversifyJS. This project is built on top of the wonderful lambda-api package.", 4 | "version": "2.4.2", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/djfdyuruiry/ts-lambda-api.git" 8 | }, 9 | "scripts": { 10 | "build": "yarn lint && rm -rf dist && tsc && yarn docs", 11 | "build-all": "yarn install && yarn build && yarn build-tests", 12 | "build-tests": "rm -rf ./tests/js && tsc -p ./tests", 13 | "clean-install": "rm -rf node_modules && yarn install", 14 | "docs": "rm -rf ./docs && typedoc --entryPoints ./src/ts-lambda-api.ts --excludePrivate --includeVersion --gitRevision master --out ./docs", 15 | "lint": "eslint 'src/**/*.ts'", 16 | "shell": "$SHELL", 17 | "improved-audit": "improved-yarn-audit --fail-on-missing-exclusions ", 18 | "test": "yarn build-all && yarn improved-audit && scripts/runTests.sh" 19 | }, 20 | "main": "dist/ts-lambda-api.js", 21 | "typings": "dist/ts-lambda-api.d.ts", 22 | "author": "Matthew Snoddy", 23 | "license": "MIT", 24 | "files": [ 25 | "README.md", 26 | "LICENSE", 27 | "dist/**/*" 28 | ], 29 | "nyc": { 30 | "check-coverage": true, 31 | "per-file": true, 32 | "lines": 70, 33 | "statements": 70, 34 | "functions": 70, 35 | "branches": 50, 36 | "exclude": [ 37 | "tests/**/*", 38 | "src/util/RequestBuilder.ts" 39 | ] 40 | }, 41 | "dependencies": { 42 | "@types/aws-lambda": "^8.10.98", 43 | "fast-json-patch": "^3.1.1", 44 | "inversify": "^6.0.1", 45 | "lambda-api": "^1.0.2", 46 | "marky": "^1.2.4", 47 | "openapi3-ts": "^4.1.2", 48 | "reflect-metadata": "^0.1.13", 49 | "sprintf-js": "^1.1.2" 50 | }, 51 | "devDependencies": { 52 | "@aws-sdk/client-s3": "^3.0.0", 53 | "@aws-sdk/s3-request-presigner": "^3.0.0", 54 | "@types/js-yaml": "^4.0.1", 55 | "@types/node": "^20.4.1", 56 | "@types/sprintf-js": "^1.1.2", 57 | "@types/temp": "^0.9.0", 58 | "@typescript-eslint/eslint-plugin": "^5.61.0", 59 | "@typescript-eslint/parser": "^5.61.0", 60 | "alsatian": "^3.2.1", 61 | "eslint": "^8.44.0", 62 | "eslint-plugin-import": "^2.27.5", 63 | "eslint-plugin-jsdoc": "^46.4.3", 64 | "eslint-plugin-prefer-arrow": "^1.2.3", 65 | "fs-extra": "^11.1.1", 66 | "improved-yarn-audit": "^3.0.0", 67 | "js-yaml": "^4.1.0", 68 | "junit-bark": "^1.3.1", 69 | "md5-file": "^5.0.0", 70 | "nyc": "^15.1.0", 71 | "tap-spec": "^5.0.0", 72 | "temp": "^0.9.4", 73 | "typedoc": "^0.24.8", 74 | "typescript": "^5.1.6" 75 | }, 76 | "resolutions": { 77 | "trim": "^1.0.1", 78 | "semver": "^7.5.2" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /scripts/runTests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | resultsDir=".test_results" 3 | 4 | tapFile="${resultsDir}/results.tap" 5 | junitFile="${resultsDir}/results.xml" 6 | reportFile="${resultsDir}/results.html" 7 | codeCoverageReportFile="coverage/index.html" 8 | 9 | exitCode=0 10 | 11 | # polyfill for realpath command using python 12 | command -v realpath &> /dev/null || realpath() { 13 | python -c "import os; print os.path.abspath('$1')" 14 | } 15 | 16 | printReportSummary() { 17 | echo 18 | echo "TAP File: $(realpath ${tapFile})" 19 | 20 | if [ -f "${reportFile}" ]; then 21 | echo "HTML Test Report: $(realpath ${reportFile})" 22 | fi 23 | 24 | echo "Code Coverage Report: $(realpath ${codeCoverageReportFile})" 25 | echo 26 | } 27 | 28 | generateTestReport() { 29 | if ! [ -x "$(command -v pipenv)" ]; then 30 | echo "pipenv not found, skipping HTML test report generation" 31 | 32 | return 33 | fi 34 | 35 | pipenv install 36 | pipenv run junit2html "${junitFile}" "${reportFile}" 37 | } 38 | 39 | determineExitCode() { 40 | exitCodes="$1" 41 | nonZeroExitCodes=${exitCodes//0/} 42 | 43 | if [ -n "${nonZeroExitCodes}" ]; then 44 | exitCode=1 45 | fi 46 | } 47 | 48 | runTestsWithCoverage() { 49 | # let the framework know it is under test, see: src/util/Environment.ts 50 | export TLA_UNDER_TEST=1 51 | export PROFILE_API=1 52 | 53 | nyc --reporter=lcov --reporter=html alsatian --tap "./tests/js/**/*Tests.js" 2>&1 | \ 54 | tee "${tapFile}" | 55 | tap-spec 56 | 57 | determineExitCode "$(printf "%s" "${PIPESTATUS[@]}")" 58 | } 59 | 60 | runTests() { 61 | mkdir -p "${resultsDir}" 62 | 63 | runTestsWithCoverage 64 | 65 | cat "${tapFile}" | junit-bark > "${junitFile}" 66 | } 67 | 68 | cleanupOldResults() { 69 | rm -rf ".nyc_output" 70 | rm -rf "coverage" 71 | 72 | rm -rf "${resultsDir}" 73 | } 74 | 75 | main() { 76 | cat "tests/banner.txt" 77 | 78 | cleanupOldResults 79 | runTests 80 | generateTestReport 81 | 82 | printReportSummary 83 | 84 | exit ${exitCode} 85 | } 86 | 87 | main 88 | -------------------------------------------------------------------------------- /src/ApiApp.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata" 2 | 3 | import { Container } from "inversify" 4 | import { API } from "lambda-api" 5 | 6 | import { Server } from "./api/Server" 7 | import { AppConfig } from "./model/AppConfig" 8 | import { ApiRequest } from "./model/ApiRequest" 9 | import { ApiResponse } from "./model/ApiResponse" 10 | import { ILogger } from "./util/logging/ILogger" 11 | import { LogFactory } from "./util/logging/LogFactory" 12 | 13 | /** 14 | * Application base class which combines the `Server`, `Container` (see `InversifyJS`) 15 | * and `AppConfig` classes to create a decorator driven API with typescript 16 | * middleware and dependency injection. It uses the `lambda-api` package as the 17 | * underlying HTTP API framework. 18 | * 19 | * AWS Lambda requests are handled by the `run` method, which will return a response 20 | * compatible with either API Gateway or an ALB. 21 | * 22 | * Extending this class will allow creating an app implementation for runtimes, 23 | * AWS Lambda, Local Web Server etc. 24 | */ 25 | export abstract class ApiApp { 26 | protected readonly apiServer: Server 27 | protected readonly logFactory: LogFactory 28 | 29 | protected logger: ILogger 30 | protected initialised: boolean 31 | 32 | public get middlewareRegistry() { 33 | return this.apiServer.middlewareRegistry 34 | } 35 | 36 | /** 37 | * Create a new app. 38 | * 39 | * @param controllersPath (Optional) Paths to the directories that contain controller `js` files that 40 | * declare controllers. Required if the default `Container` is used, or the 41 | * provided `Container` instance has its `autoBindInjectable` flag set to `true`. 42 | * Ignored if the provided `Container` instance has its `autoBindInjectable` 43 | * flag set to `false`. 44 | * @param appConfig (Optional) Application config to pass to `lambda-api`, defaults to new `AppConfig`. 45 | * @param appContainer (Optional) `InversifyJS` IOC `Container` instance which can 46 | * build controllers and error interceptors, defaults to new `Container` with 47 | * `autoBindInjectable` flag set to `true`. 48 | */ 49 | public constructor( 50 | protected readonly controllersPath?: string[], 51 | protected appConfig: AppConfig = new AppConfig(), 52 | protected appContainer: Container = new Container({ autoBindInjectable: true }) 53 | ) { 54 | let autoInjectionEnabled = typeof(appContainer.options) === "object" 55 | && appContainer.options.autoBindInjectable === true 56 | 57 | if (autoInjectionEnabled) { 58 | if (!Array.isArray(controllersPath) || controllersPath.length < 1) { 59 | throw new Error("controllersPath passed to ApiApp was not an array") 60 | } 61 | 62 | if (controllersPath.length < 1) { 63 | throw new Error("controllersPath passed to ApiApp was empty") 64 | } 65 | 66 | if (controllersPath.findIndex(p => typeof p !== "string" || p.trim() === "") > -1) { 67 | throw new Error("One or more paths in controllersPaths passed to ApiApp was null, empty or whitespace") 68 | } 69 | } 70 | 71 | appContainer.bind(AppConfig).toConstantValue(this.appConfig) 72 | 73 | this.apiServer = new Server(appContainer, this.appConfig) 74 | this.logFactory = new LogFactory(appConfig) 75 | 76 | this.logger = this.logFactory.getLogger(ApiApp) 77 | this.initialised = false 78 | } 79 | 80 | /** 81 | * Configure the `InversifyJS` IOC `Container` instance. 82 | * 83 | * @param configureBlock Function that takes a `Container` instance as a parameter. 84 | */ 85 | public configureApp(configureBlock: (this: void, container: Container) => void) { 86 | configureBlock(this.appContainer) 87 | } 88 | 89 | /** 90 | * Configure the `API` instance from the `lambda-api` package. 91 | * 92 | * @param handler Function that takes an `API` instance as a parameter. 93 | */ 94 | public configureApi(configureBlock: (this: void, api: API) => void) { 95 | this.apiServer.configure(configureBlock) 96 | } 97 | 98 | /** 99 | * Run using the passed event and context, ultimately should call the 100 | * `processEvent` method on the `apiServer` instance. 101 | * 102 | * @param request API Gateway or ALB request. 103 | * @param context Request context. 104 | * @returns The response. 105 | */ 106 | public abstract run(event: ApiRequest, context: any): Promise 107 | 108 | /** 109 | * Initialise all controllers and endpoints declared using decorators. 110 | */ 111 | public async initialiseControllers() { 112 | if (this.initialised) { 113 | this.logger.debug("Ignoring call to initialiseControllers, app has already been initialised") 114 | 115 | return 116 | } 117 | 118 | this.logger.debug("Initialising app controllers") 119 | 120 | try { 121 | await this.apiServer.discoverAndBuildRoutes(this.controllersPath) 122 | 123 | this.initialised = true 124 | } catch (ex) { 125 | this.logger.fatal("Error initialising API app:\n%s", 126 | ex instanceof Error ? ex.stack : ex) 127 | 128 | throw ex 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/ApiLambdaApp.ts: -------------------------------------------------------------------------------- 1 | import { Container } from "inversify" 2 | 3 | import { ApiApp } from "./ApiApp" 4 | import { AppConfig } from "./model/AppConfig" 5 | import { ApiRequest } from "./model/ApiRequest" 6 | import { timed } from "./util/timed" 7 | 8 | /** 9 | * Impementation of the `ApiApp` class that handles native 10 | * AWS Lambda requests and can be used to provide a Lambda 11 | * function handler. 12 | * 13 | * The `run` method is the function handler entrypoint. 14 | */ 15 | export class ApiLambdaApp extends ApiApp { 16 | /** 17 | * Create a new lambda app. 18 | * 19 | * @param controllersPath (Optional) Paths to the directories that contain controller `js` files. 20 | * Required if the default `Container` is used, or the provided 21 | * `Container` instance has its `autoBindInjectable` flag set to `true`. 22 | * Ignored if the provided `Container` instance has its `autoBindInjectable` 23 | * flag set to `false`. 24 | * @param appConfig (Optional) Application config to pass to `lambda-api`. 25 | * @param appContainer (Optional) `InversifyJS` IOC `Container` instance which can build 26 | * controllers and error interceptors. 27 | */ 28 | public constructor(controllersPath?: string[], appConfig?: AppConfig, appContainer?: Container) { 29 | super(controllersPath, appConfig, appContainer) 30 | 31 | this.logger = this.logFactory.getLogger(ApiLambdaApp) 32 | } 33 | 34 | /** 35 | * Process the passed lambda event and context as a synchronous HTTP request. 36 | * 37 | * @param request API Gateway or ALB request. 38 | * @param context Request context. 39 | * @returns The response. 40 | */ 41 | @timed 42 | public async run(event: ApiRequest, context: any) { 43 | this.logger.info("Received event, initialising controllers and processing event") 44 | 45 | await super.initialiseControllers() 46 | 47 | return await this.apiServer.processEvent(event, context) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/api/Controller.ts: -------------------------------------------------------------------------------- 1 | import { applyPatch } from "fast-json-patch" 2 | import { injectable } from "inversify" 3 | import { Request, Response } from "lambda-api" 4 | 5 | import { JsonPatch } from "../model/JsonPatch" 6 | import { ILogger } from "../util/logging/ILogger" 7 | 8 | /** 9 | * Base class for API controllers. Provides access to the 10 | * current HTTP context as protected fields, and convenience 11 | * method for applying JSON patches. 12 | */ 13 | @injectable() 14 | export abstract class Controller { 15 | /** 16 | * Logger instance for this controller. 17 | */ 18 | protected _logger: ILogger 19 | 20 | /** 21 | * The current HTTP request context. 22 | */ 23 | protected request: Request 24 | 25 | /** 26 | * The current HTTP response context. 27 | */ 28 | protected response: Response 29 | 30 | public setLogger(logger: ILogger) { 31 | this._logger = logger 32 | } 33 | 34 | public setRequest(request: Request) { 35 | this.request = request 36 | } 37 | 38 | public setResponse(response: Response) { 39 | this.response = response 40 | } 41 | 42 | /** 43 | * Apply a set of JSON patch operations to an 44 | * object instance. 45 | * 46 | * @param T The type of object to be patched. 47 | * @param patch The operations to apply. 48 | * @param obj The object instance to apply operations to. 49 | */ 50 | protected applyJsonPatch(patch: JsonPatch, obj: T) { 51 | if (this._logger) { 52 | this._logger.trace("Applying JSON patch\nObject: %j\nPatch: %j", obj, patch) 53 | } 54 | 55 | let result = applyPatch(obj, patch) 56 | 57 | return result.newDocument 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/api/MiddlewareRegistry.ts: -------------------------------------------------------------------------------- 1 | import { Principal } from "../model/security/Principal" 2 | import { ILogger } from "../util/logging/ILogger" 3 | import { LogFactory } from "../util/logging/LogFactory" 4 | import { ErrorInterceptor } from "./error/ErrorInterceptor" 5 | import { IAuthFilter } from "./security/IAuthFilter" 6 | import { IAuthorizer } from "./security/IAuthorizer" 7 | 8 | /** 9 | * Holds all the middleware that will be applied to incoming HTTP 10 | * requests before or after calling endpoint methods. 11 | */ 12 | export class MiddlewareRegistry { 13 | private _authFilters: IAuthFilter[] 14 | private _authorizers: IAuthorizer[] 15 | private _errorInterceptors: ErrorInterceptor[] 16 | private readonly logger: ILogger 17 | 18 | /** 19 | * Authentication filters to apply. These are chained, meaning only 20 | * one of the filters registered needs to authenticate a user to 21 | * for the request to be authenticated. 22 | */ 23 | public get authFilters() { 24 | return this._authFilters 25 | } 26 | 27 | /** 28 | * Authorizers to apply. These are chained, meaning only 29 | * one of the authorizers registered needs to authorise a user against 30 | * a role for the request to be authorized. 31 | */ 32 | public get authorizers() { 33 | return this._authorizers 34 | } 35 | 36 | /** 37 | * Error interceptors to use. Multiple interceptors can be 38 | * registered against one endpoint/controller, however only 39 | * the first interceptor registered will be invoked. 40 | */ 41 | public get errorInterceptors() { 42 | return this._errorInterceptors 43 | } 44 | 45 | public constructor(logFactory: LogFactory) { 46 | this._authFilters = [] 47 | this._authorizers = [] 48 | this._errorInterceptors = [] 49 | 50 | this.logger = logFactory.getLogger(MiddlewareRegistry) 51 | } 52 | 53 | /** 54 | * Add an authentication filter. 55 | * 56 | * @param authFilter The filter to add. 57 | * @throws If the `authFilter` parameter is null or undefined. 58 | */ 59 | public addAuthFilter(authFilter: IAuthFilter) { 60 | if (!authFilter) { 61 | throw new Error("Null or undefined authFiler passed to MiddlewareRegistry::authFiler") 62 | } 63 | 64 | this.logger.debug("Registering authentication filter: %s", authFilter.name) 65 | 66 | this._authFilters.push(authFilter) 67 | } 68 | 69 | /** 70 | * Adds an authorizer. 71 | * 72 | * @param authorizer The authorizer to add. 73 | * @throws If the `authorizer` parameter is null or undefined. 74 | */ 75 | public addAuthorizer(authorizer: IAuthorizer) { 76 | if (!authorizer) { 77 | throw new Error("Null or undefined authorizer passed to MiddlewareRegistry::addAuthorizer") 78 | } 79 | 80 | this.logger.debug("Registering authorizer: %s", authorizer.name) 81 | 82 | this._authorizers.push(authorizer) 83 | } 84 | 85 | /** 86 | * Adds an error interceptor. 87 | * 88 | * @param errorInterceptor The interceptor to add. 89 | * @throws If the `errorInterceptor` parameter is null or undefined. 90 | */ 91 | public addErrorInterceptor(errorInterceptor: ErrorInterceptor) { 92 | if (!errorInterceptor) { 93 | throw new Error("Null or undefined errorInterceptor passed to MiddlewareRegistry::addErrorInterceptor") 94 | } 95 | 96 | this.logger.debug("Registering error interceptor for endpoint '%s' and controller '%s'", 97 | errorInterceptor.endpointTarget, errorInterceptor.controllerTarget) 98 | 99 | this._errorInterceptors.push(errorInterceptor) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/api/Server.ts: -------------------------------------------------------------------------------- 1 | import createAPI, { API } from "lambda-api" 2 | import { Container } from "inversify" 3 | 4 | import { ApiRequest } from "../model/ApiRequest" 5 | import { ApiResponse } from "../model/ApiResponse" 6 | import { AppConfig } from "../model/AppConfig" 7 | import { EndpointInfo } from "../model/reflection/EndpointInfo" 8 | import { ILogger } from "../util/logging/ILogger" 9 | import { LogFactory } from "../util/logging/LogFactory" 10 | import { timed } from "../util/timed" 11 | import { Endpoint } from "./Endpoint" 12 | import { MiddlewareRegistry } from "./MiddlewareRegistry" 13 | import { OpenApiGenerator, OpenApiFormat } from "./open-api/OpenApiGenerator" 14 | import { ControllerLoader } from "./reflection/ControllerLoader" 15 | import { DecoratorRegistry } from "./reflection/DecoratorRegistry" 16 | 17 | /** 18 | * Server that discovers routes using decorators on controller 19 | * classes and methods. Processing of requests is preformed by the 20 | * `lambda-api` package. 21 | */ 22 | export class Server { 23 | private readonly logFactory: LogFactory 24 | private readonly logger: ILogger 25 | private readonly api: API 26 | private readonly _middlewareRegistry: MiddlewareRegistry 27 | private readonly openApiGenerator?: OpenApiGenerator 28 | 29 | public get middlewareRegistry() { 30 | return this._middlewareRegistry 31 | } 32 | 33 | /** 34 | * Create a new server. 35 | * 36 | * @param appContainer Application container to use to build containers. 37 | * @param appConfig Application config to pass to `lambda-api`. 38 | */ 39 | public constructor(private appContainer: Container, private appConfig: AppConfig) { 40 | this.logFactory = new LogFactory(appConfig) 41 | this.logger = this.logFactory.getLogger(Server) 42 | 43 | // ensure decorator registry has the right log level (now that we know the app config) 44 | DecoratorRegistry.setLogger(this.logFactory) 45 | 46 | this.api = createAPI(appConfig) 47 | this._middlewareRegistry = new MiddlewareRegistry(this.logFactory) 48 | 49 | if (this.appConfig.openApi && this.appConfig.openApi.enabled) { 50 | this.openApiGenerator = new OpenApiGenerator(this.appConfig, this._middlewareRegistry, this.logFactory) 51 | } 52 | } 53 | 54 | /** 55 | * Configure the `API` instance from the `lambda-api` 56 | * package. 57 | * 58 | * @param handler Function that takes an `API` instance as a parameter. 59 | */ 60 | public configure(handler: (this: void, api: API) => void) { 61 | handler(this.api) 62 | } 63 | 64 | /** 65 | * Scans the specified path for javascript files and loads these into 66 | * the current runtime. Importing the files will invoke the decorators 67 | * declared within them. Note: this scans only the top level files. 68 | * 69 | * API decorators register controllers, endpoints, configuration and middleware. 70 | * A series of endpoints are built using the decorator components and registered 71 | * with the `lambda-api` package routing engine. 72 | * 73 | * Controllers and error interceptors registered by decorators are built using 74 | * an IOC container, which allows dependency injection. 75 | * 76 | * OpenAPI endpoints will be registered here, if they are enabled in the app 77 | * config by setting the `openApi.enabled` flag to true. 78 | * 79 | * This method must be called before invoking the `processEvent` method. 80 | * 81 | * @param controllersPath (Optional) Paths to the directories that contain controller `js` files. 82 | * Dynamic loading of `injectable` controllers is disabled if undefined 83 | * or the app `Container` instance has its `autoBindInjectable` 84 | * flag set to `false`. 85 | */ 86 | @timed 87 | public async discoverAndBuildRoutes(controllersPath?: string[]) { 88 | if (this.appContainer.options.autoBindInjectable && controllersPath) { 89 | for (let path of controllersPath){ 90 | this.logger.debug("Loading controllers from path: %s", path) 91 | await ControllerLoader.loadControllers(path, this.logFactory) 92 | } 93 | } else { 94 | this.logger.debug("Dynamic loading of injectable controllers disabled") 95 | } 96 | 97 | if (this.openApiGenerator) { 98 | this.registerOpenApiEndpoints() 99 | } 100 | 101 | for (let endpointKey in DecoratorRegistry.Endpoints) { 102 | if (!DecoratorRegistry.Endpoints.hasOwnProperty(endpointKey)) { 103 | continue 104 | } 105 | 106 | this.registerEndpoint(DecoratorRegistry.Endpoints[endpointKey]) 107 | } 108 | } 109 | 110 | private registerOpenApiEndpoints() { 111 | this.logger.info("Registering OpenAPI endpoints") 112 | 113 | this.registerOpenApiEndpoint("json") 114 | this.registerOpenApiEndpoint("yml") 115 | } 116 | 117 | private registerOpenApiEndpoint(format: OpenApiFormat) { 118 | let specEndpoint = new EndpointInfo( 119 | `internal__openapi::${format}`, 120 | async () => await this.openApiGenerator.exportOpenApiSpec(format) 121 | ) 122 | 123 | specEndpoint.path = `/open-api.${format}` 124 | specEndpoint.httpMethod = "GET" 125 | specEndpoint.noAuth = !this.appConfig.openApi.useAuthentication 126 | specEndpoint.produces = `application/${format}` 127 | 128 | this.registerEndpoint(specEndpoint) 129 | } 130 | 131 | private registerEndpoint(endpointInfo: EndpointInfo) { 132 | let apiEndpoint = new Endpoint( 133 | endpointInfo, 134 | c => this.appContainer.get(c), 135 | ei => this.appContainer.get(ei), 136 | this._middlewareRegistry, 137 | this.logFactory 138 | ) 139 | 140 | apiEndpoint.register(this.api) 141 | } 142 | 143 | /** 144 | * Takes an API request passed in by AWS Lambda and processes 145 | * it using the `lambda-api` package. 146 | * 147 | * @param request API Gateway or ALB request. 148 | * @param context Request context. 149 | * @returns The response. 150 | */ 151 | @timed 152 | public async processEvent(request: ApiRequest, context: any): Promise { 153 | let event: any = request 154 | 155 | this.logger.info("Processing API request event for path: %s", request.path) 156 | 157 | this.logger.debug("Event data: %j", event) 158 | this.logger.debug("Event context: %j", context) 159 | 160 | return await this.api.run(event, context) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/api/decorator/apiController.ts: -------------------------------------------------------------------------------- 1 | import { DecoratorRegistry } from "../reflection/DecoratorRegistry" 2 | 3 | /** 4 | * Decorator that can be placed on a class to mark it is an API controller. 5 | * 6 | * @param path A root URL path that all endpoints in this controller share; optional. 7 | * This URL can contain path parameters, prefixed with a colon (':') character. 8 | */ 9 | export function apiController(path?: string) { 10 | return (constructor: Function) => { 11 | let controller = DecoratorRegistry.getOrCreateController(constructor) 12 | 13 | DecoratorRegistry.getLogger().debug("@apiController('%s') decorator executed for controller: %s", 14 | path, controller.name) 15 | 16 | controller.path = path 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/api/decorator/context/consumes.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from "util" 2 | 3 | import { ApiBody } from "../../../model/open-api/ApiBody" 4 | import { DecoratorRegistry } from "../../reflection/DecoratorRegistry" 5 | 6 | /** 7 | * Decorator for an endpoint method that details the HTTP request Content-Type header value. 8 | * 9 | * Overrides the controller value set by the `controllerConsumes` decorator, if any. 10 | * 11 | * @param contentType Request content type. 12 | */ 13 | export function consumes(contentType?: string, apiBodyInfo?: ApiBody) { 14 | return (classDefinition: Object | Function, methodName: string) => { 15 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 16 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 17 | let operationInfo = endpoint.getOrCreateApiOperationInfo() 18 | let requestInfo = operationInfo.getOrCreateRequest() 19 | 20 | if (DecoratorRegistry.getLogger().debugEnabled()) { 21 | DecoratorRegistry.getLogger().debug("@consumes('%s'%s) decorator executed for endpoint: %s", 22 | contentType, 23 | apiBodyInfo ? `, ${inspect(apiBodyInfo)}` : "", 24 | endpoint.name) 25 | } 26 | 27 | endpoint.consumes = contentType 28 | requestInfo.contentType = contentType 29 | 30 | if (apiBodyInfo) { 31 | requestInfo.mergeInfo(apiBodyInfo) 32 | } 33 | } 34 | } 35 | 36 | /** 37 | * Decorator for a controller class that details the HTTP request Content-Type header value for 38 | * all endpoints. 39 | * 40 | * @param contentType Request content type. 41 | */ 42 | export function controllerConsumes(contentType: string, apiBodyInfo?: ApiBody) { 43 | return (classDefinition: Function) => { 44 | let controller = DecoratorRegistry.getOrCreateController(classDefinition) 45 | let requestInfo = controller.getOrCreateRequestInfo() 46 | 47 | if (DecoratorRegistry.getLogger().debugEnabled()) { 48 | DecoratorRegistry.getLogger().debug("@controllerConsumes('%s'%s) decorator executed for controller: %s", 49 | contentType, 50 | apiBodyInfo ? `, ${inspect(apiBodyInfo)}` : "", 51 | controller.name) 52 | } 53 | 54 | controller.consumes = contentType 55 | requestInfo.contentType = contentType 56 | 57 | if (apiBodyInfo) { 58 | requestInfo.mergeInfo(apiBodyInfo) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/api/decorator/context/parameters/body.ts: -------------------------------------------------------------------------------- 1 | import { BodyParameterExtractor } from "../../../parameters/BodyParameterExtractor" 2 | import { DecoratorRegistry } from "../../../reflection/DecoratorRegistry" 3 | 4 | /** 5 | * Decorator which injects the HTTP request body as a parameter value. 6 | * 7 | * Value passed to the method will be an object, array or primitive value 8 | * if the request body is JSON, otherwise it will be a string. 9 | */ 10 | export function body(classDefinition: Object | Function, methodName: string, paramIndex: number) { 11 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 12 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 13 | 14 | endpoint.parameterExtractors[paramIndex] = new BodyParameterExtractor() 15 | } 16 | -------------------------------------------------------------------------------- /src/api/decorator/context/parameters/header.ts: -------------------------------------------------------------------------------- 1 | import { ApiParam } from "../../../../model/open-api/ApiParam" 2 | import { HeaderParameterExtractor } from "../../../parameters/HeaderParameterExtractor" 3 | import { DecoratorRegistry } from "../../../reflection/DecoratorRegistry" 4 | 5 | /** 6 | * Decorator which injects a HTTP request header as a parameter value. 7 | * 8 | * Value passed to the method will be a string. 9 | * 10 | * @param headerName The name of the header to inject. 11 | */ 12 | export function header(headerName: string, apiParamInfo?: ApiParam) { 13 | return (classDefinition: Object | Function, methodName: string, paramIndex: number) => { 14 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 15 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 16 | 17 | endpoint.parameterExtractors[paramIndex] = new HeaderParameterExtractor(headerName, apiParamInfo) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/api/decorator/context/parameters/pathParam.ts: -------------------------------------------------------------------------------- 1 | import { ApiParam } from "../../../../model/open-api/ApiParam" 2 | import { PathParameterExtractor } from "../../../parameters/PathParameterExtractor" 3 | import { DecoratorRegistry } from "../../../reflection/DecoratorRegistry" 4 | 5 | /** 6 | * Decorator which injects a path parameter value as an endpoint parameter value. 7 | * 8 | * Value passed to the method will be a string. 9 | * 10 | * @param paramName The name of the path parameter to inject. 11 | * @param apiParamInfo (Optional) OpenApi metadata about the path parameter. 12 | */ 13 | export function pathParam(paramName: string, apiParamInfo?: ApiParam) { 14 | return (classDefinition: Object | Function, methodName: string, paramIndex: number) => { 15 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 16 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 17 | 18 | endpoint.parameterExtractors[paramIndex] = new PathParameterExtractor(paramName, apiParamInfo) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/api/decorator/context/parameters/principal.ts: -------------------------------------------------------------------------------- 1 | import { UserParameterExtractor } from "../../../parameters/UserParameterExtractor" 2 | import { DecoratorRegistry } from "../../../reflection/DecoratorRegistry" 3 | 4 | /** 5 | * Decorator which injects the current authentication principal as a parameter value. 6 | * 7 | * Value passed to the method will be an implementation of the `Principal` interface. 8 | */ 9 | export function principal(classDefinition: Object | Function, methodName: string, paramIndex: number) { 10 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 11 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 12 | 13 | endpoint.parameterExtractors[paramIndex] = new UserParameterExtractor() 14 | } 15 | -------------------------------------------------------------------------------- /src/api/decorator/context/parameters/queryParam.ts: -------------------------------------------------------------------------------- 1 | import { ApiParam } from "../../../../model/open-api/ApiParam" 2 | import { QueryParameterExtractor } from "../../../parameters/QueryParameterExtractor" 3 | import { DecoratorRegistry } from "../../../reflection/DecoratorRegistry" 4 | 5 | /** 6 | * Decorator which injects a query parameter value as an endpoint parameter value. 7 | * 8 | * Value passed to the method will be a string. 9 | * 10 | * @param paramName The name of the query parameter to inject. 11 | */ 12 | export function queryParam(paramName: string, apiParamInfo?: ApiParam) { 13 | return (classDefinition: Object | Function, methodName: string, paramIndex: number) => { 14 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 15 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 16 | 17 | endpoint.parameterExtractors[paramIndex] = new QueryParameterExtractor(paramName, apiParamInfo) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/api/decorator/context/parameters/rawBody.ts: -------------------------------------------------------------------------------- 1 | import { RawBodyParameterExtractor } from "../../../parameters/RawBodyParameterExtractor" 2 | import { DecoratorRegistry } from "../../../reflection/DecoratorRegistry" 3 | 4 | /** 5 | * Decorator which injects the raw HTTP request body as a parameter value. 6 | * 7 | * Value passed to the method will be a Buffer. 8 | */ 9 | export function rawBody(classDefinition: Object | Function, methodName: string, paramIndex: number) { 10 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 11 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 12 | 13 | endpoint.parameterExtractors[paramIndex] = new RawBodyParameterExtractor() 14 | } 15 | -------------------------------------------------------------------------------- /src/api/decorator/context/parameters/request.ts: -------------------------------------------------------------------------------- 1 | import { RequestParameterExtractor } from "../../../parameters/RequestParameterExtractor" 2 | import { DecoratorRegistry } from "../../../reflection/DecoratorRegistry" 3 | 4 | /** 5 | * Decorator which injects a the current HTTP request context as a parameter value. 6 | * 7 | * Value passed to the method will be of type `Request` from the lambda-api package. 8 | */ 9 | export function request(classDefinition: Object | Function, methodName: string, paramIndex: number) { 10 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 11 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 12 | 13 | endpoint.parameterExtractors[paramIndex] = new RequestParameterExtractor() 14 | } 15 | -------------------------------------------------------------------------------- /src/api/decorator/context/parameters/response.ts: -------------------------------------------------------------------------------- 1 | import { ResponseParameterExtractor } from "../../../parameters/ResponseParameterExtractor" 2 | import { DecoratorRegistry } from "../../../reflection/DecoratorRegistry" 3 | 4 | /** 5 | * Decorator which injects a the current HTTP response context as a parameter value. 6 | * 7 | * Value passed to the method will be of type `Response` from the lambda-api package. 8 | */ 9 | export function response(classDefinition: Object | Function, methodName: string, paramIndex: number) { 10 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 11 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 12 | 13 | endpoint.parameterExtractors[paramIndex] = new ResponseParameterExtractor() 14 | } 15 | -------------------------------------------------------------------------------- /src/api/decorator/context/produces.ts: -------------------------------------------------------------------------------- 1 | import { DecoratorRegistry } from "../../reflection/DecoratorRegistry" 2 | 3 | /** 4 | * Decorator for an endpoint method that sets the HTTP response Content-Type header value. 5 | * 6 | * Overrides the controller value set by the `controllerProduces` decorator, if any. 7 | * 8 | * @param contentType Content-Type header value. 9 | */ 10 | export function produces(contentType: string) { 11 | return (classDefinition: Object | Function, methodName: string) => { 12 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 13 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 14 | 15 | DecoratorRegistry.getLogger().debug("@produces('%s') decorator executed for endpoint: %s", 16 | contentType, endpoint.name) 17 | 18 | endpoint.produces = contentType 19 | } 20 | } 21 | 22 | /** 23 | * Decorator for a controller class that sets the HTTP response Content-Type header value for 24 | * all endpoints. 25 | * 26 | * @param contentType Content-Type header value. 27 | */ 28 | export function controllerProduces(contentType: string) { 29 | return (classDefinition: Function) => { 30 | let apiController = DecoratorRegistry.getOrCreateController(classDefinition) 31 | 32 | DecoratorRegistry.getLogger().debug("@controllerProduces('%s') decorator executed for controller: %s", 33 | contentType, apiController.name) 34 | 35 | apiController.produces = contentType 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/api/decorator/endpoints.ts: -------------------------------------------------------------------------------- 1 | import { DecoratorRegistry } from "../reflection/DecoratorRegistry" 2 | 3 | /** 4 | * Decorator that can be placed on a method to mark it is an API endpoint 5 | * that responds to HTTP GET requests. 6 | * 7 | * @param path The URL path that triggers this endpoint; optional if you set a root path on the 8 | * class using a `apiController` decorator. This URL can contain path parameters, 9 | * prefixed with a colon (':') character. 10 | */ 11 | export function GET(path = "") { 12 | return (classDefinition: Object, methodName: string) => 13 | registerApiEndpoint(classDefinition, methodName, path, "GET") 14 | } 15 | 16 | /** 17 | * Decorator that can be placed on a method to mark it is an API endpoint 18 | * that responds to HTTP POST requests. 19 | * 20 | * @param path The URL path that triggers this endpoint; optional if you set a root path on 21 | * the class using a `apiController` decorator. This URL can contain path parameters, 22 | * prefixed with a colon (':') character. 23 | */ 24 | export function POST(path = "") { 25 | return (classDefinition: Object, methodName: string) => 26 | registerApiEndpoint(classDefinition, methodName, path, "POST") 27 | } 28 | 29 | /** 30 | * Decorator that can be placed on a method to mark it is an API endpoint 31 | * that responds to HTTP PUT requests. 32 | * 33 | * @param path The URL path that triggers this endpoint; optional if you set a root path on 34 | * the class using a `apiController` decorator. This URL can contain path parameters, 35 | * prefixed with a colon (':') character. 36 | */ 37 | export function PUT(path = "") { 38 | return (classDefinition: Object, methodName: string) => 39 | registerApiEndpoint(classDefinition, methodName, path, "PUT") 40 | } 41 | 42 | /** 43 | * Decorator that can be placed on a method to mark it is an API endpoint 44 | * that responds to HTTP DELETE requests. 45 | * 46 | * @param path The URL path that triggers this endpoint; optional if you set a root path on 47 | * the class using a `apiController` decorator. This URL can contain path parameters, 48 | * prefixed with a colon (':') character. 49 | */ 50 | export function DELETE(path = "") { 51 | return (classDefinition: Object, methodName: string) => 52 | registerApiEndpoint(classDefinition, methodName, path, "DELETE") 53 | } 54 | 55 | /** 56 | * Decorator that can be placed on a method to mark it is an API endpoint 57 | * that responds to HTTP PATCH requests. 58 | * 59 | * @param path The URL path that triggers this endpoint; optional if you set a root path on 60 | * the class using a `apiController` decorator. This URL can contain path parameters, 61 | * prefixed with a colon (':') character. 62 | */ 63 | export function PATCH(path = "") { 64 | return (classDefinition: Object, methodName: string) => 65 | registerApiEndpoint(classDefinition, methodName, path, "PATCH") 66 | } 67 | 68 | function registerApiEndpoint(classDefinition: Object, methodName: string, path: string, httpMethod: string) { 69 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 70 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 71 | 72 | DecoratorRegistry.getLogger().debug("@%s(%s) decorator executed for endpoint: %s", 73 | httpMethod, 74 | path.trim() === "" ? "" : `'${path}'`, 75 | endpoint.name) 76 | 77 | endpoint.httpMethod = httpMethod 78 | endpoint.path = path 79 | } 80 | -------------------------------------------------------------------------------- /src/api/decorator/error/errorInterceptor.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from "util" 2 | 3 | import { interfaces } from "inversify" 4 | 5 | import { ErrorInterceptor } from "../../error/ErrorInterceptor" 6 | import { DecoratorRegistry } from "../../reflection/DecoratorRegistry" 7 | 8 | /** 9 | * Decorator for an endpoint method that configures the type to use to intercept errors. 10 | * 11 | * Overrides the controller error interceptor set by the `controllerErrorInterceptor` decorator, if any. 12 | * 13 | * Error interceptors instances are built using the InversifyJS IOC container for the current app. 14 | */ 15 | export function errorInterceptor(interceptor: interfaces.ServiceIdentifier) { 16 | return (classDefinition: Object | Function, methodName: string) => { 17 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 18 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 19 | 20 | if (DecoratorRegistry.getLogger().debugEnabled()) { 21 | DecoratorRegistry.getLogger().debug("@errorInterceptor(%s) decorator executed for endpoint: %s", 22 | inspect(interceptor), 23 | endpoint.name) 24 | } 25 | 26 | endpoint.errorInterceptor = interceptor 27 | } 28 | } 29 | 30 | /** 31 | * Decorator for a controller class that configures the type to use to intercept errors. 32 | * 33 | * Error interceptors instances are built using the current app InversifyJS IOC container. 34 | */ 35 | export function controllerErrorInterceptor(interceptor: interfaces.ServiceIdentifier) { 36 | return (classDefinition: Function) => { 37 | let controller = DecoratorRegistry.getOrCreateController(classDefinition) 38 | 39 | if (DecoratorRegistry.getLogger().debugEnabled()) { 40 | DecoratorRegistry.getLogger().debug("@controllerErrorInterceptor(%s) decorator executed for controller: %s", 41 | inspect(interceptor), 42 | controller.name) 43 | } 44 | 45 | controller.errorInterceptor = interceptor 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/api/decorator/open-api/api.ts: -------------------------------------------------------------------------------- 1 | import { DecoratorRegistry } from "../../reflection/DecoratorRegistry" 2 | 3 | /** 4 | * Decorator for a controller class to describe it in any generated 5 | * OpenAPI specification. 6 | * 7 | * @param name Name of the API used to categorise endpoints in this controller. 8 | */ 9 | export function api(name: string, description?: string) { 10 | return (classDefinition: Function) => { 11 | let controller = DecoratorRegistry.getOrCreateController(classDefinition) 12 | 13 | DecoratorRegistry.getLogger().debug("@api('%s'%s) decorator executed for controller: %s", 14 | name, 15 | description ? `, '${description}'` : "", 16 | controller.name) 17 | 18 | controller.apiName = name 19 | controller.apiDescription = description 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/api/decorator/open-api/apiIgnore.ts: -------------------------------------------------------------------------------- 1 | import { DecoratorRegistry } from "../../reflection/DecoratorRegistry" 2 | 3 | /** 4 | * Decorator that can be placed on an endpoint method to exclude it from any generated 5 | * OpenAPI specification. 6 | */ 7 | export function apiIgnore() { 8 | return (classDefinition: Object, methodName: string) => { 9 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 10 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 11 | 12 | if (DecoratorRegistry.getLogger().debugEnabled()) { 13 | DecoratorRegistry.getLogger().debug("@apiIgnore() decorator executed for endpoint: %s", 14 | endpoint.name) 15 | } 16 | 17 | endpoint.apiIgnore = true; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/api/decorator/open-api/apiIgnoreController.ts: -------------------------------------------------------------------------------- 1 | import { DecoratorRegistry } from "../../reflection/DecoratorRegistry" 2 | 3 | /** 4 | * Decorator for a controller class to exclude it from any generated 5 | * OpenAPI specification. 6 | */ 7 | export function apiIgnoreController() { 8 | return (classDefinition: Function) => { 9 | let controller = DecoratorRegistry.getOrCreateController(classDefinition) 10 | 11 | DecoratorRegistry.getLogger().debug("@apiIgnoreController() decorator executed for controller: %s", 12 | controller.name) 13 | 14 | controller.apiIgnore = true; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/api/decorator/open-api/apiOperation.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from "util" 2 | 3 | import { ApiOperation } from "../../../model/open-api/ApiOperation" 4 | import { DecoratorRegistry } from "../../reflection/DecoratorRegistry" 5 | 6 | /** 7 | * Decorator that can be placed on an endpoint to describe it in any generated 8 | * OpenAPI specification. 9 | * 10 | * @param apiOperationInfo Information about this api operation; will be merged with 11 | * existing info if present, replacing any existing properties, 12 | * if provided in this parameter. 13 | */ 14 | export function apiOperation(apiOperationInfo: ApiOperation) { 15 | return (classDefinition: Object, methodName: string) => { 16 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 17 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 18 | 19 | if (DecoratorRegistry.getLogger().debugEnabled()) { 20 | DecoratorRegistry.getLogger().debug("@apiOperation(%s) decorator executed for endpoint: %s", 21 | inspect(apiOperationInfo), 22 | endpoint.name) 23 | } 24 | 25 | endpoint.getOrCreateApiOperationInfo().mergeInfo(apiOperationInfo) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/api/decorator/open-api/apiRequest.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from "util" 2 | 3 | import { ApiBody } from "../../../model/open-api/ApiBody" 4 | import { DecoratorRegistry } from "../../reflection/DecoratorRegistry" 5 | 6 | /** 7 | * Decorator that can be placed on an endpoint to describe the request 8 | * in any generated OpenAPI specification. 9 | * 10 | * @param apiBodyInfo Request body info. 11 | */ 12 | export function apiRequest(apiBodyInfo: ApiBody) { 13 | return (classDefinition: Object | Function, methodName: string) => { 14 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 15 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 16 | let operationInfo = endpoint.getOrCreateApiOperationInfo() 17 | let requestInfo = operationInfo.getOrCreateRequest() 18 | 19 | if (DecoratorRegistry.getLogger().debugEnabled()) { 20 | DecoratorRegistry.getLogger().debug("@apiRequest(%s) decorator executed for endpoint: %s", 21 | inspect(apiBodyInfo), 22 | endpoint.name) 23 | } 24 | 25 | requestInfo.mergeInfo(apiBodyInfo) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/api/decorator/open-api/apiResponse.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from "util" 2 | 3 | import { ApiBody } from "../../../model/open-api/ApiBody" 4 | import { IDictionary } from "../../../util/IDictionary" 5 | import { DecoratorRegistry } from "../../reflection/DecoratorRegistry" 6 | 7 | /** 8 | * Decorator that can be placed on an endpoint to describe a possible response 9 | * in any generated OpenAPI specification. 10 | * 11 | * @param statusCode HTTP status code that will be sent in this response. 12 | * @param apiBodyInfo Information about the response body generated. 13 | */ 14 | export function apiResponse(statusCode: number, apiBodyInfo?: ApiBody) { 15 | return (classDefinition: Object, methodName: string) => { 16 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 17 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 18 | let responses: IDictionary = {} 19 | 20 | if (DecoratorRegistry.getLogger().debugEnabled()) { 21 | DecoratorRegistry.getLogger().debug("@apiResponse(%d%s) decorator executed for endpoint: %s", 22 | statusCode, 23 | apiBodyInfo ? `, ${inspect(apiBodyInfo)}` : "", 24 | endpoint.name) 25 | } 26 | 27 | responses[`${statusCode}`] = apiBodyInfo 28 | 29 | endpoint.getOrCreateApiOperationInfo().mergeResponses(responses) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/api/decorator/open-api/apiSecurity.ts: -------------------------------------------------------------------------------- 1 | import { SecuritySchemeObject } from "openapi3-ts/oas31" 2 | 3 | import { DecoratorRegistry } from "../../reflection/DecoratorRegistry" 4 | 5 | /** 6 | * Decorator for a auth filter implementation to describe it in any generated 7 | * OpenAPI specification as a security scheme. 8 | * 9 | * @param name Name of the API security scheme. 10 | * @param securitySchemeInfo `openapi3-ts` security scheme model. 11 | */ 12 | export function apiSecurity(name: string, securitySchemeInfo: SecuritySchemeObject) { 13 | return (classDefinition: Function) => { 14 | let authFilterInfo = DecoratorRegistry.getOrCreateAuthFilter(classDefinition) 15 | 16 | DecoratorRegistry.getLogger().debug("@apiSecurity('%s', %j) decorator executed for auth filter: %s", 17 | name, 18 | securitySchemeInfo, 19 | authFilterInfo.name) 20 | 21 | authFilterInfo.name = name 22 | authFilterInfo.securitySchemeInfo = securitySchemeInfo 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/api/decorator/security/noAuth.ts: -------------------------------------------------------------------------------- 1 | import { DecoratorRegistry } from "../../reflection/DecoratorRegistry" 2 | 3 | /** 4 | * Decorator for an endpoint method that marks it as not requiring authentication. 5 | */ 6 | export function noAuth(classDefinition: Object | Function, methodName: string) { 7 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 8 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 9 | 10 | DecoratorRegistry.getLogger().debug("@noAuth() decorator executed for endpoint: %s", endpoint.name) 11 | 12 | endpoint.noAuth = true 13 | } 14 | 15 | /** 16 | * Decorator for a controller class that marks it not requiring authentication for any of it's endpoints. 17 | */ 18 | export function controllerNoAuth(classDefinition: Function) { 19 | let controller = DecoratorRegistry.getOrCreateController(classDefinition) 20 | 21 | DecoratorRegistry.getLogger().debug("@controllerNoAuth() decorator executed for controller: %s", controller.name) 22 | 23 | controller.noAuth = true 24 | } 25 | -------------------------------------------------------------------------------- /src/api/decorator/security/rolesAllowed.ts: -------------------------------------------------------------------------------- 1 | import { DecoratorRegistry } from "../../reflection/DecoratorRegistry"; 2 | 3 | /** 4 | * Decorator for an endpoint method that defines roles that are allowed to use it. 5 | * 6 | * Overrides the controller roles set by the `controllerRolesAllowed` decorator, if any. 7 | * 8 | * Role based authorization is managed by an `IAuthorizer` implementation registered 9 | * with the current app. 10 | * 11 | * @param roleNames Names of roles that are permitted to use the endpoint. 12 | */ 13 | export function rolesAllowed(...roleNames: string[]) { 14 | return (classDefinition: Object | Function, methodName: string) => { 15 | let controller = DecoratorRegistry.getOrCreateController(classDefinition.constructor) 16 | let endpoint = DecoratorRegistry.getOrCreateEndpoint(controller, methodName) 17 | 18 | DecoratorRegistry.getLogger().debug("@rolesAllowed(%j) decorator executed for endpoint: %s", 19 | roleNames, 20 | endpoint.name) 21 | 22 | endpoint.rolesAllowed = roleNames 23 | } 24 | } 25 | 26 | /** 27 | * Decorator for a controller class that defines roles that are allowed to user all 28 | * endpoints within it. 29 | * 30 | * Role based authorization is managed by a `IAuthorizer` implementation registered 31 | * with the current app. 32 | * 33 | * @param roleNames Names of roles that are permitted to use the endpoints in this controller. 34 | */ 35 | export function controllerRolesAllowed(...roleNames: string[]) { 36 | return (classDefinition: Function) => { 37 | let controller = DecoratorRegistry.getOrCreateController(classDefinition) 38 | 39 | DecoratorRegistry.getLogger().debug("@controllerRolesAllowed(%j) executed for controller: %s", 40 | roleNames, 41 | controller.name) 42 | 43 | controller.rolesAllowed = roleNames 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/api/error/ErrorInterceptor.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify"; 2 | 3 | import { ApiError } from "../../model/ApiError" 4 | 5 | /** 6 | * Base class for implementing error interceptors that are invoked 7 | * to handle errors thrown by endpoints. 8 | */ 9 | @injectable() 10 | export abstract class ErrorInterceptor { 11 | public endpointTarget?: string 12 | public controllerTarget?: string 13 | 14 | public constructor() { 15 | this.endpointTarget = "*" 16 | } 17 | 18 | /** 19 | * Should this interceptor handle a given endpoint error? 20 | * 21 | * @param controller Class name of the controller where the error occurred. 22 | * @param endpoint Name of the endpoint; format is ${ClassName}:${MethodName}. 23 | * 24 | * @returns If this is a global error interceptor, denoted by a endpointTarget 25 | * that equals "*", return true. Otherwise return whether the endpointTarget 26 | * or controllerTarget match the controller or endpoint respectively. 27 | */ 28 | public shouldIntercept(controller: string, endpoint: string) { 29 | return this.endpointTarget === "*" || 30 | this.endpointTarget === endpoint || 31 | this.controllerTarget === controller 32 | } 33 | 34 | /** 35 | * Error interceptors implement this method to handle errors. 36 | * 37 | * @param apiError Details of error that was thrown by an endpoint, contains 38 | * the request and response context, which can used to return a 39 | * custom HTTP response. 40 | * @returns Value that you want to send back in the HTTP response; optional. 41 | */ 42 | public abstract intercept(apiError: ApiError): Promise 43 | } 44 | -------------------------------------------------------------------------------- /src/api/parameters/BaseParameterExtractor.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "lambda-api" 2 | 3 | import { ApiParam } from "../../model/open-api/ApiParam" 4 | import { Principal } from "../../model/security/Principal" 5 | import { ILogger } from "../../util/logging/ILogger" 6 | import { LogFactory } from "../../util/logging/LogFactory" 7 | import { IParameterExtractor, ParameterSource } from "./IParameterExtractor" 8 | 9 | export abstract class BaseParameterExtractor implements IParameterExtractor { 10 | protected logger: ILogger 11 | 12 | public abstract readonly source: ParameterSource 13 | public abstract readonly name: string 14 | 15 | public constructor( 16 | private readonly clazz: Function, 17 | public readonly apiParamInfo?: ApiParam 18 | ) { 19 | } 20 | 21 | public setLogger(logFactory: LogFactory) { 22 | this.logger = logFactory.getLogger(this.clazz) 23 | } 24 | 25 | public abstract extract(request: Request, response: Response, user: Principal): any 26 | } 27 | -------------------------------------------------------------------------------- /src/api/parameters/BodyParameterExtractor.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "lambda-api" 2 | 3 | import { BaseParameterExtractor } from "./BaseParameterExtractor" 4 | 5 | export class BodyParameterExtractor extends BaseParameterExtractor { 6 | public readonly source = "virtual" 7 | public readonly name = "body" 8 | 9 | public constructor() { 10 | super(BodyParameterExtractor) 11 | } 12 | 13 | public extract(request: Request) { 14 | this.logger.debug("Extracting body from request") 15 | this.logger.trace("Request body: %j", request.body) 16 | 17 | return request.body 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/api/parameters/HeaderParameterExtractor.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "lambda-api" 2 | 3 | import { ApiParam } from "../../model/open-api/ApiParam" 4 | import { BaseParameterExtractor } from "./BaseParameterExtractor" 5 | 6 | export class HeaderParameterExtractor extends BaseParameterExtractor { 7 | public readonly source = "header" 8 | 9 | public constructor(public readonly name: string, apiParamInfo?: ApiParam) { 10 | super(HeaderParameterExtractor, apiParamInfo) 11 | } 12 | 13 | public extract(request: Request) { 14 | this.logger.debug("Extracting header '%s' from request", this.name) 15 | 16 | let headerValue = request.headers[this.name] 17 | 18 | this.logger.trace("Header '%s' value: %s", this.name, headerValue) 19 | 20 | return headerValue 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/api/parameters/IParameterExtractor.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "lambda-api" 2 | 3 | import { ApiParam } from "../../model/open-api/ApiParam" 4 | import { Principal } from "../../model/security/Principal" 5 | import { LogFactory } from "../../util/logging/LogFactory" 6 | 7 | export type ParameterSource = "query" | "header" | "path" | "cookie" | "virtual" 8 | 9 | export interface IParameterExtractor { 10 | readonly source: ParameterSource 11 | readonly name: string 12 | readonly apiParamInfo?: ApiParam 13 | 14 | setLogger(logFactory: LogFactory) 15 | 16 | extract(request: Request, response: Response, user: Principal): any 17 | } 18 | -------------------------------------------------------------------------------- /src/api/parameters/PathParameterExtractor.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "lambda-api" 2 | 3 | import { ApiParam } from "../../model/open-api/ApiParam" 4 | import { BaseParameterExtractor } from "./BaseParameterExtractor" 5 | 6 | export class PathParameterExtractor extends BaseParameterExtractor { 7 | public readonly source = "path" 8 | 9 | public constructor(public readonly name: string, apiParamInfo?: ApiParam) { 10 | super(PathParameterExtractor, apiParamInfo) 11 | } 12 | 13 | public extract(request: Request) { 14 | this.logger.debug("Extracting path parameter '%s' from request", this.name) 15 | 16 | let pathParamValue = request.params[this.name] 17 | 18 | this.logger.trace("Path parameter '%s' value: %s", this.name, pathParamValue) 19 | 20 | return pathParamValue 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/api/parameters/QueryParameterExtractor.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "lambda-api" 2 | 3 | import { ApiParam } from "../../model/open-api/ApiParam" 4 | import { BaseParameterExtractor } from "./BaseParameterExtractor" 5 | 6 | export class QueryParameterExtractor extends BaseParameterExtractor { 7 | public readonly source = "query" 8 | 9 | public constructor(public readonly name: string, apiParamInfo?: ApiParam) { 10 | super(QueryParameterExtractor, apiParamInfo) 11 | } 12 | 13 | public extract(request: Request) { 14 | this.logger.debug("Extracting query parameter '%s' from request", this.name) 15 | 16 | let queryParamValue = request.query[this.name] 17 | 18 | this.logger.trace("Query parameter '%s' value: %s", this.name, queryParamValue) 19 | 20 | return queryParamValue 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/api/parameters/RawBodyParameterExtractor.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "lambda-api" 2 | 3 | import { BaseParameterExtractor } from "./BaseParameterExtractor" 4 | 5 | export class RawBodyParameterExtractor extends BaseParameterExtractor { 6 | public readonly source = "virtual" 7 | public readonly name = "raw-body" 8 | 9 | public constructor() { 10 | super(RawBodyParameterExtractor) 11 | } 12 | 13 | public extract(request: Request) { 14 | this.logger.debug("Extracting raw body from request") 15 | 16 | let rawBody = request.isBase64Encoded ? 17 | Buffer.from(request.rawBody, "base64") : 18 | Buffer.from(request.rawBody) 19 | 20 | this.logger.trace("Request raw body size in bytes: %d", rawBody.byteLength) 21 | 22 | return rawBody 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/api/parameters/RequestParameterExtractor.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from "util" 2 | 3 | import { Request } from "lambda-api" 4 | 5 | import { BaseParameterExtractor } from "./BaseParameterExtractor" 6 | 7 | export class RequestParameterExtractor extends BaseParameterExtractor { 8 | public readonly source = "virtual" 9 | public readonly name = "request" 10 | 11 | public constructor() { 12 | super(RequestParameterExtractor) 13 | } 14 | 15 | public extract(request: Request) { 16 | this.logger.debug("Injecting request as parameter") 17 | 18 | if (this.logger.traceEnabled()) { 19 | this.logger.trace("Request:\n%s", inspect(request)) 20 | } 21 | 22 | return request 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/api/parameters/ResponseParameterExtractor.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from "util" 2 | 3 | import { Request, Response } from "lambda-api" 4 | 5 | import { BaseParameterExtractor } from "./BaseParameterExtractor" 6 | 7 | export class ResponseParameterExtractor extends BaseParameterExtractor { 8 | public readonly source = "virtual" 9 | public readonly name = "response" 10 | 11 | public constructor() { 12 | super(ResponseParameterExtractor) 13 | } 14 | 15 | public extract(_: Request, response: Response) { 16 | this.logger.debug("Injecting response as parameter") 17 | 18 | if (this.logger.traceEnabled()) { 19 | this.logger.trace("Response:\n%s", inspect(response)) 20 | } 21 | 22 | return response 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/api/parameters/UserParameterExtractor.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "lambda-api" 2 | 3 | import { BaseParameterExtractor } from "./BaseParameterExtractor" 4 | import { Principal } from "../../model/security/Principal" 5 | 6 | export class UserParameterExtractor extends BaseParameterExtractor { 7 | public readonly source = "virtual" 8 | public readonly name = "user" 9 | 10 | public constructor() { 11 | super(UserParameterExtractor) 12 | } 13 | 14 | public extract(request: Request, response: Response, user: Principal) { 15 | this.logger.debug("Extracting user from request") 16 | this.logger.trace("User: %j", user) 17 | 18 | return user 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/api/reflection/ControllerLoader.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | 3 | import { LogFactory } from "../../util/logging/LogFactory" 4 | 5 | export class ControllerLoader { 6 | public static async loadControllers(controllersDirectory: string, logFactory: LogFactory) { 7 | let logger = logFactory.getLogger(ControllerLoader) 8 | 9 | logger.debug("Scanning directory for javascript files: %s", controllersDirectory) 10 | 11 | for (let file of fs.readdirSync(controllersDirectory)) { 12 | if (file.endsWith(".js")) { 13 | logger.debug("Importing javascript file: %s", file) 14 | 15 | await import(`${controllersDirectory}/${file}`) 16 | } else { 17 | logger.trace("Ignoring non javascript file in controllers directory: %s", file) 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/api/reflection/DecoratorRegistry.ts: -------------------------------------------------------------------------------- 1 | import { AuthFilterInfo } from "../../model/open-api/AuthFilterInfo" 2 | import { ControllerInfo } from "../../model/reflection/ControllerInfo" 3 | import { EndpointInfo } from "../../model/reflection/EndpointInfo" 4 | import { IDictionary } from "../../util/IDictionary" 5 | import { ILogger } from "../../util/logging/ILogger" 6 | import { LogFactory } from "../../util/logging/LogFactory" 7 | 8 | export class DecoratorRegistry { 9 | private static logger: ILogger = LogFactory.getDefaultLogger(DecoratorRegistry) 10 | 11 | // these are required to be dictionaries, using Map here causes weird issues with persistance 12 | public static readonly Endpoints: IDictionary = {} 13 | public static readonly Controllers: IDictionary = {} 14 | public static readonly AuthFilters: IDictionary = {} 15 | 16 | public static getLogger() { 17 | return DecoratorRegistry.logger 18 | } 19 | 20 | public static setLogger(logFactory: LogFactory) { 21 | DecoratorRegistry.logger = logFactory.getLogger(DecoratorRegistry) 22 | } 23 | 24 | public static getOrCreateController(constructor: Function): ControllerInfo { 25 | let name = constructor.name 26 | 27 | if (!DecoratorRegistry.Controllers[name]) { 28 | DecoratorRegistry.logger.debug("Controller registered: %s", name) 29 | 30 | DecoratorRegistry.Controllers[name] = new ControllerInfo(name, constructor) 31 | } 32 | 33 | return DecoratorRegistry.Controllers[name] 34 | } 35 | 36 | public static getOrCreateEndpoint(controller: ControllerInfo, methodName: string): EndpointInfo { 37 | let endpointKey = `${controller.name}::${methodName}` 38 | 39 | if (!DecoratorRegistry.Endpoints[endpointKey]) { 40 | DecoratorRegistry.logger.debug("Endpoint registered: %s", endpointKey) 41 | 42 | DecoratorRegistry.Endpoints[endpointKey] = new EndpointInfo(endpointKey, controller, methodName) 43 | } 44 | 45 | controller.endpoints[methodName] = DecoratorRegistry.Endpoints[endpointKey] 46 | 47 | return DecoratorRegistry.Endpoints[endpointKey] 48 | } 49 | 50 | public static getOrCreateAuthFilter(constructor: Function) { 51 | let name = constructor.name 52 | 53 | if (!DecoratorRegistry.AuthFilters[name]) { 54 | DecoratorRegistry.logger.debug("Authenticaion filter registered: %s", name) 55 | 56 | DecoratorRegistry.AuthFilters[name] = new AuthFilterInfo() 57 | } 58 | 59 | return DecoratorRegistry.AuthFilters[name] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/api/security/BasicAuthFilter.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "lambda-api" 2 | 3 | import { IAuthFilter } from "./IAuthFilter" 4 | import { BasicAuth } from "../../model/security/BasicAuth" 5 | import { Principal } from "../../model/security/Principal" 6 | import { apiSecurity } from "../decorator/open-api/apiSecurity"; 7 | 8 | /** 9 | * IAuthFilter implementation that supports the HTTP Basic authentication scheme. 10 | */ 11 | @apiSecurity("basic", { 12 | scheme: "basic", 13 | type: "http" 14 | }) 15 | export abstract class BasicAuthFilter implements IAuthFilter { 16 | public readonly authenticationSchemeName: string = "Basic" 17 | public abstract readonly name: string 18 | 19 | /** 20 | * If the authentication scheme is 'Basic', returns a BasicAuth instance containing 21 | * the username and password, otherwise returns undefined. 22 | * 23 | * @param request Request context to use. 24 | */ 25 | // eslint-disable-next-line @typescript-eslint/require-await 26 | public async extractAuthData(request: Request): Promise { 27 | if (request.auth.type === "Basic") { 28 | return { 29 | password: request.auth.password, 30 | username: request.auth.username 31 | } 32 | } 33 | } 34 | 35 | /** 36 | * 37 | * @param basicAuth 38 | */ 39 | public abstract authenticate(basicAuth: BasicAuth): Promise 40 | } 41 | -------------------------------------------------------------------------------- /src/api/security/IAuthFilter.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "lambda-api" 2 | 3 | import { Principal } from "../../model/security/Principal" 4 | 5 | /** 6 | * Authentication filter that can extract authentication data 7 | * from a HTTP request and preform authentication using that data. 8 | * 9 | * @param T Authentication data type 10 | * @param U Principal data type, the type must extend `Principal` 11 | */ 12 | export interface IAuthFilter { 13 | /** 14 | * String to use in `WWW-Authenticate` header when returing 15 | * a HTTP 401 response, see: 16 | * https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml 17 | */ 18 | readonly authenticationSchemeName: string 19 | 20 | /** 21 | * A human readable name for this authentication filter. 22 | */ 23 | readonly name: string 24 | 25 | /** 26 | * Extract an instance of the authentication data type `T` 27 | * from a HTTP request. If extraction is not possible due to 28 | * missing request headers/data, `undefined` should be returned. 29 | * 30 | * @param request Request context to use. 31 | * @returns Instance of type `T` or `undefined` on extraction failure. 32 | */ 33 | extractAuthData(request: Request): Promise 34 | 35 | /** 36 | * Attempt to authorise a user suing the authentication data supplied. 37 | * An instance of the principal type `U` should be returned on authentication 38 | * success, otherwise `undefined` should be returned. 39 | * 40 | * @param authData An instance of the authentication data type `T`. 41 | * @returns Instance of type `U` or `undefined` on authentication failure. 42 | */ 43 | authenticate(authData: T): Promise 44 | } 45 | -------------------------------------------------------------------------------- /src/api/security/IAuthorizer.ts: -------------------------------------------------------------------------------- 1 | import { Principal } from "../../model/security/Principal" 2 | 3 | /** 4 | * Authorizer to perform role based checks for a principal. 5 | * 6 | * @param U Principal data type, the type must extend `Principal` 7 | */ 8 | export interface IAuthorizer { 9 | /** 10 | * A human readable name for this authorizer. 11 | */ 12 | readonly name: string 13 | 14 | /** 15 | * Check that a principal has a named role. 16 | * 17 | * @param principal The current principal context. 18 | * @param role The name of the role to check for. 19 | * @returns `true` if the principal is authorised to use the name role, otherwise `false`. 20 | */ 21 | authorize(principal: T, role: string): Promise 22 | } 23 | -------------------------------------------------------------------------------- /src/model/ApiError.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "lambda-api" 2 | 3 | import { Controller } from "../api/Controller" 4 | 5 | /** 6 | * Context of an error thrown by an endpoint method. 7 | * 8 | * Instances of this class are passed to error interceptors. 9 | */ 10 | export class ApiError { 11 | /** 12 | * Error thrown by the endpoint method. 13 | */ 14 | public error: Error 15 | 16 | /** 17 | * Parameter values passed to the endpoint method. 18 | */ 19 | public endpointMethodParameters: any[] 20 | 21 | /** 22 | * Endpoint method defintion. 23 | */ 24 | public endpointMethod: Function 25 | 26 | /** 27 | * Controller instance used to call endpoint method. 28 | */ 29 | public endpointController: Controller 30 | 31 | /** 32 | * HTTP request context. 33 | */ 34 | public request: Request 35 | 36 | /** 37 | * HTTP response context. 38 | */ 39 | public response: Response 40 | } 41 | -------------------------------------------------------------------------------- /src/model/ApiRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * AWS Lambda HTTP request event. Used for testing, 3 | * enabling manually passing requests to a `ApiLambdaApp` 4 | * instance. 5 | */ 6 | export class ApiRequest { 7 | /** 8 | * HTTP method ('GET', 'POST', 'PUT' etc.) 9 | */ 10 | public httpMethod: string 11 | 12 | /** 13 | * Request URL path. Excludes protocol, host and port. 14 | */ 15 | public path: string 16 | 17 | /** 18 | * HTTP request headers as a map. 19 | */ 20 | public headers: object = {} 21 | 22 | /** 23 | * HTTP request query string parameters as a map. 24 | */ 25 | public queryStringParameters: object = {} 26 | 27 | /** 28 | * HTTP request event body, potentially Base64 encoded. 29 | */ 30 | public body: string 31 | 32 | /** 33 | * Is the `body` property Base64 encoded? 34 | */ 35 | public isBase64Encoded: boolean 36 | } 37 | -------------------------------------------------------------------------------- /src/model/ApiResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Response to aAWS Lambda HTTP request event. Used for testing. 3 | */ 4 | export class ApiResponse { 5 | /** 6 | * HTTP response headers as a map. 7 | */ 8 | public headers: object 9 | 10 | /** 11 | * HTTP response code (201, 400, 500 etc.) 12 | */ 13 | public statusCode: number 14 | 15 | /** 16 | * HTTP response body, potentially Base64 encoded. 17 | */ 18 | public body: string 19 | 20 | /** 21 | * Is the `body` property Base64 encoded? 22 | */ 23 | public isBase64Encoded: boolean 24 | } 25 | -------------------------------------------------------------------------------- /src/model/AppConfig.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify" 2 | import { LoggerOptions, Options, SerializerFunction } from "lambda-api" 3 | 4 | import { OpenApiConfig } from "./OpenApiConfig" 5 | import { LogLevel } from "./logging/LogLevel" 6 | import { ServerLoggerConfig } from "./logging/ServerLoggerConfig" 7 | 8 | /** 9 | * Base class for app configuration. Extend this 10 | * to supply your own configuration properties. 11 | * 12 | * It is recommended to create your own sub-class and 13 | * register it with the application IOC container. 14 | * 15 | * This class supports all the `lambda-api` config 16 | * options by implementing the `Options` interface. 17 | */ 18 | @injectable() 19 | export class AppConfig implements Options { 20 | /** 21 | * API human readable name. 22 | */ 23 | public name?: string 24 | 25 | /** 26 | * Version number accessible via `Request` context instances. 27 | */ 28 | public version?: string 29 | 30 | /** 31 | * Base path for all routes, e.g. base: 'v1' would 32 | * prefix all routes with /v1. 33 | */ 34 | public base?: string 35 | 36 | /** 37 | * Override the default callback query parameter name 38 | * for JSONP calls. 39 | */ 40 | public callbackName?: string 41 | 42 | /** 43 | * lambda-api logging configuration. Enables/disables default logging 44 | * by setting to a boolean value or allows for configuration through 45 | * a Logging Configuration object. 46 | * 47 | * Defaults to info level logging. 48 | */ 49 | public logger?: boolean | LoggerOptions 50 | 51 | /** 52 | * Logging configuration for ts-lambda-api. 53 | * See [[ServerLoggerConfig]] for more information. 54 | * 55 | * Defaults to info level plain string logging. 56 | */ 57 | public serverLogger?: ServerLoggerConfig 58 | 59 | /** 60 | * Name/value pairs of additional MIME types to be supported 61 | * by the type(). The key should be the file extension 62 | * (without the .) and the value should be the expected MIME type, 63 | * e.g. `application/json`. 64 | */ 65 | public mimeTypes?: { 66 | [key: string]: string 67 | } 68 | 69 | /** 70 | * Optional object serializer function. This function receives the 71 | * body of a response and must return a string. Defaults to JSON.stringify 72 | */ 73 | public serializer?: SerializerFunction 74 | 75 | /** 76 | * OpenAPI configuration. 77 | */ 78 | public openApi?: OpenApiConfig 79 | 80 | public constructor() { 81 | this.openApi = new OpenApiConfig() 82 | this.logger = { 83 | level: "info" 84 | } 85 | this.serverLogger = { 86 | format: "string", 87 | level: LogLevel.info 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/model/JsonPatch.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "fast-json-patch" 2 | 3 | /** 4 | * A collection of JSON patch operations, defined by the `Operation` class. 5 | */ 6 | export type JsonPatch = Operation[] 7 | -------------------------------------------------------------------------------- /src/model/OpenApiConfig.ts: -------------------------------------------------------------------------------- 1 | export class OpenApiConfig { 2 | /** 3 | * Set this flag to true to enable OpenAPI endpoints in 4 | * your API @ {basePath}/open-api.json and {basePath}/open-api.yml 5 | */ 6 | public enabled?: boolean 7 | 8 | /** 9 | * Set this flag to apply any registered authentication 10 | * filters to OpenAPI spec requests. 11 | */ 12 | public useAuthentication?: boolean 13 | } 14 | -------------------------------------------------------------------------------- /src/model/logging/LogLevel.ts: -------------------------------------------------------------------------------- 1 | export enum LogLevel { 2 | trace = 10, 3 | debug = 20, 4 | info = 30, 5 | warn = 40, 6 | error = 50, 7 | fatal = 60, 8 | off = 70 9 | } 10 | -------------------------------------------------------------------------------- /src/model/logging/ServerLoggerConfig.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel } from "./LogLevel" 2 | import { LogFormat } from "../../util/logging/ILogger" 3 | 4 | /** 5 | * Logging configuration for the `ts-lambda-api` framework. 6 | */ 7 | export class ServerLoggerConfig { 8 | /** 9 | * Lowest level of log message to output. 10 | * 11 | * See [[LogLevel]] for levels supported. 12 | */ 13 | public level: LogLevel 14 | 15 | /** 16 | * Print an ISO 8601 timestamp before every log message? (string format only) 17 | * 18 | * Defaults to false, AWS Lambda already includes timestamps 19 | * in it's log of standard output. 20 | */ 21 | public logTimestamp?: boolean 22 | 23 | /** 24 | * Format of log messages. A plain string: 25 | * 26 | * ``` 27 | * level class message 28 | * vvvvv vvvvvvvv vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 29 | * INFO Endpoint - Invoking endpoint: [GET] /open-api.yml 30 | * ``` 31 | * 32 | * A plain string with `logTimestamp` set to true: 33 | * 34 | * ``` 35 | * ISO 8601 Datetime level class message 36 | * vvvvvvvvvvvvvvvvvvvvvvvv vvvvv vvvvvvvv vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 37 | * 2019-04-21T16:38:09.680Z INFO Endpoint - Invoking endpoint: [GET] /open-api.yml 38 | * ``` 39 | * 40 | * A JSON format message (matches lambda-api format): 41 | * 42 | * ```json 43 | * { 44 | * "level": "INFO", 45 | * "msg": "Endpoint - Invoking endpoint: [GET] /open-api.yml", 46 | * "time": 1555865906882 // millis since epoch 47 | * } 48 | * ``` 49 | */ 50 | public format?: LogFormat 51 | } 52 | -------------------------------------------------------------------------------- /src/model/open-api/ApiBody.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Describes the body of a HTTP request or response. 3 | */ 4 | export class ApiBody { 5 | /** 6 | * Content (MIME) type. 7 | */ 8 | public contentType?: string 9 | 10 | /** 11 | * Type of data the content stores, one of the following: 12 | * 13 | * array 14 | * array-array 15 | * boolean 16 | * boolean-array 17 | * double 18 | * double-array 19 | * file 20 | * int 21 | * int-array 22 | * number 23 | * number-array 24 | * object 25 | * object-array 26 | * string 27 | * string-array 28 | * 29 | * If you specify this value, `class` is ignored. 30 | * 31 | */ 32 | public type?: string 33 | 34 | /** 35 | * Class type that the content will store. This will 36 | * generate a schema in the OpenAPI spec for the given 37 | * type. 38 | */ 39 | public class?: Function 40 | 41 | /** 42 | * Description of this HTTP body. 43 | */ 44 | public description?: string 45 | 46 | /** 47 | * Free form example of this body in plain 48 | * text. Setting this will prevent `type` 49 | * or `class` from setting auto-generated 50 | * examples. 51 | */ 52 | public example?: string 53 | } 54 | -------------------------------------------------------------------------------- /src/model/open-api/ApiBodyInfo.ts: -------------------------------------------------------------------------------- 1 | import { ApiBody } from "./ApiBody" 2 | 3 | /** 4 | * Describes the body of a HTTP request or response. 5 | */ 6 | export class ApiBodyInfo extends ApiBody { 7 | /** 8 | * Copy any valid properties from another instance. 9 | */ 10 | public mergeInfo(otherInstance: ApiBody) { 11 | if (otherInstance.class) { 12 | this.class = otherInstance.class 13 | } 14 | 15 | if (otherInstance.contentType) { 16 | this.contentType = otherInstance.contentType 17 | } 18 | 19 | if (otherInstance.description) { 20 | this.description = otherInstance.description 21 | } 22 | 23 | if (otherInstance.example) { 24 | this.example = otherInstance.example 25 | } 26 | 27 | if (otherInstance.type) { 28 | this.type = otherInstance.type 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/model/open-api/ApiOperation.ts: -------------------------------------------------------------------------------- 1 | import { ApiBodyInfo } from "./ApiBodyInfo"; 2 | import { IDictionary } from "../../util/IDictionary" 3 | 4 | /** 5 | * Describes an API endpoint. 6 | */ 7 | export class ApiOperation { 8 | /** 9 | * Name of the endpoint. 10 | */ 11 | public name?: string 12 | 13 | /** 14 | * Description of the endpoint. 15 | */ 16 | public description?: string 17 | 18 | /** 19 | * Information about the endpoint request body. 20 | */ 21 | public request?: ApiBodyInfo 22 | 23 | /** 24 | * Map of HTTP status codes (as strings) to response 25 | * body info. This means that you can describe the success 26 | * body of a 200 response as well as the error body of a 500 27 | * response. 28 | */ 29 | public responses?: IDictionary = {} 30 | } 31 | -------------------------------------------------------------------------------- /src/model/open-api/ApiOperationInfo.ts: -------------------------------------------------------------------------------- 1 | import { ApiBodyInfo } from "./ApiBodyInfo" 2 | import { ApiOperation } from "./ApiOperation" 3 | import { ApiBody } from "./ApiBody" 4 | import { IDictionary } from "../../util/IDictionary" 5 | 6 | /** 7 | * Describes an API endpoint. 8 | */ 9 | export class ApiOperationInfo extends ApiOperation { 10 | /** 11 | * Get the HTTP request info. 12 | */ 13 | public getOrCreateRequest() { 14 | if (!this.request) { 15 | this.request = new ApiBodyInfo() 16 | } 17 | 18 | return this.request 19 | } 20 | 21 | /** 22 | * Copy valid properties from another instance. 23 | */ 24 | public mergeInfo(otherInstance: ApiOperation) { 25 | if (otherInstance.name) { 26 | this.name = otherInstance.name 27 | } 28 | 29 | if (otherInstance.description) { 30 | this.description = otherInstance.description 31 | } 32 | 33 | if (otherInstance.request) { 34 | this.request = otherInstance.request 35 | } 36 | 37 | if (otherInstance.responses) { 38 | this.mergeResponses(otherInstance.responses) 39 | } 40 | } 41 | 42 | /** 43 | * Copy valid properties from another instance responses. 44 | * 45 | * See [[ApiBodyInfo]] 46 | */ 47 | public mergeResponses(otherResponses: IDictionary) { 48 | for (let statusCode in otherResponses) { 49 | if (!otherResponses.hasOwnProperty(statusCode)) { 50 | continue 51 | } 52 | 53 | let response = new ApiBodyInfo() 54 | 55 | if (otherResponses[statusCode]) { 56 | response.mergeInfo(otherResponses[statusCode]) 57 | } 58 | 59 | this.responses[statusCode] = response 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/model/open-api/ApiParam.ts: -------------------------------------------------------------------------------- 1 | import { ParameterStyle } from "openapi3-ts/dist/model/openapi31"; 2 | 3 | import { ApiBody } from "./ApiBody" 4 | 5 | /** 6 | * Describes a parameter in a HTTP request. 7 | */ 8 | export class ApiParam extends ApiBody { 9 | /** 10 | * Defines how array/object is delimited. Possible styles 11 | * depend on the parameter location. 12 | * 13 | * See: https://swagger.io/docs/specification/serialization/ 14 | */ 15 | public style?: ParameterStyle 16 | 17 | /** 18 | * Specifies whether arrays and objects should generate 19 | * separate parameters for each array item or object property; 20 | * in other words, muiltiple parameters of the same name for array 21 | * values/object fields (true) or one string per parameter (false); 22 | * see style field. 23 | * 24 | * See: https://swagger.io/docs/specification/serialization/ 25 | */ 26 | public explode?: boolean 27 | 28 | /** 29 | * Is this parameter required in requests? 30 | */ 31 | public required?: boolean 32 | } 33 | -------------------------------------------------------------------------------- /src/model/open-api/AuthFilterInfo.ts: -------------------------------------------------------------------------------- 1 | import { SecuritySchemeObject } from "openapi3-ts/oas31"; 2 | 3 | export class AuthFilterInfo { 4 | public name?: string 5 | public securitySchemeInfo?: SecuritySchemeObject 6 | } 7 | -------------------------------------------------------------------------------- /src/model/reflection/ControllerInfo.ts: -------------------------------------------------------------------------------- 1 | import { interfaces } from "inversify" 2 | 3 | import { EndpointInfo } from "./EndpointInfo" 4 | import { ErrorInterceptor } from "../../api/error/ErrorInterceptor" 5 | import { ApiBodyInfo } from "../open-api/ApiBodyInfo" 6 | 7 | export class ControllerInfo { 8 | public readonly name: string 9 | public readonly classConstructor: Function 10 | 11 | public apiName?: string 12 | public apiDescription?: string 13 | public apiIgnore?: boolean 14 | public path?: string 15 | public consumes?: string 16 | public apiRequestInfo?: ApiBodyInfo 17 | public produces?: string 18 | public noAuth?: boolean 19 | public rolesAllowed?: string[] 20 | public errorInterceptor?: interfaces.ServiceIdentifier 21 | public endpoints: Map = new Map() 22 | 23 | public constructor(name: string, classConstructor: Function) { 24 | this.name = name 25 | this.classConstructor = classConstructor 26 | } 27 | 28 | public getOrCreateRequestInfo() { 29 | if (!this.apiRequestInfo) { 30 | this.apiRequestInfo = new ApiBodyInfo() 31 | } 32 | 33 | return this.apiRequestInfo 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/model/reflection/EndpointInfo.ts: -------------------------------------------------------------------------------- 1 | import { interfaces } from "inversify" 2 | 3 | import { ControllerInfo } from "./ControllerInfo" 4 | import { ErrorInterceptor } from "../../api/error/ErrorInterceptor" 5 | import { IParameterExtractor } from "../../api/parameters/IParameterExtractor" 6 | import { ApiOperationInfo } from "../open-api/ApiOperationInfo" 7 | import { ApiBodyInfo } from "../open-api/ApiBodyInfo"; 8 | 9 | export class EndpointInfo { 10 | public readonly controller?: ControllerInfo 11 | public readonly method: Function 12 | public readonly parameterExtractors: IParameterExtractor[] 13 | public httpMethod: string 14 | public path?: string 15 | public consumes?: string 16 | public produces?: string 17 | public noAuth?: boolean 18 | public rolesAllowed?: string[] 19 | public errorInterceptor?: interfaces.ServiceIdentifier 20 | public apiOperationInfo?: ApiOperationInfo 21 | public apiIgnore?: boolean; 22 | 23 | public get fullPath() { 24 | let rootPath = this.getControllerPropOrDefault(c => c.path) || "" 25 | let endpointPath = this.path || "" 26 | 27 | return `${rootPath}${endpointPath}` 28 | } 29 | 30 | public get requestContentType() { 31 | return this.consumes || this.getControllerPropOrDefault(c => c.consumes) 32 | } 33 | 34 | public get responseContentType() { 35 | return this.produces || this.getControllerPropOrDefault(c => c.produces) 36 | } 37 | 38 | public get authenticationDisabled() { 39 | return this.noAuth || this.getControllerPropOrDefault(c => c.noAuth) 40 | } 41 | 42 | public get roles() { 43 | let controllerRoles = this.getControllerPropOrDefault(c => c.rolesAllowed) || [] 44 | let endpointRoles = this.rolesAllowed || [] 45 | 46 | return endpointRoles.concat(controllerRoles) 47 | } 48 | 49 | public get apiRequestInfo() { 50 | let request: ApiBodyInfo 51 | 52 | if (this.apiOperationInfo) { 53 | request = this.apiOperationInfo.request 54 | } 55 | 56 | return request || 57 | this.getControllerPropOrDefault(c => c.apiRequestInfo) 58 | } 59 | 60 | public get endpointErrorInterceptor() { 61 | return this.errorInterceptor || this.getControllerPropOrDefault(c => c.errorInterceptor ) 62 | } 63 | 64 | public constructor( 65 | public readonly name: string, 66 | controllerOrMethod?: ControllerInfo | Function, 67 | public readonly methodName?: string, 68 | ) { 69 | if (controllerOrMethod instanceof ControllerInfo) { 70 | this.controller = controllerOrMethod 71 | 72 | this.method = this.controller.classConstructor.prototype[methodName] 73 | 74 | if (this.method === undefined) { 75 | throw new Error( 76 | `Unable to read method parameters for endpoint '${this.methodName}' ` + 77 | `in controller '${this.controller.name}', this normally happens when ` + 78 | "you have two controllers with the same class name" 79 | ) 80 | } 81 | } else { 82 | this.method = controllerOrMethod 83 | 84 | if (controllerOrMethod.name && controllerOrMethod.name.trim() !== "") { 85 | this.methodName = controllerOrMethod.name 86 | } else { 87 | this.methodName = this.name 88 | } 89 | } 90 | 91 | this.parameterExtractors = Array(this.method.length) 92 | } 93 | 94 | public getControllerPropOrDefault(lookup: (c: ControllerInfo) => T, defaultValue?: T) { 95 | return this.controller ? lookup(this.controller) : defaultValue 96 | } 97 | 98 | public getOrCreateApiOperationInfo() { 99 | if (!this.apiOperationInfo) { 100 | this.apiOperationInfo = new ApiOperationInfo() 101 | } 102 | 103 | return this.apiOperationInfo 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/model/security/AuthResult.ts: -------------------------------------------------------------------------------- 1 | import { Principal } from "./Principal" 2 | 3 | export class AuthResult { 4 | public authenticated: boolean 5 | public principal: Principal 6 | } 7 | -------------------------------------------------------------------------------- /src/model/security/BasicAuth.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Basic authentication details extracted from 3 | * a request. 4 | */ 5 | export class BasicAuth { 6 | public username: string 7 | public password: string 8 | } 9 | -------------------------------------------------------------------------------- /src/model/security/Principal.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base class for security principal returned 3 | * by an authentication filter upon successful 4 | * request authentication. 5 | * 6 | * Extend this class to define a custom principal. 7 | */ 8 | export abstract class Principal { 9 | protected _name: string 10 | 11 | /** 12 | * Build a new principal. 13 | * 14 | * @param name Name of this principal. 15 | */ 16 | public constructor(name?: string) { 17 | this._name = name 18 | } 19 | 20 | public get name(): string { 21 | return this._name 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/ts-lambda-api.ts: -------------------------------------------------------------------------------- 1 | export { ApiApp } from "./ApiApp" 2 | export { ApiLambdaApp } from "./ApiLambdaApp" 3 | 4 | export { Controller } from "./api/Controller" 5 | export { MiddlewareRegistry } from "./api/MiddlewareRegistry" 6 | export { Server } from "./api/Server" 7 | 8 | export { apiController } from "./api/decorator/apiController" 9 | export { GET, POST, PUT, PATCH, DELETE } from "./api/decorator/endpoints" 10 | export { controllerRolesAllowed, rolesAllowed } from "./api/decorator/security/rolesAllowed" 11 | export { controllerNoAuth, noAuth } from "./api/decorator/security/noAuth" 12 | export { controllerErrorInterceptor, errorInterceptor } from "./api/decorator/error/errorInterceptor" 13 | export { controllerConsumes, consumes } from "./api/decorator/context/consumes" 14 | export { controllerProduces, produces } from "./api/decorator/context/produces" 15 | export { body } from "./api/decorator/context/parameters/body" 16 | export { header } from "./api/decorator/context/parameters/header" 17 | export { pathParam } from "./api/decorator/context/parameters/pathParam" 18 | export { queryParam } from "./api/decorator/context/parameters/queryParam" 19 | export { rawBody } from "./api/decorator/context/parameters/rawBody" 20 | export { request } from "./api/decorator/context/parameters/request" 21 | export { response } from "./api/decorator/context/parameters/response" 22 | export { principal } from "./api/decorator/context/parameters/principal" 23 | 24 | export { api } from "./api/decorator/open-api/api" 25 | export { apiIgnoreController } from "./api/decorator/open-api/apiIgnoreController" 26 | export { apiIgnore } from "./api/decorator/open-api/apiIgnore" 27 | export { apiOperation } from "./api/decorator/open-api/apiOperation" 28 | export { apiRequest } from "./api/decorator/open-api/apiRequest" 29 | export { apiResponse } from "./api/decorator/open-api/apiResponse" 30 | export { apiSecurity } from "./api/decorator/open-api/apiSecurity" 31 | 32 | export { ErrorInterceptor } from "./api/error/ErrorInterceptor" 33 | 34 | export { IAuthFilter } from "./api/security/IAuthFilter" 35 | export { IAuthorizer } from "./api/security/IAuthorizer" 36 | export { BasicAuthFilter } from "./api/security/BasicAuthFilter" 37 | 38 | export { AppConfig } from "./model/AppConfig" 39 | export { ApiError } from "./model/ApiError" 40 | export { ApiRequest } from "./model/ApiRequest" 41 | export { ApiResponse } from "./model/ApiResponse" 42 | export { JsonPatch } from "./model/JsonPatch" 43 | export { OpenApiConfig } from "./model/OpenApiConfig" 44 | 45 | export { LogLevel } from "./model/logging/LogLevel" 46 | export { ServerLoggerConfig } from "./model/logging/ServerLoggerConfig" 47 | 48 | export { Principal } from "./model/security/Principal" 49 | export { BasicAuth } from "./model/security/BasicAuth" 50 | 51 | export { IDictionary } from "./util/IDictionary" 52 | export { RequestBuilder } from "./util/RequestBuilder" 53 | export { timed } from "./util/timed" 54 | 55 | export { ILogger, LogFormat } from "./util/logging/ILogger" 56 | export { LogFactory } from "./util/logging/LogFactory" 57 | -------------------------------------------------------------------------------- /src/util/Environment.ts: -------------------------------------------------------------------------------- 1 | export const ProfilingEnabled: boolean = (process.env.PROFILE_API === "1") 2 | export const UnderTest: boolean = (process.env.TLA_UNDER_TEST === "1") 3 | -------------------------------------------------------------------------------- /src/util/IDictionary.ts: -------------------------------------------------------------------------------- 1 | export interface IDictionary { 2 | [key: string]: T 3 | } 4 | -------------------------------------------------------------------------------- /src/util/RequestBuilder.ts: -------------------------------------------------------------------------------- 1 | import { METHODS } from "lambda-api" 2 | 3 | import { ApiRequest } from "../model/ApiRequest" 4 | import { IDictionary } from "./IDictionary" 5 | 6 | /** 7 | * Builds `ApiRequest` instances using the builder 8 | * pattern. Used for testing. 9 | */ 10 | export class RequestBuilder { 11 | private readonly method: METHODS 12 | private readonly path: string 13 | private readonly httpHeaders: IDictionary 14 | private readonly httpQueryParams: IDictionary 15 | private httpBody: string 16 | private isBase64Encoded: boolean 17 | 18 | /** 19 | * Start building a HTTP GET request. 20 | * 21 | * @param path URL path to request. 22 | */ 23 | public static get(path: string) { 24 | return new RequestBuilder("GET", path) 25 | } 26 | 27 | /** 28 | * Start building a HTTP POST request. 29 | * 30 | * @param path URL path to request. 31 | */ 32 | public static post(path: string) { 33 | return new RequestBuilder("POST", path) 34 | } 35 | 36 | /** 37 | * Start building a HTTP PUT request. 38 | * 39 | * @param path URL path to request. 40 | */ 41 | public static put(path: string) { 42 | return new RequestBuilder("PUT", path) 43 | } 44 | 45 | /** 46 | * Start building a HTTP PATCH request. 47 | * 48 | * @param path URL path to request. 49 | */ 50 | public static patch(path: string) { 51 | return new RequestBuilder("PATCH", path) 52 | } 53 | 54 | /** 55 | * Start building a HTTP DELETE request. 56 | * 57 | * @param path URL path to request. 58 | */ 59 | public static delete(path: string) { 60 | return new RequestBuilder("DELETE", path) 61 | } 62 | 63 | /** 64 | * Start building a HTTP request. 65 | * 66 | * @param method HTTP method to use. 67 | * @param path URL path to request. 68 | */ 69 | public static do(method: METHODS, path: string) { 70 | return new RequestBuilder(method, path) 71 | } 72 | 73 | private constructor(method: METHODS, path: string) { 74 | this.method = method 75 | this.path = path 76 | 77 | this.httpHeaders = {} 78 | this.httpQueryParams = {} 79 | this.isBase64Encoded = false 80 | } 81 | 82 | /** 83 | * Add a HTTP header to the request. 84 | * 85 | * @param name Name of the header. 86 | * @param value Value to set. 87 | * @returns This builder instance. 88 | */ 89 | public header(name: string, value: string) { 90 | this.httpHeaders[name] = value 91 | return this 92 | } 93 | 94 | /** 95 | * Add multiple HTTP headers to the request. 96 | * 97 | * @param httpHeaders Map of HTTP headers to add. 98 | * @returns This builder instance. 99 | */ 100 | public headers(httpHeaders: IDictionary) { 101 | for (let key in httpHeaders) { 102 | if (!httpHeaders.hasOwnProperty(key)) { 103 | continue 104 | } 105 | 106 | this.httpHeaders[key] = httpHeaders[key] 107 | } 108 | 109 | return this 110 | } 111 | 112 | /** 113 | * Add a HTTP query parameter to the request. 114 | * 115 | * @param name Name of the query param. 116 | * @param value Value to set. 117 | * @returns This builder instance. 118 | */ 119 | public query(key: string, value: string) { 120 | this.httpQueryParams[key] = value 121 | return this 122 | } 123 | 124 | /** 125 | * Add multiple HTTP query parameters to the request. 126 | * 127 | * @param params Map of HTTP query params to add. 128 | * @returns This builder instance. 129 | */ 130 | public queryParams(params: IDictionary) { 131 | for (let key in params) { 132 | if (!params.hasOwnProperty(key)) { 133 | continue 134 | } 135 | 136 | this.httpQueryParams[key] = params[key] 137 | } 138 | 139 | return this 140 | } 141 | 142 | /** 143 | * Add a HTTP body to the request. 144 | * 145 | * @param value The body as a raw string. 146 | * @returns This builder instance. 147 | */ 148 | public body(value: string) { 149 | this.httpBody = value 150 | return this 151 | } 152 | 153 | /** 154 | * Add a base64 encoded HTTP body to the request. 155 | * 156 | * @param value The body as a base64 string. 157 | * @returns This builder instance. 158 | */ 159 | public base64EncodedBody(value: string) { 160 | this.body(value) 161 | this.isBase64Encoded = true 162 | 163 | return this 164 | } 165 | 166 | /** 167 | * Add a binary HTTP body to the request. 168 | * 169 | * @param value The binary body as a Buffer. 170 | * @returns This builder instance. 171 | */ 172 | public binaryBody(value: Buffer) { 173 | this.base64EncodedBody(value.toString("base64")) 174 | return this 175 | } 176 | 177 | /** 178 | * Add basic authentication to the request. 179 | * 180 | * @param username Username to send. 181 | * @param password Password to send. 182 | * @returns This builder instance. 183 | */ 184 | public basicAuth(username: string, password: string) { 185 | let credentials = Buffer.from(`${username}:${password}`).toString("base64") 186 | 187 | this.httpHeaders.Authorization = `Basic ${credentials}` 188 | return this 189 | } 190 | 191 | private mapToObject(map: IDictionary) { 192 | let obj = {} 193 | 194 | for (let key in map) { 195 | if (!map.hasOwnProperty(key)) { 196 | continue 197 | } 198 | 199 | obj[key] = map[key] 200 | } 201 | 202 | return obj 203 | } 204 | 205 | /** 206 | * Build a request object using the current builder config. 207 | */ 208 | public build() { 209 | let request = new ApiRequest() 210 | 211 | request.path = this.path 212 | request.httpMethod = this.method 213 | request.headers = this.mapToObject(this.httpHeaders) 214 | request.queryStringParameters = this.mapToObject(this.httpQueryParams) 215 | request.body = this.httpBody 216 | request.isBase64Encoded = this.isBase64Encoded 217 | 218 | return request 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/util/jsonUtils.ts: -------------------------------------------------------------------------------- 1 | export function toJson(value: any, spacing = 2) { 2 | return JSON.stringify(value, null, spacing) 3 | } 4 | -------------------------------------------------------------------------------- /src/util/logging/ConsoleLogger.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from "util" 2 | 3 | import { sprintf } from "sprintf-js" 4 | 5 | import { LogLevel } from "../../model/logging/LogLevel" 6 | import { UnderTest } from "../Environment" 7 | import { ILogger, LogFormat } from "./ILogger" 8 | 9 | /** 10 | * Logger implementation that uses the console for output. 11 | */ 12 | export class ConsoleLogger implements ILogger { 13 | private readonly useStringLogFormat: boolean 14 | 15 | /** 16 | * Build a new logger. 17 | * 18 | * @param clazz The enclosing class that will use the new logger. 19 | * @param logLevel Lowest level to log, defaults to `info`. 20 | * @param logFormat Format to output log messages in, defaults to `string`. 21 | * @param logTimestamp Print an ISO 8601 timestamp before every log message? (string format only) 22 | */ 23 | public constructor( 24 | private readonly clazz: string, 25 | public readonly level: LogLevel, 26 | public readonly format: LogFormat, 27 | public readonly logTimestamp: boolean 28 | ) { 29 | this.useStringLogFormat = this.format === "string" 30 | } 31 | 32 | private formatMessage(message: string, ...formatArgs: any[]) { 33 | try { 34 | return sprintf(message, ...formatArgs) 35 | } catch (ex) { 36 | this.errorWithStack( 37 | "Failed to format log message, raw log message and parameters will be output", 38 | ex 39 | ) 40 | 41 | // fall back to raw log message and args dump using inspect 42 | return `${message}\n${inspect(formatArgs)}` 43 | } 44 | } 45 | 46 | /** 47 | * Log a message with a custom level. Ignores messages of a lower log 48 | * level than the current level. If [[Environment.UnderTest]] is enabled 49 | * then this method will only log info messages or higher. 50 | * 51 | * @param level Level of the log message. 52 | * @param message String which can contain `sprintf` style format placeholders. 53 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 54 | */ 55 | public log(level: LogLevel, message: string, ...formatArgs: any[]) { 56 | if (level < this.level) { 57 | return 58 | } 59 | 60 | let uppercaseLevel = LogLevel[level].toUpperCase() 61 | let now = new Date() 62 | let formattedMessage = message 63 | 64 | if (formatArgs && formatArgs.length > 0) { 65 | formattedMessage = this.formatMessage(message, ...formatArgs) 66 | } 67 | 68 | let logLine = this.useStringLogFormat ? 69 | sprintf( 70 | "%s%s %s - %s", 71 | this.logTimestamp ? `${now.toISOString()} ` : "", 72 | uppercaseLevel, 73 | this.clazz, 74 | formattedMessage 75 | ) : 76 | JSON.stringify({ 77 | level: uppercaseLevel, 78 | msg: formattedMessage, 79 | time: now.getTime() 80 | }) 81 | 82 | if (UnderTest && level < LogLevel.info) { 83 | // prevent verbose logging when running under test, 84 | // build the message to test that, but only output it 85 | // if it is a warning or an error 86 | return 87 | } 88 | 89 | console.log(logLine) 90 | } 91 | 92 | /** 93 | * Log a trace level message. 94 | * 95 | * @param message String which can contain `sprintf` style format placeholders. 96 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 97 | */ 98 | public trace(message: string, ...formatArgs: any[]) { 99 | this.log(LogLevel.trace, message, ...formatArgs) 100 | } 101 | 102 | /** 103 | * Log a debug level message. 104 | * 105 | * @param message String which can contain `sprintf` style format placeholders. 106 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 107 | */ 108 | public debug(message: string, ...formatArgs: any[]) { 109 | this.log(LogLevel.debug, message, ...formatArgs) 110 | } 111 | 112 | /** 113 | * Log a info level message. 114 | * 115 | * @param message String which can contain `sprintf` style format placeholders. 116 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 117 | */ 118 | public info(message: string, ...formatArgs: any[]) { 119 | this.log(LogLevel.info, message, ...formatArgs) 120 | } 121 | 122 | /** 123 | * Log a warn trace level message. 124 | * 125 | * @param message String which can contain `sprintf` style format placeholders. 126 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 127 | */ 128 | public warn(message: string, ...formatArgs: any[]) { 129 | this.log(LogLevel.warn, message, ...formatArgs) 130 | } 131 | 132 | /** 133 | * Log a error level message. 134 | * 135 | * @param message String which can contain `sprintf` style format placeholders. 136 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 137 | */ 138 | public error(message: string, ...formatArgs: any[]) { 139 | this.log(LogLevel.error, message, ...formatArgs) 140 | } 141 | 142 | /** 143 | * Log a error level message with an associated error and stack trace. 144 | * 145 | * @param message String which can contain `sprintf` style format placeholders. 146 | * @param ex Error object associated with this error message. 147 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 148 | */ 149 | public errorWithStack(message: string, ex: Error, ...formatArgs: any[]) { 150 | this.log(LogLevel.error, `${message}\n${ex ? ex.stack : ""}`, ...formatArgs) 151 | } 152 | 153 | /** 154 | * Log a fatal level message. 155 | * 156 | * @param message String which can contain `sprintf` style format placeholders. 157 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 158 | */ 159 | public fatal(message: string, ...formatArgs: any[]) { 160 | this.log(LogLevel.fatal, message, ...formatArgs) 161 | } 162 | 163 | /** 164 | * Is a given log level enabled? 165 | * 166 | * @param level Level to check. 167 | */ 168 | public levelEnabled(level: LogLevel): boolean { 169 | if (level === LogLevel.off) { 170 | return this.level === level 171 | } 172 | 173 | return this.level <= level 174 | } 175 | 176 | /** 177 | * Is trace log level enabled? 178 | */ 179 | public traceEnabled(): boolean { 180 | return this.levelEnabled(LogLevel.trace) 181 | } 182 | 183 | /** 184 | * Is debug log level enabled? 185 | */ 186 | public debugEnabled(): boolean { 187 | return this.levelEnabled(LogLevel.debug) 188 | } 189 | 190 | /** 191 | * Is info log level enabled? 192 | */ 193 | public infoEnabled(): boolean { 194 | return this.levelEnabled(LogLevel.info) 195 | } 196 | 197 | /** 198 | * Is warn log level enabled? 199 | */ 200 | public warnEnabled(): boolean { 201 | return this.levelEnabled(LogLevel.warn) 202 | } 203 | 204 | /** 205 | * Is error log level enabled? 206 | */ 207 | public errorEnabled(): boolean { 208 | return this.levelEnabled(LogLevel.error) 209 | } 210 | 211 | /** 212 | * Is fatal log level enabled? 213 | */ 214 | public fatalEnabled(): boolean { 215 | return this.levelEnabled(LogLevel.fatal) 216 | } 217 | 218 | /** 219 | * Is the current log level `off`? 220 | */ 221 | public isOff(): boolean { 222 | return this.levelEnabled(LogLevel.off) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/util/logging/ILogger.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel } from "../../model/logging/LogLevel" 2 | 3 | export type LogFormat = "string" | "json" 4 | 5 | /** 6 | * Describes a generic logging implementation. 7 | */ 8 | export interface ILogger { 9 | /** 10 | * See [[ServerLoggerConfig]] 11 | */ 12 | readonly level: LogLevel 13 | 14 | /** 15 | * See [[ServerLoggerConfig]] 16 | */ 17 | readonly format: LogFormat 18 | 19 | /** 20 | * Log a message with a custom level. 21 | * 22 | * @param level Level of the log message. 23 | * @param message String which can contain `sprintf` style format placeholders. 24 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 25 | */ 26 | log(level: LogLevel, message: string, ...formatArgs: any[]) 27 | 28 | /** 29 | * Log a trace level message. 30 | * 31 | * @param message String which can contain `sprintf` style format placeholders. 32 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 33 | */ 34 | trace(message: string, ...formatArgs: any[]) 35 | 36 | /** 37 | * Log a debug level message. 38 | * 39 | * @param message String which can contain `sprintf` style format placeholders. 40 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 41 | */ 42 | debug(message: string, ...formatArgs: any[]) 43 | 44 | /** 45 | * Log a info level message. 46 | * 47 | * @param message String which can contain `sprintf` style format placeholders. 48 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 49 | */ 50 | info(message: string, ...formatArgs: any[]) 51 | 52 | /** 53 | * Log a warn trace level message. 54 | * 55 | * @param message String which can contain `sprintf` style format placeholders. 56 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 57 | */ 58 | warn(message: string, ...formatArgs: any[]) 59 | 60 | /** 61 | * Log a error level message. 62 | * 63 | * @param message String which can contain `sprintf` style format placeholders. 64 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 65 | */ 66 | error(message: string, ...formatArgs: any[]) 67 | 68 | /** 69 | * Log a error level message with an associated error and stack trace. 70 | * 71 | * @param message String which can contain `sprintf` style format placeholders. 72 | * @param ex Error object associated with this error message. 73 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 74 | */ 75 | errorWithStack(message: string, ex: Error, ...formatArgs: any[]) 76 | 77 | /** 78 | * Log a fatal level message. 79 | * 80 | * @param message String which can contain `sprintf` style format placeholders. 81 | * @param formatArgs (Optional) Arguments which are passed to `sprintf` to format the message. 82 | */ 83 | fatal(message: string, ...formatArgs: any[]) 84 | 85 | /** 86 | * Is a given log level enabled? 87 | * 88 | * @param level Level to check. 89 | */ 90 | levelEnabled(level: LogLevel): boolean 91 | 92 | /** 93 | * Is trace log level enabled? 94 | */ 95 | traceEnabled(): boolean 96 | 97 | /** 98 | * Is debug log level enabled? 99 | */ 100 | debugEnabled(): boolean 101 | 102 | /** 103 | * Is info log level enabled? 104 | */ 105 | infoEnabled(): boolean 106 | 107 | /** 108 | * Is warn log level enabled? 109 | */ 110 | warnEnabled(): boolean 111 | 112 | /** 113 | * Is error log level enabled? 114 | */ 115 | errorEnabled(): boolean 116 | 117 | /** 118 | * Is fatal log level enabled? 119 | */ 120 | fatalEnabled(): boolean 121 | 122 | /** 123 | * Is the current log level `off`? 124 | */ 125 | isOff(): boolean 126 | } 127 | -------------------------------------------------------------------------------- /src/util/logging/LogFactory.ts: -------------------------------------------------------------------------------- 1 | import { AppConfig } from "../../model/AppConfig" 2 | import { LogLevel } from "../../model/logging/LogLevel" 3 | import { ILogger, LogFormat } from "./ILogger" 4 | import { ConsoleLogger } from "./ConsoleLogger" 5 | 6 | /** 7 | * Builds implementations of [[ILogger]]. 8 | */ 9 | export class LogFactory { 10 | /** 11 | * Build a new log factory using application config. 12 | * 13 | * @param appConfig Config object to read logging config from. 14 | * @param logLevel (Optional) Lowest level to log, defaults to `info`. 15 | * @param logFormat (Optional) Format to output log messages in, defaults to `string`. 16 | * @param logTimestamp (Optional) Print an ISO 8601 timestamp before every log message? 17 | * (string format only, defaults to `false`). 18 | */ 19 | public constructor( 20 | appConfig: AppConfig, 21 | private readonly logLevel: LogLevel = LogLevel.info, 22 | private readonly logFormat: LogFormat = "string", 23 | private readonly logTimestamp = false 24 | ) { 25 | if (appConfig && appConfig.serverLogger) { 26 | if (appConfig.serverLogger.level) { 27 | this.logLevel = appConfig.serverLogger.level 28 | } 29 | 30 | if (appConfig.serverLogger.format) { 31 | this.logFormat = appConfig.serverLogger.format 32 | } 33 | 34 | if (appConfig.serverLogger.logTimestamp !== null && 35 | appConfig.serverLogger.logTimestamp !== undefined) { 36 | this.logTimestamp = appConfig.serverLogger.logTimestamp 37 | } 38 | } 39 | } 40 | 41 | /** 42 | * Create a new logger. 43 | * 44 | * @param clazz The enclosing class that will use the new logger. 45 | */ 46 | public getLogger(clazz: Function): ILogger { 47 | return new ConsoleLogger(clazz ? clazz.name : "?", this.logLevel, this.logFormat, this.logTimestamp) 48 | } 49 | 50 | /** 51 | * Create a new logger using [[AppConfig]] config defaults. 52 | * 53 | * @param clazz The enclosing class that will use the new logger. 54 | */ 55 | public static getDefaultLogger(clazz: Function) { 56 | let logFactory = new LogFactory(new AppConfig()) 57 | 58 | return logFactory.getLogger(clazz) 59 | } 60 | 61 | /** 62 | * Create a new logger using custom log configuration. 63 | * 64 | * @param clazz The enclosing class that will use the new logger. 65 | * @param logLevel (Optional) Lowest level to log, defaults to `info`. 66 | * @param logFormat (Optional) Format to output log messages in, defaults to `string`. 67 | * @param logTimestamp (Optional) Print an ISO 8601 timestamp before every log message? 68 | * (string format only, defaults to `false`). 69 | */ 70 | public static getCustomLogger( 71 | clazz: Function, 72 | level: LogLevel = LogLevel.info, 73 | format: LogFormat = "string", 74 | logTimestamp = false 75 | ) { 76 | let logFactory = new LogFactory({ 77 | serverLogger: { 78 | format, 79 | level, 80 | logTimestamp 81 | } 82 | }) 83 | 84 | return logFactory.getLogger(clazz) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/util/timed.ts: -------------------------------------------------------------------------------- 1 | import { mark, stop } from "marky" 2 | 3 | import { ProfilingEnabled } from "./Environment" 4 | 5 | /** 6 | * Decorator that can be applied to a method or function to 7 | * profile it's execution time in milliseconds. Timing info 8 | * is output to the console. 9 | * 10 | * The environment variable `PROFILE_API` must be set to `1` for 11 | * profiling information to be recorded and output. 12 | */ 13 | export function timed(_: any, propertyKey: string, descriptor: PropertyDescriptor) { 14 | let functionToMeasure: Function = descriptor.value 15 | 16 | descriptor.value = async function(this: void, ...args: any[]) { 17 | if (ProfilingEnabled) { 18 | mark(propertyKey) 19 | } 20 | 21 | let result = await functionToMeasure.apply(this, args) 22 | 23 | if (ProfilingEnabled) { 24 | let measurement = stop(propertyKey) 25 | 26 | if (measurement != null) { 27 | let name = measurement.name as string 28 | let duration = measurement.duration.toFixed(2) as string 29 | 30 | console.log(`method '${name}' took ${duration} ms`) 31 | } 32 | } 33 | 34 | return result 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/banner.txt: -------------------------------------------------------------------------------- 1 | 2 | ********************************** 3 | ts-lambda-api tests 4 | ********************************** 5 | -------------------------------------------------------------------------------- /tests/src/ApiAcceptanceTests.ts: -------------------------------------------------------------------------------- 1 | import { Expect, Test, TestCase, TestFixture } from "alsatian" 2 | import { ReplaceOperation } from "fast-json-patch" 3 | import { readFileSync, statSync as statFileSync, writeFileSync } from "fs" 4 | import { METHODS } from "lambda-api" 5 | import { sync as calculateFileMd5Sync } from "md5-file" 6 | import { join as joinPath } from "path" 7 | import { openSync as openTempFileSync } from "temp" 8 | 9 | import { ApiLambdaApp, ApiResponse, JsonPatch, RequestBuilder } from "../../dist/ts-lambda-api" 10 | 11 | import { TestBase } from "./TestBase" 12 | import { Person } from "./test-components/model/Person" 13 | 14 | @TestFixture() 15 | export class ApiAcceptanceTests extends TestBase { 16 | private static readonly TEST_FILE_PATH = joinPath(__dirname, "../test.pdf") 17 | private static readonly TEST_FILE_SIZE = 19605 18 | private static readonly TEST_FILE_MD5 = "bb0cf6ccd0fe8e18e0a14e8028709abe" 19 | 20 | @TestCase("/test/") 21 | @TestCase("/test/no-root-path") 22 | @TestCase("/test/response-model") 23 | @TestCase("/test/injected-response-model") 24 | @Test() 25 | public async when_request_made_for_decorator_route_then_app_returns_http_status_200_ok(path: string) { 26 | let response = await this.sendRequest( 27 | RequestBuilder.get(path).build() 28 | ) 29 | 30 | Expect(response.statusCode).toEqual(200) 31 | } 32 | 33 | @TestCase("POST", "/test/methods/post") 34 | @TestCase("POST", "/test/methods/post-raw") 35 | @TestCase("PUT", "/test/methods/put") 36 | @Test() 37 | public async when_request_with_body_made_for_decorator_route_then_app_passes_body_to_endpoint_and_returns_http_status_200_ok( 38 | method: METHODS, 39 | path: string 40 | ) { 41 | let requestBody: Person = { 42 | name: "ezra", 43 | age: 23 44 | } 45 | 46 | let response = await this.sendRequest( 47 | RequestBuilder.do(method, path) 48 | .body(JSON.stringify(requestBody)) 49 | .build() 50 | ) 51 | 52 | Expect(response.statusCode).toEqual(200) 53 | 54 | let body = response.body 55 | 56 | if (response.isBase64Encoded) { 57 | body = Buffer.from(response.body, 'base64').toString('utf8') 58 | } 59 | 60 | Expect(JSON.parse(body)).toEqual(requestBody) 61 | } 62 | 63 | @Test() 64 | public async when_request_with_binary_body_made_for_decorator_route_then_app_passes_raw_body_to_endpoint_and_returns_http_status_200_ok() { 65 | let response: ApiResponse 66 | let fileContent = readFileSync(ApiAcceptanceTests.TEST_FILE_PATH) 67 | 68 | response = await this.sendRequest( 69 | RequestBuilder.post("/test/methods/post-raw") 70 | .binaryBody(fileContent) 71 | .build() 72 | ) 73 | 74 | Expect(response.statusCode).toEqual(200) 75 | Expect(response.isBase64Encoded).toBe(true) 76 | 77 | let outputFile = openTempFileSync() 78 | 79 | writeFileSync(outputFile.path, Buffer.from(response.body, "base64")) 80 | 81 | Expect( 82 | statFileSync(outputFile.path).size 83 | ).toBe( 84 | ApiAcceptanceTests.TEST_FILE_SIZE 85 | ) 86 | 87 | Expect( 88 | calculateFileMd5Sync(outputFile.path) 89 | ).toBe( 90 | ApiAcceptanceTests.TEST_FILE_MD5 91 | ) 92 | } 93 | 94 | 95 | @Test() 96 | public async when_delete_request_made_then_app_returns_http_status_204_no_content() { 97 | let response = await this.sendRequest( 98 | RequestBuilder.delete("/test/methods/delete").build() 99 | ) 100 | 101 | Expect(response.statusCode).toEqual(204) 102 | } 103 | 104 | @Test() 105 | public async when_patch_request_made_then_app_endpoint_applies_json_patch() { 106 | let replaceOp: ReplaceOperation = { 107 | op: "replace", 108 | path: "/name", 109 | value: "I patched it!" 110 | } 111 | 112 | let jsonPatch: JsonPatch = [replaceOp] 113 | 114 | let response = await this.sendRequest( 115 | RequestBuilder.patch("/test/methods/patch") 116 | .body(JSON.stringify(jsonPatch)) 117 | .build() 118 | ) 119 | 120 | Expect(JSON.parse(response.body)).toEqual({ 121 | name: "I patched it!", 122 | age: 42 123 | }) 124 | } 125 | 126 | @Test() 127 | public async when_request_made_for_endpoint_that_does_not_return_response_then_app_returns_http_status_500(path: string) { 128 | let response = await this.sendRequest( 129 | RequestBuilder.get("/test/no-return").build() 130 | ) 131 | 132 | Expect(response.statusCode).toEqual(500) 133 | } 134 | 135 | @TestCase("/test/path-test") 136 | @TestCase("/test/injected-path-test") 137 | @Test() 138 | public async when_request_made_with_path_param_then_app_passes_value_to_endpoint(path: string) { 139 | let response = await this.sendRequest( 140 | RequestBuilder.get(`${path}/steve/37`).build() 141 | ) 142 | 143 | Expect(response.body).toEqual("Hey steve, you are 37") 144 | } 145 | 146 | @TestCase("/test/query-test") 147 | @TestCase("/test/injected-query-test") 148 | @Test() 149 | public async when_request_made_with_query_param_then_app_passes_value_to_endpoint(path: string) { 150 | let response = await this.sendRequest( 151 | RequestBuilder.get(path) 152 | .query("magic", "enabled") 153 | .build() 154 | ) 155 | 156 | Expect(response.body).toEqual("Magic status: enabled") 157 | } 158 | 159 | @TestCase("/test/header-test") 160 | @TestCase("/test/injected-header-test") 161 | @Test() 162 | public async when_request_made_with_header_then_app_passes_value_to_endpoint(path: string) { 163 | let response = await this.sendRequest( 164 | RequestBuilder.get(path) 165 | .header("x-test-header", "header_value") 166 | .build() 167 | ) 168 | 169 | Expect(response.body).toEqual("Header: header_value") 170 | } 171 | 172 | @Test() 173 | public async when_controller_produces_decorator_present_then_response_content_type_header_is_correct() { 174 | let response = await this.sendRequest( 175 | RequestBuilder.get("/test").build() 176 | ) 177 | 178 | Expect(response.headers["content-type"]).toEqual("text/plain") 179 | } 180 | 181 | @Test() 182 | public async when_controller_produces_decorator_present_then_response_body_is_correct() { 183 | let response = await this.sendRequest( 184 | RequestBuilder.get("/test").build() 185 | ) 186 | 187 | Expect(response.body).toEqual("OK") 188 | } 189 | 190 | @Test() 191 | public async when_endpoint_produces_decorator_present_then_response_content_type_header_is_correct() { 192 | let response = await this.sendRequest( 193 | RequestBuilder.get("/test/produces").build() 194 | ) 195 | 196 | Expect(response.headers["content-type"]).toEqual("application/json") 197 | } 198 | 199 | @Test() 200 | public async when_endpoint_produces_decorator_present_then_response_body_is_correct() { 201 | let response = await this.sendRequest( 202 | RequestBuilder.get("/test/produces").build() 203 | ) 204 | 205 | Expect(JSON.parse(response.body)).toEqual({ 206 | some: "value" 207 | }) 208 | } 209 | 210 | @TestCase([""]) 211 | @TestCase([" "]) 212 | @TestCase(null) 213 | @TestCase(undefined) 214 | @Test() 215 | public when_app_built_with_invalid_controller_path_then_error_is_thrown(controllerPath: string[]) { 216 | Expect(() => new ApiLambdaApp(controllerPath) ).toThrow() 217 | } 218 | 219 | @Test() 220 | public async when_app_built_with_missing_controller_path_then_error_is_thrown() { 221 | await Expect(async () => 222 | await (new ApiLambdaApp(["/some/fake/path"])).initialiseControllers() 223 | ).toThrowAsync() 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /tests/src/ApiLambdaAppTests.ts: -------------------------------------------------------------------------------- 1 | import { Expect, Test, TestFixture } from "alsatian" 2 | import { Container } from "inversify" 3 | 4 | import { ApiLambdaApp, RequestBuilder } from "../../dist/ts-lambda-api" 5 | 6 | import { TestBase } from "./TestBase" 7 | 8 | @TestFixture() 9 | export class ApiLambdaAppTests extends TestBase { 10 | @Test() 11 | public async when_custom_config_passed_to_app_then_it_is_respected() { 12 | this.appConfig.base = "api/v1/" 13 | 14 | this.app = new ApiLambdaApp(TestBase.CONTROLLERS_PATH, this.appConfig) 15 | 16 | let response = await this.sendRequest( 17 | RequestBuilder.get("/test").build() 18 | ) 19 | 20 | Expect(response.statusCode).toEqual(404) 21 | 22 | response = await this.sendRequest( 23 | RequestBuilder.get("/api/v1/test").build() 24 | ) 25 | 26 | Expect(response.statusCode).toEqual(200) 27 | } 28 | 29 | @Test() 30 | public async when_custom_container_passed_to_app_then_it_is_available_for_configuration() { 31 | let container = new Container({ autoBindInjectable: true }) 32 | 33 | let app = new ApiLambdaApp( 34 | TestBase.CONTROLLERS_PATH, 35 | this.appConfig, 36 | container 37 | ) 38 | 39 | app.configureApp(c => Expect(c).toBe(container)) 40 | } 41 | 42 | @Test() 43 | public async when_default_app_container_then_contoller_path_must_be_valid() { 44 | Expect(() => new ApiLambdaApp( 45 | [" "], 46 | this.appConfig 47 | )).toThrow() 48 | } 49 | 50 | @Test() 51 | public async when_default_app_container_then_contoller_path_is_required() { 52 | Expect(() => new ApiLambdaApp( 53 | undefined, 54 | this.appConfig 55 | )).toThrow() 56 | } 57 | 58 | @Test() 59 | public async when_custom_container_passed_to_app_with_auto_bind_injectable_enabled_then_contoller_path_must_be_valid() { 60 | let container = new Container({ autoBindInjectable: true }) 61 | 62 | Expect(() => new ApiLambdaApp( 63 | [" "], 64 | this.appConfig, 65 | container 66 | )).toThrow() 67 | } 68 | 69 | @Test() 70 | public async when_custom_container_passed_to_app_with_auto_bind_injectable_enabled_then_contoller_path_is_required() { 71 | let container = new Container({ autoBindInjectable: true }) 72 | 73 | Expect(() => new ApiLambdaApp( 74 | undefined, 75 | this.appConfig, 76 | container 77 | )).toThrow() 78 | } 79 | 80 | @Test() 81 | public async when_custom_container_passed_to_app_with_auto_bind_injectable_disabled_then_contoller_path_can_be_undefined() { 82 | let container = new Container({ autoBindInjectable: false }) 83 | 84 | let app = new ApiLambdaApp( 85 | undefined, 86 | this.appConfig, 87 | container 88 | ) 89 | 90 | app.configureApp(c => Expect(c).toBe(container)) 91 | } 92 | 93 | @Test() 94 | public async when_custom_container_passed_to_app_with_auto_bind_injectable_disabled_then_contoller_path_ignored() { 95 | let container = new Container({ autoBindInjectable: false }) 96 | 97 | let app = new ApiLambdaApp( 98 | [" "], 99 | this.appConfig, 100 | container 101 | ) 102 | 103 | app.configureApp(c => Expect(c).toBe(container)) 104 | } 105 | 106 | @Test() 107 | public async when_api_is_configured_using_app_then_configuration_is_respected() { 108 | this.app.configureApi(a => a.get("/manual-endpoint", (_, res) => { 109 | res.send("OK") 110 | })) 111 | 112 | let response = await this.sendRequest( 113 | RequestBuilder.get("/manual-endpoint").build() 114 | ) 115 | 116 | Expect(response.statusCode).toEqual(200) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /tests/src/AuthFilterTests.ts: -------------------------------------------------------------------------------- 1 | import { Expect, Test, TestCase, TestFixture } from "alsatian" 2 | 3 | import { RequestBuilder } from "../../dist/ts-lambda-api" 4 | 5 | import { TestBase } from "./TestBase" 6 | import { TestAuthFilter } from "./test-components/TestAuthFilter" 7 | 8 | @TestFixture() 9 | export class AuthFilterTests extends TestBase { 10 | @TestCase(null) 11 | @TestCase(undefined) 12 | @Test() 13 | public async when_invalid_api_auth_filter_is_passed_to_app_then_throws_an_error(invalidAuthFilter: TestAuthFilter) { 14 | Expect(() => this.app.middlewareRegistry.addAuthFilter(invalidAuthFilter)) 15 | .toThrow() 16 | } 17 | 18 | @Test() 19 | public async when_api_auth_filter_configured_and_request_is_made_then_filter_is_invoked() { 20 | let authFilter = new TestAuthFilter("luke", "vaderismydad") 21 | 22 | this.app.middlewareRegistry.addAuthFilter(authFilter) 23 | 24 | await this.sendRequest( 25 | RequestBuilder.get("/test") 26 | .basicAuth("luke", "vaderismydad") 27 | .build() 28 | ) 29 | 30 | Expect(authFilter.wasInvoked).toBeTruthy() 31 | } 32 | 33 | @Test() 34 | public async when_api_auth_filter_configured_and_request_is_made_then_credentials_are_passed_to_filter() { 35 | let authFilter = new TestAuthFilter("luke", "vaderismydad") 36 | 37 | this.app.middlewareRegistry.addAuthFilter(authFilter) 38 | 39 | await this.sendRequest( 40 | RequestBuilder.get("/test") 41 | .basicAuth("luke", "vaderismydad") 42 | .build() 43 | ) 44 | 45 | Expect(authFilter.passedCredentials.username).toEqual("luke") 46 | Expect(authFilter.passedCredentials.password).toEqual("vaderismydad") 47 | } 48 | 49 | @TestCase("vaderismydad", 200) 50 | @TestCase("whoismydad?", 401) 51 | @Test() 52 | public async when_api_auth_filter_configured_and_credentials_passed_then_valid_endpoint_request_returns_correct_status(password: string, expectedStatus: number) { 53 | this.app.middlewareRegistry.addAuthFilter( 54 | new TestAuthFilter("luke", "vaderismydad") 55 | ) 56 | 57 | let response = await this.sendRequest( 58 | RequestBuilder.get("/test") 59 | .basicAuth("luke", password) 60 | .build() 61 | ) 62 | 63 | Expect(response.statusCode).toEqual(expectedStatus) 64 | } 65 | 66 | @Test() 67 | public async when_api_auth_filter_configured_and_no_credentials_passed_then_valid_endpoint_request_returns_401_unauthorized() { 68 | this.app.middlewareRegistry.addAuthFilter( 69 | new TestAuthFilter("luke", "vaderismydad") 70 | ) 71 | 72 | let response = await this.sendRequest( 73 | RequestBuilder.get("/test") 74 | .build() 75 | ) 76 | 77 | Expect(response.statusCode).toEqual(401) 78 | } 79 | 80 | @Test() 81 | public async when_api_auth_filter_configured_then_401_unauthorized_response_contains_www_authenticate_header() { 82 | this.app.middlewareRegistry.addAuthFilter( 83 | new TestAuthFilter("luke", "vaderismydad") 84 | ) 85 | 86 | let response = await this.sendRequest( 87 | RequestBuilder.get("/test") 88 | .build() 89 | ) 90 | 91 | Expect(response.headers["www-authenticate"]).toBeDefined() 92 | } 93 | 94 | @Test() 95 | public async when_api_auth_filter_configured_then_401_unauthorized_response_contains_basic_www_authenticate_header() { 96 | this.app.middlewareRegistry.addAuthFilter( 97 | new TestAuthFilter("luke", "vaderismydad") 98 | ) 99 | 100 | let response = await this.sendRequest( 101 | RequestBuilder.get("/test") 102 | .build() 103 | ) 104 | 105 | Expect(response.headers["www-authenticate"]).toEqual("Basic") 106 | } 107 | 108 | @TestCase("/test-no-auth") 109 | @TestCase("/test/no-auth") 110 | @Test() 111 | public async when_api_auth_filter_configured_and_credentials_not_passed_then_valid_no_auth_endpoint_request_returns_200_ok(path: string) { 112 | this.app.middlewareRegistry.addAuthFilter( 113 | new TestAuthFilter("luke", "vaderismydad") 114 | ) 115 | 116 | let response = await this.sendRequest( 117 | RequestBuilder.get(path).build() 118 | ) 119 | 120 | Expect(response.statusCode).toEqual(200) 121 | } 122 | 123 | @Test() 124 | public async when_api_auth_filter_configured_and_filter_throws_error_then_request_returns_500_server_error() { 125 | this.app.middlewareRegistry.addAuthFilter( 126 | new TestAuthFilter("luke", "vaderismydad", true) 127 | ) 128 | 129 | let response = await this.sendRequest( 130 | RequestBuilder.get("/test") 131 | .basicAuth("luke", "vaderismydad") 132 | .build() 133 | ) 134 | 135 | Expect(response.statusCode).toEqual(500) 136 | } 137 | 138 | @Test() 139 | public async when_api_auth_filter_configured_and_credentials_passed_then_endpoint_is_passed_current_principal() { 140 | this.app.middlewareRegistry.addAuthFilter( 141 | new TestAuthFilter("stoat", "ihavenoideawhatiam") 142 | ) 143 | 144 | let response = await this.sendRequest( 145 | RequestBuilder.get("/test/user-test") 146 | .basicAuth("stoat", "ihavenoideawhatiam") 147 | .build() 148 | ) 149 | 150 | Expect(response.body).toEqual("stoat") 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /tests/src/AuthorizerTests.ts: -------------------------------------------------------------------------------- 1 | import { Expect, Test, TestCase, TestFixture } from "alsatian" 2 | 3 | import { RequestBuilder } from "../../dist/ts-lambda-api" 4 | 5 | import { TestBase } from "./TestBase" 6 | import { TestAuthFilter } from "./test-components/TestAuthFilter" 7 | import { TestAuthorizer } from "./test-components/TestAuthorizer" 8 | 9 | @TestFixture() 10 | export class AuthorizerTests extends TestBase { 11 | @Test() 12 | @TestCase(null) 13 | @TestCase(undefined) 14 | @Test() 15 | public async when_invalid_api_authorizer_is_passed_to_app_then_throws_an_error(invalidAuthorizer: TestAuthorizer) { 16 | Expect(() => this.app.middlewareRegistry.addAuthorizer(invalidAuthorizer)) 17 | .toThrow() 18 | } 19 | 20 | @Test() 21 | public async when_api_authorizer_configured_and_no_roles_declared_then_unprotected_resource_returns_200_ok() { 22 | this.app.middlewareRegistry.addAuthorizer(new TestAuthorizer()) 23 | 24 | let response = await this.sendRequest( 25 | RequestBuilder.get("/test") 26 | .build() 27 | ) 28 | 29 | Expect(response.statusCode).toEqual(200) 30 | } 31 | 32 | @TestCase("/test/restricted") 33 | @TestCase("/test-restricted") 34 | @Test() 35 | public async when_api_authorizer_not_configured_and_roles_declared_then_protected_resource_returns_500_server_error(path: string) { 36 | let response = await this.sendRequest( 37 | RequestBuilder.get(path) 38 | .build() 39 | ) 40 | 41 | Expect(response.statusCode).toEqual(500) 42 | } 43 | 44 | @TestCase({ path: "/test/restricted", userRoles: ["SPECIAL_USER"] }) 45 | @TestCase({ path: "/test-restricted", userRoles: ["SPECIAL_USER"] }) 46 | @TestCase({ path: "/test-restricted", userRoles: ["SUPER_SPECIAL_USER"] }) 47 | @Test() 48 | public async when_api_authorizer_configured_with_roles_declared_and_user_is_authorized_then_valid_request_returns_200_ok(testCase: any) { 49 | this.app.middlewareRegistry.addAuthFilter( 50 | new TestAuthFilter("stoat", "ihavenoideawhatiam", false, testCase.userRoles) 51 | ) 52 | this.app.middlewareRegistry.addAuthorizer(new TestAuthorizer()) 53 | 54 | let response = await this.sendRequest( 55 | RequestBuilder.get(testCase.path) 56 | .basicAuth("stoat", "ihavenoideawhatiam") 57 | .build() 58 | ) 59 | 60 | Expect(response.statusCode).toEqual(200) 61 | } 62 | 63 | @TestCase({ path: "/test/restricted", userRoles: ["SPECIAL_USER"] }) 64 | @TestCase({ path: "/test-restricted", userRoles: ["SPECIAL_USER"] }) 65 | @TestCase({ path: "/test-restricted", userRoles: ["SUPER_SPECIAL_USER"] }) 66 | @Test() 67 | public async when_api_authorizer_configured_with_roles_declared_and_user_is_authorized_and_authorizer_throws_error_then_valid_request_returns_500_server_error(testCase: any) { 68 | this.app.middlewareRegistry.addAuthFilter( 69 | new TestAuthFilter("stoat", "ihavenoideawhatiam", false, testCase.userRoles) 70 | ) 71 | this.app.middlewareRegistry.addAuthorizer(new TestAuthorizer(true)) 72 | 73 | let response = await this.sendRequest( 74 | RequestBuilder.get(testCase.path) 75 | .basicAuth("stoat", "ihavenoideawhatiam") 76 | .build() 77 | ) 78 | 79 | Expect(response.statusCode).toEqual(500) 80 | } 81 | 82 | @TestCase("/test/restricted") 83 | @TestCase("/test-restricted") 84 | @Test() 85 | public async when_api_authorizer_configured_with_roles_declared_and_user_is_not_authorized_then_valid_request_returns_403_forbidden(path: string) { 86 | this.app.middlewareRegistry.addAuthFilter( 87 | new TestAuthFilter("stoat", "ihavenoideawhatiam", false, ["ANOTHER_THING"]) 88 | ) 89 | this.app.middlewareRegistry.addAuthorizer(new TestAuthorizer()) 90 | 91 | let response = await this.sendRequest( 92 | RequestBuilder.get(path) 93 | .basicAuth("stoat", "ihavenoideawhatiam") 94 | .build() 95 | ) 96 | 97 | Expect(response.statusCode).toEqual(403) 98 | } 99 | 100 | @TestCase("/test/restricted") 101 | @TestCase("/test-restricted") 102 | @Test() 103 | public async when_api_authorizer_configured_and_role_declared_then_principle_and_role_are_passed_to_authorizer(path: string) { 104 | let authorizer = new TestAuthorizer() 105 | 106 | this.app.middlewareRegistry.addAuthFilter( 107 | new TestAuthFilter("stoat", "ihavenoideawhatiam") 108 | ) 109 | this.app.middlewareRegistry.addAuthorizer(authorizer) 110 | 111 | await this.sendRequest( 112 | RequestBuilder.get(path) 113 | .basicAuth("stoat", "ihavenoideawhatiam") 114 | .build() 115 | ) 116 | 117 | Expect(authorizer.principalPassed.name).toEqual("stoat") 118 | Expect(authorizer.rolePassed).toEqual("SPECIAL_USER") 119 | } 120 | 121 | @TestCase("/test-no-auth") 122 | @TestCase("/test/no-auth") 123 | @Test() 124 | public async when_api_authorizer_configured_then_valid_no_auth_endpoint_request_does_not_invoke_authorizer_and_responds_with_200_ok(path: string) { 125 | let authorizer = new TestAuthorizer() 126 | 127 | this.app.middlewareRegistry.addAuthFilter( 128 | new TestAuthFilter("stoat", "ihavenoideawhatiam") 129 | ) 130 | this.app.middlewareRegistry.addAuthorizer(authorizer) 131 | 132 | let response = await this.sendRequest( 133 | RequestBuilder.get(path).build() 134 | ) 135 | 136 | Expect(authorizer.wasInvoked).toBe(false) 137 | Expect(response.statusCode).toEqual(200) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /tests/src/ErrorInterceptorTests.ts: -------------------------------------------------------------------------------- 1 | import { Expect, Test, TestCase, TestFixture } from "alsatian" 2 | 3 | import { ErrorInterceptor, RequestBuilder } from "../../dist/ts-lambda-api" 4 | 5 | import { TestBase } from "./TestBase" 6 | import { TestDecoratorErrorInterceptor } from "./test-components/TestDecoratorErrorInterceptor" 7 | import { TestErrorInterceptor } from "./test-components/TestErrorInterceptor" 8 | 9 | @TestFixture() 10 | export class ErrorInterceptorTests extends TestBase { 11 | @TestCase(null) 12 | @TestCase(undefined) 13 | @Test() 14 | public async when_invalid_api_error_interceptor_is_passed_to_app_then_throws_an_error(invalidErrorInterceptor: ErrorInterceptor) { 15 | Expect(() => this.app.middlewareRegistry.addErrorInterceptor(invalidErrorInterceptor)) 16 | .toThrow() 17 | } 18 | 19 | @TestCase({ controller: "TestController" }) 20 | @TestCase({ endpoint: "TestController::raiseError" }) 21 | @Test() 22 | public async when_api_error_interceptor_is_configured_and_it_throws_an_error_then_interceptor_is_invoked(testCase: any) { 23 | let errorInterceptor = new TestErrorInterceptor(testCase.endpoint, testCase.controller) 24 | 25 | this.app.middlewareRegistry.addErrorInterceptor(errorInterceptor) 26 | 27 | await this.sendRequest( 28 | RequestBuilder.get("/test/raise-error").build() 29 | ) 30 | 31 | Expect(errorInterceptor.wasInvoked).toBeTruthy() 32 | } 33 | 34 | @Test() 35 | public async when_api_controller_decorator_error_interceptor_is_present_and_it_throws_an_error_then_interceptor_is_invoked() { 36 | TestDecoratorErrorInterceptor.wasInvoked = false 37 | 38 | await this.sendRequest( 39 | RequestBuilder.get("/test/controller-ei-decorator").build() 40 | ) 41 | 42 | Expect(TestDecoratorErrorInterceptor.wasInvoked).toBeTruthy() 43 | } 44 | 45 | @Test() 46 | public async when_api_endpoint_decorator_error_interceptor_is_present_and_it_throws_an_error_then_interceptor_is_invoked() { 47 | TestDecoratorErrorInterceptor.wasInvoked = false 48 | 49 | await this.sendRequest( 50 | RequestBuilder.get("/test/ei-decorator").build() 51 | ) 52 | 53 | Expect(TestDecoratorErrorInterceptor.wasInvoked).toBeTruthy() 54 | } 55 | 56 | @Test() 57 | public async when_api_error_interceptor_is_configured_and_no_error_is_thrown_then_interceptor_is_not_invoked() { 58 | let errorInterceptor = new TestErrorInterceptor(null, "TestController") 59 | 60 | this.app.middlewareRegistry.addErrorInterceptor(errorInterceptor) 61 | 62 | await this.sendRequest( 63 | RequestBuilder.get("/test").build() 64 | ) 65 | 66 | Expect(errorInterceptor.wasInvoked).toBe(false) 67 | } 68 | 69 | @Test() 70 | public async when_api_error_interceptor_is_invoked_and_no_response_is_returned_by_interceptor_then_original_error_is_returned() { 71 | this.app.middlewareRegistry.addErrorInterceptor( 72 | new TestErrorInterceptor("TestController::raiseError") 73 | ) 74 | 75 | let response = await this.sendRequest( 76 | RequestBuilder.get("/test/raise-error").build() 77 | ) 78 | 79 | Expect(response.statusCode).toEqual(500) 80 | Expect(response.body).toEqual("{\"error\":\"all I do is throw an error\"}") 81 | } 82 | 83 | @Test() 84 | public async when_api_error_interceptor_is_invoked_and_response_is_returned_by_interceptor_then_interceptor_response_is_returned() { 85 | this.app.middlewareRegistry.addErrorInterceptor( 86 | new TestErrorInterceptor("TestController::raiseError", null, true) 87 | ) 88 | 89 | let response = await this.sendRequest( 90 | RequestBuilder.get("/test/raise-error").build() 91 | ) 92 | 93 | Expect(response.statusCode).toEqual(200) 94 | Expect(response.body).toEqual("interceptor return value") 95 | } 96 | 97 | @TestCase("/test/raise-error") 98 | @TestCase("/test/methods/raise-error") 99 | @Test() 100 | public async when_global_api_error_interceptor_is_invoked_and_error_is_thrown_in_any_endpoint_then_interceptor_is_invoked(path: string) { 101 | let errorInterceptor = new TestErrorInterceptor("*", null) 102 | 103 | this.app.middlewareRegistry.addErrorInterceptor(errorInterceptor) 104 | 105 | await this.sendRequest( 106 | RequestBuilder.get(path).build() 107 | ) 108 | 109 | Expect(errorInterceptor.wasInvoked).toBeTruthy() 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tests/src/LogFactoryTests.ts: -------------------------------------------------------------------------------- 1 | import { TestFixture, Test, TestCase, Expect } from "alsatian" 2 | 3 | import { LogFactory, LogFormat, LogLevel } from "../../dist/ts-lambda-api" 4 | 5 | @TestFixture() 6 | export class LogFactoryTests { 7 | @Test() 8 | public when_logfactory_called_to_build_logger_then_no_exceptions_occur() { 9 | LogFactory.getDefaultLogger(LogFactoryTests) 10 | } 11 | 12 | @Test() 13 | public when_logfactory_called_to_build_default_logger_then_no_exceptions_occur() { 14 | LogFactory.getDefaultLogger(LogFactoryTests) 15 | } 16 | 17 | @Test() 18 | public when_logfactory_called_to_build_custom_logger_then_no_exceptions_occur() { 19 | LogFactory.getCustomLogger(LogFactoryTests, LogLevel.trace) 20 | } 21 | 22 | @Test() 23 | @TestCase(LogLevel.trace, ["traceEnabled", "debugEnabled", "infoEnabled", "warnEnabled", "errorEnabled", "fatalEnabled"]) 24 | @TestCase(LogLevel.debug, ["debugEnabled", "infoEnabled", "warnEnabled", "errorEnabled", "fatalEnabled"]) 25 | @TestCase(LogLevel.info, ["infoEnabled", "warnEnabled", "errorEnabled", "fatalEnabled"]) 26 | @TestCase(LogLevel.warn, ["warnEnabled", "errorEnabled", "fatalEnabled"]) 27 | @TestCase(LogLevel.error, ["errorEnabled", "fatalEnabled"]) 28 | @TestCase(LogLevel.fatal, ["fatalEnabled"]) 29 | @TestCase(LogLevel.off, ["isOff"]) 30 | public when_level_set_then_all_levels_above_the_current_level_report_enabled(level: LogLevel, methods: string[]) { 31 | let logger = LogFactory.getCustomLogger(LogFactoryTests, level) 32 | 33 | methods.map(m => 34 | Expect(logger[m]()).toBe(true) 35 | ) 36 | } 37 | 38 | @Test() 39 | @TestCase(LogLevel.debug, ["traceEnabled"]) 40 | @TestCase(LogLevel.info, ["traceEnabled", "debugEnabled"]) 41 | @TestCase(LogLevel.warn, ["traceEnabled", "debugEnabled", "infoEnabled"]) 42 | @TestCase(LogLevel.error, ["traceEnabled", "debugEnabled", "infoEnabled", "warnEnabled"]) 43 | @TestCase(LogLevel.fatal, ["traceEnabled", "debugEnabled", "infoEnabled", "warnEnabled", "errorEnabled"]) 44 | @TestCase(LogLevel.off, ["traceEnabled", "debugEnabled", "infoEnabled", "warnEnabled", "errorEnabled", "fatalEnabled"]) 45 | public when_level_set_then_all_levels_below_the_current_level_report_disabled(level: LogLevel, methods: string[]) { 46 | let logger = LogFactory.getCustomLogger(LogFactoryTests, level) 47 | 48 | methods.map(m => 49 | Expect(logger[m]()).toBe(false) 50 | ) 51 | } 52 | 53 | @Test() 54 | @TestCase("trace", "string") 55 | @TestCase("trace", "json") 56 | @TestCase("debug", "string") 57 | @TestCase("debug", "json") 58 | @TestCase("info", "string") 59 | @TestCase("info", "json") 60 | @TestCase("warn", "string") 61 | @TestCase("warn", "json") 62 | @TestCase("error", "string") 63 | @TestCase("error", "json") 64 | @TestCase("fatal", "string") 65 | @TestCase("fatal", "json") 66 | public when_logger_called_for_level_then_no_exceptions_occur(levelMethod: string, format: LogFormat) { 67 | let logger = LogFactory.getCustomLogger(LogFactoryTests, LogLevel.trace, format) 68 | 69 | logger[levelMethod]("I am %s: %j", levelMethod, {some: "value"}) 70 | } 71 | 72 | @Test() 73 | @TestCase("string") 74 | @TestCase("json") 75 | public when_logger_called_with_timestamp_enabled_then_no_exceptions_occur(format: LogFormat) { 76 | let logger = LogFactory.getCustomLogger(LogFactoryTests, LogLevel.trace, format, true) 77 | 78 | logger.info("%s: %j", "Timestamp enabled test", {so: "I am"}) 79 | } 80 | 81 | @Test() 82 | public when_logger_called_with_circular_array_then_no_exceptions_occur() { 83 | let logger = LogFactory.getCustomLogger(LogFactoryTests, LogLevel.trace) 84 | 85 | var circularArray = []; 86 | circularArray[0] = circularArray; 87 | 88 | logger.info("not going to work: %j", circularArray) 89 | } 90 | 91 | @Test() 92 | public when_logger_called_with_circular_object_then_no_exceptions_occur() { 93 | let logger = LogFactory.getCustomLogger(LogFactoryTests, LogLevel.trace) 94 | 95 | function CircularBoy() { 96 | this.circular = this 97 | } 98 | 99 | var oneCircularBoy = new CircularBoy(); 100 | 101 | logger.info("not going to work: %j", oneCircularBoy) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/src/TestBase.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | 3 | import { Setup } from "alsatian" 4 | 5 | import { AppConfig, ApiLambdaApp, ApiRequest, ApiResponse, LogLevel } from "../../dist/ts-lambda-api" 6 | 7 | export class TestBase { 8 | protected static readonly CONTROLLERS_PATH: string[] = [path.join(__dirname, "test-controllers")] 9 | 10 | protected appConfig: AppConfig 11 | protected app: ApiLambdaApp 12 | 13 | @Setup 14 | public setup(appConfig?: AppConfig) { 15 | this.appConfig = appConfig || new AppConfig() 16 | this.appConfig.serverLogger = { 17 | level: LogLevel.trace 18 | } 19 | 20 | this.appConfig.logger = { 21 | level: "trace" 22 | } 23 | 24 | this.app = new ApiLambdaApp(TestBase.CONTROLLERS_PATH, this.appConfig) 25 | } 26 | 27 | protected async sendRequest(request: ApiRequest): Promise { 28 | return await this.app.run(request, {}) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/src/test-components/TestAuthFilter.ts: -------------------------------------------------------------------------------- 1 | import { BasicAuthFilter, BasicAuth } from "../../../dist/ts-lambda-api" 2 | 3 | import { TestUser } from "./model/TestUser"; 4 | 5 | export class TestAuthFilter extends BasicAuthFilter { 6 | public readonly name: string = TestAuthFilter.name 7 | 8 | public wasInvoked: boolean 9 | public passedCredentials: BasicAuth 10 | 11 | public constructor(private readonly username: string, 12 | private readonly password: string, 13 | private readonly shouldThrowError: boolean = false, 14 | private readonly roles: string[] = []) { 15 | super() 16 | 17 | this.wasInvoked = false 18 | } 19 | 20 | public async authenticate(basicAuth: BasicAuth) { 21 | this.wasInvoked = true 22 | this.passedCredentials = basicAuth 23 | 24 | if (this.shouldThrowError) { 25 | throw Error("authenticate failed") 26 | } 27 | 28 | if (basicAuth.username === this.username && basicAuth.password === this.password) { 29 | return new TestUser(basicAuth.username, this.roles) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/src/test-components/TestAuthorizer.ts: -------------------------------------------------------------------------------- 1 | import { IAuthorizer, Principal } from "../../../dist/ts-lambda-api" 2 | 3 | import { TestUser } from "./model/TestUser" 4 | 5 | export class TestAuthorizer implements IAuthorizer { 6 | public readonly name: string = TestAuthorizer.name 7 | 8 | public wasInvoked: boolean 9 | public principalPassed: Principal 10 | public rolePassed: string 11 | 12 | public constructor(private readonly throwError: boolean = false) { 13 | this.wasInvoked = false 14 | } 15 | 16 | public async authorize(principal: TestUser, role: string): Promise { 17 | this.wasInvoked = true 18 | this.principalPassed = principal 19 | this.rolePassed = role 20 | 21 | if (this.throwError) { 22 | throw new Error("Uh oh spaghettios!") 23 | } 24 | 25 | return principal.roles.includes(role) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/src/test-components/TestCustomAuthFilter.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "lambda-api" 2 | 3 | import { IAuthFilter, apiSecurity } from "../../../dist/ts-lambda-api" 4 | 5 | import { TestUser } from "./model/TestUser" 6 | 7 | @apiSecurity("bearerAuth", { 8 | type: "http", 9 | scheme: "bearer", 10 | bearerFormat: "JWT" 11 | }) 12 | export class TestCustomAuthFilter implements IAuthFilter { 13 | public readonly authenticationSchemeName: string = "Bearer" 14 | public readonly name: string = TestCustomAuthFilter.name 15 | 16 | public wasInvoked: boolean 17 | public passedCredentials: string 18 | 19 | public constructor( 20 | private readonly username: string, 21 | private readonly shouldThrowError: boolean = false, 22 | private readonly roles: string[] = [] 23 | ) { 24 | this.wasInvoked = false 25 | } 26 | 27 | public async extractAuthData(request: Request) { 28 | let authHeader = request.headers["Authorization"] 29 | 30 | if ( 31 | authHeader && (authHeader.length > 7) && 32 | authHeader.toLowerCase().startsWith("bearer ") 33 | ) { 34 | return authHeader.substr(6) 35 | } 36 | } 37 | 38 | public async authenticate(jwtToken: string) { 39 | this.wasInvoked = true 40 | this.passedCredentials = jwtToken 41 | 42 | if (this.shouldThrowError) { 43 | throw Error("authenticate failed") 44 | } 45 | 46 | if (jwtToken === "__token__") { 47 | return new TestUser(this.username, this.roles) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/src/test-components/TestDecoratorErrorInterceptor.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify" 2 | 3 | import { ErrorInterceptor } from "../../../dist/ts-lambda-api" 4 | 5 | @injectable() 6 | export class TestDecoratorErrorInterceptor extends ErrorInterceptor { 7 | public static wasInvoked: boolean = false 8 | 9 | public async intercept() { 10 | TestDecoratorErrorInterceptor.wasInvoked = true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/src/test-components/TestErrorInterceptor.ts: -------------------------------------------------------------------------------- 1 | import { ApiError, ErrorInterceptor } from "../../../dist/ts-lambda-api" 2 | 3 | export class TestErrorInterceptor extends ErrorInterceptor { 4 | public wasInvoked: boolean 5 | public apiErrorPassed?: ApiError 6 | 7 | public constructor(forEndpoint?: string, forController?: string, 8 | private readonly returnValue: boolean = false) { 9 | super() 10 | 11 | this.endpointTarget = forEndpoint 12 | this.controllerTarget = forController 13 | 14 | this.wasInvoked = false 15 | } 16 | 17 | public async intercept(apiError: ApiError) { 18 | this.wasInvoked = true 19 | this.apiErrorPassed = apiError 20 | 21 | if (this.returnValue) { 22 | return "interceptor return value" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/src/test-components/model/ApiError.ts: -------------------------------------------------------------------------------- 1 | export class ApiError { 2 | public statusCode: number 3 | public error: string 4 | 5 | public static example() { 6 | let error = new ApiError() 7 | 8 | error.statusCode = 500 9 | error.error = "error description" 10 | 11 | return error 12 | } 13 | 14 | public toString() { 15 | return `${this.statusCode}: ${this.error}` 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/src/test-components/model/ArrayOfPrimitivesExample.ts: -------------------------------------------------------------------------------- 1 | export class ArrayofPrimitivesExample { 2 | public static example() { 3 | return [ 4 | "a", 5 | "b" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/src/test-components/model/ConstructorOnlyModel.ts: -------------------------------------------------------------------------------- 1 | export class ConstructorOnlyModel { 2 | public field: string 3 | 4 | public constructor() { 5 | this.field = "" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/src/test-components/model/EdgeCaseModel.ts: -------------------------------------------------------------------------------- 1 | export class EdgeCaseModel { 2 | public invalidArray: Function[] 3 | public arrayOfObjects: Object[] 4 | public arrayOfArrays: Object[] 5 | 6 | public static example() { 7 | let edgeCaseModel = new EdgeCaseModel() 8 | 9 | edgeCaseModel.invalidArray = [ 10 | () => { 11 | }, 12 | () => { 13 | } 14 | ] 15 | 16 | edgeCaseModel.arrayOfObjects = [ 17 | { 18 | field1: "field1", 19 | field2: 2, 20 | field3: [1, 2, 3] 21 | }, 22 | { 23 | field1: "field1", 24 | field2: 2, 25 | field3: [1, 2, 3] 26 | } 27 | ] 28 | 29 | edgeCaseModel.arrayOfArrays = [ 30 | ["a", "b", "c"], 31 | ["a", "b", "c"] 32 | ] 33 | 34 | return edgeCaseModel 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/src/test-components/model/Location.ts: -------------------------------------------------------------------------------- 1 | export class Location { 2 | public city: string 3 | public country: string 4 | public localeCodes: number[] 5 | } 6 | -------------------------------------------------------------------------------- /tests/src/test-components/model/NullFieldsModel.ts: -------------------------------------------------------------------------------- 1 | export class NullFieldsModel { 2 | public populatedField: number; 3 | public emptyString: string; 4 | public nullString: string; 5 | 6 | public static example() { 7 | let nullFields = new NullFieldsModel() 8 | 9 | nullFields.populatedField = 30; 10 | nullFields.emptyString = ""; 11 | nullFields.nullString = null; 12 | 13 | return nullFields; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/src/test-components/model/People.ts: -------------------------------------------------------------------------------- 1 | import { Person } from "./Person" 2 | 3 | export class People { 4 | public static example() { 5 | return [ 6 | Person.example(), 7 | Person.example(), 8 | Person.example() 9 | ] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/src/test-components/model/Person.ts: -------------------------------------------------------------------------------- 1 | import { Location } from "./Location"; 2 | 3 | export class Person { 4 | public name: string 5 | public age: number 6 | public location?: Location 7 | public roles?: string[] 8 | 9 | public static example() { 10 | let person = new Person() 11 | 12 | person.name = "name" 13 | person.age = 18 14 | person.location = { 15 | city: "city", 16 | country: "country", 17 | localeCodes: [10, 20, 30] 18 | } 19 | person.roles = ["role1", "role2", "roleN"] 20 | 21 | return person 22 | } 23 | 24 | public toString() { 25 | return `${this.name} is ${this.age} years old` 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/src/test-components/model/PrimitiveExample.ts: -------------------------------------------------------------------------------- 1 | export class PrimitiveExample { 2 | public static example() { 3 | return 30 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/src/test-components/model/ResponseWithValue.ts: -------------------------------------------------------------------------------- 1 | import { ApiResponse } from "../../../../dist/ts-lambda-api" 2 | 3 | export class ResponseWithValue extends ApiResponse { 4 | public value?: T 5 | } 6 | -------------------------------------------------------------------------------- /tests/src/test-components/model/TestUser.ts: -------------------------------------------------------------------------------- 1 | import { Principal } from "../../../../dist/ts-lambda-api" 2 | 3 | export class TestUser extends Principal { 4 | public constructor(name: string, public readonly roles: string[]) { 5 | super(name) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/src/test-controllers/ConsumesTestController.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify" 2 | 3 | import { apiController, apiOperation, apiRequest, apiResponse, consumes, controllerConsumes, body, Controller, POST } from "../../../dist/ts-lambda-api" 4 | 5 | import { Person } from "../test-components/model/Person" 6 | 7 | @apiController("/test/consumes") 8 | @controllerConsumes("text/plain") 9 | @injectable() 10 | export class ConsumesTestControllerController extends Controller { 11 | @POST() 12 | @apiOperation({name: "add stuff", description: "go add some stuff"}) 13 | @apiRequest({class: Person}) 14 | @apiResponse(201, {class: Person}) 15 | public post(@body person: Person) { 16 | return person 17 | } 18 | 19 | @POST("/xml") 20 | @apiOperation({name: "add xml stuff", description: "go add some xml stuff"}) 21 | @consumes("application/xml") 22 | @apiRequest({class: Person}) 23 | @apiResponse(201, {class: Person}) 24 | public xmlPost(@body person: Person) { 25 | return person 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/src/test-controllers/ControllerErrorDecoratorTestController.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify" 2 | 3 | import { apiController, controllerErrorInterceptor, Controller, GET } from "../../../dist/ts-lambda-api" 4 | 5 | import { TestDecoratorErrorInterceptor } from "../test-components/TestDecoratorErrorInterceptor" 6 | 7 | @apiController("/test/controller-ei-decorator") 8 | @controllerErrorInterceptor(TestDecoratorErrorInterceptor) 9 | @injectable() 10 | export class ControllerErrorDecoratorTestController extends Controller { 11 | @GET() 12 | public get() { 13 | throw new Error("Oh no!") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/src/test-controllers/EdgeCaseController.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify" 2 | 3 | import { api, apiController, consumes, controllerConsumes, Controller, POST } from "../../../dist/ts-lambda-api" 4 | import { Person } from '../test-components/model/Person'; 5 | 6 | // this controller uses parameters from `api`, `controllerConsumes` and `consumes` not used anywhere else 7 | @apiController("/test/edge-case-controller") 8 | @api("Edge Cases") 9 | @controllerConsumes("application/xml", { contentType: "application/xml", class: Person }) 10 | @injectable() 11 | export class EdgeCaseController extends Controller { 12 | @POST() 13 | @consumes("application/xml", { contentType: "application/xml", class: Person }) 14 | public post() { 15 | return "nada" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/src/test-controllers/ErrorDecoratorTestController.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify" 2 | 3 | import { apiController, errorInterceptor, Controller, GET } from "../../../dist/ts-lambda-api" 4 | 5 | import { TestDecoratorErrorInterceptor } from "../test-components/TestDecoratorErrorInterceptor" 6 | 7 | @apiController("/test/ei-decorator") 8 | @injectable() 9 | export class ErrorDecoratorTestController extends Controller { 10 | @GET() 11 | @errorInterceptor(TestDecoratorErrorInterceptor) 12 | public get() { 13 | throw new Error("Oh no!") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/src/test-controllers/ImplicitRoutesController.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify" 2 | 3 | import { apiController, body, Controller, JsonPatch, GET, POST, PUT, PATCH, DELETE } from "../../../dist/ts-lambda-api" 4 | 5 | import { Person } from "../test-components/model/Person" 6 | 7 | @apiController("/test/implicit") 8 | @injectable() 9 | export class ImplicitRoutesController extends Controller { 10 | @GET() 11 | public get() { 12 | return "OK" 13 | } 14 | 15 | @POST() 16 | public post(@body person: Person) { 17 | return person 18 | } 19 | 20 | @PUT() 21 | public put(@body person: Person) { 22 | return person 23 | } 24 | 25 | @PATCH() 26 | public patch(@body jsonPatch: JsonPatch) { 27 | let somePerson: Person = { 28 | name: "Should Not Come Back", 29 | age: 42 30 | } 31 | 32 | return this.applyJsonPatch(jsonPatch, somePerson) 33 | } 34 | 35 | @DELETE() 36 | public delete() { 37 | this.response.status(204).send("") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/src/test-controllers/MethodTestsController.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify" 2 | 3 | import { apiController, body, Controller, JsonPatch, rawBody, GET, POST, PUT, PATCH, DELETE } from "../../../dist/ts-lambda-api" 4 | 5 | import { Person } from "../test-components/model/Person" 6 | 7 | @apiController("/test/methods") 8 | @injectable() 9 | export class MethodTestsController extends Controller { 10 | @POST("/post") 11 | public post(@body person: Person) { 12 | return person 13 | } 14 | 15 | @POST("/post-raw") 16 | public postFile(@rawBody file: Buffer) { 17 | this.response.sendFile(file) 18 | } 19 | 20 | @PUT("/put") 21 | public put(@body person: Person) { 22 | return person 23 | } 24 | 25 | @PATCH("/patch") 26 | public patch(@body jsonPatch: JsonPatch) { 27 | let somePerson: Person = { 28 | name: "Should Not Come Back", 29 | age: 42 30 | } 31 | 32 | return this.applyJsonPatch(jsonPatch, somePerson) 33 | } 34 | 35 | @DELETE("/delete") 36 | public delete() { 37 | this.response.status(204).send("") 38 | } 39 | 40 | @GET("/raise-error") 41 | public raiseError() { 42 | throw new Error("Panic!") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/src/test-controllers/NoAuthController.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify" 2 | 3 | import { apiController, controllerNoAuth, Controller, GET } from "../../../dist/ts-lambda-api" 4 | 5 | @apiController("/test-no-auth") 6 | @controllerNoAuth 7 | @injectable() 8 | export class NoAuthController extends Controller { 9 | @GET() 10 | public get() { 11 | return "I really does what I likes" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/src/test-controllers/NoRootPathController.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify" 2 | 3 | import { GET } from "../../../dist/ts-lambda-api" 4 | 5 | @injectable() 6 | export class NoRootPathController { 7 | @GET("/test/no-root-path") 8 | public get() { 9 | return "OK" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/src/test-controllers/OpenApiTestController.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify" 2 | 3 | import { api, apiController, apiOperation, apiRequest, apiResponse, body, header, pathParam, queryParam, rawBody, Controller, JsonPatch, GET, POST, PUT, PATCH, DELETE} from "../../../dist/ts-lambda-api" 4 | 5 | import { ApiError } from "../test-components/model/ApiError" 6 | import { ArrayofPrimitivesExample } from '../test-components/model/ArrayOfPrimitivesExample' 7 | import { ConstructorOnlyModel } from "../test-components/model/ConstructorOnlyModel" 8 | import { EdgeCaseModel } from "../test-components/model/EdgeCaseModel" 9 | import { NullFieldsModel } from '../test-components/model/NullFieldsModel' 10 | import { People } from "../test-components/model/People" 11 | import { Person } from "../test-components/model/Person" 12 | import { PrimitiveExample } from '../test-components/model/PrimitiveExample' 13 | 14 | @api("Open API Test", "Endpoints with OpenAPI decorators") 15 | @apiController("/test/open-api") 16 | @injectable() 17 | export class OpenApiTestControllerController extends Controller { 18 | @GET() 19 | @apiOperation({ name: "get stuff", description: "go get some stuff"}) 20 | @apiResponse(200, {type: "string"}) 21 | public get() { 22 | return "OK" 23 | } 24 | 25 | @GET("/example-array-objects") 26 | @apiOperation({ name: "get example array of objects", description: "go get example array of objects"}) 27 | @apiResponse(200, {type: "object-array", example: `[{"age":23,"name":"carlos","gender":"male","city":"Dubai"},{"age":24,"name":"carlos","gender":"male","city":"Dubai"},{"age":25,"name":"carlos","gender":"male","city":"Dubai"}]` }) 28 | public getExampleArrayOfObjects() { 29 | return [{"age":23,"name":"carlos","gender":"male","city":"Dubai"},{"age":24,"name":"carlos","gender":"male","city":"Dubai"},{"age":25,"name":"carlos","gender":"male","city":"Dubai"}] 30 | } 31 | 32 | @GET("/primitive-class-example") 33 | @apiOperation({ name: "get primitive class example", description: "go get primitive class example"}) 34 | @apiResponse(200, { class: PrimitiveExample }) 35 | public getPrimitiveClassExample() { 36 | return PrimitiveExample.example() 37 | } 38 | 39 | @GET("/primitive-array-class-example") 40 | @apiOperation({ name: "get primitive array class example", description: "go get primitive array class example"}) 41 | @apiResponse(200, { class: ArrayofPrimitivesExample }) 42 | public getPrimitiveArrayClassExample() { 43 | return ArrayofPrimitivesExample.example() 44 | } 45 | 46 | @GET("/array-objects") 47 | @apiOperation({ name: "get array of objects", description: "go get array of objects"}) 48 | @apiResponse(200, { class: People }) 49 | public getArrayOfObjects() { 50 | return People.example() 51 | } 52 | 53 | @GET("/constructor") 54 | @apiOperation({ name: "get constructor stuff", description: "go construct some stuff"}) 55 | @apiResponse(200, {class: ConstructorOnlyModel}) 56 | public getConstructorOnly() { 57 | return new ConstructorOnlyModel() 58 | } 59 | 60 | @GET("/edge-case") 61 | @apiOperation({ name: "get edge case stuff", description: "go get some edge case stuff"}) 62 | @apiResponse(200, {class: EdgeCaseModel}) 63 | public getEdgeCase() { 64 | return new EdgeCaseModel() 65 | } 66 | 67 | @GET("/null-fields") 68 | @apiOperation({ name: "get null fields stuff", description: "go get some null fields stuff"}) 69 | @apiResponse(200, {class: NullFieldsModel}) 70 | public getNullFields() { 71 | return new NullFieldsModel() 72 | } 73 | 74 | @POST() 75 | @apiOperation({name: "add stuff", description: "go add some stuff"}) 76 | @apiRequest({class: Person}) 77 | @apiResponse(201, {class: Person}) 78 | @apiResponse(400, {class: ApiError}) 79 | @apiResponse(500, {class: ApiError}) 80 | public post(@body person: Person) { 81 | return person 82 | } 83 | 84 | @POST("/custom-info") 85 | @apiOperation({ 86 | name: "add custom stuff", 87 | description: "go add some custom stuff" 88 | }) 89 | @apiRequest({ 90 | class: Person, 91 | example: `{"name": "some name", "age": 22}`, 92 | description: "Details for a person" 93 | }) 94 | @apiResponse(201, { 95 | class: Person, 96 | example: `{"name": "another name", "age": 30}`, 97 | description: "Uploaded person information" 98 | }) 99 | @apiResponse(400, { 100 | class: ApiError, 101 | example: `{"statusCode": 400, "error": "you screwed up"}`, 102 | description: "A bad request error message" 103 | }) 104 | public postCustomInfo(@body person: Person) { 105 | return person 106 | } 107 | 108 | @POST("/plain") 109 | @apiOperation({ name: "add some plain stuff", description: "go get some plain stuff"}) 110 | @apiRequest({contentType: "text/plain", type: "string"}) 111 | @apiResponse(200, {type: "string"}) 112 | public postString(@body stuff: string) { 113 | return stuff 114 | } 115 | 116 | @POST("/files") 117 | @apiOperation({ name: "add file", description: "upload a file"}) 118 | @apiRequest({contentType: "application/octet-stream", type: "file"}) 119 | @apiResponse(201, {contentType: "application/octet-stream", type: "file"}) 120 | public postFile(@rawBody file: Buffer) { 121 | this.response.sendFile(file) 122 | } 123 | 124 | @PUT() 125 | @apiOperation({name: "put stuff", description: "go put some stuff"}) 126 | @apiRequest({class: Person}) 127 | @apiResponse(200, {class: Person}) 128 | public put(@body person: Person) { 129 | return person 130 | } 131 | 132 | @PATCH() 133 | @apiOperation({name: "patch stuff", description: "go patch some stuff"}) 134 | @apiResponse(200, {class: Person}) 135 | public patch(@body jsonPatch: JsonPatch) { 136 | let somePerson: Person = { 137 | name: "Should Not Come Back", 138 | age: 42 139 | } 140 | 141 | return this.applyJsonPatch(jsonPatch, somePerson) 142 | } 143 | 144 | @DELETE() 145 | @apiOperation({name: "delete stuff", description: "go delete some stuff"}) 146 | @apiResponse(204) 147 | public delete() { 148 | this.response.status(204).send("") 149 | } 150 | 151 | @GET("/path-info-test/:pathTest") 152 | @apiOperation({name: "path info test", description: "go get query info stuff"}) 153 | public getPathTest(@pathParam("pathTest", { description: "test path param" }) test: string) { 154 | this.response.status(200).send("") 155 | } 156 | 157 | @GET("/query-info-test") 158 | @apiOperation({name: "query info test", description: "go get query info stuff"}) 159 | public getQueryTest( 160 | @queryParam("queryTest", { description: "test query param", type: "int" }) test: string, 161 | @queryParam("queryTest2", { description: "test query param 2", type: "int-array", required: true, style: "pipeDelimited", explode: false, example: "1|2|3" }) test2: string 162 | ) { 163 | this.response.status(200).send("") 164 | } 165 | 166 | @GET("/header-info-test") 167 | @apiOperation({name: "header info test", description: "go get header info stuff"}) 168 | public getHeaderTest( 169 | @header("x-test-header", { description: "test header param" }) test: string, 170 | @header("x-test-header2", { description: "test header param 2", class: Person, contentType: "application/json" }) test2: string 171 | ) { 172 | this.response.status(200).send("") 173 | } 174 | } -------------------------------------------------------------------------------- /tests/src/test-controllers/OpenApiTestIgnoredController.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify'; 2 | import { apiController, apiIgnoreController, header, Controller, GET} from '../../../dist/ts-lambda-api'; 3 | 4 | @apiController("/test/internal") 5 | @apiIgnoreController() 6 | @injectable() 7 | export class OpenApiTestIgnoredController extends Controller { 8 | @GET('/header') 9 | public privateApi( 10 | @header("x-test-header", { description: "test header param" }) test: string, 11 | ) { 12 | this.response.status(200).send("") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/src/test-controllers/OpenApiTestIgnoredEndpointController.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify'; 2 | import { apiController, apiIgnore, header, Controller, GET} from '../../../dist/ts-lambda-api'; 3 | 4 | @apiController("/test/part-internal") 5 | @injectable() 6 | export class OpenApiTestIgnoredEndpointController extends Controller { 7 | @GET('/public') 8 | public publicApi( 9 | @header("x-test-header", { description: "test header param" }) test: string, 10 | ) { 11 | this.response.status(200).send("") 12 | } 13 | 14 | @apiIgnore() 15 | @GET('/private') 16 | public privateApi( 17 | @header("x-test-header", { description: "test header param" }) test: string, 18 | ) { 19 | this.response.status(200).send("") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/src/test-controllers/RestrictedTestController.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify" 2 | 3 | import { apiController, controllerRolesAllowed, Controller, GET } from "../../../dist/ts-lambda-api" 4 | 5 | @apiController("/test-restricted") 6 | @controllerRolesAllowed("SUPER_SPECIAL_USER", "SPECIAL_USER") 7 | @injectable() 8 | export class RestrictedTestController extends Controller { 9 | @GET() 10 | public get() { 11 | return "I does what I likes" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/src/test-controllers/TestController.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify" 2 | import { Response, Request } from "lambda-api" 3 | 4 | import { apiController, controllerProduces, noAuth, header, pathParam, queryParam, response, request, rolesAllowed, produces, principal, Controller, GET } from "../../../dist/ts-lambda-api" 5 | 6 | import { TestUser } from '../test-components/model/TestUser'; 7 | 8 | @apiController("/test") 9 | @controllerProduces("text/plain") 10 | @injectable() 11 | export class TestController extends Controller { 12 | @GET() 13 | public get_Return() { 14 | return "OK" 15 | } 16 | 17 | @GET("/response-model") 18 | public get_ResponseModel() { 19 | this.response.send("OK") 20 | } 21 | 22 | @GET("/injected-response-model") 23 | public get_InjectedResponseModel(@response response: Response) { 24 | response.send("OK") 25 | } 26 | 27 | @GET("/no-return") 28 | public get_NoReturnOrResponseModel() { 29 | // this should cause the framework to throw an exception 30 | } 31 | 32 | @GET("/no-content") 33 | public get_NoContent(){ 34 | this.response.status(204).send("") 35 | } 36 | 37 | @GET("/path-test/:name/:age") 38 | public get_PathTest( 39 | @pathParam("name") name: string, 40 | @pathParam("age") age: string 41 | ) { 42 | this.response.send(`Hey ${name}, you are ${age}`) 43 | } 44 | 45 | @GET("/injected-path-test/:name/:age") 46 | public get_InjectedPathTest(@request request: Request) { 47 | this.response.send(`Hey ${request.params["name"]}, you are ${request.params["age"]}`) 48 | } 49 | 50 | @GET("/query-test/") 51 | public get_QueryTest(@queryParam("magic") magic: string) { 52 | this.response.send(`Magic status: ${magic}`) 53 | } 54 | 55 | @GET("/injected-query-test/") 56 | public get_InjectedQueryTest(@request request: Request) { 57 | this.response.send(`Magic status: ${request.query["magic"]}`) 58 | } 59 | 60 | @GET("/produces") 61 | @produces("application/json") 62 | public get_Produces() { 63 | return { 64 | some: "value" 65 | } 66 | } 67 | 68 | @GET("/header-test") 69 | public get_HeaderTest( 70 | @header("x-test-header") testHeader: string 71 | ) { 72 | this.response.send(`Header: ${testHeader}`) 73 | } 74 | 75 | @GET("/injected-header-test") 76 | public get_InjectedHeaderTest(@request request: Request) { 77 | this.response.send(`Header: ${request.headers["x-test-header"]}`) 78 | } 79 | 80 | @GET("/raise-error") 81 | public raiseError() { 82 | throw new Error("all I do is throw an error") 83 | } 84 | 85 | @GET("/user-test") 86 | public userTest(@principal testUser: TestUser) { 87 | return testUser.name 88 | } 89 | 90 | @GET("/restricted") 91 | @rolesAllowed("SPECIAL_USER") 92 | public restricted() { 93 | return "allowed in" 94 | } 95 | 96 | @GET("/no-auth") 97 | @noAuth 98 | public get_no_auth() { 99 | return "who needs auth" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/test.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djfdyuruiry/ts-lambda-api/c4222fb46e9fa2a7ab3c3d63c9bc797c3625c55d/tests/test.pdf -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "baseUrl": ".", 4 | "compilerOptions": { 5 | "outDir": "js" 6 | }, 7 | "include": [ 8 | "src/**/*" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /ts-lambda-api.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "npm.packageManager": "yarn" 9 | }, 10 | "extensions": { 11 | "recommendations": [ 12 | "dskwrk.vscode-generate-getter-setter", 13 | "loiane.ts-extension-pack" 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "emitDecoratorMetadata": true, 6 | "esModuleInterop": true, 7 | "experimentalDecorators": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "baseUrl": "src", 11 | "outDir": "dist", 12 | "paths": { 13 | "*": [ 14 | "node_modules/*" 15 | ] 16 | }, 17 | "target": "es2017", 18 | "lib": [ 19 | "es2017" 20 | ] 21 | }, 22 | "include": [ 23 | "src/**/*" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------