├── .auditignore ├── .github ├── release-drafter.yml └── workflows │ └── node.js.yml ├── .gitignore ├── .npmignore ├── .vscode └── launch.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── LICENSE ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── eslint.config.mjs ├── jest.config.json ├── package-lock.json ├── package.json ├── prettier.config.mjs ├── renovate.json ├── rollup.config.mjs ├── scripts ├── writeCommonJsPackageJson.mjs └── writeEsmPackageJson.mjs ├── src ├── base_http_controller.ts ├── base_middleware.ts ├── constants.ts ├── content │ ├── httpContent.ts │ ├── jsonContent.ts │ ├── streamContent.ts │ └── stringContent.ts ├── debug.ts ├── decorators.ts ├── httpResponseMessage.ts ├── index.ts ├── interfaces.ts ├── results │ ├── BadRequestErrorMessageResult.ts │ ├── BadRequestResult.ts │ ├── ConflictResult.ts │ ├── CreatedNegotiatedContentResult.ts │ ├── ExceptionResult.ts │ ├── InternalServerError.ts │ ├── JsonResult.ts │ ├── NotFoundResult.ts │ ├── OkNegotiatedContentResult.ts │ ├── OkResult.ts │ ├── RedirectResult.ts │ ├── ResponseMessageResult.ts │ ├── StatusCodeResult.ts │ ├── StreamResult.ts │ └── index.ts ├── server.ts ├── test │ ├── action_result.test.ts │ ├── auth_provider.test.ts │ ├── base_http_controller.test.ts │ ├── base_middleware.test.ts │ ├── constants.test.ts │ ├── content │ │ ├── jsonContent.test.ts │ │ └── streamContent.test.ts │ ├── debug.test.ts │ ├── decorators.test.ts │ ├── features │ │ ├── controller_inheritance.test.ts │ │ └── decorator_middleware.test.ts │ ├── framework.test.ts │ ├── helpers │ │ └── jest.setup.ts │ ├── http_context.test.ts │ ├── issue_590.test.ts │ ├── issues │ │ └── issue_420.test.ts │ ├── server.test.ts │ └── utils.test.ts └── utils.ts ├── tsconfig.base.cjs.json ├── tsconfig.base.esm.json ├── tsconfig.base.json ├── tsconfig.cjs.json ├── tsconfig.esm.json └── tsconfig.json /.auditignore: -------------------------------------------------------------------------------- 1 | https://npmjs.com/advisories/577 2 | https://nodesecurity.io/advisories/118 3 | https://nodesecurity.io/advisories/157 4 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | 2 | name-template: 'Next Release' 3 | tag-template: 'next' 4 | change-template: '- $TITLE #$NUMBER' 5 | no-changes-template: '- No changes yet' 6 | template: | 7 | $CHANGES 8 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 20.x] 20 | ts-project: [src/tsconfig.json, src/tsconfig-es6.json] 21 | 22 | env: 23 | TS_NODE_PROJECT: ${{ matrix.ts-project }} 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Use Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v1 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | - run: npm cache clean --force 32 | - run: npm ci 33 | - run: npm run build --if-present 34 | - run: npm test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Coverage directory used by tools like istanbul 7 | coverage 8 | 9 | # Dependency directory 10 | node_modules 11 | 12 | # Optional npm cache directory 13 | .npm 14 | 15 | # JetBrains IDE 16 | .idea 17 | .iml 18 | inversify-express-utils.iml 19 | 20 | # OS folders 21 | .DS_Store 22 | 23 | # Typescript build info 24 | tsconfig.cjs.tsbuildinfo 25 | 26 | # Typescript compiled file 27 | lib 28 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | src/test 3 | typings 4 | bundled 5 | build 6 | coverage 7 | docs 8 | wiki 9 | tsconfig.json 10 | CONTRIBUTING.md 11 | ISSUE_TEMPLATE.md 12 | PULL_REQUEST_TEMPLATE.md 13 | .gitignore 14 | .vscode 15 | .github/ 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Mocha Tests", 11 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 12 | "args": [ 13 | "--require", 14 | "reflect-metadata", 15 | "-u", 16 | "tdd", 17 | "--timeout", 18 | "999999", 19 | "--colors", 20 | "${workspaceRoot}/test/**/*.test.js" 21 | ], 22 | "sourceMaps": true, 23 | "protocol": "inspector" 24 | }, 25 | { 26 | "type": "node", 27 | "request": "launch", 28 | "name": "Jest All", 29 | "program": "${workspaceFolder}/node_modules/.bin/jest", 30 | "args": [ 31 | "--watch", 32 | ], 33 | "console": "integratedTerminal", 34 | "internalConsoleOptions": "neverOpen", 35 | "disableOptimisticBPs": true, 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project from 6.4.4 forward will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | 12 | ### Changed 13 | 14 | ### Fixed 15 | 16 | - Added explicit check for when both `genericMetadata` and `methodMetadata` are `undefined` in the `getControllerMethodMetadata` function, preventing potential errors when no metadata is available. 17 | 18 | ## [6.5.0] 19 | 20 | ### Added 21 | 22 | ### Changed 23 | 24 | - Updated `BaseMiddleware.handler` to allow async handlers. 25 | - Updated `Middleware` to allow include any `ServiceIdentifier`. 26 | - Updated `JsonContent` with no generic. 27 | 28 | ### Fixed 29 | 30 | - Updated `BaseController.ok` to no longer return `text/plain` responses when non string content is passed. 31 | 32 | ## [6.4.10] 33 | 34 | ### Fixed 35 | 36 | - Fixed `Controller` without wrong constraints (#417). 37 | 38 | ## [6.4.9] 39 | 40 | ### Fixed 41 | 42 | - Fixed wrong emited typescript delclaration files (#1668). 43 | 44 | ## [6.4.8] 45 | 46 | ### Fixed 47 | 48 | - Fixed can't set headers after they are sent (#255 / #412). 49 | 50 | ## [6.4.7] 51 | 52 | ### Fixed 53 | 54 | - Updated `inversify` and `express` dependencies to be peer dependnecies as stated in the docs. 55 | 56 | ## [6.4.4] 57 | 58 | ### Added 59 | 60 | ### Changed 61 | 62 | - Update dependencies (`minimist`, `json5`, `@babel/traverse`, `tough-cookie`, `ansi-regex`, `cookiejar`, `express`, `decode-uri-component`). 63 | 64 | ### Fixed 65 | 66 | - Change JsonContent to return object rather than string (#379 / #378). 67 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at remo.jansen@wolksoftware.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Setup 4 | 5 | 1 Clone your fork of the repository 6 | ``` 7 | $ git clone https://github.com/YOUR_USERNAME/inversify-express-utils.git 8 | ``` 9 | 10 | 2 Install npm dependencies 11 | ``` 12 | $ npm install 13 | ``` 14 | 15 | 3 Run build process 16 | ``` 17 | $ npm run build 18 | ``` 19 | 20 | ## Guidelines 21 | 22 | - Please try to [combine multiple commits before pushing](http://stackoverflow.com/questions/6934752/combining-multiple-commits-before-pushing-in-git) 23 | 24 | - Please use `TDD` when fixing bugs. This means that you should write a unit test that fails because it reproduces the issue, 25 | then fix the issue and finally run the test to ensure that the issue has been resolved. This helps us prevent fixed bugs from 26 | happening again in the future 27 | 28 | - Please keep the test coverage at 100%. Write additional unit tests if necessary 29 | 30 | - Please create an issue before sending a PR if it is going to change the public interface of InversifyJS or includes significant architecture changes 31 | 32 | - Feel free to ask for help from other members of the InversifyJS team via the chat / mailing list or github issues 33 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | PLEASE DO NOT REPORT THE ISSUE HERE! 2 | 3 | PLEASE USE THE INVERSIFYJS REPO INSTEAD YOU CAN FIND THE REPO AT: 4 | 5 | https://github.com/inversify/InversifyJS/issues 6 | 7 | YOU CAN ALSO FIND US ON GITTER AT: 8 | 9 | https://gitter.im/inversify/InversifyJS 10 | 11 | THANKS! 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 inversify 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. 22 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Related Issue 7 | 8 | 9 | 10 | 11 | 12 | ## Motivation and Context 13 | 14 | 15 | ## How Has This Been Tested? 16 | 17 | 18 | 19 | 20 | ## Types of changes 21 | - [ ] Bug fix (non-breaking change which fixes an issue) 22 | - [ ] New feature (non-breaking change which adds functionality) 23 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 24 | 25 | ## Checklist: 26 | 27 | 28 | - [ ] My code follows the code style of this project. 29 | - [ ] My change requires a change to the documentation. 30 | - [ ] I have updated the documentation accordingly. 31 | - [ ] I have read the **CONTRIBUTING** document. 32 | - [ ] I have added tests to cover my changes. 33 | - [ ] All new and existing tests passed. 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # inversify-express-utils 2 | 3 | [![Join the chat at https://gitter.im/inversify/InversifyJS](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/inversify/InversifyJS?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | [![Build Status](https://secure.travis-ci.org/inversify/inversify-express-utils.svg?branch=master)](https://travis-ci.org/inversify/inversify-express-utils) 5 | [![Test Coverage](https://codeclimate.com/github/inversify/inversify-express-utils/badges/coverage.svg)](https://codeclimate.com/github/inversify/inversify-express-utils/coverage) 6 | [![npm version](https://badge.fury.io/js/inversify-express-utils.svg)](http://badge.fury.io/js/inversify-express-utils) 7 | [![Dependencies](https://david-dm.org/inversify/inversify-express-utils.svg)](https://david-dm.org/inversify/inversify-express-utils#info=dependencies) 8 | [![img](https://david-dm.org/inversify/inversify-express-utils/dev-status.svg)](https://david-dm.org/inversify/inversify-express-utils/#info=devDependencies) 9 | [![img](https://david-dm.org/inversify/inversify-express-utils/peer-status.svg)](https://david-dm.org/inversify/inversify-express-utils/#info=peerDependenciess) 10 | [![Known Vulnerabilities](https://snyk.io/test/github/inversify/inversify-express-utils/badge.svg)](https://snyk.io/test/github/inversify/inversify-express-utils) 11 | 12 | [![NPM](https://nodei.co/npm/inversify-express-utils.png?downloads=true&downloadRank=true)](https://nodei.co/npm/inversify-express-utils/) 13 | [![NPM](https://nodei.co/npm-dl/inversify-express-utils.png?months=9&height=3)](https://nodei.co/npm/inversify-express-utils/) 14 | 15 | Some utilities for the development of express applications with Inversify. 16 | 17 | ## Installation 18 | 19 | You can install `inversify-express-utils` using npm: 20 | 21 | ```sh 22 | npm install inversify inversify-express-utils reflect-metadata --save 23 | ``` 24 | 25 | The `inversify-express-utils` type definitions are included in the npm module and require TypeScript 2.0. 26 | Please refer to the [InversifyJS documentation](https://github.com/inversify/InversifyJS#installation) to learn more about the installation process. 27 | 28 | ## The Basics 29 | 30 | ### Step 1: Decorate your controllers 31 | 32 | To use a class as a "controller" for your express app, simply add the `@controller` decorator to the class. Similarly, decorate methods of the class to serve as request handlers. 33 | 34 | The following example will declare a controller that responds to `GET /foo'. 35 | 36 | ```ts 37 | import * as express from "express"; 38 | import { interfaces, controller, httpGet, httpPost, httpDelete, request, queryParam, response, requestParam } from "inversify-express-utils"; 39 | import { injectable, inject } from "inversify"; 40 | 41 | @controller("/foo") 42 | export class FooController implements interfaces.Controller { 43 | 44 | constructor( @inject("FooService") private fooService: FooService ) {} 45 | 46 | @httpGet("/") 47 | private index(@request() req: express.Request, @response() res: express.Response, @next() next: express.NextFunction): string { 48 | return this.fooService.get(req.query.id); 49 | } 50 | 51 | @httpGet("/") 52 | private list(@queryParam("start") start: number, @queryParam("count") count: number): string { 53 | return this.fooService.get(start, count); 54 | } 55 | 56 | @httpPost("/") 57 | private async create(@request() req: express.Request, @response() res: express.Response) { 58 | try { 59 | await this.fooService.create(req.body); 60 | res.sendStatus(201); 61 | } catch (err) { 62 | res.status(400).json({ error: err.message }); 63 | } 64 | } 65 | 66 | @httpDelete("/:id") 67 | private delete(@requestParam("id") id: string, @response() res: express.Response): Promise { 68 | return this.fooService.delete(id) 69 | .then(() => res.sendStatus(204)) 70 | .catch((err: Error) => { 71 | res.status(400).json({ error: err.message }); 72 | }); 73 | } 74 | } 75 | ``` 76 | 77 | ### Step 2: Configure container and server 78 | 79 | Configure the inversify container in your composition root as usual. 80 | 81 | Then, pass the container to the InversifyExpressServer constructor. This will allow it to register all controllers and their dependencies from your container and attach them to the express app. 82 | Then just call server.build() to prepare your app. 83 | 84 | In order for the InversifyExpressServer to find your controllers, you must bind them to the `TYPE.Controller` service identifier and tag the binding with the controller's name. 85 | The `Controller` interface exported by inversify-express-utils is empty and solely for convenience, so feel free to implement your own if you want. 86 | 87 | ```ts 88 | import * as bodyParser from 'body-parser'; 89 | 90 | import { Container } from 'inversify'; 91 | import { interfaces, InversifyExpressServer, TYPE } from 'inversify-express-utils'; 92 | 93 | // declare metadata by @controller annotation 94 | import "./controllers/foo_controller"; 95 | 96 | // set up container 97 | let container = new Container(); 98 | 99 | // set up bindings 100 | container.bind('FooService').to(FooService); 101 | 102 | // create server 103 | let server = new InversifyExpressServer(container); 104 | server.setConfig((app) => { 105 | // add body parser 106 | app.use(bodyParser.urlencoded({ 107 | extended: true 108 | })); 109 | app.use(bodyParser.json()); 110 | }); 111 | 112 | let app = server.build(); 113 | app.listen(3000); 114 | ``` 115 | 116 | ## Important information about the @controller decorator 117 | 118 | Since the `inversify-express-util@5.0.0` release. The `@injectable` annotation is no longer required in classes annotated with `@controller`. Declaring a type binding for controllers is also no longer required in classes annotated with `@controller`. 119 | 120 | :warning: Declaring a binding is not required for Controllers but **it is required to import the controller one unique time**. When the controller file is imported (e.g. `import "./controllers/some_controller"`) the class is declared and the metadata is generated. If you don't import it the metadata is never generated and therefore the controller is not found. An example of this can be found [here](https://github.com/inversify/inversify-express-example/blob/master/MongoDB/bootstrap.ts#L10-L11). 121 | 122 | If you run the application multiple times within a shared runtime process (e.g. unit testing) you might need to clean up the existing metadata before each test. 123 | 124 | ```ts 125 | import { cleanUpMetadata } from "inversify-express-utils"; 126 | 127 | describe("Some Component", () => { 128 | 129 | beforeEach(() => { 130 | cleanUpMetadata(); 131 | }); 132 | 133 | it("Some test case", () => { 134 | // ... 135 | }); 136 | 137 | }); 138 | ``` 139 | 140 | You can find an example of this in [our unit tests](https://github.com/inversify/inversify-express-utils/blob/master/test/framework.test.ts#L25-L29). 141 | 142 | Inversify express utils will throw an exception if your application doesn't have controllers. You can disable this behaviour using the `forceControllers` option. You can find some examples of `forceControllers` in [our unit tests](https://github.com/inversify/inversify-express-utils/blob/master/test/issue_590.test.ts). 143 | 144 | ## InversifyExpressServer 145 | 146 | A wrapper for an express Application. 147 | 148 | ### `.setConfig(configFn)` 149 | 150 | Optional - exposes the express application object for convenient loading of server-level middleware. 151 | 152 | ```ts 153 | import * as morgan from 'morgan'; 154 | // ... 155 | let server = new InversifyExpressServer(container); 156 | 157 | server.setConfig((app) => { 158 | var logger = morgan('combined') 159 | app.use(logger); 160 | }); 161 | ``` 162 | 163 | ### `.setErrorConfig(errorConfigFn)` 164 | 165 | Optional - like `.setConfig()`, except this function is applied after registering all app middleware and controller routes. 166 | 167 | ```ts 168 | let server = new InversifyExpressServer(container); 169 | server.setErrorConfig((app) => { 170 | app.use((err, req, res, next) => { 171 | console.error(err.stack); 172 | res.status(500).send('Something broke!'); 173 | }); 174 | }); 175 | ``` 176 | 177 | ### `.build()` 178 | 179 | Attaches all registered controllers and middleware to the express application. Returns the application instance. 180 | 181 | ```ts 182 | // ... 183 | let server = new InversifyExpressServer(container); 184 | server 185 | .setConfig(configFn) 186 | .setErrorConfig(errorConfigFn) 187 | .build() 188 | .listen(3000, 'localhost', callback); 189 | ``` 190 | 191 | ## Using a custom Router 192 | 193 | It is possible to pass a custom `Router` instance to `InversifyExpressServer`: 194 | 195 | ```ts 196 | let container = new Container(); 197 | 198 | let router = express.Router({ 199 | caseSensitive: false, 200 | mergeParams: false, 201 | strict: false 202 | }); 203 | 204 | let server = new InversifyExpressServer(container, router); 205 | ``` 206 | 207 | By default server will serve the API at `/` path, but sometimes you might need to use different root namespace, for 208 | example all routes should start with `/api/v1`. It is possible to pass this setting via routing configuration to 209 | `InversifyExpressServer` 210 | 211 | ```ts 212 | let container = new Container(); 213 | 214 | let server = new InversifyExpressServer(container, null, { rootPath: "/api/v1" }); 215 | ``` 216 | 217 | ## Using a custom express application 218 | 219 | It is possible to pass a custom `express.Application` instance to `InversifyExpressServer`: 220 | 221 | ```ts 222 | let container = new Container(); 223 | 224 | let app = express(); 225 | //Do stuff with app 226 | 227 | let server = new InversifyExpressServer(container, null, null, app); 228 | ``` 229 | 230 | ## Decorators 231 | 232 | ### `@controller(path, [middleware, ...])` 233 | 234 | Registers the decorated class as a controller with a root path, and optionally registers any global middleware for this controller. 235 | 236 | ### `@httpMethod(method, path, [middleware, ...])` 237 | 238 | Registers the decorated controller method as a request handler for a particular path and method, where the method name is a valid express routing method. 239 | 240 | ### `@SHORTCUT(path, [middleware, ...])` 241 | 242 | Shortcut decorators which are simply wrappers for `@httpMethod`. Right now these include `@httpGet`, `@httpPost`, `@httpPut`, `@httpPatch`, `@httpHead`, `@httpDelete`, `@httpOptions`, and `@All`. For anything more obscure, use `@httpMethod` (Or make a PR :smile:). 243 | 244 | ### `@request()` 245 | 246 | Binds a method parameter to the request object. 247 | 248 | ### `@response()` 249 | 250 | Binds a method parameter to the response object. 251 | 252 | ### `@requestParam(name: string)` 253 | 254 | Binds a method parameter to request.params object or to a specific parameter if a name is passed. 255 | 256 | ### `@queryParam(name: string)` 257 | 258 | Binds a method parameter to request.query or to a specific query parameter if a name is passed. 259 | 260 | ### `@requestBody()` 261 | 262 | Binds a method parameter to the request.body. If the bodyParser middleware is not used on the express app, this will bind the method parameter to the express request object. 263 | 264 | ### `@requestHeaders(name: string)` 265 | 266 | Binds a method parameter to the request headers. 267 | 268 | ### `@cookies(name: string)` 269 | 270 | Binds a method parameter to the request cookies. 271 | 272 | ### `@next()` 273 | 274 | Binds a method parameter to the next() function. 275 | 276 | ### `@principal()` 277 | 278 | Binds a method parameter to the user principal obtained from the AuthProvider. 279 | 280 | ## BaseHttpController 281 | 282 | The `BaseHttpController` is a base class that provides a significant amount of helper functions in order to aid writing testable controllers. When returning a response from a method defined on one of these controllers, you may use the `response` object available on the `httpContext` property described in the next section, or you may return an `HttpResponseMessage`, or you may return an object that implements the IHttpActionResult interface. 283 | 284 | The benefit of the latter two methods is that since your controller is no longer directly coupled to requiring an httpContext to send a response, unit testing controllers becomes extraordinarily simple as you no longer need to mock the entire response object, you can simply run assertions on the returned value. This API also allows us to make future improvements in this area and add in functionality that exists in similar frameworks (.NET WebAPI) such as media formatters, content negotation, etc. 285 | 286 | ```ts 287 | import { injectable, inject } from "inversify"; 288 | import { 289 | controller, httpGet, BaseHttpController, HttpResponseMessage, StringContent 290 | } from "inversify-express-utils"; 291 | 292 | @controller("/") 293 | class ExampleController extends BaseHttpController { 294 | @httpGet("/") 295 | public async get() { 296 | const response = new HttpResponseMessage(200); 297 | response.content = new StringContent("foo"); 298 | return response; 299 | } 300 | ``` 301 | 302 | On the BaseHttpController, we provide a litany of helper methods to ease returning common IHttpActionResults including 303 | 304 | * OkResult 305 | * OkNegotiatedContentResult 306 | * RedirectResult 307 | * ResponseMessageResult 308 | * StatusCodeResult 309 | * BadRequestErrorMessageResult 310 | * BadRequestResult 311 | * ConflictResult 312 | * CreatedNegotiatedContentResult 313 | * ExceptionResult 314 | * InternalServerError 315 | * NotFoundResult 316 | * JsonResult 317 | * StreamResult 318 | 319 | ```ts 320 | import { injectable, inject } from "inversify"; 321 | import { 322 | controller, httpGet, BaseHttpController 323 | } from "inversify-express-utils"; 324 | 325 | @controller("/") 326 | class ExampleController extends BaseHttpController { 327 | @httpGet("/") 328 | public async get() { 329 | return this.ok("foo"); 330 | } 331 | ``` 332 | 333 | ### JsonResult 334 | 335 | In some scenarios, you'll want to set the status code of the response. 336 | This can be done by using the `json` helper method provided by `BaseHttpController`. 337 | 338 | ```ts 339 | import { 340 | controller, httpGet, BaseHttpController 341 | } from "inversify-express-utils"; 342 | 343 | @controller("/") 344 | export class ExampleController extends BaseHttpController { 345 | @httpGet("/") 346 | public async get() { 347 | const content = { foo: "bar" }; 348 | const statusCode = 403; 349 | 350 | return this.json(content, statusCode); 351 | } 352 | } 353 | ``` 354 | 355 | This gives you the flexability to create your own responses while keeping unit testing simple. 356 | 357 | ```ts 358 | import { expect } from "chai"; 359 | 360 | import { ExampleController } from "./example-controller"; 361 | import { results } from "inversify-express-utils"; 362 | 363 | describe("ExampleController", () => { 364 | let controller: ExampleController; 365 | 366 | beforeEach(() => { 367 | controller = new ExampleController(); 368 | }); 369 | 370 | describe("#get", () => { 371 | it("should have a status code of 403", async () => { 372 | const response = await controller.get(); 373 | 374 | expect(response).to.be.an.instanceof(results.JsonResult); 375 | expect(response.statusCode).to.equal(403); 376 | }); 377 | }); 378 | }); 379 | ``` 380 | *This example uses [Mocha](https://mochajs.org) and [Chai](http://www.chaijs.com) as a unit testing framework* 381 | 382 | ### StreamResult 383 | 384 | In some cases, you'll want to proxy data stream from remote resource in response. 385 | This can be done by using the `stream` helper method provided by `BaseHttpController`. 386 | Useful in cases when you need to return large data. 387 | 388 | ```ts 389 | import { inject } from "inversify"; 390 | import { 391 | controller, httpGet, BaseHttpController 392 | } from "inversify-express-utils"; 393 | import TYPES from "../constants"; 394 | import { FileServiceInterface } from "../interfaces"; 395 | 396 | @controller("/cats") 397 | export class CatController extends BaseHttpController { 398 | @inject(TYPES.FileService) private fileService: FileServiceInterface; 399 | 400 | @httpGet("/image") 401 | public async getCatImage() { 402 | const readableStream = this.fileService.getFileStream("cat.jpeg"); 403 | 404 | return this.stream(content, "image/jpeg", 200); 405 | } 406 | } 407 | ``` 408 | 409 | ## HttpContext 410 | 411 | The `HttpContext` property allow us to access the current request, 412 | response and user with ease. `HttpContext` is available as a property 413 | in controllers derived from `BaseHttpController`. 414 | 415 | ```ts 416 | import { injectable, inject } from "inversify"; 417 | import { 418 | controller, httpGet, BaseHttpController 419 | } from "inversify-express-utils"; 420 | 421 | @controller("/") 422 | class UserPreferencesController extends BaseHttpController { 423 | 424 | @inject("AuthService") private readonly _authService: AuthService; 425 | 426 | @httpGet("/") 427 | public async get() { 428 | const token = this.httpContext.request.headers["x-auth-token"]; 429 | return await this._authService.getUserPreferences(token); 430 | } 431 | } 432 | ``` 433 | 434 | If you are creating a custom controller you will need to inject `HttpContext` manually 435 | using the `@injectHttpContext` decorator: 436 | 437 | ```ts 438 | import { injectable, inject } from "inversify"; 439 | import { 440 | controller, httpGet, BaseHttpController, httpContext, interfaces 441 | } from "inversify-express-utils"; 442 | 443 | const authService = inject("AuthService") 444 | 445 | @controller("/") 446 | class UserPreferencesController { 447 | 448 | @injectHttpContext private readonly _httpContext: interfaces.HttpContext; 449 | @authService private readonly _authService: AuthService; 450 | 451 | @httpGet("/") 452 | public async get() { 453 | const token = this.httpContext.request.headers["x-auth-token"]; 454 | return await this._authService.getUserPreferences(token); 455 | } 456 | } 457 | ``` 458 | 459 | ## AuthProvider 460 | 461 | The `HttpContext` will not have access to the current user if you don't 462 | create a custom `AuthProvider` implementation: 463 | 464 | ```ts 465 | const server = new InversifyExpressServer( 466 | container, null, null, null, CustomAuthProvider 467 | ); 468 | ``` 469 | 470 | We need to implement the `AuthProvider` interface. 471 | 472 | The `AuthProvider` allow us to get a user (`Principal`): 473 | 474 | ```ts 475 | import { injectable, inject } from "inversify"; 476 | import { interfaces } from "inversify-express-utils"; 477 | 478 | const authService = inject("AuthService"); 479 | 480 | @injectable() 481 | class CustomAuthProvider implements interfaces.AuthProvider { 482 | 483 | @authService private readonly _authService: AuthService; 484 | 485 | public async getUser( 486 | req: express.Request, 487 | res: express.Response, 488 | next: express.NextFunction 489 | ): Promise { 490 | const token = req.headers["x-auth-token"] 491 | const user = await this._authService.getUser(token); 492 | const principal = new Principal(user); 493 | return principal; 494 | } 495 | 496 | } 497 | ``` 498 | 499 | We also need to implement the Principal interface. 500 | The `Principal` interface allow us to: 501 | 502 | - Access the details of a user 503 | - Check if it has access to certain resource 504 | - Check if it is authenticated 505 | - Check if it is in a user role 506 | 507 | ```ts 508 | class Principal implements interfaces.Principal { 509 | public details: T; 510 | public constructor(details: T) { 511 | this.details = details; 512 | } 513 | public isAuthenticated(): Promise { 514 | return Promise.resolve(true); 515 | } 516 | public isResourceOwner(resourceId: unknown): Promise { 517 | return Promise.resolve(resourceId === 1111); 518 | } 519 | public isInRole(role: string): Promise { 520 | return Promise.resolve(role === "admin"); 521 | } 522 | } 523 | ``` 524 | 525 | We can then access the current user (Principal) via the `HttpContext`: 526 | 527 | ```ts 528 | @controller("/") 529 | class UserDetailsController extends BaseHttpController { 530 | 531 | @inject("AuthService") private readonly _authService: AuthService; 532 | 533 | @httpGet("/") 534 | public async getUserDetails() { 535 | if (this.httpContext.user.isAuthenticated()) { 536 | return this._authService.getUserDetails(this.httpContext.user.details.id); 537 | } else { 538 | throw new Error(); 539 | } 540 | } 541 | } 542 | ``` 543 | 544 | ## BaseMiddleware 545 | 546 | Extending `BaseMiddleware` allow us to inject dependencies 547 | and to access the current `HttpContext` in Express middleware function. 548 | 549 | ```ts 550 | import { BaseMiddleware } from "inversify-express-utils"; 551 | 552 | @injectable() 553 | class LoggerMiddleware extends BaseMiddleware { 554 | @inject(TYPES.Logger) private readonly _logger: Logger; 555 | public handler( 556 | req: express.Request, 557 | res: express.Response, 558 | next: express.NextFunction 559 | ) { 560 | if (this.httpContext.user.isAuthenticated()) { 561 | this._logger.info(`${this.httpContext.user.details.email} => ${req.url}`); 562 | } else { 563 | this._logger.info(`Anonymous => ${req.url}`); 564 | } 565 | next(); 566 | } 567 | } 568 | ``` 569 | 570 | We also need to declare some type bindings: 571 | 572 | ```ts 573 | const container = new Container(); 574 | 575 | container.bind(TYPES.Logger) 576 | .to(Logger); 577 | 578 | container.bind(TYPES.LoggerMiddleware) 579 | .to(LoggerMiddleware); 580 | 581 | ``` 582 | 583 | We can then inject `TYPES.LoggerMiddleware` into one of our controllers. 584 | 585 | ```ts 586 | @controller("/") 587 | class UserDetailsController extends BaseHttpController { 588 | 589 | @inject("AuthService") private readonly _authService: AuthService; 590 | 591 | @httpGet("/", TYPES.LoggerMiddleware) 592 | public async getUserDetails() { 593 | if (this.httpContext.user.isAuthenticated()) { 594 | return this._authService.getUserDetails(this.httpContext.user.details.id); 595 | } else { 596 | throw new Error(); 597 | } 598 | } 599 | } 600 | ``` 601 | 602 | ### Request-scope services 603 | 604 | Middleware extending `BaseMiddleware` is capable of re-binding services in the scope of a HTTP request. 605 | This is useful if you need access to a HTTP request or context-specific property in a service that doesn't have 606 | the direct access to them otherwise. 607 | 608 | Consider the below `TracingMiddleware`. In this example we want to capture the `X-Trace-Id` header from the incoming request 609 | and make it available to our IoC services as `TYPES.TraceIdValue`: 610 | 611 | ```typescript 612 | import { inject, injectable } from "inversify"; 613 | import { BaseHttpController, BaseMiddleware, controller, httpGet } from "inversify-express-utils"; 614 | import * as express from "express"; 615 | 616 | const TYPES = { 617 | TraceId: Symbol.for("TraceIdValue"), 618 | TracingMiddleware: Symbol.for("TracingMiddleware"), 619 | Service: Symbol.for("Service"), 620 | }; 621 | 622 | @injectable() 623 | class TracingMiddleware extends BaseMiddleware { 624 | 625 | public handler( 626 | req: express.Request, 627 | res: express.Response, 628 | next: express.NextFunction 629 | ) { 630 | this.bind(TYPES.TraceIdValue) 631 | .toConstantValue(`${ req.header('X-Trace-Id') }`); 632 | 633 | next(); 634 | } 635 | } 636 | 637 | @controller("/") 638 | class TracingTestController extends BaseHttpController { 639 | 640 | constructor(@inject(TYPES.Service) private readonly service: Service) { 641 | super(); 642 | } 643 | 644 | @httpGet( 645 | "/", 646 | TYPES.TracingMiddleware 647 | ) 648 | public getTest() { 649 | return this.service.doSomethingThatRequiresTheTraceID(); 650 | } 651 | } 652 | 653 | @injectable() 654 | class Service { 655 | constructor(@inject(TYPES.TraceIdValue) private readonly traceID: string) { 656 | } 657 | 658 | public doSomethingThatRequiresTheTraceID() { 659 | // ... 660 | } 661 | } 662 | ``` 663 | 664 | The `BaseMiddleware.bind()` method will bind the `TYPES.TraceIdValue` if it hasn't been bound yet or re-bind if it has 665 | already been bound. 666 | 667 | ### Middleware decorators 668 | You can use the `@withMiddleware()` decorator to register middleware on controllers and handlers. For example: 669 | ```typescript 670 | function authenticate() { 671 | return withMiddleware( 672 | (req, res, next) => { 673 | if (req.user === undefined) { 674 | res.status(401).json({ errors: [ 'You must be logged in to access this resource.' ] }) 675 | } 676 | next() 677 | } 678 | ) 679 | } 680 | 681 | function authorizeRole(role: string) { 682 | return withMiddleware( 683 | (req, res, next) => { 684 | if (!req.user.roles.includes(role)) { 685 | res.status(403).json({ errors: [ 'Get out.' ] }) 686 | } 687 | next() 688 | } 689 | ) 690 | } 691 | 692 | @controller('/api/user') 693 | @authenticate() 694 | class UserController { 695 | 696 | @httpGet('/admin/:id') 697 | @authorizeRole('ADMIN') 698 | public getById(@requestParam('id') id: string) { 699 | ... 700 | } 701 | } 702 | ``` 703 | You can also decorate controllers and handlers with middleware using BaseMiddleware identitifers: 704 | ```typescript 705 | class AuthenticationMiddleware extends BaseMiddleware { 706 | handler(req, res, next) { 707 | if (req.user === undefined) { 708 | res.status(401).json({ errors: [ 'User is not logged in.' ] }) 709 | } 710 | } 711 | } 712 | 713 | container.bind("AuthMiddleware").to(AuthenticationMiddleware) 714 | 715 | @controller('/api/users') 716 | @withMiddleware("AuthMiddleware") 717 | class UserController { 718 | ... 719 | } 720 | ``` 721 | 722 | ## Route Map 723 | 724 | If we have some controllers like for example: 725 | 726 | ```ts 727 | @controller("/api/user") 728 | class UserController extends BaseHttpController { 729 | @httpGet("/") 730 | public get() { 731 | return {}; 732 | } 733 | @httpPost("/") 734 | public post() { 735 | return {}; 736 | } 737 | @httpDelete("/:id") 738 | public delete(@requestParam("id") id: string) { 739 | return {}; 740 | } 741 | } 742 | 743 | @controller("/api/order") 744 | class OrderController extends BaseHttpController { 745 | @httpGet("/") 746 | public get() { 747 | return {}; 748 | } 749 | @httpPost("/") 750 | public post() { 751 | return {}; 752 | } 753 | @httpDelete("/:id") 754 | public delete(@requestParam("id") id: string) { 755 | return {}; 756 | } 757 | } 758 | ``` 759 | 760 | We can use the `prettyjson` function to see all the available enpoints: 761 | 762 | ```ts 763 | import { getRouteInfo } from "inversify-express-utils"; 764 | import * as prettyjson from "prettyjson"; 765 | 766 | // ... 767 | 768 | let server = new InversifyExpressServer(container); 769 | let app = server.build(); 770 | const routeInfo = getRouteInfo(container); 771 | 772 | console.log(prettyjson.render({ routes: routeInfo })); 773 | 774 | // ... 775 | ``` 776 | 777 | > :warning: Please ensure that you invoke `getRouteInfo` after invoking `server.build()`! 778 | 779 | The output formatter by `prettyjson` looks as follows: 780 | 781 | ```txt 782 | routes: 783 | - 784 | controller: OrderController 785 | endpoints: 786 | - 787 | route: GET /api/order/ 788 | - 789 | route: POST /api/order/ 790 | - 791 | path: DELETE /api/order/:id 792 | route: 793 | - @requestParam id 794 | - 795 | controller: UserController 796 | endpoints: 797 | - 798 | route: GET /api/user/ 799 | - 800 | route: POST /api/user/ 801 | - 802 | route: DELETE /api/user/:id 803 | args: 804 | - @requestParam id 805 | ``` 806 | 807 | ## Examples 808 | 809 | Some examples can be found at the [inversify-express-example](https://github.com/inversify/inversify-express-example) repository. 810 | 811 | ## License 812 | 813 | License under the MIT License (MIT) 814 | 815 | Copyright © 2016-2017 [Cody Simms](https://github.com/codyjs) 816 | 817 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 818 | 819 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 820 | 821 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 822 | 823 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 824 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | import eslintPrettierConfig from 'eslint-plugin-prettier/recommended'; 6 | import simpleImportSort from 'eslint-plugin-simple-import-sort'; 7 | 8 | /** 9 | * @returns {import('typescript-eslint').ConfigWithExtends} 10 | */ 11 | function buildBaseConfig() { 12 | return { 13 | extends: [ 14 | eslint.configs.recommended, 15 | ...tseslint.configs.strictTypeChecked, 16 | ], 17 | languageOptions: { 18 | parser: tseslint.parser, 19 | parserOptions: { 20 | project: './tsconfig.json', 21 | }, 22 | }, 23 | plugins: { 24 | '@typescript-eslint': tseslint.plugin, 25 | 'simple-import-sort': simpleImportSort, 26 | }, 27 | rules: { 28 | '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], 29 | '@typescript-eslint/explicit-member-accessibility': [ 30 | 'error', 31 | { 32 | overrides: { 33 | constructors: 'no-public', 34 | }, 35 | }, 36 | ], 37 | '@typescript-eslint/member-ordering': ['warn'], 38 | '@typescript-eslint/naming-convention': [ 39 | 'error', 40 | { 41 | selector: ['classProperty'], 42 | format: ['strictCamelCase', 'UPPER_CASE', 'snake_case'], 43 | leadingUnderscore: 'allow', 44 | }, 45 | { 46 | selector: 'typeParameter', 47 | format: ['StrictPascalCase'], 48 | prefix: ['T'], 49 | }, 50 | { 51 | selector: ['typeLike'], 52 | format: ['StrictPascalCase'], 53 | }, 54 | { 55 | selector: ['function', 'classMethod'], 56 | format: ['strictCamelCase'], 57 | leadingUnderscore: 'allow', 58 | }, 59 | { 60 | selector: ['parameter'], 61 | format: ['strictCamelCase'], 62 | leadingUnderscore: 'allow', 63 | }, 64 | { 65 | selector: ['variableLike'], 66 | format: ['strictCamelCase', 'UPPER_CASE', 'snake_case'], 67 | }, 68 | ], 69 | '@typescript-eslint/no-deprecated': 'error', 70 | '@typescript-eslint/no-duplicate-type-constituents': 'off', 71 | '@typescript-eslint/no-dynamic-delete': 'error', 72 | '@typescript-eslint/no-extraneous-class': 'off', 73 | '@typescript-eslint/no-inferrable-types': 'off', 74 | '@typescript-eslint/no-empty-interface': 'warn', 75 | '@typescript-eslint/no-explicit-any': 'error', 76 | '@typescript-eslint/no-floating-promises': ['error'], 77 | '@typescript-eslint/no-unsafe-enum-comparison': 'off', 78 | 'no-magic-numbers': 'off', 79 | '@typescript-eslint/no-magic-numbers': [ 80 | 'warn', 81 | { 82 | ignore: [0, 1], 83 | ignoreArrayIndexes: true, 84 | ignoreEnums: true, 85 | ignoreReadonlyClassProperties: true, 86 | }, 87 | ], 88 | '@typescript-eslint/no-require-imports': 'error', 89 | '@typescript-eslint/no-unnecessary-type-arguments': 'off', 90 | '@typescript-eslint/no-unused-expressions': ['error'], 91 | '@typescript-eslint/no-useless-constructor': 'error', 92 | '@typescript-eslint/prefer-for-of': 'error', 93 | '@typescript-eslint/prefer-nullish-coalescing': ['off'], 94 | '@typescript-eslint/prefer-optional-chain': 'off', 95 | '@typescript-eslint/prefer-readonly': ['warn'], 96 | '@typescript-eslint/promise-function-async': ['error'], 97 | '@typescript-eslint/require-await': 'off', 98 | '@typescript-eslint/restrict-plus-operands': [ 99 | 'error', 100 | { 101 | skipCompoundAssignments: false, 102 | }, 103 | ], 104 | '@typescript-eslint/typedef': [ 105 | 'error', 106 | { 107 | arrayDestructuring: true, 108 | arrowParameter: true, 109 | memberVariableDeclaration: true, 110 | objectDestructuring: true, 111 | parameter: true, 112 | propertyDeclaration: true, 113 | variableDeclaration: true, 114 | }, 115 | ], 116 | '@typescript-eslint/unified-signatures': 'error', 117 | '@typescript-eslint/strict-boolean-expressions': 'error', 118 | '@typescript-eslint/switch-exhaustiveness-check': [ 119 | 'error', 120 | { 121 | considerDefaultExhaustiveForUnions: true, 122 | }, 123 | ], 124 | '@typescript-eslint/no-unused-vars': [ 125 | 'warn', 126 | { 127 | args: 'all', 128 | argsIgnorePattern: '^_', 129 | caughtErrors: 'all', 130 | caughtErrorsIgnorePattern: '^_', 131 | destructuredArrayIgnorePattern: '^_', 132 | varsIgnorePattern: '^_', 133 | ignoreRestSiblings: true, 134 | }, 135 | ], 136 | 'simple-import-sort/imports': [ 137 | 'error', 138 | { 139 | groups: [['^\\u0000'], ['^node:'], ['^@?\\w'], ['^'], ['^\\.']], 140 | }, 141 | ], 142 | 'sort-keys': [ 143 | 'error', 144 | 'asc', 145 | { 146 | caseSensitive: false, 147 | natural: true, 148 | }, 149 | ], 150 | }, 151 | }; 152 | } 153 | 154 | const baseRules = buildBaseConfig(); 155 | 156 | const config = tseslint.config( 157 | { 158 | ...baseRules, 159 | files: ['**/*.ts'], 160 | ignores: ['**/*.test.ts'], 161 | }, 162 | { 163 | ...baseRules, 164 | files: ['**/*.test.ts'], 165 | rules: { 166 | ...(baseRules.rules ?? {}), 167 | '@typescript-eslint/no-confusing-void-expression': 'off', 168 | '@typescript-eslint/unbound-method': 'off', 169 | '@typescript-eslint/no-magic-numbers': 'off', 170 | }, 171 | }, 172 | /** @type {import('typescript-eslint').ConfigWithExtends} */ ( 173 | eslintPrettierConfig 174 | ), 175 | ); 176 | 177 | export default [...config]; 178 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "collectCoverage": true, 3 | "coverageDirectory": "/coverage", 4 | "coverageReporters": [ 5 | "html", 6 | "json", 7 | "text" 8 | ], 9 | "moduleFileExtensions": [ 10 | "js", 11 | "json", 12 | "ts" 13 | ], 14 | "rootDir": ".", 15 | "setupFilesAfterEnv": [ 16 | "/src/test/helpers/jest.setup.ts" 17 | ], 18 | "testEnvironment": "node", 19 | "testPathIgnorePatterns": [ 20 | "node_modules" 21 | ], 22 | "testRegex": ".test.ts$", 23 | "transform": { 24 | "^.+\\.ts$": "ts-jest" 25 | } 26 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "inversify-express-utils", 3 | "version": "6.5.0", 4 | "author": "Cody Simms", 5 | "description": "Some utilities for the development of express applications with Inversify", 6 | "license": "MIT", 7 | "main": "lib/cjs/index.js", 8 | "module": "lib/esm/index.js", 9 | "exports": { 10 | ".": { 11 | "import": "./lib/esm/index.js", 12 | "require": "./lib/cjs/index.js" 13 | } 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/inversify/inversify-express-utils.git" 18 | }, 19 | "scripts": { 20 | "build": "npm run build:cjs && npm run build:esm", 21 | "build:cjs": "tsc --build tsconfig.cjs.json && node ./scripts/writeCommonJsPackageJson.mjs ./lib/cjs", 22 | "build:esm": "rollup -c ./rollup.config.mjs && node ./scripts/writeEsmPackageJson.mjs ./lib/esm", 23 | "build:clean": "rimraf lib", 24 | "format": "prettier --write ./src/**/*.ts", 25 | "lint": "eslint ./src", 26 | "prebuild": "npm run build:clean", 27 | "prepublish": "npm run build", 28 | "test": "jest", 29 | "test:coverage": "jest --coverage", 30 | "test:watch": "jest --watch" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/inversify/inversify-express-utils/issues" 34 | }, 35 | "homepage": "https://github.com/inversify/inversify-express-utils#readme", 36 | "jsnext:main": "es/index.js", 37 | "keywords": [ 38 | "InversifyJS", 39 | "express" 40 | ], 41 | "dependencies": { 42 | "http-status-codes": "2.3.0" 43 | }, 44 | "devDependencies": { 45 | "@eslint/js": "9.18.0", 46 | "@jest/globals": "29.7.0", 47 | "@rollup/plugin-terser": "0.4.4", 48 | "@rollup/plugin-typescript": "12.1.2", 49 | "@types/cookie-parser": "1.4.7", 50 | "@types/express": "^4.17.21", 51 | "@types/node": "22.10.7", 52 | "@types/supertest": "6.0.2", 53 | "@typescript-eslint/eslint-plugin": "8.20.0", 54 | "@typescript-eslint/parser": "8.20.0", 55 | "body-parser": "1.20.3", 56 | "cookie-parser": "1.4.7", 57 | "eslint": "9.18.0", 58 | "eslint-config-prettier": "10.0.1", 59 | "eslint-plugin-prettier": "5.2.2", 60 | "eslint-plugin-simple-import-sort": "12.1.1", 61 | "jest": "29.7.0", 62 | "prettier": "3.4.2", 63 | "reflect-metadata": "0.2.2", 64 | "rimraf": "6.0.1", 65 | "rollup-plugin-dts": "6.1.1", 66 | "supertest": "7.1.0", 67 | "ts-loader": "9.5.2", 68 | "ts-jest": "29.2.5", 69 | "typescript": "5.6.3", 70 | "typescript-eslint": "8.20.0" 71 | }, 72 | "peerDependencies": { 73 | "express": "^4.21.1", 74 | "inversify": "^6.0.3", 75 | "reflect-metadata": "~0.2.2" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | printWidth: 80, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: true, 6 | singleQuote: true, 7 | bracketSpacing: true, 8 | arrowParens: 'always', 9 | endOfLine: 'lf', 10 | trailingComma: 'all', 11 | }; 12 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "automerge": false, 4 | "extends": [ 5 | "config:base", 6 | ":disableRateLimiting", 7 | ":semanticCommitScopeDisabled" 8 | ], 9 | "ignoreDeps": [], 10 | "packageRules": [ 11 | { 12 | "groupName": "auto merge on patch or minor", 13 | "automerge": true, 14 | "matchUpdateTypes": ["patch", "minor"], 15 | "excludePackageNames": ["turbo", "typescript"] 16 | } 17 | ], 18 | "rangeStrategy": "bump", 19 | "rebaseWhen": "conflicted", 20 | "semanticCommits": "enabled", 21 | "schedule": ["at any time"] 22 | } 23 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | 3 | import terser from '@rollup/plugin-terser'; 4 | import typescript from '@rollup/plugin-typescript'; 5 | import { dts } from 'rollup-plugin-dts'; 6 | 7 | const NODE_REGEX = /^node:/; 8 | 9 | /** 10 | * @param {string} path 11 | * @returns {Promise} 12 | */ 13 | async function pathExists(path) { 14 | try { 15 | await fs.access(path); 16 | return true; 17 | } catch (_err) { 18 | return false; 19 | } 20 | } 21 | 22 | const PACKAGE_JSON_PATH = './package.json'; 23 | 24 | if (!pathExists(PACKAGE_JSON_PATH)) { 25 | throw new Error(`Expected "${PACKAGE_JSON_PATH}" path to exist`); 26 | } 27 | 28 | const packageJsonObject = JSON.parse(await fs.readFile(PACKAGE_JSON_PATH)); 29 | const packageDependencies = Object.keys(packageJsonObject.dependencies ?? {}); 30 | const packagePeerDependencies = Object.keys( 31 | packageJsonObject.peerDependencies ?? {}, 32 | ); 33 | 34 | /** @type {!import("rollup").MergedRollupOptions[]} */ 35 | export default [ 36 | { 37 | input: './src/index.ts', 38 | external: [NODE_REGEX, ...packageDependencies, ...packagePeerDependencies], 39 | output: [ 40 | { 41 | dir: './lib/esm', 42 | format: 'esm', 43 | sourcemap: true, 44 | sourcemapPathTransform: (relativeSourcePath) => { 45 | // Rollup seems to generate source maps pointing to the wrong directory. Ugly patch to fix it 46 | if (relativeSourcePath.startsWith('../')) { 47 | return relativeSourcePath.slice(3); 48 | } else { 49 | return relativeSourcePath; 50 | } 51 | }, 52 | }, 53 | ], 54 | plugins: [ 55 | typescript({ 56 | tsconfig: './tsconfig.esm.json', 57 | }), 58 | terser(), 59 | ], 60 | }, 61 | { 62 | input: 'lib/esm/index.d.ts', 63 | external: [NODE_REGEX, ...packageDependencies, ...packagePeerDependencies], 64 | output: [{ file: 'lib/esm/index.d.ts', format: 'es' }], 65 | plugins: [ 66 | dts({ 67 | tsconfig: './tsconfig.esm.json', 68 | }), 69 | ], 70 | }, 71 | ]; 72 | -------------------------------------------------------------------------------- /scripts/writeCommonJsPackageJson.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'node:fs/promises'; 4 | import { argv } from 'node:process'; 5 | import path from 'node:path'; 6 | import { writeFile } from 'node:fs/promises'; 7 | 8 | /** 9 | * @param {string} path 10 | * @returns {Promise} 11 | */ 12 | async function pathExists(path) { 13 | try { 14 | await fs.access(path); 15 | return true; 16 | } catch (_err) { 17 | return false; 18 | } 19 | } 20 | 21 | const directory = argv[2]; 22 | 23 | if (directory === undefined) { 24 | throw new Error('Expected a path'); 25 | } 26 | 27 | const directoryExists = await pathExists(directory); 28 | 29 | if (!directoryExists) { 30 | throw new Error(`Path ${directory} not found`); 31 | } 32 | 33 | const filePath = path.join(directory, 'package.json'); 34 | 35 | const packageJsonFileContent = JSON.stringify( 36 | { 37 | type: 'commonjs', 38 | }, 39 | undefined, 40 | 2, 41 | ); 42 | 43 | await writeFile(filePath, packageJsonFileContent); 44 | -------------------------------------------------------------------------------- /scripts/writeEsmPackageJson.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'node:fs/promises'; 4 | import { argv } from 'node:process'; 5 | import path from 'node:path'; 6 | import { writeFile } from 'node:fs/promises'; 7 | 8 | /** 9 | * @param {string} path 10 | * @returns {Promise} 11 | */ 12 | async function pathExists(path) { 13 | try { 14 | await fs.access(path); 15 | return true; 16 | } catch (_err) { 17 | return false; 18 | } 19 | } 20 | 21 | const directory = argv[2]; 22 | 23 | if (directory === undefined) { 24 | throw new Error('Expected a path'); 25 | } 26 | 27 | const directoryExists = await pathExists(directory); 28 | 29 | if (!directoryExists) { 30 | throw new Error(`Path ${directory} not found`); 31 | } 32 | 33 | const filePath = path.join(directory, 'package.json'); 34 | 35 | const packageJsonFileContent = JSON.stringify( 36 | { 37 | type: 'module', 38 | }, 39 | undefined, 40 | 2, 41 | ); 42 | 43 | await writeFile(filePath, packageJsonFileContent); 44 | -------------------------------------------------------------------------------- /src/base_http_controller.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'node:stream'; 2 | import { URL } from 'node:url'; 3 | 4 | import { StatusCodes } from 'http-status-codes'; 5 | import { injectable } from 'inversify'; 6 | 7 | import { injectHttpContext } from './decorators'; 8 | import { HttpResponseMessage } from './httpResponseMessage'; 9 | import type { HttpContext } from './interfaces'; 10 | import { 11 | BadRequestErrorMessageResult, 12 | BadRequestResult, 13 | ConflictResult, 14 | CreatedNegotiatedContentResult, 15 | ExceptionResult, 16 | InternalServerErrorResult, 17 | JsonResult, 18 | NotFoundResult, 19 | OkNegotiatedContentResult, 20 | OkResult, 21 | RedirectResult, 22 | ResponseMessageResult, 23 | StatusCodeResult, 24 | StreamResult, 25 | } from './results'; 26 | 27 | @injectable() 28 | export class BaseHttpController { 29 | @injectHttpContext protected readonly httpContext!: HttpContext; 30 | 31 | protected created( 32 | location: string | URL, 33 | content: T, 34 | ): CreatedNegotiatedContentResult { 35 | return new CreatedNegotiatedContentResult(location, content); 36 | } 37 | 38 | protected conflict(): ConflictResult { 39 | return new ConflictResult(); 40 | } 41 | 42 | protected ok(content: T): OkNegotiatedContentResult; 43 | protected ok(): OkResult; 44 | protected ok(content?: T): OkResult { 45 | return content === undefined 46 | ? new OkResult() 47 | : new OkNegotiatedContentResult(content); 48 | } 49 | 50 | protected badRequest(): BadRequestResult; 51 | protected badRequest(message: string): BadRequestErrorMessageResult; 52 | protected badRequest(message?: string): BadRequestResult { 53 | return message === undefined 54 | ? new BadRequestResult() 55 | : new BadRequestErrorMessageResult(message); 56 | } 57 | 58 | protected internalServerError(): InternalServerErrorResult; 59 | protected internalServerError(error: Error): ExceptionResult; 60 | protected internalServerError(error?: Error): InternalServerErrorResult { 61 | return error ? new ExceptionResult(error) : new InternalServerErrorResult(); 62 | } 63 | 64 | protected notFound(): NotFoundResult { 65 | return new NotFoundResult(); 66 | } 67 | 68 | protected redirect(uri: string | URL): RedirectResult { 69 | return new RedirectResult(uri); 70 | } 71 | 72 | protected responseMessage( 73 | message: HttpResponseMessage, 74 | ): ResponseMessageResult { 75 | return new ResponseMessageResult(message); 76 | } 77 | 78 | protected statusCode(statusCode: number): StatusCodeResult { 79 | return new StatusCodeResult(statusCode); 80 | } 81 | 82 | protected json( 83 | content: unknown, 84 | statusCode: number = StatusCodes.OK, 85 | ): JsonResult { 86 | return new JsonResult(content, statusCode); 87 | } 88 | 89 | protected stream( 90 | readableStream: Readable, 91 | contentType: string, 92 | statusCode: number = StatusCodes.OK, 93 | ): StreamResult { 94 | return new StreamResult(readableStream, contentType, statusCode); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/base_middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from 'express'; 2 | import { injectable, interfaces as inversifyInterfaces } from 'inversify'; 3 | 4 | import type { HttpContext } from './interfaces'; 5 | 6 | @injectable() 7 | export abstract class BaseMiddleware implements BaseMiddleware { 8 | // httpContext is initialized when the middleware is invoked 9 | // see resolveMidleware in server.ts for more details 10 | public httpContext!: HttpContext; 11 | 12 | protected bind( 13 | serviceIdentifier: inversifyInterfaces.ServiceIdentifier, 14 | ): inversifyInterfaces.BindingToSyntax { 15 | return this.httpContext.container.bind(serviceIdentifier); 16 | } 17 | 18 | public abstract handler( 19 | req: Request, 20 | res: Response, 21 | next: NextFunction 22 | ): void | Promise; 23 | } 24 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/typedef */ 2 | /* eslint-disable @typescript-eslint/naming-convention */ 3 | export const TYPE = { 4 | AuthProvider: Symbol.for('AuthProvider'), 5 | Controller: Symbol.for('Controller'), 6 | HttpContext: Symbol.for('HttpContext'), 7 | }; 8 | 9 | export const METADATA_KEY = { 10 | controller: 'inversify-express-utils:controller', 11 | controllerMethod: 'inversify-express-utils:controller-method', 12 | controllerParameter: 'inversify-express-utils:controller-parameter', 13 | httpContext: 'inversify-express-utils:httpcontext', 14 | middleware: 'inversify-express-utils:middleware', 15 | }; 16 | 17 | export enum PARAMETER_TYPE { 18 | REQUEST, 19 | RESPONSE, 20 | PARAMS, 21 | QUERY, 22 | BODY, 23 | HEADERS, 24 | COOKIES, 25 | NEXT, 26 | PRINCIPAL, 27 | } 28 | 29 | export enum HTTP_VERBS_ENUM { 30 | all = 'all', 31 | connect = 'connect', 32 | delete = 'delete', 33 | get = 'get', 34 | head = 'head', 35 | options = 'options', 36 | patch = 'patch', 37 | post = 'post', 38 | propfind = 'propfind', 39 | put = 'put', 40 | trace = 'trace', 41 | } 42 | 43 | export const DUPLICATED_CONTROLLER_NAME: (name: string) => string = ( 44 | name: string, 45 | ): string => `Two controllers cannot have the same name: ${name}`; 46 | 47 | export const NO_CONTROLLERS_FOUND: string = 48 | 'No controllers have been found! Please ensure that you have register at least one Controller.'; 49 | 50 | export const DEFAULT_ROUTING_ROOT_PATH: string = '/'; 51 | -------------------------------------------------------------------------------- /src/content/httpContent.ts: -------------------------------------------------------------------------------- 1 | import type { OutgoingHttpHeaders } from 'node:http'; 2 | 3 | export abstract class HttpContent { 4 | private readonly _headers: OutgoingHttpHeaders = {}; 5 | 6 | public get headers(): OutgoingHttpHeaders { 7 | return this._headers; 8 | } 9 | 10 | public abstract readAsync(): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /src/content/jsonContent.ts: -------------------------------------------------------------------------------- 1 | import { HttpContent } from './httpContent'; 2 | 3 | const DEFAULT_MEDIA_TYPE: string = 'application/json'; 4 | 5 | export class JsonContent extends HttpContent { 6 | constructor(private readonly content: unknown) { 7 | super(); 8 | 9 | this.headers['content-type'] = DEFAULT_MEDIA_TYPE; 10 | } 11 | 12 | public async readAsync(): Promise { 13 | return this.content; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/content/streamContent.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'node:stream'; 2 | 3 | import { HttpContent } from './httpContent'; 4 | 5 | export class StreamContent extends HttpContent { 6 | constructor( 7 | private readonly content: Readable, 8 | mediaType: string, 9 | ) { 10 | super(); 11 | 12 | this.headers['content-type'] = mediaType; 13 | } 14 | public async readAsync(): Promise { 15 | return this.content; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/content/stringContent.ts: -------------------------------------------------------------------------------- 1 | import { HttpContent } from './httpContent'; 2 | 3 | const DEFAULT_MEDIA_TYPE: string = 'text/plain'; 4 | 5 | export class StringContent extends HttpContent { 6 | constructor(private readonly content: string) { 7 | super(); 8 | 9 | this.headers['content-type'] = DEFAULT_MEDIA_TYPE; 10 | } 11 | 12 | public async readAsync(): Promise { 13 | return this.content; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | import { interfaces as inversifyInterfaces } from 'inversify'; 2 | 3 | import { PARAMETER_TYPE } from './constants'; 4 | import type { 5 | Controller, 6 | ControllerMethodMetadata, 7 | ControllerParameterMetadata, 8 | ParameterMetadata, 9 | RawMetadata, 10 | RouteDetails, 11 | RouteInfo, 12 | } from './interfaces'; 13 | import { 14 | getControllerMetadata, 15 | getControllerMethodMetadata, 16 | getControllerParameterMetadata, 17 | getControllersFromContainer, 18 | } from './utils'; 19 | 20 | export function getRouteInfo( 21 | container: inversifyInterfaces.Container, 22 | ): RouteInfo[] { 23 | const raw: RawMetadata[] = getRawMetadata(container); 24 | 25 | return raw.map((r: RawMetadata) => { 26 | const controllerId: string = ( 27 | r.controllerMetadata.target as { name: string } 28 | ).name; 29 | 30 | const endpoints: RouteDetails[] = r.methodMetadata.map( 31 | (m: ControllerMethodMetadata) => { 32 | const method: string = m.method.toUpperCase(); 33 | const controllerPath: string = r.controllerMetadata.path; 34 | const actionPath: string = m.path; 35 | const paramMetadata: ControllerParameterMetadata = r.parameterMetadata; 36 | let args: (string | undefined)[] | undefined = undefined; 37 | 38 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 39 | if (paramMetadata !== undefined) { 40 | const paramMetadataForKey: ParameterMetadata[] | undefined = 41 | paramMetadata[m.key] || undefined; 42 | if (paramMetadataForKey) { 43 | args = (r.parameterMetadata[m.key] || []).map( 44 | (a: ParameterMetadata) => { 45 | let type: string = ''; 46 | switch (a.type) { 47 | case PARAMETER_TYPE.RESPONSE: 48 | type = '@response'; 49 | break; 50 | case PARAMETER_TYPE.REQUEST: 51 | type = '@request'; 52 | break; 53 | case PARAMETER_TYPE.NEXT: 54 | type = '@next'; 55 | break; 56 | case PARAMETER_TYPE.PARAMS: 57 | type = '@requestParam'; 58 | break; 59 | case PARAMETER_TYPE.QUERY: 60 | type = 'queryParam'; 61 | break; 62 | case PARAMETER_TYPE.BODY: 63 | type = '@requestBody'; 64 | break; 65 | case PARAMETER_TYPE.HEADERS: 66 | type = '@requestHeaders'; 67 | break; 68 | case PARAMETER_TYPE.COOKIES: 69 | type = '@cookies'; 70 | break; 71 | case PARAMETER_TYPE.PRINCIPAL: 72 | type = '@principal'; 73 | break; 74 | default: 75 | break; 76 | } 77 | 78 | return `${type} ${a.parameterName as string}`; 79 | }, 80 | ); 81 | } 82 | } 83 | 84 | const details: RouteDetails = { 85 | route: `${method} ${controllerPath}${actionPath}`, 86 | }; 87 | 88 | if (args) { 89 | details.args = args as string[]; 90 | } 91 | 92 | return details; 93 | }, 94 | ); 95 | 96 | return { 97 | controller: controllerId, 98 | endpoints, 99 | }; 100 | }); 101 | } 102 | 103 | export function getRawMetadata( 104 | container: inversifyInterfaces.Container, 105 | ): RawMetadata[] { 106 | const controllers: Controller[] = getControllersFromContainer( 107 | container, 108 | true, 109 | ); 110 | 111 | return controllers.map((controller: Controller) => { 112 | const { constructor }: Controller = controller; 113 | 114 | return { 115 | controllerMetadata: getControllerMetadata(constructor), 116 | methodMetadata: getControllerMethodMetadata(constructor), 117 | parameterMetadata: getControllerParameterMetadata(constructor), 118 | }; 119 | }); 120 | } 121 | -------------------------------------------------------------------------------- /src/decorators.ts: -------------------------------------------------------------------------------- 1 | import { decorate, inject, injectable } from 'inversify'; 2 | 3 | import { 4 | HTTP_VERBS_ENUM, 5 | METADATA_KEY, 6 | PARAMETER_TYPE, 7 | TYPE, 8 | } from './constants'; 9 | import type { 10 | ControllerMetadata, 11 | ControllerMethodMetadata, 12 | ControllerParameterMetadata, 13 | DecoratorTarget, 14 | HandlerDecorator, 15 | Middleware, 16 | MiddlewareMetaData, 17 | ParameterMetadata, 18 | } from './interfaces'; 19 | import { getMiddlewareMetadata, getOrCreateMetadata } from './utils'; 20 | 21 | export const injectHttpContext: ( 22 | target: DecoratorTarget, 23 | targetKey?: string | symbol, 24 | indexOrPropertyDescriptor?: number | TypedPropertyDescriptor, 25 | ) => void = inject(TYPE.HttpContext); 26 | 27 | function defineMiddlewareMetadata( 28 | target: DecoratorTarget, 29 | metaDataKey: string, 30 | ...middleware: Middleware[] 31 | ): void { 32 | // We register decorated middleware meteadata in a map, e.g. { "controller": [ middleware ] } 33 | const middlewareMap: MiddlewareMetaData = getOrCreateMetadata( 34 | METADATA_KEY.middleware, 35 | target, 36 | {}, 37 | ); 38 | 39 | if (!(metaDataKey in middlewareMap)) { 40 | middlewareMap[metaDataKey] = []; 41 | } 42 | 43 | middlewareMap[metaDataKey]?.push(...middleware); 44 | Reflect.defineMetadata(METADATA_KEY.middleware, middlewareMap, target); 45 | } 46 | 47 | export function withMiddleware(...middleware: Middleware[]) { 48 | return function ( 49 | target: DecoratorTarget | NewableFunction, 50 | methodName?: string, 51 | ): void { 52 | if (methodName !== undefined) { 53 | defineMiddlewareMetadata(target, methodName, ...middleware); 54 | } else if (isNewableFunction(target)) { 55 | defineMiddlewareMetadata(target.constructor, target.name, ...middleware); 56 | } 57 | }; 58 | } 59 | 60 | function isNewableFunction(target: unknown): target is NewableFunction { 61 | return typeof target === 'function' && target.prototype !== undefined; 62 | } 63 | 64 | export function controller(path: string, ...middleware: Middleware[]) { 65 | return (target: NewableFunction): void => { 66 | // Get the list of middleware registered with @middleware() decorators 67 | const decoratedMiddleware: Middleware[] = getMiddlewareMetadata( 68 | target.constructor, 69 | target.name, 70 | ); 71 | 72 | const currentMetadata: ControllerMetadata = { 73 | middleware: middleware.concat(decoratedMiddleware), 74 | path, 75 | target, 76 | }; 77 | 78 | decorate(injectable(), target); 79 | Reflect.defineMetadata(METADATA_KEY.controller, currentMetadata, target); 80 | 81 | // We need to create an array that contains the metadata of all 82 | // the controllers in the application, the metadata cannot be 83 | // attached to a controller. It needs to be attached to a global 84 | // We attach metadata to the Reflect object itself to avoid 85 | // declaring additional globals. Also, the Reflect is available 86 | // in both node and web browsers. 87 | const previousMetadata: ControllerMetadata[] = 88 | (Reflect.getMetadata(METADATA_KEY.controller, Reflect) as 89 | | ControllerMetadata[] 90 | | undefined) ?? []; 91 | 92 | const newMetadata: ControllerMetadata[] = [ 93 | currentMetadata, 94 | ...previousMetadata, 95 | ]; 96 | 97 | Reflect.defineMetadata(METADATA_KEY.controller, newMetadata, Reflect); 98 | }; 99 | } 100 | 101 | export function all( 102 | path: string, 103 | ...middleware: Middleware[] 104 | ): HandlerDecorator { 105 | return httpMethod(HTTP_VERBS_ENUM.all, path, ...middleware); 106 | } 107 | 108 | export function httpGet( 109 | path: string, 110 | ...middleware: Middleware[] 111 | ): HandlerDecorator { 112 | return httpMethod(HTTP_VERBS_ENUM.get, path, ...middleware); 113 | } 114 | 115 | export function httpPost( 116 | path: string, 117 | ...middleware: Middleware[] 118 | ): HandlerDecorator { 119 | return httpMethod(HTTP_VERBS_ENUM.post, path, ...middleware); 120 | } 121 | 122 | export function httpPut( 123 | path: string, 124 | ...middleware: Middleware[] 125 | ): HandlerDecorator { 126 | return httpMethod(HTTP_VERBS_ENUM.put, path, ...middleware); 127 | } 128 | 129 | export function httpPatch( 130 | path: string, 131 | ...middleware: Middleware[] 132 | ): HandlerDecorator { 133 | return httpMethod(HTTP_VERBS_ENUM.patch, path, ...middleware); 134 | } 135 | 136 | export function httpHead( 137 | path: string, 138 | ...middleware: Middleware[] 139 | ): HandlerDecorator { 140 | return httpMethod(HTTP_VERBS_ENUM.head, path, ...middleware); 141 | } 142 | 143 | export function httpDelete( 144 | path: string, 145 | ...middleware: Middleware[] 146 | ): HandlerDecorator { 147 | return httpMethod(HTTP_VERBS_ENUM.delete, path, ...middleware); 148 | } 149 | 150 | export function httpOptions( 151 | path: string, 152 | ...middleware: Middleware[] 153 | ): HandlerDecorator { 154 | return httpMethod(HTTP_VERBS_ENUM.options, path, ...middleware); 155 | } 156 | 157 | export function httpMethod( 158 | method: HTTP_VERBS_ENUM, 159 | path: string, 160 | ...middleware: Middleware[] 161 | ): HandlerDecorator { 162 | return (target: DecoratorTarget, key: string): void => { 163 | const decoratedMiddleware: Middleware[] = getMiddlewareMetadata( 164 | target, 165 | key, 166 | ); 167 | 168 | const metadata: ControllerMethodMetadata = { 169 | key, 170 | method, 171 | middleware: middleware.concat(decoratedMiddleware), 172 | path, 173 | target, 174 | }; 175 | 176 | let metadataList: ControllerMethodMetadata[] = []; 177 | 178 | if ( 179 | !Reflect.hasOwnMetadata(METADATA_KEY.controllerMethod, target.constructor) 180 | ) { 181 | Reflect.defineMetadata( 182 | METADATA_KEY.controllerMethod, 183 | metadataList, 184 | target.constructor, 185 | ); 186 | } else { 187 | metadataList = Reflect.getOwnMetadata( 188 | METADATA_KEY.controllerMethod, 189 | target.constructor, 190 | ) as ControllerMethodMetadata[]; 191 | } 192 | 193 | metadataList.push(metadata); 194 | }; 195 | } 196 | 197 | export const request: () => ParameterDecorator = paramDecoratorFactory( 198 | PARAMETER_TYPE.REQUEST, 199 | ); 200 | 201 | export const response: () => ParameterDecorator = paramDecoratorFactory( 202 | PARAMETER_TYPE.RESPONSE, 203 | ); 204 | 205 | export const requestParam: (paramName?: string) => ParameterDecorator = 206 | paramDecoratorFactory(PARAMETER_TYPE.PARAMS); 207 | 208 | export const queryParam: (queryParamName?: string) => ParameterDecorator = 209 | paramDecoratorFactory(PARAMETER_TYPE.QUERY); 210 | 211 | export const requestBody: () => ParameterDecorator = paramDecoratorFactory( 212 | PARAMETER_TYPE.BODY, 213 | ); 214 | 215 | export const requestHeaders: (headerName?: string) => ParameterDecorator = 216 | paramDecoratorFactory(PARAMETER_TYPE.HEADERS); 217 | 218 | export const cookies: (cookieName?: string) => ParameterDecorator = 219 | paramDecoratorFactory(PARAMETER_TYPE.COOKIES); 220 | 221 | export const next: () => ParameterDecorator = paramDecoratorFactory( 222 | PARAMETER_TYPE.NEXT, 223 | ); 224 | 225 | export const principal: () => ParameterDecorator = paramDecoratorFactory( 226 | PARAMETER_TYPE.PRINCIPAL, 227 | ); 228 | 229 | function paramDecoratorFactory( 230 | parameterType: PARAMETER_TYPE, 231 | ): (name?: string | symbol) => ParameterDecorator { 232 | return (name?: string | symbol): ParameterDecorator => 233 | params(parameterType, name); 234 | } 235 | 236 | export function params(type: PARAMETER_TYPE, parameterName?: string | symbol) { 237 | return ( 238 | target: object, 239 | methodName: string | symbol | undefined, 240 | index: number, 241 | ): void => { 242 | let metadataList: ControllerParameterMetadata = {}; 243 | let parameterMetadataList: ParameterMetadata[] = []; 244 | const parameterMetadata: ParameterMetadata = { 245 | index, 246 | injectRoot: parameterName === undefined, 247 | parameterName, 248 | type, 249 | }; 250 | if ( 251 | !Reflect.hasOwnMetadata( 252 | METADATA_KEY.controllerParameter, 253 | target.constructor, 254 | ) 255 | ) { 256 | parameterMetadataList.unshift(parameterMetadata); 257 | } else { 258 | metadataList = Reflect.getOwnMetadata( 259 | METADATA_KEY.controllerParameter, 260 | target.constructor, 261 | ) as ControllerParameterMetadata; 262 | if (metadataList[methodName as string]) { 263 | parameterMetadataList = metadataList[methodName as string] || []; 264 | } 265 | parameterMetadataList.unshift(parameterMetadata); 266 | } 267 | metadataList[methodName as string] = parameterMetadataList; 268 | Reflect.defineMetadata( 269 | METADATA_KEY.controllerParameter, 270 | metadataList, 271 | target.constructor, 272 | ); 273 | }; 274 | } 275 | -------------------------------------------------------------------------------- /src/httpResponseMessage.ts: -------------------------------------------------------------------------------- 1 | import type { OutgoingHttpHeaders } from 'node:http'; 2 | 3 | import { StatusCodes } from 'http-status-codes'; 4 | 5 | import { HttpContent } from './content/httpContent'; 6 | 7 | const MAX_STATUS_CODE: number = 999; 8 | 9 | export class HttpResponseMessage { 10 | private _content!: HttpContent; 11 | 12 | private _headers: OutgoingHttpHeaders = {}; 13 | 14 | private _statusCode!: number; 15 | 16 | constructor(statusCode: number = StatusCodes.OK) { 17 | this.statusCode = statusCode; 18 | } 19 | 20 | public get content(): HttpContent { 21 | return this._content; 22 | } 23 | 24 | public get headers(): OutgoingHttpHeaders { 25 | return this._headers; 26 | } 27 | 28 | public get statusCode(): number { 29 | return this._statusCode; 30 | } 31 | 32 | public set content(value: HttpContent) { 33 | this._content = value; 34 | } 35 | 36 | public set headers(headers: OutgoingHttpHeaders) { 37 | this._headers = headers; 38 | } 39 | 40 | public set statusCode(code: number) { 41 | if (code < 0 || code > MAX_STATUS_CODE) { 42 | throw new Error(`${code.toString()} is not a valid status code`); 43 | } 44 | 45 | this._statusCode = code; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './server'; 2 | export * from './decorators'; 3 | export * from './constants'; 4 | export * from './interfaces'; 5 | export * as interfaces from './interfaces'; 6 | export * as results from './results'; 7 | export * from './base_http_controller'; 8 | export * from './base_middleware'; 9 | export * from './utils'; 10 | export * from './debug'; 11 | export * from './httpResponseMessage'; 12 | export * from './content/stringContent'; 13 | export * from './content/jsonContent'; 14 | export * from './content/httpContent'; 15 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Application, 3 | NextFunction, 4 | Request, 5 | RequestHandler, 6 | Response, 7 | } from 'express'; 8 | import { interfaces as inversifyInterfaces } from 'inversify'; 9 | 10 | import { HTTP_VERBS_ENUM, PARAMETER_TYPE } from './constants'; 11 | import { HttpResponseMessage } from './httpResponseMessage'; 12 | 13 | type Prototype = { 14 | [P in keyof T]: T[P] extends NewableFunction ? T[P] : T[P] | undefined; 15 | } & { 16 | constructor: NewableFunction; 17 | }; 18 | 19 | interface ConstructorFunction> { 20 | prototype: Prototype; 21 | new (...args: unknown[]): T; 22 | } 23 | 24 | export type DecoratorTarget = 25 | | ConstructorFunction 26 | | Prototype; 27 | 28 | export type Middleware = inversifyInterfaces.ServiceIdentifier | RequestHandler; 29 | 30 | export interface MiddlewareMetaData { 31 | [identifier: string]: Middleware[]; 32 | } 33 | 34 | export type ControllerHandler = (...params: unknown[]) => unknown; 35 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-empty-interface 36 | export interface Controller {} 37 | 38 | export interface ControllerMetadata { 39 | middleware: Middleware[]; 40 | path: string; 41 | target: DecoratorTarget; 42 | } 43 | 44 | export interface ControllerMethodMetadata extends ControllerMetadata { 45 | key: string; 46 | method: HTTP_VERBS_ENUM; 47 | } 48 | 49 | export interface ControllerParameterMetadata { 50 | [methodName: string]: ParameterMetadata[]; 51 | } 52 | 53 | export interface ParameterMetadata { 54 | index: number; 55 | injectRoot: boolean; 56 | parameterName?: string | symbol | undefined; 57 | type: PARAMETER_TYPE; 58 | } 59 | 60 | export type ExtractedParameters = 61 | | ParameterMetadata[] 62 | | [Request, Response, NextFunction] 63 | | unknown[]; 64 | 65 | export type HandlerDecorator = ( 66 | target: DecoratorTarget, 67 | key: string, 68 | value: unknown, 69 | ) => void; 70 | 71 | export type ConfigFunction = (app: Application) => void; 72 | 73 | export interface RoutingConfig { 74 | rootPath: string; 75 | } 76 | 77 | export interface Principal { 78 | details: T; 79 | isAuthenticated(): Promise; 80 | // Allows role-based auth 81 | isInRole(role: string): Promise; 82 | // Allows content-based auth 83 | isResourceOwner(resourceId: unknown): Promise; 84 | } 85 | 86 | export interface AuthProvider { 87 | getUser(req: Request, res: Response, next: NextFunction): Promise; 88 | } 89 | 90 | export interface HttpContext { 91 | container: inversifyInterfaces.Container; 92 | request: Request; 93 | response: Response; 94 | user: Principal; 95 | } 96 | 97 | // eslint-disable-next-line @typescript-eslint/naming-convention 98 | export interface IHttpActionResult { 99 | executeAsync(): Promise; 100 | } 101 | 102 | export interface RouteDetails { 103 | args?: string[]; 104 | route: string; 105 | } 106 | 107 | export interface RouteInfo { 108 | controller: string; 109 | endpoints: RouteDetails[]; 110 | } 111 | 112 | export interface RawMetadata { 113 | controllerMetadata: ControllerMetadata; 114 | methodMetadata: ControllerMethodMetadata[]; 115 | parameterMetadata: ControllerParameterMetadata; 116 | } 117 | -------------------------------------------------------------------------------- /src/results/BadRequestErrorMessageResult.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | import { StringContent } from '../content/stringContent'; 4 | import { HttpResponseMessage } from '../httpResponseMessage'; 5 | import type { IHttpActionResult } from '../interfaces'; 6 | 7 | export class BadRequestErrorMessageResult implements IHttpActionResult { 8 | constructor(private readonly message: string) {} 9 | 10 | public async executeAsync(): Promise { 11 | const response: HttpResponseMessage = new HttpResponseMessage( 12 | StatusCodes.BAD_REQUEST, 13 | ); 14 | response.content = new StringContent(this.message); 15 | 16 | return response; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/results/BadRequestResult.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | import { HttpResponseMessage } from '../httpResponseMessage'; 4 | import type { IHttpActionResult } from '../interfaces'; 5 | 6 | export class BadRequestResult implements IHttpActionResult { 7 | public async executeAsync(): Promise { 8 | return new HttpResponseMessage(StatusCodes.BAD_REQUEST); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/results/ConflictResult.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | import { HttpResponseMessage } from '../httpResponseMessage'; 4 | import type { IHttpActionResult } from '../interfaces'; 5 | 6 | export class ConflictResult implements IHttpActionResult { 7 | public async executeAsync(): Promise { 8 | return new HttpResponseMessage(StatusCodes.CONFLICT); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/results/CreatedNegotiatedContentResult.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'node:url'; 2 | 3 | import { StatusCodes } from 'http-status-codes'; 4 | 5 | import { StringContent } from '../content/stringContent'; 6 | import { HttpResponseMessage } from '../httpResponseMessage'; 7 | import type { IHttpActionResult } from '../interfaces'; 8 | 9 | export class CreatedNegotiatedContentResult implements IHttpActionResult { 10 | constructor( 11 | private readonly location: string | URL, 12 | private readonly content: T, 13 | ) {} 14 | 15 | public async executeAsync(): Promise { 16 | const response: HttpResponseMessage = new HttpResponseMessage( 17 | StatusCodes.CREATED, 18 | ); 19 | response.content = new StringContent(JSON.stringify(this.content)); 20 | response.headers['location'] = this.location.toString(); 21 | 22 | return response; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/results/ExceptionResult.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | import { StringContent } from '../content/stringContent'; 4 | import { HttpResponseMessage } from '../httpResponseMessage'; 5 | import type { IHttpActionResult } from '../interfaces'; 6 | 7 | export class ExceptionResult implements IHttpActionResult { 8 | constructor(private readonly error: Error) {} 9 | 10 | public async executeAsync(): Promise { 11 | const response: HttpResponseMessage = new HttpResponseMessage( 12 | StatusCodes.INTERNAL_SERVER_ERROR, 13 | ); 14 | response.content = new StringContent(this.error.toString()); 15 | 16 | return response; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/results/InternalServerError.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | import { HttpResponseMessage } from '../httpResponseMessage'; 4 | import type { IHttpActionResult } from '../interfaces'; 5 | 6 | export class InternalServerErrorResult implements IHttpActionResult { 7 | public async executeAsync(): Promise { 8 | return new HttpResponseMessage(StatusCodes.INTERNAL_SERVER_ERROR); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/results/JsonResult.ts: -------------------------------------------------------------------------------- 1 | import { JsonContent } from '../content/jsonContent'; 2 | import { HttpResponseMessage } from '../httpResponseMessage'; 3 | import type { IHttpActionResult } from '../interfaces'; 4 | 5 | export class JsonResult implements IHttpActionResult { 6 | constructor( 7 | public readonly json: unknown, 8 | public readonly statusCode: number, 9 | ) {} 10 | 11 | public async executeAsync(): Promise { 12 | const response: HttpResponseMessage = new HttpResponseMessage( 13 | this.statusCode, 14 | ); 15 | response.content = new JsonContent(this.json); 16 | 17 | return response; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/results/NotFoundResult.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | import { HttpResponseMessage } from '../httpResponseMessage'; 4 | import type { IHttpActionResult } from '../interfaces'; 5 | 6 | export class NotFoundResult implements IHttpActionResult { 7 | public async executeAsync(): Promise { 8 | return new HttpResponseMessage(StatusCodes.NOT_FOUND); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/results/OkNegotiatedContentResult.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | import { JsonContent } from '../content/jsonContent'; 4 | import { StringContent } from '../content/stringContent'; 5 | import { HttpResponseMessage } from '../httpResponseMessage'; 6 | import type { IHttpActionResult } from '../interfaces'; 7 | 8 | export class OkNegotiatedContentResult implements IHttpActionResult { 9 | constructor(private readonly content: T) {} 10 | 11 | public async executeAsync(): Promise { 12 | const response: HttpResponseMessage = new HttpResponseMessage( 13 | StatusCodes.OK, 14 | ); 15 | 16 | if (typeof this.content === 'string') { 17 | response.content = new StringContent(this.content); 18 | } else { 19 | response.content = new JsonContent(this.content); 20 | } 21 | 22 | return response; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/results/OkResult.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | import { HttpResponseMessage } from '../httpResponseMessage'; 4 | import type { IHttpActionResult } from '../interfaces'; 5 | 6 | export class OkResult implements IHttpActionResult { 7 | public async executeAsync(): Promise { 8 | return new HttpResponseMessage(StatusCodes.OK); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/results/RedirectResult.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'node:url'; 2 | 3 | import { StatusCodes } from 'http-status-codes'; 4 | 5 | import { HttpResponseMessage } from '../httpResponseMessage'; 6 | import type { IHttpActionResult } from '../interfaces'; 7 | 8 | export class RedirectResult implements IHttpActionResult { 9 | constructor(private readonly location: string | URL) {} 10 | 11 | public async executeAsync(): Promise { 12 | const response: HttpResponseMessage = new HttpResponseMessage( 13 | StatusCodes.MOVED_TEMPORARILY, 14 | ); 15 | response.headers['location'] = this.location.toString(); 16 | 17 | return response; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/results/ResponseMessageResult.ts: -------------------------------------------------------------------------------- 1 | import { HttpResponseMessage } from '../httpResponseMessage'; 2 | import type { IHttpActionResult } from '../interfaces'; 3 | 4 | export class ResponseMessageResult implements IHttpActionResult { 5 | constructor(private readonly message: HttpResponseMessage) {} 6 | 7 | public async executeAsync(): Promise { 8 | return this.message; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/results/StatusCodeResult.ts: -------------------------------------------------------------------------------- 1 | import { HttpResponseMessage } from '../httpResponseMessage'; 2 | import type { IHttpActionResult } from '../interfaces'; 3 | 4 | export class StatusCodeResult implements IHttpActionResult { 5 | constructor(private readonly statusCode: number) {} 6 | 7 | public async executeAsync(): Promise { 8 | return new HttpResponseMessage(this.statusCode); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/results/StreamResult.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'node:stream'; 2 | 3 | import { StreamContent } from '../content/streamContent'; 4 | import { HttpResponseMessage } from '../httpResponseMessage'; 5 | import { IHttpActionResult } from '../interfaces'; 6 | 7 | export class StreamResult implements IHttpActionResult { 8 | constructor( 9 | public readableStream: Readable, 10 | public contentType: string, 11 | public readonly statusCode: number, 12 | ) {} 13 | 14 | public async executeAsync(): Promise { 15 | const response: HttpResponseMessage = new HttpResponseMessage( 16 | this.statusCode, 17 | ); 18 | 19 | response.content = new StreamContent(this.readableStream, this.contentType); 20 | 21 | return response; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/results/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BadRequestErrorMessageResult'; 2 | export * from './BadRequestResult'; 3 | export * from './ConflictResult'; 4 | export * from './CreatedNegotiatedContentResult'; 5 | export * from './ExceptionResult'; 6 | export * from './InternalServerError'; 7 | export * from './JsonResult'; 8 | export * from './NotFoundResult'; 9 | export * from './OkNegotiatedContentResult'; 10 | export * from './OkResult'; 11 | export * from './RedirectResult'; 12 | export * from './ResponseMessageResult'; 13 | export * from './StatusCodeResult'; 14 | export * from './StreamResult'; 15 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import type { OutgoingHttpHeader, OutgoingHttpHeaders } from 'node:http'; 4 | 5 | import express, { 6 | Application, 7 | NextFunction, 8 | Request, 9 | RequestHandler, 10 | Response, 11 | Router, 12 | } from 'express'; 13 | import { interfaces } from 'inversify'; 14 | 15 | import { BaseMiddleware } from './base_middleware'; 16 | import { 17 | DEFAULT_ROUTING_ROOT_PATH, 18 | DUPLICATED_CONTROLLER_NAME, 19 | HTTP_VERBS_ENUM, 20 | METADATA_KEY, 21 | PARAMETER_TYPE, 22 | TYPE, 23 | } from './constants'; 24 | import { HttpResponseMessage } from './httpResponseMessage'; 25 | import type { 26 | AuthProvider, 27 | ConfigFunction, 28 | Controller, 29 | ControllerHandler, 30 | ControllerMetadata, 31 | ControllerMethodMetadata, 32 | ControllerParameterMetadata, 33 | DecoratorTarget, 34 | ExtractedParameters, 35 | HttpContext, 36 | Middleware, 37 | ParameterMetadata, 38 | Principal, 39 | RoutingConfig, 40 | } from './interfaces'; 41 | import { 42 | getControllerMetadata, 43 | getControllerMethodMetadata, 44 | getControllerParameterMetadata, 45 | getControllersFromContainer, 46 | getControllersFromMetadata, 47 | instanceOfIhttpActionResult, 48 | } from './utils'; 49 | 50 | export class InversifyExpressServer { 51 | private readonly _router: Router; 52 | private readonly _container: interfaces.Container; 53 | private readonly _app: Application; 54 | private _configFn!: ConfigFunction; 55 | private _errorConfigFn!: ConfigFunction; 56 | private readonly _routingConfig: RoutingConfig; 57 | private readonly _authProvider!: new () => AuthProvider; 58 | private readonly _forceControllers: boolean; 59 | 60 | /** 61 | * Wrapper for the express server. 62 | * 63 | * @param container Container loaded with all controllers and their dependencies. 64 | * @param customRouter optional express.Router custom router 65 | * @param routingConfig optional interfaces.RoutingConfig routing config 66 | * @param customApp optional express.Application custom app 67 | * @param authProvider optional interfaces.AuthProvider auth provider 68 | * @param forceControllers optional boolean setting to force controllers (defaults do true) 69 | */ 70 | constructor( 71 | container: interfaces.Container, 72 | customRouter?: Router | null, 73 | routingConfig?: RoutingConfig | null, 74 | customApp?: Application | null, 75 | authProvider?: (new () => AuthProvider) | null, 76 | forceControllers: boolean = true, 77 | ) { 78 | this._container = container; 79 | this._forceControllers = forceControllers; 80 | this._router = customRouter || Router(); 81 | this._routingConfig = routingConfig || { 82 | rootPath: DEFAULT_ROUTING_ROOT_PATH, 83 | }; 84 | this._app = customApp || express(); 85 | if (authProvider) { 86 | this._authProvider = authProvider; 87 | container.bind(TYPE.AuthProvider).to(this._authProvider); 88 | } 89 | } 90 | 91 | /** 92 | * Sets the configuration function to be applied to the application. 93 | * Note that the config function is not actually executed until a call to 94 | * InversifyExpresServer.build(). 95 | * 96 | * This method is chainable. 97 | * 98 | * @param fn Function in which app-level middleware can be registered. 99 | */ 100 | public setConfig(fn: ConfigFunction): this { 101 | this._configFn = fn; 102 | return this; 103 | } 104 | 105 | /** 106 | * Sets the error handler configuration function to be applied to the application. 107 | * Note that the error config function is not actually executed until a call to 108 | * InversifyExpresServer.build(). 109 | * 110 | * This method is chainable. 111 | * 112 | * @param fn Function in which app-level error handlers can be registered. 113 | */ 114 | public setErrorConfig(fn: ConfigFunction): this { 115 | this._errorConfigFn = fn; 116 | return this; 117 | } 118 | 119 | /** 120 | * Applies all routes and configuration to the server, returning the express application. 121 | */ 122 | public build(): express.Application { 123 | // The very first middleware to be invoked 124 | // it creates a new httpContext and attaches it to the 125 | // current request as metadata using Reflect 126 | this._app.all('*', (req: Request, res: Response, next: NextFunction) => { 127 | void (async (): Promise => { 128 | const httpContext: HttpContext = await this._createHttpContext( 129 | req, 130 | res, 131 | next, 132 | ); 133 | Reflect.defineMetadata(METADATA_KEY.httpContext, httpContext, req); 134 | next(); 135 | })(); 136 | }); 137 | 138 | // register server-level middleware before anything else 139 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions 140 | if (this._configFn) { 141 | this._configFn.apply(undefined, [this._app]); 142 | } 143 | 144 | this.registerControllers(); 145 | 146 | // register error handlers after controllers 147 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions 148 | if (this._errorConfigFn) { 149 | this._errorConfigFn.apply(undefined, [this._app]); 150 | } 151 | 152 | return this._app; 153 | } 154 | 155 | private registerControllers(): void { 156 | // Fake HttpContext is needed during registration 157 | this._container 158 | .bind(TYPE.HttpContext) 159 | .toConstantValue({} as HttpContext); 160 | 161 | const constructors: DecoratorTarget[] = getControllersFromMetadata(); 162 | 163 | constructors.forEach((constructor: DecoratorTarget) => { 164 | const { name }: { name: string } = constructor as { name: string }; 165 | 166 | if (this._container.isBoundNamed(TYPE.Controller, name)) { 167 | throw new Error(DUPLICATED_CONTROLLER_NAME(name)); 168 | } 169 | 170 | this._container 171 | .bind(TYPE.Controller) 172 | .to(constructor as new (...args: never[]) => unknown) 173 | .whenTargetNamed(name); 174 | }); 175 | 176 | const controllers: Controller[] = getControllersFromContainer( 177 | this._container, 178 | this._forceControllers, 179 | ); 180 | 181 | controllers.forEach((controller: Controller) => { 182 | const controllerMetadata: ControllerMetadata = getControllerMetadata( 183 | controller.constructor, 184 | ); 185 | const methodMetadata: ControllerMethodMetadata[] = 186 | getControllerMethodMetadata(controller.constructor); 187 | const parameterMetadata: ControllerParameterMetadata = 188 | getControllerParameterMetadata(controller.constructor); 189 | 190 | // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition 191 | if (controllerMetadata && methodMetadata) { 192 | const controllerMiddleware: RequestHandler[] = this.resolveMiddlewere( 193 | ...controllerMetadata.middleware, 194 | ); 195 | 196 | // Priorirties for HTTP methods. Lower value means higher priority. Default is 0. 197 | const methodToPriorityMap: Record = { 198 | [HTTP_VERBS_ENUM.head]: -1, 199 | }; 200 | 201 | const sortedMethodMetadata: ControllerMethodMetadata[] = 202 | methodMetadata.sort((a, b) => { 203 | const aPriority: number = methodToPriorityMap[a.method] ?? 0; 204 | const bPriority: number = methodToPriorityMap[b.method] ?? 0; 205 | return aPriority - bPriority; 206 | }); 207 | 208 | sortedMethodMetadata.forEach((metadata: ControllerMethodMetadata) => { 209 | let paramList: ParameterMetadata[] = []; 210 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions 211 | if (parameterMetadata) { 212 | paramList = parameterMetadata[metadata.key] || []; 213 | } 214 | const handler: RequestHandler = this.handlerFactory( 215 | (controllerMetadata.target as { name: string }).name, 216 | metadata.key, 217 | paramList, 218 | ); 219 | 220 | const routeMiddleware: RequestHandler[] = this.resolveMiddlewere( 221 | ...metadata.middleware, 222 | ); 223 | 224 | const path: string = this.mergePaths( 225 | controllerMetadata.path, 226 | metadata.path, 227 | ); 228 | 229 | this._router[metadata.method]( 230 | path, 231 | ...controllerMiddleware, 232 | ...routeMiddleware, 233 | handler, 234 | ); 235 | }); 236 | } 237 | }); 238 | 239 | this._app.use(this._routingConfig.rootPath, this._router); 240 | } 241 | 242 | private mergePaths(...paths: string[]) { 243 | return paths 244 | .map((path: string) => { 245 | let finalPath: string = 246 | path.startsWith('/') || path.startsWith('.') ? path : `/${path}`; 247 | 248 | if (path.endsWith('/')) { 249 | finalPath = finalPath.substring(0, finalPath.length - 1); 250 | } 251 | 252 | return finalPath; 253 | }) 254 | .join(''); 255 | } 256 | 257 | private resolveMiddlewere(...middleware: Middleware[]): RequestHandler[] { 258 | return middleware.map((middlewareItem: Middleware) => { 259 | if (!this._container.isBound(middlewareItem)) { 260 | return middlewareItem as express.RequestHandler; 261 | } 262 | 263 | type MiddlewareInstance = RequestHandler | BaseMiddleware; 264 | const middlewareInstance: MiddlewareInstance = 265 | this._container.get(middlewareItem); 266 | 267 | if (middlewareInstance instanceof BaseMiddleware) { 268 | return ( 269 | req: Request, 270 | res: Response, 271 | next: NextFunction, 272 | ): void | Promise => { 273 | const mReq: BaseMiddleware = 274 | this._container.get(middlewareItem); 275 | mReq.httpContext = this._getHttpContext(req); 276 | 277 | return mReq.handler(req, res, next); 278 | }; 279 | } 280 | 281 | return middlewareInstance; 282 | }); 283 | } 284 | 285 | private copyHeadersTo(headers: OutgoingHttpHeaders, target: Response): void { 286 | for (const name of Object.keys(headers)) { 287 | const headerValue: OutgoingHttpHeader | undefined = headers[name]; 288 | 289 | target.append( 290 | name, 291 | typeof headerValue === 'number' ? headerValue.toString() : headerValue, 292 | ); 293 | } 294 | } 295 | 296 | private async handleHttpResponseMessage( 297 | message: HttpResponseMessage, 298 | res: express.Response, 299 | ): Promise { 300 | this.copyHeadersTo(message.headers, res); 301 | 302 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 303 | if (message.content !== undefined) { 304 | this.copyHeadersTo(message.content.headers, res); 305 | 306 | res 307 | .status(message.statusCode) 308 | // If the content is a number, ensure we change it to a string, else our content is 309 | // treated as a statusCode rather than as the content of the Response 310 | .send(await message.content.readAsync()); 311 | } else { 312 | res.sendStatus(message.statusCode); 313 | } 314 | } 315 | 316 | private handlerFactory( 317 | controllerName: string, 318 | key: string, 319 | parameterMetadata: ParameterMetadata[], 320 | ): RequestHandler { 321 | return (async ( 322 | req: Request, 323 | res: Response, 324 | next: NextFunction, 325 | ): Promise => { 326 | try { 327 | const args: ExtractedParameters = this.extractParameters( 328 | req, 329 | res, 330 | next, 331 | parameterMetadata, 332 | ); 333 | const httpContext: HttpContext = this._getHttpContext(req); 334 | httpContext.container 335 | .bind(TYPE.HttpContext) 336 | .toConstantValue(httpContext); 337 | 338 | // invoke controller's action 339 | const value: unknown = await ( 340 | httpContext.container.getNamed>( 341 | TYPE.Controller, 342 | controllerName, 343 | )[key] as ControllerHandler 344 | )(...args); 345 | 346 | if (value instanceof HttpResponseMessage) { 347 | await this.handleHttpResponseMessage(value, res); 348 | } else if (instanceOfIhttpActionResult(value)) { 349 | const httpResponseMessage: HttpResponseMessage = 350 | await value.executeAsync(); 351 | await this.handleHttpResponseMessage(httpResponseMessage, res); 352 | } else if (value instanceof Function) { 353 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 354 | value(); 355 | } else if (!res.headersSent) { 356 | if (value !== undefined) { 357 | res.send(value); 358 | } 359 | } 360 | } catch (err) { 361 | next(err); 362 | } 363 | }) as RequestHandler; 364 | } 365 | 366 | private _getHttpContext(req: express.Request): HttpContext { 367 | return Reflect.getMetadata(METADATA_KEY.httpContext, req) as HttpContext; 368 | } 369 | 370 | private async _createHttpContext( 371 | req: Request, 372 | res: Response, 373 | next: NextFunction, 374 | ): Promise { 375 | const principal: Principal = await this._getCurrentUser(req, res, next); 376 | 377 | return { 378 | // We use a childContainer for each request so we can be 379 | // sure that the binding is unique for each HTTP request 380 | container: this._container.createChild(), 381 | request: req, 382 | response: res, 383 | user: principal, 384 | }; 385 | } 386 | 387 | private async _getCurrentUser( 388 | req: Request, 389 | res: Response, 390 | next: NextFunction, 391 | ): Promise { 392 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 393 | if (this._authProvider !== undefined) { 394 | const authProvider: AuthProvider = this._container.get( 395 | TYPE.AuthProvider, 396 | ); 397 | return authProvider.getUser(req, res, next); 398 | } 399 | return Promise.resolve({ 400 | details: null, 401 | isAuthenticated: async (): Promise => false, 402 | isInRole: async (_role: string): Promise => false, 403 | isResourceOwner: async (_resourceId: unknown): Promise => false, 404 | }); 405 | } 406 | 407 | private extractParameters( 408 | req: Request, 409 | res: Response, 410 | next: NextFunction, 411 | params: ParameterMetadata[], 412 | ): ExtractedParameters { 413 | const args: unknown[] = []; 414 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/strict-boolean-expressions 415 | if (!params || !params.length) { 416 | return [req, res, next]; 417 | } 418 | 419 | params.forEach( 420 | ({ type, index, parameterName, injectRoot }: ParameterMetadata) => { 421 | switch (type) { 422 | case PARAMETER_TYPE.REQUEST: 423 | args[index] = req; 424 | break; 425 | case PARAMETER_TYPE.NEXT: 426 | args[index] = next; 427 | break; 428 | case PARAMETER_TYPE.PARAMS: 429 | args[index] = this.getParam( 430 | req, 431 | 'params', 432 | injectRoot, 433 | parameterName, 434 | ); 435 | break; 436 | case PARAMETER_TYPE.QUERY: 437 | args[index] = this.getParam( 438 | req, 439 | 'query', 440 | injectRoot, 441 | parameterName, 442 | ); 443 | break; 444 | case PARAMETER_TYPE.BODY: 445 | args[index] = req.body; 446 | break; 447 | case PARAMETER_TYPE.HEADERS: 448 | args[index] = this.getParam( 449 | req, 450 | 'headers', 451 | injectRoot, 452 | parameterName, 453 | ); 454 | break; 455 | case PARAMETER_TYPE.COOKIES: 456 | args[index] = this.getParam( 457 | req, 458 | 'cookies', 459 | injectRoot, 460 | parameterName, 461 | ); 462 | break; 463 | case PARAMETER_TYPE.PRINCIPAL: 464 | args[index] = this._getPrincipal(req); 465 | break; 466 | default: 467 | args[index] = res; 468 | break; // response 469 | } 470 | }, 471 | ); 472 | 473 | args.push(req, res, next); 474 | return args; 475 | } 476 | 477 | private getParam( 478 | source: Request, 479 | paramType: 'params' | 'query' | 'headers' | 'cookies', 480 | injectRoot: boolean, 481 | name?: string | symbol, 482 | ): unknown { 483 | const key: string | undefined = 484 | paramType === 'headers' 485 | ? typeof name === 'symbol' 486 | ? name.toString() 487 | : name?.toLowerCase() 488 | : (name as string); 489 | 490 | const param: Record = source[paramType] as Record< 491 | string, 492 | unknown 493 | >; 494 | 495 | if (injectRoot) { 496 | return param; 497 | } 498 | 499 | // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-unnecessary-condition 500 | return param && key ? param[key] : undefined; 501 | } 502 | 503 | private _getPrincipal(req: express.Request): Principal | null { 504 | return this._getHttpContext(req).user; 505 | } 506 | } 507 | -------------------------------------------------------------------------------- /src/test/action_result.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from '@jest/globals'; 2 | import { StatusCodes } from 'http-status-codes'; 3 | 4 | import { HttpResponseMessage } from '../httpResponseMessage'; 5 | import { 6 | BadRequestErrorMessageResult, 7 | BadRequestResult, 8 | ConflictResult, 9 | CreatedNegotiatedContentResult, 10 | ExceptionResult, 11 | InternalServerErrorResult, 12 | NotFoundResult, 13 | OkNegotiatedContentResult, 14 | OkResult, 15 | RedirectResult, 16 | ResponseMessageResult, 17 | StatusCodeResult, 18 | } from '../results'; 19 | 20 | describe('ActionResults', () => { 21 | describe('OkResult', () => { 22 | it('should respond with an HTTP 200', async () => { 23 | const actionResult: OkResult = new OkResult(); 24 | const responseMessage: HttpResponseMessage = 25 | await actionResult.executeAsync(); 26 | 27 | expect(responseMessage.statusCode).toBe(StatusCodes.OK); 28 | }); 29 | }); 30 | 31 | describe('OkNegotiatedContentResult', () => { 32 | it('should respond with an HTTP 200 with content', async () => { 33 | const content: { foo: string } = { 34 | foo: 'bar', 35 | }; 36 | 37 | const actionResult: OkNegotiatedContentResult<{ foo: string }> = 38 | new OkNegotiatedContentResult(content); 39 | 40 | const responseMessage: HttpResponseMessage = 41 | await actionResult.executeAsync(); 42 | 43 | expect(responseMessage.statusCode).toBe(StatusCodes.OK); 44 | expect(await responseMessage.content.readAsync()).toStrictEqual(content); 45 | }); 46 | }); 47 | 48 | describe('BadRequestResult', () => { 49 | it('should respond with an HTTP 400', async () => { 50 | const actionResult: BadRequestResult = new BadRequestResult(); 51 | const responseMessage: HttpResponseMessage = 52 | await actionResult.executeAsync(); 53 | 54 | expect(responseMessage.statusCode).toBe(StatusCodes.BAD_REQUEST); 55 | }); 56 | }); 57 | 58 | describe('BadRequestErrorMessageResult', () => { 59 | it('should respond with an HTTP 400 and an error message', async () => { 60 | const message: string = 'uh oh!'; 61 | const actionResult: BadRequestErrorMessageResult = 62 | new BadRequestErrorMessageResult(message); 63 | const responseMessage: HttpResponseMessage = 64 | await actionResult.executeAsync(); 65 | 66 | expect(responseMessage.statusCode).toBe(StatusCodes.BAD_REQUEST); 67 | expect(await responseMessage.content.readAsync()).toBe(message); 68 | }); 69 | }); 70 | 71 | describe('ConflictResult', () => { 72 | it('should respond with an HTTP 409', async () => { 73 | const actionResult: ConflictResult = new ConflictResult(); 74 | const responseMessage: HttpResponseMessage = 75 | await actionResult.executeAsync(); 76 | 77 | expect(responseMessage.statusCode).toBe(StatusCodes.CONFLICT); 78 | }); 79 | }); 80 | 81 | describe('CreatedNegotiatedContentResult', () => { 82 | it('should respond with an HTTP 302 and appropriate headers', async () => { 83 | const uri: string = 'http://foo/bar'; 84 | const content: { foo: string } = { 85 | foo: 'bar', 86 | }; 87 | 88 | const actionResult: CreatedNegotiatedContentResult<{ foo: string }> = 89 | new CreatedNegotiatedContentResult(uri, content); 90 | 91 | const responseMessage: HttpResponseMessage = 92 | await actionResult.executeAsync(); 93 | 94 | expect(responseMessage.statusCode).toBe(StatusCodes.CREATED); 95 | expect(await responseMessage.content.readAsync()).toBe( 96 | JSON.stringify(content), 97 | ); 98 | expect(responseMessage.headers['location']).toBe(uri); 99 | }); 100 | }); 101 | 102 | describe('ExceptionResult', () => { 103 | it('should respond with an HTTP 500 and the error message', async () => { 104 | const error: Error = new Error('foo'); 105 | 106 | const actionResult: ExceptionResult = new ExceptionResult(error); 107 | const responseMessage: HttpResponseMessage = 108 | await actionResult.executeAsync(); 109 | 110 | expect(responseMessage.statusCode).toBe( 111 | StatusCodes.INTERNAL_SERVER_ERROR, 112 | ); 113 | 114 | expect(await responseMessage.content.readAsync()).toBe('Error: foo'); 115 | }); 116 | }); 117 | 118 | describe('InternalServerErrorResult', () => { 119 | it('should respond with an HTTP 500', async () => { 120 | const actionResult: InternalServerErrorResult = 121 | new InternalServerErrorResult(); 122 | const responseMessage: HttpResponseMessage = 123 | await actionResult.executeAsync(); 124 | 125 | expect(responseMessage.statusCode).toBe( 126 | StatusCodes.INTERNAL_SERVER_ERROR, 127 | ); 128 | }); 129 | }); 130 | 131 | describe('NotFoundResult', () => { 132 | it('should respond with an HTTP 404', async () => { 133 | const actionResult: NotFoundResult = new NotFoundResult(); 134 | const responseMessage: HttpResponseMessage = 135 | await actionResult.executeAsync(); 136 | 137 | expect(responseMessage.statusCode).toBe(StatusCodes.NOT_FOUND); 138 | }); 139 | }); 140 | 141 | describe('RedirectResult', () => { 142 | it('should respond with an HTTP 302', async () => { 143 | const uri: string = 'http://foo/bar'; 144 | const actionResult: RedirectResult = new RedirectResult(uri); 145 | const responseMessage: HttpResponseMessage = 146 | await actionResult.executeAsync(); 147 | 148 | expect(responseMessage.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY); 149 | expect(responseMessage.headers['location']).toBe(uri); 150 | }); 151 | }); 152 | 153 | describe('ResponseMessageResult', () => { 154 | it('should respond with an HTTP 302', async () => { 155 | const responseMessage: HttpResponseMessage = new HttpResponseMessage(200); 156 | const actionResult: ResponseMessageResult = new ResponseMessageResult( 157 | responseMessage, 158 | ); 159 | 160 | expect(await actionResult.executeAsync()).toBe(responseMessage); 161 | }); 162 | }); 163 | 164 | describe('StatusCodeResult', () => { 165 | it('should respond with the specified status code', async () => { 166 | const actionResult: StatusCodeResult = new StatusCodeResult(417); 167 | const responseMessage: HttpResponseMessage = 168 | await actionResult.executeAsync(); 169 | 170 | expect(responseMessage.statusCode).toBe(417); 171 | }); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /src/test/auth_provider.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { beforeEach, describe, expect, it } from '@jest/globals'; 3 | import { Container, inject, injectable } from 'inversify'; 4 | import supertest from 'supertest'; 5 | 6 | import { 7 | AuthProvider, 8 | BaseHttpController, 9 | controller, 10 | httpGet, 11 | InversifyExpressServer, 12 | Principal, 13 | } from '../index'; 14 | import { cleanUpMetadata } from '../utils'; 15 | 16 | describe('AuthProvider', () => { 17 | beforeEach(() => { 18 | cleanUpMetadata(); 19 | }); 20 | 21 | it('Should be able to access current user via HttpContext', async () => { 22 | interface SomeDependency { 23 | name: string; 24 | } 25 | 26 | class PrincipalClass implements Principal { 27 | public details: unknown; 28 | constructor(details: unknown) { 29 | this.details = details; 30 | } 31 | 32 | public async isAuthenticated() { 33 | return Promise.resolve(true); 34 | } 35 | 36 | public async isResourceOwner(resourceId: unknown) { 37 | return Promise.resolve(resourceId === 1111); 38 | } 39 | 40 | public async isInRole(role: string) { 41 | return Promise.resolve(role === 'admin'); 42 | } 43 | } 44 | 45 | @injectable() 46 | class CustomAuthProvider implements AuthProvider { 47 | @inject('SomeDependency') 48 | private readonly _someDependency!: SomeDependency; 49 | 50 | public async getUser() { 51 | const principal: PrincipalClass = new PrincipalClass({ 52 | email: `${this._someDependency.name}@test.com`, 53 | }); 54 | return Promise.resolve(principal); 55 | } 56 | } 57 | 58 | interface SomeDependency { 59 | name: string; 60 | } 61 | 62 | @controller('/') 63 | class TestController extends BaseHttpController { 64 | @inject('SomeDependency') 65 | private readonly _someDependency!: SomeDependency; 66 | 67 | @httpGet('/') 68 | public async getTest() { 69 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 70 | if (this.httpContext.user !== null) { 71 | const { email }: { email: string } = this.httpContext.user 72 | .details as { email: string }; 73 | const { name }: SomeDependency = this._someDependency; 74 | const isAuthenticated: boolean = 75 | await this.httpContext.user.isAuthenticated(); 76 | 77 | expect(isAuthenticated).toEqual(true); 78 | 79 | return `${email} & ${name}`; 80 | } 81 | return null; 82 | } 83 | } 84 | 85 | const container: Container = new Container(); 86 | 87 | container 88 | .bind('SomeDependency') 89 | .toConstantValue({ name: 'SomeDependency!' }); 90 | 91 | const server: InversifyExpressServer = new InversifyExpressServer( 92 | container, 93 | null, 94 | null, 95 | null, 96 | CustomAuthProvider, 97 | ); 98 | 99 | await supertest(server.build()) 100 | .get('/') 101 | .expect(200, 'SomeDependency!@test.com & SomeDependency!'); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/test/base_http_controller.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { beforeEach, describe, expect, it } from '@jest/globals'; 3 | import { Container, inject } from 'inversify'; 4 | import supertest from 'supertest'; 5 | 6 | import { BaseHttpController } from '../base_http_controller'; 7 | import { StringContent } from '../content/stringContent'; 8 | import { controller, httpGet } from '../decorators'; 9 | import { HttpResponseMessage } from '../httpResponseMessage'; 10 | import { IHttpActionResult } from '../interfaces'; 11 | import { InversifyExpressServer } from '../server'; 12 | import { cleanUpMetadata } from '../utils'; 13 | 14 | describe('BaseHttpController', () => { 15 | beforeEach(() => { 16 | cleanUpMetadata(); 17 | }); 18 | 19 | it('Should contain httpContext instance', async () => { 20 | interface SomeDependency { 21 | name: string; 22 | } 23 | 24 | @controller('/') 25 | class TestController extends BaseHttpController { 26 | private readonly _someDependency: SomeDependency; 27 | constructor(@inject('SomeDependency') someDependency: SomeDependency) { 28 | super(); 29 | this._someDependency = someDependency; 30 | } 31 | 32 | @httpGet('/') 33 | public async getTest() { 34 | const headerVal: string | string[] | undefined = 35 | this.httpContext.request.headers['x-custom']; 36 | const { name }: SomeDependency = this._someDependency; 37 | const isAuthenticated: boolean = 38 | await this.httpContext.user.isAuthenticated(); 39 | 40 | expect(isAuthenticated).toBe(false); 41 | 42 | return `${headerVal as string} & ${name}`; 43 | } 44 | } 45 | 46 | const container: Container = new Container(); 47 | 48 | container 49 | .bind('SomeDependency') 50 | .toConstantValue({ name: 'SomeDependency!' }); 51 | 52 | const server: InversifyExpressServer = new InversifyExpressServer( 53 | container, 54 | ); 55 | 56 | await supertest(server.build()) 57 | .get('/') 58 | .set('x-custom', 'test-header!') 59 | .expect(200, 'test-header! & SomeDependency!'); 60 | }); 61 | 62 | it('should support returning an HttpResponseMessage from a method', async () => { 63 | @controller('/') 64 | class TestController extends BaseHttpController { 65 | @httpGet('/') 66 | public async getTest() { 67 | const response: HttpResponseMessage = new HttpResponseMessage(200); 68 | response.headers['x-custom'] = 'test-header'; 69 | response.content = new StringContent('12345'); 70 | return Promise.resolve(response); 71 | } 72 | } 73 | 74 | const server: InversifyExpressServer = new InversifyExpressServer( 75 | new Container(), 76 | ); 77 | 78 | await supertest(server.build()) 79 | .get('/') 80 | .expect(200, '12345') 81 | .expect('x-custom', 'test-header') 82 | .expect('content-type', 'text/plain; charset=utf-8'); 83 | }); 84 | 85 | it('should support returning an IHttpActionResult from a method', async () => { 86 | @controller('/') 87 | class TestController extends BaseHttpController { 88 | @httpGet('/') 89 | public getTest() { 90 | return new (class TestActionResult implements IHttpActionResult { 91 | public async executeAsync() { 92 | const response: HttpResponseMessage = new HttpResponseMessage(400); 93 | response.content = new StringContent('You done did that wrong'); 94 | 95 | return response; 96 | } 97 | })(); 98 | } 99 | } 100 | 101 | const server: InversifyExpressServer = new InversifyExpressServer( 102 | new Container(), 103 | ); 104 | 105 | await supertest(server.build()) 106 | .get('/') 107 | .expect(400, 'You done did that wrong'); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/test/base_middleware.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { beforeEach, describe, expect, it } from '@jest/globals'; 3 | import { Application, NextFunction, Request, Response } from 'express'; 4 | import { Container, inject, injectable, optional } from 'inversify'; 5 | import supertest from 'supertest'; 6 | 7 | import { 8 | AuthProvider, 9 | BaseHttpController, 10 | BaseMiddleware, 11 | controller, 12 | httpGet, 13 | InversifyExpressServer, 14 | Principal, 15 | } from '../index'; 16 | import { cleanUpMetadata } from '../utils'; 17 | 18 | describe('BaseMiddleware', () => { 19 | beforeEach(() => { 20 | cleanUpMetadata(); 21 | }); 22 | 23 | it('Should be able to inject BaseMiddleware implementations', async () => { 24 | // eslint-disable-next-line @typescript-eslint/typedef 25 | const TYPES = { 26 | LoggerMiddleware: Symbol.for('LoggerMiddleware'), 27 | SomeDependency: Symbol.for('SomeDependency'), 28 | }; 29 | 30 | let principalInstanceCount: number = 0; 31 | 32 | class PrincipalClass implements Principal { 33 | public details: unknown; 34 | constructor(details: unknown) { 35 | this.details = details; 36 | } 37 | 38 | public async isAuthenticated() { 39 | return Promise.resolve(true); 40 | } 41 | 42 | public async isResourceOwner(resourceId: unknown) { 43 | return Promise.resolve(resourceId === 1111); 44 | } 45 | 46 | public async isInRole(role: string) { 47 | return Promise.resolve(role === 'admin'); 48 | } 49 | } 50 | 51 | @injectable() 52 | class CustomAuthProvider implements AuthProvider { 53 | public async getUser(_req: Request, _res: Response, _next: NextFunction) { 54 | principalInstanceCount += 1; 55 | 56 | const principal: PrincipalClass = new PrincipalClass({ 57 | email: 'test@test.com', 58 | }); 59 | 60 | return principal; 61 | } 62 | } 63 | 64 | interface SomeDependency { 65 | name: string; 66 | } 67 | 68 | const logEntries: string[] = []; 69 | 70 | @injectable() 71 | class LoggerMiddleware extends BaseMiddleware { 72 | @inject(TYPES.SomeDependency) 73 | private readonly _someDependency!: SomeDependency; 74 | public handler(req: Request, _res: Response, next: NextFunction) { 75 | const { email }: { email: string } = this.httpContext.user.details as { 76 | email: string; 77 | }; 78 | logEntries.push(`${email} => ${req.url} ${this._someDependency.name}`); 79 | next(); 80 | } 81 | } 82 | 83 | @controller('/', (_req: unknown, _res: unknown, next: NextFunction) => { 84 | logEntries.push('Hello from controller middleware!'); 85 | next(); 86 | }) 87 | class TestController extends BaseHttpController { 88 | @httpGet('/', TYPES.LoggerMiddleware) 89 | public async getTest() { 90 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 91 | if (this.httpContext.user !== null) { 92 | const { email }: { email: string } = this.httpContext.user 93 | .details as { email: string }; 94 | 95 | const isAuthenticated: boolean = 96 | await this.httpContext.user.isAuthenticated(); 97 | logEntries.push( 98 | `${email} => isAuthenticated() => ${String(isAuthenticated)}`, 99 | ); 100 | return email; 101 | } 102 | return null; 103 | } 104 | } 105 | 106 | const container: Container = new Container(); 107 | 108 | container 109 | .bind(TYPES.SomeDependency) 110 | .toConstantValue({ name: 'SomeDependency!' }); 111 | 112 | container 113 | .bind(TYPES.LoggerMiddleware) 114 | .to(LoggerMiddleware); 115 | 116 | const server: InversifyExpressServer = new InversifyExpressServer( 117 | container, 118 | null, 119 | null, 120 | null, 121 | CustomAuthProvider, 122 | ); 123 | 124 | await supertest(server.build()).get('/').expect(200, 'test@test.com'); 125 | 126 | expect(principalInstanceCount).toBe(1); 127 | expect(logEntries.length).toBe(3); 128 | expect(logEntries[0]).toBe('Hello from controller middleware!'); 129 | expect(logEntries[1]).toBe('test@test.com => / SomeDependency!'); 130 | expect(logEntries[2]).toBe('test@test.com => isAuthenticated() => true'); 131 | }); 132 | 133 | it('Should allow the middleware to inject services in a HTTP request scope', async () => { 134 | const TRACE_HEADER: string = 'X-Trace-Id'; 135 | 136 | // eslint-disable-next-line @typescript-eslint/typedef 137 | const TYPES = { 138 | Service: Symbol.for('Service'), 139 | TraceIdValue: Symbol.for('TraceIdValue'), 140 | TracingMiddleware: Symbol.for('TracingMiddleware'), 141 | }; 142 | 143 | @injectable() 144 | class TracingMiddleware extends BaseMiddleware { 145 | public handler(req: Request, res: Response, next: NextFunction) { 146 | setTimeout( 147 | () => { 148 | this.bind(TYPES.TraceIdValue).toConstantValue( 149 | req.header(TRACE_HEADER) as string, 150 | ); 151 | next(); 152 | }, 153 | someTimeBetween(0, 500), 154 | ); 155 | } 156 | } 157 | 158 | @injectable() 159 | class Service { 160 | constructor( 161 | @inject(TYPES.TraceIdValue) 162 | private readonly traceID: string, 163 | ) {} 164 | 165 | public async doSomethingThatRequiresTraceId() { 166 | return new Promise((resolve: (value: unknown) => void) => { 167 | setTimeout( 168 | () => { 169 | resolve(this.traceID); 170 | }, 171 | someTimeBetween(0, 500), 172 | ); 173 | }); 174 | } 175 | } 176 | 177 | @controller('/') 178 | class TracingTestController extends BaseHttpController { 179 | constructor(@inject(TYPES.Service) private readonly service: Service) { 180 | super(); 181 | } 182 | 183 | @httpGet('/', TYPES.TracingMiddleware) 184 | public async getTest() { 185 | return this.service.doSomethingThatRequiresTraceId(); 186 | } 187 | } 188 | 189 | const container: Container = new Container(); 190 | 191 | container 192 | .bind(TYPES.TracingMiddleware) 193 | .to(TracingMiddleware); 194 | container.bind(TYPES.Service).to(Service); 195 | container.bind(TYPES.TraceIdValue).toConstantValue('undefined'); 196 | 197 | const api: Application = new InversifyExpressServer(container).build(); 198 | 199 | const expectedRequests: number = 100; 200 | 201 | await run(expectedRequests, async (executionId: number) => { 202 | await supertest(api) 203 | .get('/') 204 | .set(TRACE_HEADER, `trace-id-${executionId.toString()}`) 205 | .expect(200, `trace-id-${executionId.toString()}`); 206 | }); 207 | }); 208 | 209 | it('Should not allow services injected into a HTTP request scope to be accessible outside the request scope', async () => { 210 | // eslint-disable-next-line @typescript-eslint/typedef 211 | const TYPES = { 212 | Transaction: Symbol.for('Transaction'), 213 | TransactionMiddleware: Symbol.for('TransactionMiddleware'), 214 | }; 215 | 216 | class TransactionMiddleware extends BaseMiddleware { 217 | private count: number = 0; 218 | 219 | public handler(req: Request, res: Response, next: NextFunction) { 220 | this.bind(TYPES.Transaction).toConstantValue( 221 | `I am transaction #${(this.count += 1).toString()}\n`, 222 | ); 223 | next(); 224 | } 225 | } 226 | 227 | @controller('/') 228 | class TransactionTestController extends BaseHttpController { 229 | constructor( 230 | @inject(TYPES.Transaction) 231 | @optional() 232 | private readonly transaction: string, 233 | ) { 234 | super(); 235 | } 236 | 237 | @httpGet('/1', TYPES.TransactionMiddleware) 238 | public getTest1() { 239 | return this.transaction; 240 | } 241 | 242 | @httpGet('/2' /*<= No middleware!*/) 243 | public getTest2() { 244 | return 'No middleware!'; 245 | } 246 | } 247 | 248 | const container: Container = new Container(); 249 | 250 | container 251 | .bind(TYPES.TransactionMiddleware) 252 | .to(TransactionMiddleware) 253 | .inSingletonScope(); 254 | 255 | const app: Application = new InversifyExpressServer(container).build(); 256 | 257 | await supertest(app).get('/1').expect(200, 'I am transaction #1\n'); 258 | await supertest(app).get('/1').expect(200, 'I am transaction #2\n'); 259 | await supertest(app).get('/2').expect(200, 'No middleware!'); 260 | }); 261 | }); 262 | 263 | async function run( 264 | parallelRuns: number, 265 | test: (executionId: number) => PromiseLike, 266 | ) { 267 | const tasks: PromiseLike[] = new Array>( 268 | parallelRuns, 269 | ); 270 | 271 | for (let i: number = 0; i < parallelRuns; ++i) { 272 | tasks[i] = test(i); 273 | } 274 | 275 | await Promise.all(tasks); 276 | } 277 | 278 | function someTimeBetween(minimum: number, maximum: number) { 279 | const min: number = Math.ceil(minimum); 280 | const max: number = Math.floor(maximum); 281 | 282 | return Math.floor(Math.random() * (max - min + 1)) + min; 283 | } 284 | -------------------------------------------------------------------------------- /src/test/constants.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from '@jest/globals'; 2 | 3 | import { DUPLICATED_CONTROLLER_NAME } from '../constants'; 4 | 5 | describe('Constants test', () => { 6 | it('should return correct message', () => { 7 | expect(DUPLICATED_CONTROLLER_NAME('test')).toBe( 8 | 'Two controllers cannot have the same name: test', 9 | ); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/test/content/jsonContent.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from '@jest/globals'; 2 | 3 | import { JsonContent } from '../../content/jsonContent'; 4 | 5 | describe('JsonContent', () => { 6 | it('should have application/json as the default media type', () => { 7 | const content: JsonContent = new JsonContent({}); 8 | expect(content.headers['content-type']).toBe('application/json'); 9 | }); 10 | 11 | it('should respond with the original object', async () => { 12 | const mockObject: Record = { 13 | count: 6, 14 | success: true, 15 | type: 'fake', 16 | }; 17 | 18 | const content: unknown = await new JsonContent(mockObject).readAsync(); 19 | 20 | expect(content).toBe(mockObject); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/test/content/streamContent.test.ts: -------------------------------------------------------------------------------- 1 | import { Readable, Writable } from 'node:stream'; 2 | 3 | import { describe, expect, it } from '@jest/globals'; 4 | 5 | import { StreamContent } from '../../content/streamContent'; 6 | 7 | describe('StreamContent', () => { 8 | it('should have text/plain as the set media type', () => { 9 | const stream: Readable = new Readable(); 10 | 11 | const content: StreamContent = new StreamContent(stream, 'text/plain'); 12 | 13 | expect(content.headers['content-type']).toEqual('text/plain'); 14 | }); 15 | 16 | it('should be able to pipe stream which was given to it', async () => { 17 | const stream: Readable = new Readable({ 18 | read() { 19 | this.push(Buffer.from('test')); 20 | this.push(null); 21 | }, 22 | }); 23 | 24 | const readable: Readable = await new StreamContent( 25 | stream, 26 | 'text/plain', 27 | ).readAsync(); 28 | 29 | const chunks: Array = []; 30 | 31 | let buffer: Buffer | null = null; 32 | 33 | return new Promise((resolve: () => void) => { 34 | readable.on('end', () => { 35 | buffer = Buffer.concat(chunks); 36 | 37 | expect(buffer.toString()).toEqual('test'); 38 | 39 | resolve(); 40 | }); 41 | 42 | const writableStream: Writable = new Writable({ 43 | write(chunk: unknown) { 44 | chunks.push(chunk as Buffer); 45 | }, 46 | }); 47 | 48 | readable.pipe(writableStream); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/test/debug.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from '@jest/globals'; 2 | import { Container } from 'inversify'; 3 | 4 | import { BaseHttpController } from '../base_http_controller'; 5 | import { getRouteInfo } from '../debug'; 6 | import { 7 | controller, 8 | httpDelete, 9 | httpGet, 10 | httpPost, 11 | requestParam, 12 | } from '../decorators'; 13 | import { RouteInfo } from '../interfaces'; 14 | import { InversifyExpressServer } from '../server'; 15 | import { cleanUpMetadata } from '../utils'; 16 | 17 | describe('Debug utils', () => { 18 | beforeEach(() => { 19 | cleanUpMetadata(); 20 | }); 21 | 22 | it('should be able to get router info', () => { 23 | const container: Container = new Container(); 24 | 25 | @controller('/api/user') 26 | class UserController extends BaseHttpController { 27 | @httpGet('/') 28 | public get() { 29 | return {}; 30 | } 31 | 32 | @httpPost('/') 33 | public post() { 34 | return {}; 35 | } 36 | 37 | @httpDelete('/:id') 38 | public delete(@requestParam('id') _id: string) { 39 | return {}; 40 | } 41 | } 42 | 43 | @controller('/api/order') 44 | class OrderController extends BaseHttpController { 45 | @httpGet('/') 46 | public get() { 47 | return {}; 48 | } 49 | 50 | @httpPost('/') 51 | public post() { 52 | return {}; 53 | } 54 | 55 | @httpDelete('/:id') 56 | public delete(@requestParam('id') _id: string) { 57 | return {}; 58 | } 59 | } 60 | 61 | // eslint-disable-next-line @typescript-eslint/typedef 62 | const TYPES = { 63 | OrderController: OrderController.name, 64 | UserController: UserController.name, 65 | }; 66 | 67 | const server: InversifyExpressServer = new InversifyExpressServer( 68 | container, 69 | ); 70 | server.build(); 71 | 72 | const routeInfo: RouteInfo[] = getRouteInfo(container); 73 | 74 | expect(routeInfo[0]?.controller).toBe(TYPES.OrderController); 75 | expect(routeInfo[0]?.endpoints[0]?.route).toBe('GET /api/order/'); 76 | expect(routeInfo[0]?.endpoints[0]?.args).toBeUndefined(); 77 | expect(routeInfo[0]?.endpoints[1]?.route).toBe('POST /api/order/'); 78 | expect(routeInfo[0]?.endpoints[1]?.args).toBeUndefined(); 79 | expect(routeInfo[0]?.endpoints[2]?.route).toBe('DELETE /api/order/:id'); 80 | 81 | const arg1: string[] | undefined = routeInfo[0]?.endpoints[2]?.args; 82 | if (arg1 !== undefined) { 83 | expect(arg1[0]).toBe('@requestParam id'); 84 | } else { 85 | expect(true).toBe(false); 86 | } 87 | 88 | expect(routeInfo[1]?.controller).toBe(TYPES.UserController); 89 | expect(routeInfo[1]?.endpoints[0]?.route).toBe('GET /api/user/'); 90 | expect(routeInfo[1]?.endpoints[1]?.args).toBeUndefined(); 91 | expect(routeInfo[1]?.endpoints[1]?.route).toBe('POST /api/user/'); 92 | expect(routeInfo[1]?.endpoints[1]?.args).toBeUndefined(); 93 | expect(routeInfo[1]?.endpoints[2]?.route).toBe('DELETE /api/user/:id'); 94 | 95 | const arg2: string[] | undefined = routeInfo[1]?.endpoints[2]?.args; 96 | if (arg2 !== undefined) { 97 | expect(arg2[0]).toBe('@requestParam id'); 98 | } else { 99 | expect(true).toBe(false); 100 | } 101 | }); 102 | 103 | it('should be able to handle missig parameter metadata', () => { 104 | const container: Container = new Container(); 105 | 106 | @controller('/api/order') 107 | class OrderController extends BaseHttpController { 108 | @httpGet('/') 109 | public get() { 110 | return {}; 111 | } 112 | 113 | @httpPost('/') 114 | public post() { 115 | return {}; 116 | } 117 | } 118 | 119 | // eslint-disable-next-line @typescript-eslint/typedef 120 | const TYPES = { 121 | OrderController: OrderController.name, 122 | }; 123 | 124 | const server: InversifyExpressServer = new InversifyExpressServer( 125 | container, 126 | ); 127 | server.build(); 128 | 129 | const routeInfo: RouteInfo[] = getRouteInfo(container); 130 | 131 | expect(routeInfo[0]?.controller).toBe(TYPES.OrderController); 132 | expect(routeInfo[0]?.endpoints[0]?.route).toBe('GET /api/order/'); 133 | expect(routeInfo[0]?.endpoints[0]?.args).toBeUndefined(); 134 | expect(routeInfo[0]?.endpoints[1]?.route).toBe('POST /api/order/'); 135 | expect(routeInfo[0]?.endpoints[1]?.args).toBeUndefined(); 136 | }); 137 | 138 | it('should handle controllers without methods', () => { 139 | const container: Container = new Container(); 140 | 141 | @controller('/api/empty') 142 | class EmptyController extends BaseHttpController { 143 | // empty Controller 144 | } 145 | 146 | // eslint-disable-next-line @typescript-eslint/typedef 147 | const TYPES = { 148 | EmptyController: EmptyController.name, 149 | }; 150 | 151 | const server: InversifyExpressServer = new InversifyExpressServer( 152 | container, 153 | ); 154 | server.build(); 155 | 156 | const routeInfo: RouteInfo[] = getRouteInfo(container); 157 | 158 | expect(routeInfo[0]?.controller).toBe(TYPES.EmptyController); 159 | expect(routeInfo[0]?.endpoints).toEqual([]); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /src/test/decorators.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from '@jest/globals'; 2 | 3 | import { HTTP_VERBS_ENUM, METADATA_KEY, PARAMETER_TYPE } from '../constants'; 4 | import { controller, httpMethod, params } from '../decorators'; 5 | import { 6 | ControllerMetadata, 7 | ControllerMethodMetadata, 8 | ControllerParameterMetadata, 9 | Middleware, 10 | ParameterMetadata, 11 | } from '../interfaces'; 12 | 13 | describe('Unit Test: Controller Decorators', () => { 14 | it('should add controller metadata to a class when decorated with @controller', () => { 15 | const middleware: Middleware[] = [ 16 | () => undefined, 17 | 'foo', 18 | Symbol.for('bar'), 19 | ]; 20 | const path: string = 'foo'; 21 | 22 | @controller(path, ...middleware) 23 | class TestController {} 24 | 25 | const controllerMetadata: ControllerMetadata = Reflect.getMetadata( 26 | METADATA_KEY.controller, 27 | TestController, 28 | ) as ControllerMetadata; 29 | 30 | expect(controllerMetadata.middleware).toEqual(middleware); 31 | expect(controllerMetadata.path).toEqual(path); 32 | expect(controllerMetadata.target).toEqual(TestController); 33 | }); 34 | 35 | it('should add method metadata to a class when decorated with @httpMethod', () => { 36 | const middleware: Middleware[] = [ 37 | () => undefined, 38 | 'bar', 39 | Symbol.for('baz'), 40 | ]; 41 | const path: string = 'foo'; 42 | const method: HTTP_VERBS_ENUM = HTTP_VERBS_ENUM.get; 43 | 44 | class TestController { 45 | @httpMethod(method, path, ...middleware) 46 | public test() { 47 | return undefined; 48 | } 49 | 50 | @httpMethod('foo' as unknown as HTTP_VERBS_ENUM, 'bar') 51 | public test2() { 52 | return undefined; 53 | } 54 | 55 | @httpMethod('bar' as unknown as HTTP_VERBS_ENUM, 'foo') 56 | public test3() { 57 | return undefined; 58 | } 59 | } 60 | 61 | const methodMetadata: ControllerMethodMetadata[] = Reflect.getMetadata( 62 | METADATA_KEY.controllerMethod, 63 | TestController, 64 | ) as ControllerMethodMetadata[]; 65 | 66 | expect(methodMetadata.length).toEqual(3); 67 | 68 | const metadata: ControllerMethodMetadata | undefined = methodMetadata[0]; 69 | 70 | expect(metadata?.middleware).toEqual(middleware); 71 | expect(metadata?.path).toEqual(path); 72 | expect(metadata?.target.constructor).toEqual(TestController); 73 | expect(metadata?.key).toEqual('test'); 74 | expect(metadata?.method).toEqual(method); 75 | }); 76 | 77 | it('should add parameter metadata to a class when decorated with @params', () => { 78 | const middleware: Middleware[] = [ 79 | () => { 80 | // 81 | }, 82 | 'bar', 83 | Symbol.for('baz'), 84 | ]; 85 | const path: string = 'foo'; 86 | const method: HTTP_VERBS_ENUM = HTTP_VERBS_ENUM.get; 87 | const methodName: string = 'test'; 88 | 89 | class TestController { 90 | @httpMethod(method, path, ...middleware) 91 | public test( 92 | @params(PARAMETER_TYPE.PARAMS, 'id') _id: unknown, 93 | @params(PARAMETER_TYPE.PARAMS, 'cat') _cat: Record, 94 | ) { 95 | // 96 | } 97 | 98 | @httpMethod('foo' as unknown as HTTP_VERBS_ENUM, 'bar') 99 | public test2( 100 | @params(PARAMETER_TYPE.PARAMS, 'dog') _dog: Record, 101 | ) { 102 | // 103 | } 104 | 105 | @httpMethod('bar' as unknown as HTTP_VERBS_ENUM, 'foo') 106 | public test3() { 107 | // 108 | } 109 | } 110 | const methodMetadataList: ControllerParameterMetadata = Reflect.getMetadata( 111 | METADATA_KEY.controllerParameter, 112 | TestController, 113 | ) as ControllerParameterMetadata; 114 | 115 | expect(methodMetadataList['test'] && true).toEqual(true); 116 | 117 | const paramaterMetadataList: ParameterMetadata[] | undefined = 118 | methodMetadataList[methodName]; 119 | expect(paramaterMetadataList?.length).toEqual(2); 120 | 121 | const paramaterMetadata: ParameterMetadata | undefined = 122 | paramaterMetadataList?.[0]; 123 | expect(paramaterMetadata?.index).toEqual(0); 124 | expect(paramaterMetadata?.parameterName).toEqual('id'); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /src/test/features/controller_inheritance.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { beforeEach, describe, expect, it } from '@jest/globals'; 3 | import { Application, json, urlencoded } from 'express'; 4 | import { Container, injectable } from 'inversify'; 5 | import { Response } from 'superagent'; 6 | import supertest from 'supertest'; 7 | 8 | import { 9 | controller, 10 | httpDelete, 11 | httpGet, 12 | httpOptions, 13 | httpPost, 14 | httpPut, 15 | requestBody, 16 | requestParam, 17 | } from '../../decorators'; 18 | import { InversifyExpressServer } from '../../server'; 19 | import { cleanUpMetadata } from '../../utils'; 20 | 21 | interface ResponseBody { 22 | args: string; 23 | status: number; 24 | } 25 | 26 | function getDemoServer() { 27 | interface Movie { 28 | name: string; 29 | } 30 | 31 | const container: Container = new Container(); 32 | 33 | @injectable() 34 | class GenericController { 35 | @httpGet('/') 36 | public get() { 37 | return { status: 'BASE GET!' }; 38 | } 39 | 40 | @httpGet('/:id') 41 | public getById(@requestParam('id') id: string) { 42 | return { status: `BASE GET BY ID! ${id}` }; 43 | } 44 | 45 | @httpPost('/') 46 | public post(@requestBody() body: T) { 47 | return { 48 | args: body, 49 | status: 'BASE POST!', 50 | }; 51 | } 52 | 53 | @httpPut('/:id') 54 | public put(@requestBody() body: T, @requestParam('id') id: string) { 55 | return { 56 | args: body, 57 | status: `BASE PUT! ${id}`, 58 | }; 59 | } 60 | 61 | @httpDelete('/:id') 62 | public delete(@requestParam('id') id: string) { 63 | return { status: `BASE DELETE! ${id}` }; 64 | } 65 | 66 | @httpOptions('/:id') 67 | public options(@requestParam('id') id: string) { 68 | return { status: `BASE OPTIONS! ${id}` }; 69 | } 70 | } 71 | 72 | @controller('/api/v1/movies') 73 | class MoviesController extends GenericController { 74 | @httpDelete('/:movieId/actors/:actorId') 75 | public deleteActor( 76 | @requestParam('movieId') movieId: string, 77 | @requestParam('actorId') actorId: string, 78 | ) { 79 | return { 80 | status: `DERIVED DELETE ACTOR! MOVIECONTROLLER1 ${movieId} ${actorId}`, 81 | }; 82 | } 83 | } 84 | 85 | @controller('/api/v1/movies2') 86 | class MoviesController2 extends GenericController { 87 | @httpDelete('/:movieId2/actors/:actorId2') 88 | public deleteActor( 89 | @requestParam('movieId2') movieId: string, 90 | @requestParam('actorId2') actorId: string, 91 | ) { 92 | return { 93 | status: `DERIVED DELETE ACTOR! MOVIECONTROLLER2 ${movieId} ${actorId}`, 94 | }; 95 | } 96 | } 97 | 98 | @controller('/api/v1/movies3') 99 | class MoviesController3 extends GenericController { 100 | @httpDelete('/:movieId3/actors/:actorId3') 101 | public deleteActor( 102 | @requestParam('movieId3') movieId: string, 103 | @requestParam('actorId3') actorId: string, 104 | ) { 105 | return { 106 | status: `DERIVED DELETE ACTOR! MOVIECONTROLLER3 ${movieId} ${actorId}`, 107 | }; 108 | } 109 | } 110 | 111 | const app: InversifyExpressServer = new InversifyExpressServer(container); 112 | 113 | app.setConfig((a: Application) => { 114 | a.use(json()); 115 | a.use(urlencoded({ extended: true })); 116 | }); 117 | 118 | const server: Application = app.build(); 119 | 120 | return server; 121 | } 122 | 123 | describe('Derived controller', () => { 124 | beforeEach(() => { 125 | cleanUpMetadata(); 126 | }); 127 | 128 | it('Can access methods decorated with @httpGet from parent', async () => { 129 | const server: Application = getDemoServer(); 130 | 131 | const response: Response = await supertest(server) 132 | .get('/api/v1/movies') 133 | .expect(200); 134 | 135 | const body: ResponseBody = response.body as ResponseBody; 136 | 137 | expect(body.status).toEqual('BASE GET!'); 138 | }); 139 | 140 | it('Can access methods decorated with @httpGet from parent', async () => { 141 | const server: Application = getDemoServer(); 142 | const id: number = 5; 143 | 144 | const response: Response = await supertest(server) 145 | .get(`/api/v1/movies/${id.toString()}`) 146 | .expect(200); 147 | 148 | const body: ResponseBody = response.body as ResponseBody; 149 | 150 | expect(body.status).toEqual(`BASE GET BY ID! ${id.toString()}`); 151 | }); 152 | 153 | it('Can access methods decorated with @httpPost from parent', async () => { 154 | const server: Application = getDemoServer(); 155 | const movie: object = { name: 'The Shining' }; 156 | const status: string = 'BASE POST!'; 157 | 158 | const response: Response = await supertest(server) 159 | .post('/api/v1/movies') 160 | .send(movie) 161 | .set('Content-Type', 'application/json') 162 | .set('Accept', 'application/json') 163 | .expect(200); 164 | 165 | const body: ResponseBody = response.body as ResponseBody; 166 | expect(body.status).toEqual(status); 167 | expect(body.args).toEqual(movie); 168 | }); 169 | 170 | it('Can access methods decorated with @httpPut from parent', async () => { 171 | const server: Application = getDemoServer(); 172 | const id: number = 5; 173 | const movie: object = { name: 'The Shining' }; 174 | 175 | const response: Response = await supertest(server) 176 | .put(`/api/v1/movies/${id.toString()}`) 177 | .send(movie) 178 | .set('Content-Type', 'application/json') 179 | .set('Accept', 'application/json') 180 | .expect(200); 181 | 182 | const body: ResponseBody = response.body as ResponseBody; 183 | expect(body.status).toEqual(`BASE PUT! ${id.toString()}`); 184 | expect(body.args).toEqual(movie); 185 | }); 186 | 187 | it('Can access methods decorated with @httpDelete from parent', async () => { 188 | const server: Application = getDemoServer(); 189 | const id: number = 5; 190 | 191 | const response: Response = await supertest(server) 192 | .delete(`/api/v1/movies/${id.toString()}`) 193 | .expect(200); 194 | 195 | const body: ResponseBody = response.body as ResponseBody; 196 | expect(body.status).toEqual(`BASE DELETE! ${id.toString()}`); 197 | }); 198 | 199 | it('Can access methods decorated with @httpOptions from parent', async () => { 200 | const server: Application = getDemoServer(); 201 | const id: number = 5; 202 | 203 | const response: Response = await supertest(server) 204 | .options(`/api/v1/movies/${id.toString()}`) 205 | .expect(200); 206 | 207 | const body: ResponseBody = response.body as ResponseBody; 208 | expect(body.status).toEqual(`BASE OPTIONS! ${id.toString()}`); 209 | }); 210 | 211 | it('Derived controller can have its own methods', async () => { 212 | const server: Application = getDemoServer(); 213 | const movieId: number = 5; 214 | const actorId: number = 3; 215 | 216 | const response: Response = await supertest(server) 217 | .delete( 218 | `/api/v1/movies/${movieId.toString()}/actors/${actorId.toString()}`, 219 | ) 220 | .expect(200); 221 | 222 | const body: ResponseBody = response.body as ResponseBody; 223 | expect(body.status).toEqual( 224 | `DERIVED DELETE ACTOR! MOVIECONTROLLER1 ${movieId.toString()} ${actorId.toString()}`, 225 | ); 226 | }); 227 | 228 | it('Derived controller 2 can have its own methods', async () => { 229 | const server: Application = getDemoServer(); 230 | const movieId: number = 5; 231 | const actorId: number = 3; 232 | 233 | const response: Response = await supertest(server) 234 | .delete( 235 | `/api/v1/movies2/${movieId.toString()}/actors/${actorId.toString()}`, 236 | ) 237 | .expect(200); 238 | 239 | const body: ResponseBody = response.body as ResponseBody; 240 | expect(body.status).toEqual( 241 | `DERIVED DELETE ACTOR! MOVIECONTROLLER2 ${movieId.toString()} ${actorId.toString()}`, 242 | ); 243 | }); 244 | 245 | it('Derived controller 3 can have its own methods', async () => { 246 | const server: Application = getDemoServer(); 247 | const movieId: number = 5; 248 | const actorId: number = 3; 249 | 250 | const response: Response = await supertest(server) 251 | .delete( 252 | `/api/v1/movies3/${movieId.toString()}/actors/${actorId.toString()}`, 253 | ) 254 | .expect(200); 255 | 256 | const body: ResponseBody = response.body as ResponseBody; 257 | 258 | expect(body.status).toEqual( 259 | `DERIVED DELETE ACTOR! MOVIECONTROLLER3 ${movieId.toString()} ${actorId.toString()}`, 260 | ); 261 | }); 262 | }); 263 | -------------------------------------------------------------------------------- /src/test/features/decorator_middleware.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from '@jest/globals'; 2 | import assert from 'assert'; 3 | import * as express from 'express'; 4 | import { Container } from 'inversify'; 5 | import { Response } from 'superagent'; 6 | import supertest from 'supertest'; 7 | 8 | import { BaseMiddleware } from '../../base_middleware'; 9 | import { HTTP_VERBS_ENUM, METADATA_KEY } from '../../constants'; 10 | import { 11 | controller, 12 | httpGet, 13 | httpMethod, 14 | httpPut, 15 | withMiddleware, 16 | } from '../../decorators'; 17 | import { 18 | ControllerMetadata, 19 | ControllerMethodMetadata, 20 | DecoratorTarget, 21 | } from '../../interfaces'; 22 | import { InversifyExpressServer } from '../../server'; 23 | import { cleanUpMetadata } from '../../utils'; 24 | 25 | function cleanUpMidDecTestControllerMetadata() { 26 | class MidDecTestController {} 27 | Reflect.defineMetadata( 28 | METADATA_KEY.middleware, 29 | {}, 30 | MidDecTestController.constructor, 31 | ); 32 | } 33 | 34 | describe('Unit Test: @middleware decorator', () => { 35 | beforeEach(() => { 36 | cleanUpMetadata(); 37 | cleanUpMidDecTestControllerMetadata(); 38 | }); 39 | 40 | it('should add method metadata to a class when a handler is decorated with @withMiddleware', () => { 41 | const functionMiddleware: () => void = () => undefined; 42 | const identifierMiddleware: symbol = Symbol.for('foo'); 43 | const path: string = 'foo'; 44 | const method: HTTP_VERBS_ENUM = HTTP_VERBS_ENUM.get; 45 | 46 | class MidDecTestController { 47 | @httpMethod(method, path) 48 | @withMiddleware(functionMiddleware) 49 | public test() { 50 | // do nothing 51 | } 52 | 53 | @httpMethod(method, path) 54 | @withMiddleware(functionMiddleware, identifierMiddleware) 55 | public test2() { 56 | // do nothing 57 | } 58 | } 59 | 60 | const methodMetadata: ControllerMethodMetadata[] = Reflect.getMetadata( 61 | METADATA_KEY.controllerMethod, 62 | MidDecTestController, 63 | ) as ControllerMethodMetadata[]; 64 | 65 | const [testMetaData, test2MetaData]: ControllerMethodMetadata[] = 66 | methodMetadata; 67 | assert.strictEqual(testMetaData?.middleware.length, 1); 68 | assert.strictEqual(test2MetaData?.middleware.length, 2); 69 | assert.deepStrictEqual(testMetaData.middleware, [functionMiddleware]); 70 | assert.deepStrictEqual(test2MetaData.middleware, [ 71 | functionMiddleware, 72 | identifierMiddleware, 73 | ]); 74 | }); 75 | 76 | it('should add class metadata to a controller class when decorated with @withMiddleware', () => { 77 | const identifierMiddleware: symbol = Symbol.for('foo'); 78 | const functionMiddleware: () => void = () => undefined; 79 | 80 | @controller('/foo') 81 | @withMiddleware(identifierMiddleware, functionMiddleware) 82 | class MidDecTestController {} 83 | 84 | const controllerMetaData: ControllerMetadata = Reflect.getMetadata( 85 | METADATA_KEY.controller, 86 | MidDecTestController, 87 | ) as ControllerMetadata; 88 | 89 | assert.strictEqual(controllerMetaData.middleware.length, 2); 90 | assert.deepStrictEqual(controllerMetaData.middleware, [ 91 | identifierMiddleware, 92 | functionMiddleware, 93 | ]); 94 | }); 95 | 96 | it('should be able to add middleware from multiple decorations', () => { 97 | const identifierMiddleware: symbol = Symbol.for('foo'); 98 | const functionMiddleware: () => void = () => undefined; 99 | 100 | const first: ( 101 | target: DecoratorTarget | NewableFunction, 102 | methodName?: string, 103 | ) => void = withMiddleware(identifierMiddleware); 104 | const second: ( 105 | target: DecoratorTarget | NewableFunction, 106 | methodName?: string, 107 | ) => void = withMiddleware(functionMiddleware); 108 | 109 | @controller('/foo') 110 | @first 111 | @second 112 | class MidDecTestController {} 113 | 114 | const controllerMetaData: ControllerMetadata = Reflect.getMetadata( 115 | METADATA_KEY.controller, 116 | MidDecTestController, 117 | ) as ControllerMetadata; 118 | 119 | assert.strictEqual(controllerMetaData.middleware.length, 2); 120 | assert.deepStrictEqual(controllerMetaData.middleware, [ 121 | functionMiddleware, 122 | identifierMiddleware, 123 | ]); 124 | }); 125 | 126 | it('should process all requests when decorating a controller', async () => { 127 | const addTestHeader: ( 128 | target: DecoratorTarget | NewableFunction, 129 | methodName?: string, 130 | ) => void = withMiddleware( 131 | ( 132 | _req: express.Request, 133 | res: express.Response, 134 | next: express.NextFunction, 135 | ) => { 136 | res.set('test-header', 'foo'); 137 | next(); 138 | }, 139 | ); 140 | 141 | @controller('/foo') 142 | @addTestHeader 143 | class MidDecTestController { 144 | @httpGet('/bar') 145 | public get() { 146 | return { data: 'hello' }; 147 | } 148 | 149 | @httpPut('/baz') 150 | public put() { 151 | return { data: 'there' }; 152 | } 153 | } 154 | 155 | const container: Container = new Container(); 156 | container 157 | .bind('MidDecTestController') 158 | .to(MidDecTestController); 159 | const server: InversifyExpressServer = new InversifyExpressServer( 160 | container, 161 | ); 162 | 163 | const app: express.Application = server.build(); 164 | 165 | const barResponse: Response = await supertest(app).get('/foo/bar'); 166 | const bazResponse: Response = await supertest(app).put('/foo/baz'); 167 | 168 | assert.strictEqual(barResponse.header['test-header'], 'foo'); 169 | assert.strictEqual(bazResponse.header['test-header'], 'foo'); 170 | }); 171 | 172 | it('should process only specific requests when decorating a handler', async () => { 173 | const addTestHeader: ( 174 | target: DecoratorTarget | NewableFunction, 175 | methodName?: string, 176 | ) => void = withMiddleware( 177 | ( 178 | _req: express.Request, 179 | res: express.Response, 180 | next: express.NextFunction, 181 | ) => { 182 | res.set('test-header', 'foo'); 183 | next(); 184 | }, 185 | ); 186 | 187 | @controller('/foo') 188 | class MidDecTestController { 189 | @httpGet('/bar') 190 | public get() { 191 | return { data: 'hello' }; 192 | } 193 | 194 | @httpPut('/baz') 195 | @addTestHeader 196 | public put() { 197 | return { data: 'there' }; 198 | } 199 | } 200 | 201 | const container: Container = new Container(); 202 | container 203 | .bind('MidDecTestController') 204 | .to(MidDecTestController); 205 | const server: InversifyExpressServer = new InversifyExpressServer( 206 | container, 207 | ); 208 | 209 | const app: express.Application = server.build(); 210 | 211 | const barResponse: Response = await supertest(app).get('/foo/bar'); 212 | const bazResponse: Response = await supertest(app).put('/foo/baz'); 213 | 214 | assert.strictEqual(barResponse.header['test-header'], undefined); 215 | assert.strictEqual(bazResponse.header['test-header'], 'foo'); 216 | }); 217 | 218 | it('should process requests with both controller- and handler middleware', async () => { 219 | const addHandlerHeader: ( 220 | target: DecoratorTarget | NewableFunction, 221 | methodName?: string, 222 | ) => void = withMiddleware( 223 | ( 224 | _req: express.Request, 225 | res: express.Response, 226 | next: express.NextFunction, 227 | ) => { 228 | res.set('test-handler', 'hello there!'); 229 | next(); 230 | }, 231 | ); 232 | 233 | const addControllerHeader: ( 234 | target: DecoratorTarget | NewableFunction, 235 | methodName?: string, 236 | ) => void = withMiddleware( 237 | ( 238 | _req: express.Request, 239 | res: express.Response, 240 | next: express.NextFunction, 241 | ) => { 242 | res.set('test-controller', 'general kenobi'); 243 | next(); 244 | }, 245 | ); 246 | 247 | @controller('/foo') 248 | @addControllerHeader 249 | class MidDecTestController { 250 | @httpGet('/bar') 251 | public get() { 252 | return { data: 'hello' }; 253 | } 254 | 255 | @httpPut('/baz') 256 | @addHandlerHeader 257 | public put() { 258 | return { data: 'there' }; 259 | } 260 | } 261 | 262 | const container: Container = new Container(); 263 | container 264 | .bind('MidDecTestController') 265 | .to(MidDecTestController); 266 | 267 | const server: InversifyExpressServer = new InversifyExpressServer( 268 | container, 269 | ); 270 | 271 | const app: express.Application = server.build(); 272 | 273 | const barResponse: Response = await supertest(app).get('/foo/bar'); 274 | 275 | assert.strictEqual(barResponse.header['test-controller'], 'general kenobi'); 276 | assert.strictEqual(barResponse.header['test-handler'], undefined); 277 | 278 | const bazResponse: Response = await supertest(app).put('/foo/baz'); 279 | 280 | assert.strictEqual(bazResponse.header['test-controller'], 'general kenobi'); 281 | assert.strictEqual(bazResponse.header['test-handler'], 'hello there!'); 282 | }); 283 | 284 | it('should be able to inject BaseMiddleware services by identifier', async () => { 285 | const container: Container = new Container(); 286 | class MidDecTestMiddleware extends BaseMiddleware { 287 | public handler( 288 | _req: express.Request, 289 | res: express.Response, 290 | next: express.NextFunction, 291 | ) { 292 | res.set('test-base-middleware', 'working'); 293 | next(); 294 | } 295 | } 296 | container 297 | .bind('TestMiddleware') 298 | .to(MidDecTestMiddleware); 299 | 300 | @controller('/foo') 301 | @withMiddleware('TestMiddleware') 302 | class MidDecTestController { 303 | @httpGet('/bar') 304 | public get() { 305 | return { data: 'hello' }; 306 | } 307 | } 308 | container 309 | .bind('MidDecTestController') 310 | .to(MidDecTestController); 311 | 312 | const server: InversifyExpressServer = new InversifyExpressServer( 313 | container, 314 | ); 315 | 316 | const app: express.Application = server.build(); 317 | 318 | const response: Response = await supertest(app).get('/foo/bar'); 319 | 320 | assert.strictEqual(response.header['test-base-middleware'], 'working'); 321 | }); 322 | }); 323 | -------------------------------------------------------------------------------- /src/test/framework.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { beforeEach, describe, expect, it, jest } from '@jest/globals'; 3 | import express, { 4 | Application, 5 | NextFunction, 6 | Request, 7 | RequestHandler, 8 | Response, 9 | Router, 10 | } from 'express'; 11 | import { Container } from 'inversify'; 12 | 13 | import { controller } from '../decorators'; 14 | import { HttpResponseMessage } from '../httpResponseMessage'; 15 | import { ConfigFunction, Middleware, RoutingConfig } from '../interfaces'; 16 | import { InversifyExpressServer } from '../server'; 17 | import { cleanUpMetadata } from '../utils'; 18 | 19 | interface ServerWithTypes { 20 | _app: Application; 21 | _router: Router; 22 | _routingConfig: RoutingConfig; 23 | handleHttpResponseMessage: ( 24 | message: HttpResponseMessage, 25 | res: Response, 26 | ) => void; 27 | } 28 | 29 | describe('Unit Test: InversifyExpressServer', () => { 30 | beforeEach(() => { 31 | cleanUpMetadata(); 32 | }); 33 | 34 | it('should call the configFn before the errorConfigFn', () => { 35 | const middleware: Middleware & RequestHandler = ( 36 | _req: Request, 37 | _res: Response, 38 | _next: NextFunction, 39 | ) => undefined; 40 | 41 | const configFn: jest.Mock = jest.fn((app: Application) => { 42 | app.use(middleware); 43 | }); 44 | 45 | const errorConfigFn: jest.Mock = jest.fn( 46 | (app: Application) => { 47 | app.use(middleware); 48 | }, 49 | ); 50 | 51 | const container: Container = new Container(); 52 | 53 | @controller('/') 54 | class TestController {} 55 | 56 | const server: InversifyExpressServer = new InversifyExpressServer( 57 | container, 58 | ); 59 | 60 | server.setConfig(configFn).setErrorConfig(errorConfigFn); 61 | 62 | expect(configFn).not.toHaveBeenCalled(); 63 | expect(errorConfigFn).not.toHaveBeenCalled(); 64 | 65 | server.build(); 66 | 67 | expect(configFn).toHaveBeenCalledTimes(1); 68 | expect(errorConfigFn).toHaveBeenCalledTimes(1); 69 | }); 70 | 71 | it('Should allow to pass a custom Router instance as config', () => { 72 | const container: Container = new Container(); 73 | 74 | const customRouter: Router = Router({ 75 | caseSensitive: false, 76 | mergeParams: false, 77 | strict: false, 78 | }); 79 | 80 | const serverWithDefaultRouter: InversifyExpressServer = 81 | new InversifyExpressServer(container); 82 | const serverWithCustomRouter: InversifyExpressServer = 83 | new InversifyExpressServer(container, customRouter); 84 | 85 | expect( 86 | (serverWithDefaultRouter as unknown as ServerWithTypes)._router === 87 | customRouter, 88 | ).toBe(false); 89 | expect( 90 | (serverWithCustomRouter as unknown as ServerWithTypes)._router === 91 | customRouter, 92 | ).toBe(true); 93 | }); 94 | 95 | it('Should allow to provide custom routing configuration', () => { 96 | const container: Container = new Container(); 97 | 98 | // eslint-disable-next-line @typescript-eslint/typedef 99 | const routingConfig = { 100 | rootPath: '/such/root/path', 101 | }; 102 | 103 | const serverWithDefaultConfig: InversifyExpressServer = 104 | new InversifyExpressServer(container); 105 | const serverWithCustomConfig: InversifyExpressServer = 106 | new InversifyExpressServer(container, null, routingConfig); 107 | 108 | expect( 109 | (serverWithCustomConfig as unknown as ServerWithTypes)._routingConfig, 110 | ).toBe(routingConfig); 111 | 112 | expect( 113 | (serverWithDefaultConfig as unknown as ServerWithTypes)._routingConfig, 114 | ).not.toEqual( 115 | (serverWithCustomConfig as unknown as ServerWithTypes)._routingConfig, 116 | ); 117 | }); 118 | 119 | it('Should allow to provide a custom express application', () => { 120 | const container: Container = new Container(); 121 | const app: Application = express(); 122 | const serverWithDefaultApp: InversifyExpressServer = 123 | new InversifyExpressServer(container); 124 | const serverWithCustomApp: InversifyExpressServer = 125 | new InversifyExpressServer(container, null, null, app); 126 | 127 | expect((serverWithCustomApp as unknown as ServerWithTypes)._app).toBe(app); 128 | expect( 129 | (serverWithDefaultApp as unknown as ServerWithTypes)._app, 130 | ).not.toEqual((serverWithCustomApp as unknown as ServerWithTypes)._app); 131 | }); 132 | 133 | it('Should handle a HttpResponseMessage that has no content', () => { 134 | const container: Container = new Container(); 135 | const server: InversifyExpressServer = new InversifyExpressServer( 136 | container, 137 | ); 138 | 139 | const httpResponseMessageWithoutContent: HttpResponseMessage = 140 | new HttpResponseMessage(404); 141 | 142 | const mockResponse: Partial> = { 143 | sendStatus: jest.fn() as unknown, 144 | } as Partial>; 145 | 146 | (server as unknown as ServerWithTypes).handleHttpResponseMessage( 147 | httpResponseMessageWithoutContent, 148 | mockResponse as unknown as Response, 149 | ); 150 | 151 | expect(mockResponse.sendStatus).toHaveBeenCalledWith(404); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /src/test/helpers/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | -------------------------------------------------------------------------------- /src/test/http_context.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { beforeEach, describe, expect, it } from '@jest/globals'; 3 | import { Container, inject } from 'inversify'; 4 | import supertest from 'supertest'; 5 | 6 | import { controller, httpGet, injectHttpContext } from '../decorators'; 7 | import { HttpContext } from '../interfaces'; 8 | import { InversifyExpressServer } from '../server'; 9 | import { cleanUpMetadata } from '../utils'; 10 | 11 | describe('HttpContex', () => { 12 | beforeEach(() => { 13 | cleanUpMetadata(); 14 | }); 15 | 16 | it('Should be able to httpContext manually with the @injectHttpContext decorator', async () => { 17 | interface SomeDependency { 18 | name: string; 19 | } 20 | 21 | @controller('/') 22 | class TestController { 23 | @injectHttpContext private readonly _httpContext!: HttpContext; 24 | @inject('SomeDependency') 25 | private readonly _someDependency!: SomeDependency; 26 | 27 | @httpGet('/') 28 | public async getTest() { 29 | const headerVal: string | string[] | undefined = 30 | this._httpContext.request.headers['x-custom']; 31 | const { name }: SomeDependency = this._someDependency; 32 | const isAuthenticated: boolean = 33 | await this._httpContext.user.isAuthenticated(); 34 | expect(isAuthenticated).toBe(false); 35 | return `${headerVal as string} & ${name}`; 36 | } 37 | } 38 | 39 | const container: Container = new Container(); 40 | 41 | container 42 | .bind('SomeDependency') 43 | .toConstantValue({ name: 'SomeDependency!' }); 44 | 45 | const server: InversifyExpressServer = new InversifyExpressServer( 46 | container, 47 | ); 48 | 49 | await supertest(server.build()) 50 | .get('/') 51 | .set('x-custom', 'test-header!') 52 | .expect(200, 'test-header! & SomeDependency!'); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/test/issue_590.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from '@jest/globals'; 2 | import { Application } from 'express'; 3 | import { Container } from 'inversify'; 4 | 5 | import { NO_CONTROLLERS_FOUND } from '../constants'; 6 | import { InversifyExpressServer } from '../server'; 7 | import { cleanUpMetadata } from '../utils'; 8 | 9 | describe('Issue 590', () => { 10 | beforeEach(() => { 11 | cleanUpMetadata(); 12 | }); 13 | 14 | it('should throw if no bindings for controllers are declared', () => { 15 | const container: Container = new Container(); 16 | const server: InversifyExpressServer = new InversifyExpressServer( 17 | container, 18 | ); 19 | const throws: () => Application = (): Application => server.build(); 20 | expect(throws).toThrowError(NO_CONTROLLERS_FOUND); 21 | }); 22 | 23 | it('should not throw if forceControllers is false and no bindings for controllers are declared', () => { 24 | const container: Container = new Container(); 25 | const server: InversifyExpressServer = new InversifyExpressServer( 26 | container, 27 | null, 28 | null, 29 | null, 30 | null, 31 | false, 32 | ); 33 | const throws: () => Application = (): Application => server.build(); 34 | expect(throws).not.toThrowError(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/test/issues/issue_420.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from '@jest/globals'; 2 | import * as bodyParser from 'body-parser'; 3 | import { Application, Request } from 'express'; 4 | import { Container } from 'inversify'; 5 | import supertest from 'supertest'; 6 | import TestAgent from 'supertest/lib/agent'; 7 | 8 | import { BaseHttpController } from '../../base_http_controller'; 9 | import { controller, httpPut, request } from '../../decorators'; 10 | import { InversifyExpressServer } from '../../server'; 11 | import { cleanUpMetadata } from '../../utils'; 12 | 13 | describe('Issue 420', () => { 14 | beforeEach(() => { 15 | cleanUpMetadata(); 16 | }); 17 | 18 | it('should work with no url params', async () => { 19 | @controller('/controller') 20 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 21 | class TestController extends BaseHttpController { 22 | @httpPut('/test') 23 | public async updateTest( 24 | @request() 25 | req: Request, 26 | ) { 27 | return this.ok({ message: req.body.test }); 28 | } 29 | } 30 | 31 | const container: Container = new Container(); 32 | 33 | const server: InversifyExpressServer = new InversifyExpressServer( 34 | container, 35 | ); 36 | 37 | server.setConfig((app: Application) => { 38 | app.use( 39 | bodyParser.urlencoded({ 40 | extended: true, 41 | }), 42 | ); 43 | app.use(bodyParser.json()); 44 | }); 45 | 46 | const agent: TestAgent = supertest(server.build()); 47 | 48 | const response: supertest.Response = await agent 49 | .put('/controller/test') 50 | .send({ test: 'test' }) 51 | .set('Content-Type', 'application/json') 52 | .set('Accept', 'application/json'); 53 | 54 | expect(response.status).toBe(200); 55 | expect(response.body).toStrictEqual({ message: 'test' }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/test/server.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { beforeEach, describe, expect, it, jest } from '@jest/globals'; 3 | import cookieParser from 'cookie-parser'; 4 | import { 5 | Application, 6 | CookieOptions, 7 | json, 8 | NextFunction, 9 | Request, 10 | RequestHandler, 11 | Response, 12 | Router, 13 | } from 'express'; 14 | import { interfaces } from 'inversify'; 15 | import { Container, injectable } from 'inversify'; 16 | import supertest from 'supertest'; 17 | import TestAgent from 'supertest/lib/agent'; 18 | 19 | import { HTTP_VERBS_ENUM } from '../constants'; 20 | import { 21 | all, 22 | controller, 23 | cookies, 24 | httpDelete, 25 | httpGet, 26 | httpHead, 27 | httpMethod, 28 | httpPatch, 29 | httpPost, 30 | httpPut, 31 | next, 32 | principal, 33 | queryParam, 34 | request, 35 | requestBody, 36 | requestHeaders, 37 | requestParam, 38 | response, 39 | } from '../decorators'; 40 | import { AuthProvider, Principal } from '../interfaces'; 41 | import { InversifyExpressServer } from '../server'; 42 | import { cleanUpMetadata } from '../utils'; 43 | 44 | describe('Integration Tests:', () => { 45 | let server: InversifyExpressServer; 46 | let container: interfaces.Container; 47 | 48 | beforeEach(() => { 49 | cleanUpMetadata(); 50 | container = new Container(); 51 | }); 52 | 53 | describe('Routing & Request Handling:', () => { 54 | it('should work for async controller methods', async () => { 55 | @controller('/') 56 | class TestController { 57 | @httpGet('/') public async getTest(req: Request, res: Response) { 58 | return new Promise((resolve: (value: string) => void) => { 59 | setTimeout(() => { 60 | resolve('GET'); 61 | }, 10); 62 | }); 63 | } 64 | } 65 | 66 | server = new InversifyExpressServer(container); 67 | 68 | await supertest(server.build()).get('/').expect(200, 'GET'); 69 | }); 70 | 71 | it('should work for async controller methods that fails', async () => { 72 | @controller('/') 73 | class TestController { 74 | @httpGet('/') public async getTest(req: Request, res: Response) { 75 | return new Promise( 76 | ( 77 | _resolve: (value: unknown) => void, 78 | reject: (reason: unknown) => void, 79 | ) => { 80 | setTimeout(() => { 81 | // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors 82 | reject('GET'); 83 | }, 10); 84 | }, 85 | ); 86 | } 87 | } 88 | 89 | server = new InversifyExpressServer(container); 90 | 91 | await supertest(server.build()).get('/').expect(500); 92 | }); 93 | 94 | it('should work for methods which call nextFunc()', async () => { 95 | @controller('/') 96 | class TestController { 97 | @httpGet('/') 98 | public getTest(req: Request, res: Response, nextFunc: NextFunction) { 99 | nextFunc(); 100 | } 101 | 102 | @httpGet('/') public getTest2(req: Request, res: Response) { 103 | return 'GET'; 104 | } 105 | } 106 | 107 | server = new InversifyExpressServer(container); 108 | await supertest(server.build()).get('/').expect(200, 'GET'); 109 | }); 110 | 111 | it('should work for async methods which call nextFunc()', async () => { 112 | @controller('/') 113 | class TestController { 114 | @httpGet('/') 115 | public async getTest( 116 | _req: Request, 117 | _res: Response, 118 | nextFunc: NextFunction, 119 | ) { 120 | return new Promise((resolve: (value: unknown) => void) => { 121 | setTimeout(() => { 122 | nextFunc(); 123 | resolve(null); 124 | }, 10); 125 | }); 126 | } 127 | 128 | @httpGet('/') public getTest2(req: Request, res: Response) { 129 | return 'GET'; 130 | } 131 | } 132 | 133 | server = new InversifyExpressServer(container); 134 | await supertest(server.build()).get('/').expect(200, 'GET'); 135 | }); 136 | 137 | it('should work for async methods called by nextFunc()', async () => { 138 | @controller('/') 139 | class TestController { 140 | @httpGet('/') 141 | public getTest(req: Request, res: Response, nextFunc: NextFunction) { 142 | return nextFunc; 143 | } 144 | 145 | @httpGet('/') public async getTest2(req: Request, res: Response) { 146 | return new Promise((resolve: (value: string) => void) => { 147 | setTimeout(() => { 148 | resolve('GET'); 149 | }, 10); 150 | }); 151 | } 152 | } 153 | 154 | server = new InversifyExpressServer(container); 155 | await supertest(server.build()).get('/').expect(200, 'GET'); 156 | }); 157 | 158 | it('should work for each shortcut decorator', async () => { 159 | @controller('/') 160 | class TestController { 161 | @httpGet('/') 162 | public getTest(_req: Request, res: Response) { 163 | res.send('GET'); 164 | } 165 | 166 | @httpPost('/') 167 | public postTest(_req: Request, res: Response) { 168 | res.send('POST'); 169 | } 170 | 171 | @httpPut('/') 172 | public putTest(_req: Request, res: Response) { 173 | res.send('PUT'); 174 | } 175 | 176 | @httpPatch('/') 177 | public patchTest(_req: Request, res: Response) { 178 | res.send('PATCH'); 179 | } 180 | 181 | @httpHead('/') 182 | public headTest(_req: Request, res: Response) { 183 | res.send('HEAD'); 184 | } 185 | 186 | @httpDelete('/') 187 | public deleteTest(_req: Request, res: Response) { 188 | res.send('DELETE'); 189 | } 190 | } 191 | 192 | server = new InversifyExpressServer(container); 193 | const agent: TestAgent = supertest(server.build()); 194 | 195 | await agent.get('/').expect(200, 'GET'); 196 | await agent.post('/').expect(200, 'POST'); 197 | await agent.put('/').expect(200, 'PUT'); 198 | await agent.patch('/').expect(200, 'PATCH'); 199 | await agent.head('/').expect(200, undefined); // HEAD requests have no body 200 | await agent.delete('/').expect(200, 'DELETE'); 201 | }); 202 | 203 | it('should work for more obscure HTTP methods using the httpMethod decorator', async () => { 204 | @controller('/') 205 | class TestController { 206 | @httpMethod('propfind' as HTTP_VERBS_ENUM, '/') 207 | public getTest(req: Request, res: Response) { 208 | res.send('PROPFIND'); 209 | } 210 | } 211 | 212 | server = new InversifyExpressServer(container); 213 | await supertest(server.build()).propfind('/').expect(200, 'PROPFIND'); 214 | }); 215 | 216 | it('should use returned values as response', async () => { 217 | const result: unknown = { hello: 'world' }; 218 | 219 | @controller('/') 220 | class TestController { 221 | @httpGet('/') 222 | public getTest(req: Request, res: Response) { 223 | return result; 224 | } 225 | } 226 | 227 | server = new InversifyExpressServer(container); 228 | await supertest(server.build()) 229 | .get('/') 230 | .expect(200, JSON.stringify(result)); 231 | }); 232 | 233 | it('should use custom router passed from configuration', async () => { 234 | @controller('/CaseSensitive') 235 | class TestController { 236 | @httpGet('/Endpoint') public get() { 237 | return 'Such Text'; 238 | } 239 | } 240 | 241 | const customRouter: Router = Router({ 242 | caseSensitive: true, 243 | }); 244 | 245 | server = new InversifyExpressServer(container, customRouter); 246 | const app: Application = server.build(); 247 | 248 | const expectedSuccess: supertest.Test = supertest(app) 249 | .get('/CaseSensitive/Endpoint') 250 | .expect(200, 'Such Text'); 251 | 252 | const expectedNotFound1: supertest.Test = supertest(app) 253 | .get('/casesensitive/endpoint') 254 | .expect(404); 255 | 256 | const expectedNotFound2: supertest.Test = supertest(app) 257 | .get('/CaseSensitive/endpoint') 258 | .expect(404); 259 | 260 | return Promise.all([ 261 | expectedSuccess, 262 | expectedNotFound1, 263 | expectedNotFound2, 264 | ]); 265 | }); 266 | 267 | it('should use custom routing configuration', () => { 268 | @controller('/ping') 269 | class TestController { 270 | @httpGet('/endpoint') public get() { 271 | return 'pong'; 272 | } 273 | } 274 | 275 | server = new InversifyExpressServer(container, null, { 276 | rootPath: '/api/v1', 277 | }); 278 | 279 | return supertest(server.build()) 280 | .get('/api/v1/ping/endpoint') 281 | .expect(200, 'pong'); 282 | }); 283 | 284 | it("should work for controller methods who's return value is falsey", async () => { 285 | @controller('/user') 286 | class TestController { 287 | @httpDelete('/') public async delete(): Promise { 288 | return undefined; 289 | } 290 | } 291 | 292 | server = new InversifyExpressServer(container); 293 | 294 | try { 295 | await supertest(server.build()) 296 | .delete('/user') 297 | .timeout({ deadline: 200, response: 100 }); 298 | 299 | throw new Error( 300 | 'Expected request to hang, but a response was received', 301 | ); 302 | } catch (error: unknown) { 303 | if (!('timeout' in (error as object))) { 304 | throw error; 305 | } 306 | } 307 | }); 308 | }); 309 | 310 | describe('Middleware:', () => { 311 | let result: string; 312 | interface Middleware { 313 | a: (req: Request, res: Response, nextFunc: NextFunction) => void; 314 | b: (req: Request, res: Response, nextFunc: NextFunction) => void; 315 | c: (req: Request, res: Response, nextFunc: NextFunction) => void; 316 | } 317 | const middleware: Middleware = { 318 | a(req: Request, res: Response, nextFunc: NextFunction) { 319 | result += 'a'; 320 | nextFunc(); 321 | }, 322 | b(req: Request, res: Response, nextFunc: NextFunction) { 323 | result += 'b'; 324 | nextFunc(); 325 | }, 326 | c(req: Request, res: Response, nextFunc: NextFunction) { 327 | result += 'c'; 328 | nextFunc(); 329 | }, 330 | }; 331 | 332 | const spyA: jest.Mock = jest 333 | .fn() 334 | .mockImplementation(middleware.a.bind(middleware)); 335 | const spyB: jest.Mock = jest 336 | .fn() 337 | .mockImplementation(middleware.b.bind(middleware)); 338 | const spyC: jest.Mock = jest 339 | .fn() 340 | .mockImplementation(middleware.c.bind(middleware)); 341 | 342 | beforeEach(() => { 343 | spyA.mockClear(); 344 | spyB.mockClear(); 345 | spyC.mockClear(); 346 | result = ''; 347 | }); 348 | 349 | it('should call method-level middleware correctly (GET)', async () => { 350 | @controller('/') 351 | class TestController { 352 | @httpGet('/', spyA, spyB, spyC) 353 | public getTest(req: Request, res: Response) { 354 | res.send('GET'); 355 | } 356 | } 357 | 358 | server = new InversifyExpressServer(container); 359 | const agent: TestAgent = supertest(server.build()); 360 | 361 | await agent.get('/').expect(200, 'GET'); 362 | 363 | expect(spyA).toHaveBeenCalledTimes(1); 364 | expect(spyB).toHaveBeenCalledTimes(1); 365 | expect(spyC).toHaveBeenCalledTimes(1); 366 | expect(result).toBe('abc'); 367 | }); 368 | 369 | it('should call method-level middleware correctly (POST)', async () => { 370 | @controller('/') 371 | class TestController { 372 | @httpPost('/', spyA, spyB, spyC) 373 | public postTest(req: Request, res: Response) { 374 | res.send('POST'); 375 | } 376 | } 377 | 378 | server = new InversifyExpressServer(container); 379 | const agent: TestAgent = supertest(server.build()); 380 | 381 | await agent.post('/').expect(200, 'POST'); 382 | 383 | expect(spyA).toHaveBeenCalledTimes(1); 384 | expect(spyB).toHaveBeenCalledTimes(1); 385 | expect(spyC).toHaveBeenCalledTimes(1); 386 | expect(result).toBe('abc'); 387 | }); 388 | 389 | it('should call method-level middleware correctly (PUT)', async () => { 390 | @controller('/') 391 | class TestController { 392 | @httpPut('/', spyA, spyB, spyC) 393 | public postTest(req: Request, res: Response) { 394 | res.send('PUT'); 395 | } 396 | } 397 | 398 | server = new InversifyExpressServer(container); 399 | const agent: TestAgent = supertest(server.build()); 400 | 401 | await agent.put('/').expect(200, 'PUT'); 402 | 403 | expect(spyA).toHaveBeenCalledTimes(1); 404 | expect(spyB).toHaveBeenCalledTimes(1); 405 | expect(spyC).toHaveBeenCalledTimes(1); 406 | expect(result).toBe('abc'); 407 | }); 408 | 409 | it('should call method-level middleware correctly (PATCH)', async () => { 410 | @controller('/') 411 | class TestController { 412 | @httpPatch('/', spyA, spyB, spyC) 413 | public postTest(req: Request, res: Response) { 414 | res.send('PATCH'); 415 | } 416 | } 417 | 418 | server = new InversifyExpressServer(container); 419 | const agent: TestAgent = supertest(server.build()); 420 | 421 | await agent.patch('/').expect(200, 'PATCH'); 422 | 423 | expect(spyA).toHaveBeenCalledTimes(1); 424 | expect(spyB).toHaveBeenCalledTimes(1); 425 | expect(spyC).toHaveBeenCalledTimes(1); 426 | expect(result).toBe('abc'); 427 | }); 428 | 429 | it('should call method-level middleware correctly (HEAD)', async () => { 430 | @controller('/') 431 | class TestController { 432 | @httpHead('/', spyA, spyB, spyC) 433 | public postTest(req: Request, res: Response) { 434 | res.send('HEAD'); 435 | } 436 | } 437 | 438 | server = new InversifyExpressServer(container); 439 | const agent: TestAgent = supertest(server.build()); 440 | 441 | await agent.head('/').expect(200, undefined); // HEAD requests have no body 442 | 443 | expect(spyA).toHaveBeenCalledTimes(1); 444 | expect(spyB).toHaveBeenCalledTimes(1); 445 | expect(spyC).toHaveBeenCalledTimes(1); 446 | expect(result).toBe('abc'); 447 | }); 448 | 449 | it('should call method-level middleware correctly (DELETE)', async () => { 450 | @controller('/') 451 | class TestController { 452 | @httpDelete('/', spyA, spyB, spyC) 453 | public postTest(req: Request, res: Response) { 454 | res.send('DELETE'); 455 | } 456 | } 457 | 458 | server = new InversifyExpressServer(container); 459 | const agent: TestAgent = supertest(server.build()); 460 | 461 | await agent.delete('/').expect(200, 'DELETE'); 462 | 463 | expect(spyA).toHaveBeenCalledTimes(1); 464 | expect(spyB).toHaveBeenCalledTimes(1); 465 | expect(spyC).toHaveBeenCalledTimes(1); 466 | expect(result).toBe('abc'); 467 | }); 468 | 469 | it('should call method-level middleware correctly (ALL)', async () => { 470 | @controller('/') 471 | class TestController { 472 | @all('/', spyA, spyB, spyC) 473 | public postTest(req: Request, res: Response) { 474 | res.send('ALL'); 475 | } 476 | } 477 | 478 | server = new InversifyExpressServer(container); 479 | const agent: TestAgent = supertest(server.build()); 480 | 481 | await agent.get('/').expect(200, 'ALL'); 482 | 483 | expect(spyA).toHaveBeenCalledTimes(1); 484 | expect(spyB).toHaveBeenCalledTimes(1); 485 | expect(spyC).toHaveBeenCalledTimes(1); 486 | expect(result).toBe('abc'); 487 | }); 488 | 489 | it('should call controller-level middleware correctly', async () => { 490 | @controller('/', spyA, spyB, spyC) 491 | class TestController { 492 | @httpGet('/') 493 | public getTest(req: Request, res: Response) { 494 | res.send('GET'); 495 | } 496 | } 497 | 498 | server = new InversifyExpressServer(container); 499 | 500 | await supertest(server.build()).get('/').expect(200, 'GET'); 501 | 502 | expect(spyA).toHaveBeenCalledTimes(1); 503 | expect(spyB).toHaveBeenCalledTimes(1); 504 | expect(spyC).toHaveBeenCalledTimes(1); 505 | expect(result).toBe('abc'); 506 | }); 507 | 508 | it('should call server-level middleware correctly', async () => { 509 | @controller('/') 510 | class TestController { 511 | @httpGet('/') 512 | public getTest(req: Request, res: Response) { 513 | res.send('GET'); 514 | } 515 | } 516 | 517 | server = new InversifyExpressServer(container); 518 | 519 | server.setConfig((app: Application) => { 520 | app.use(spyA); 521 | app.use(spyB); 522 | app.use(spyC); 523 | }); 524 | 525 | await supertest(server.build()).get('/').expect(200, 'GET'); 526 | 527 | expect(spyA).toHaveBeenCalledTimes(1); 528 | expect(spyB).toHaveBeenCalledTimes(1); 529 | expect(spyC).toHaveBeenCalledTimes(1); 530 | expect(result).toBe('abc'); 531 | }); 532 | 533 | it('should call all middleware in correct order', async () => { 534 | @controller('/', spyB) 535 | class TestController { 536 | @httpGet('/', spyC) 537 | public getTest(req: Request, res: Response) { 538 | res.send('GET'); 539 | } 540 | } 541 | 542 | server = new InversifyExpressServer(container); 543 | 544 | server.setConfig((app: Application) => { 545 | app.use(spyA); 546 | }); 547 | 548 | await supertest(server.build()).get('/').expect(200, 'GET'); 549 | 550 | expect(spyA).toHaveBeenCalledTimes(1); 551 | expect(spyB).toHaveBeenCalledTimes(1); 552 | expect(spyC).toHaveBeenCalledTimes(1); 553 | expect(result).toBe('abc'); 554 | }); 555 | 556 | it('should resolve controller-level middleware', async () => { 557 | const symbolId: symbol = Symbol.for('spyA'); 558 | const strId: string = 'spyB'; 559 | 560 | @controller('/', symbolId, strId) 561 | class TestController { 562 | @httpGet('/') 563 | public getTest(req: Request, res: Response) { 564 | res.send('GET'); 565 | } 566 | } 567 | 568 | container.bind(symbolId).toConstantValue(spyA); 569 | container.bind(strId).toConstantValue(spyB); 570 | 571 | server = new InversifyExpressServer(container); 572 | 573 | const agent: TestAgent = supertest(server.build()); 574 | 575 | await agent.get('/').expect(200, 'GET'); 576 | 577 | expect(spyA).toHaveBeenCalledTimes(1); 578 | expect(spyB).toHaveBeenCalledTimes(1); 579 | expect(result).toBe('ab'); 580 | }); 581 | 582 | it('should resolve method-level middleware', async () => { 583 | const symbolId: symbol = Symbol.for('spyA'); 584 | const strId: string = 'spyB'; 585 | 586 | @controller('/') 587 | class TestController { 588 | @httpGet('/', symbolId, strId) 589 | public getTest(req: Request, res: Response) { 590 | res.send('GET'); 591 | } 592 | } 593 | 594 | container.bind(symbolId).toConstantValue(spyA); 595 | container.bind(strId).toConstantValue(spyB); 596 | 597 | server = new InversifyExpressServer(container); 598 | 599 | const agent: TestAgent = supertest(server.build()); 600 | 601 | await agent.get('/').expect(200, 'GET'); 602 | 603 | expect(spyA).toHaveBeenCalledTimes(1); 604 | expect(spyB).toHaveBeenCalledTimes(1); 605 | expect(result).toBe('ab'); 606 | }); 607 | 608 | it('should compose controller- and method-level middleware', async () => { 609 | const symbolId: symbol = Symbol.for('spyA'); 610 | const strId: string = 'spyB'; 611 | 612 | @controller('/', symbolId) 613 | class TestController { 614 | @httpGet('/', strId) 615 | public getTest(req: Request, res: Response) { 616 | res.send('GET'); 617 | } 618 | } 619 | 620 | container.bind(symbolId).toConstantValue(spyA); 621 | container.bind(strId).toConstantValue(spyB); 622 | 623 | server = new InversifyExpressServer(container); 624 | 625 | const agent: TestAgent = supertest(server.build()); 626 | 627 | await agent.get('/').expect(200, 'GET'); 628 | 629 | expect(spyA).toHaveBeenCalledTimes(1); 630 | expect(spyB).toHaveBeenCalledTimes(1); 631 | expect(result).toBe('ab'); 632 | }); 633 | }); 634 | 635 | describe('Parameters:', () => { 636 | it('should bind a method parameter to the url parameter of the web request', async () => { 637 | @controller('/') 638 | class TestController { 639 | @httpGet(':id') 640 | public getTest( 641 | @requestParam('id') id: string, 642 | req: Request, 643 | res: Response, 644 | ) { 645 | return id; 646 | } 647 | } 648 | 649 | server = new InversifyExpressServer(container); 650 | await supertest(server.build()).get('/foo').expect(200, 'foo'); 651 | }); 652 | 653 | it('should bind a method parameter to the request object', async () => { 654 | @controller('/') 655 | class TestController { 656 | @httpGet(':id') 657 | public getTest(@request() req: Request) { 658 | return req.params['id']; 659 | } 660 | } 661 | 662 | server = new InversifyExpressServer(container); 663 | await supertest(server.build()).get('/GET').expect(200, 'GET'); 664 | }); 665 | 666 | it('should bind a method parameter to the response object', async () => { 667 | @controller('/') 668 | class TestController { 669 | @httpGet('/') 670 | public getTest(@response() res: Response) { 671 | return res.send('foo'); 672 | } 673 | } 674 | 675 | server = new InversifyExpressServer(container); 676 | await supertest(server.build()).get('/').expect(200, 'foo'); 677 | }); 678 | 679 | it('should bind a method parameter to a query parameter', async () => { 680 | @controller('/') 681 | class TestController { 682 | @httpGet('/') 683 | public getTest(@queryParam('id') id: string) { 684 | return id; 685 | } 686 | } 687 | 688 | server = new InversifyExpressServer(container); 689 | await supertest(server.build()) 690 | .get('/') 691 | .query('id=foo') 692 | .expect(200, 'foo'); 693 | }); 694 | 695 | it('should bind a method parameter to the request body', async () => { 696 | @controller('/') 697 | class TestController { 698 | @httpPost('/') public getTest(@requestBody() reqBody: string) { 699 | return reqBody; 700 | } 701 | } 702 | 703 | server = new InversifyExpressServer(container); 704 | const body: Record = { foo: 'bar' }; 705 | server.setConfig((app: Application) => { 706 | app.use(json()); 707 | }); 708 | 709 | await supertest(server.build()).post('/').send(body).expect(200, body); 710 | }); 711 | 712 | it('should bind a method parameter to the request headers', async () => { 713 | @controller('/') 714 | class TestController { 715 | @httpGet('/') 716 | public getTest( 717 | @requestHeaders('testhead') headers: Record, 718 | ) { 719 | return headers; 720 | } 721 | } 722 | 723 | server = new InversifyExpressServer(container); 724 | 725 | await supertest(server.build()) 726 | .get('/') 727 | .set('TestHead', 'foo') 728 | .expect(200, 'foo'); 729 | }); 730 | 731 | it('should be case insensitive to request headers', async () => { 732 | @controller('/') 733 | class TestController { 734 | @httpGet('/') 735 | public getTest( 736 | @requestHeaders('TestHead') headers: Record, 737 | ) { 738 | return headers; 739 | } 740 | } 741 | 742 | server = new InversifyExpressServer(container); 743 | 744 | await supertest(server.build()) 745 | .get('/') 746 | .set('TestHead', 'foo') 747 | .expect(200, 'foo'); 748 | }); 749 | 750 | it('should bind a method parameter to a cookie', async () => { 751 | @controller('/') 752 | class TestController { 753 | @httpGet('/') public getCookie( 754 | @cookies('Cookie') cookie: CookieOptions, 755 | req: Request, 756 | res: Response, 757 | ) { 758 | return cookie; 759 | } 760 | } 761 | 762 | server = new InversifyExpressServer(container); 763 | server.setConfig((app: Application) => { 764 | app.use(cookieParser()); 765 | }); 766 | 767 | await supertest(server.build()) 768 | .get('/') 769 | .set('Cookie', 'Cookie=hey') 770 | .expect(200, 'hey'); 771 | }); 772 | 773 | it('should bind a method parameter to the next function', async () => { 774 | @controller('/') 775 | class TestController { 776 | @httpGet('/') public getTest(@next() nextFunc: NextFunction) { 777 | return nextFunc(); 778 | } 779 | 780 | @httpGet('/') public getResult() { 781 | return 'foo'; 782 | } 783 | } 784 | 785 | server = new InversifyExpressServer(container); 786 | 787 | await supertest(server.build()).get('/').expect(200, 'foo'); 788 | }); 789 | 790 | it('should bind a method parameter to a principal with null (empty) details when no AuthProvider is set.', async () => { 791 | @controller('/') 792 | class TestController { 793 | @httpGet('/') 794 | public getPrincipalTest(@principal() userPrincipal: Principal) { 795 | return userPrincipal.details; 796 | } 797 | } 798 | 799 | server = new InversifyExpressServer(container); 800 | await supertest(server.build()).get('/').expect(200, ''); 801 | }); 802 | 803 | it('should bind a method parameter to a principal with valid details when an AuthProvider is set.', async () => { 804 | @controller('/') 805 | class TestController { 806 | @httpGet('/') 807 | public getPrincipalTest(@principal() userPrincipal: Principal) { 808 | return userPrincipal.details; 809 | } 810 | } 811 | 812 | @injectable() 813 | class CustomAuthProvider implements AuthProvider { 814 | public async getUser( 815 | req: Request, 816 | res: Response, 817 | nextFunc: NextFunction, 818 | ): Promise { 819 | return Promise.resolve({ 820 | details: 'something', 821 | isAuthenticated: async () => Promise.resolve(true), 822 | isInRole: async () => Promise.resolve(true), 823 | isResourceOwner: async () => Promise.resolve(true), 824 | } as Principal); 825 | } 826 | } 827 | 828 | server = new InversifyExpressServer( 829 | container, 830 | null, 831 | null, 832 | null, 833 | CustomAuthProvider, 834 | ); 835 | await supertest(server.build()).get('/').expect(200, 'something'); 836 | }); 837 | }); 838 | }); 839 | -------------------------------------------------------------------------------- /src/test/utils.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/typedef */ 2 | import { describe, expect, it } from '@jest/globals'; 3 | 4 | import { METADATA_KEY } from '../constants'; 5 | import { getControllerMethodMetadata } from '../utils'; 6 | 7 | describe('Utils', () => { 8 | describe('getControllerMethodMetadata', () => { 9 | it('should return an empty array when controller has no methods', () => { 10 | class EmptyController {} 11 | 12 | const result = getControllerMethodMetadata(EmptyController); 13 | 14 | expect(result).toEqual([]); 15 | }); 16 | 17 | it('should return metadata from controller own methods', () => { 18 | class TestController {} 19 | const methodMetadata = [ 20 | { 21 | key: 'get', 22 | method: 'testMethod', 23 | middleware: [], 24 | path: '/test', 25 | target: TestController, 26 | }, 27 | ]; 28 | 29 | Reflect.defineMetadata( 30 | METADATA_KEY.controllerMethod, 31 | methodMetadata, 32 | TestController, 33 | ); 34 | 35 | const result = getControllerMethodMetadata(TestController); 36 | 37 | expect(result).toEqual(methodMetadata); 38 | }); 39 | 40 | it('should return metadata from inherited methods', () => { 41 | class BaseController {} 42 | class ChildController extends BaseController {} 43 | 44 | const genericMetadata = [ 45 | { 46 | key: 'get', 47 | method: 'baseMethod', 48 | middleware: [], 49 | path: '/base', 50 | target: BaseController, 51 | }, 52 | ]; 53 | 54 | Reflect.defineMetadata( 55 | METADATA_KEY.controllerMethod, 56 | genericMetadata, 57 | BaseController, 58 | ); 59 | 60 | const result = getControllerMethodMetadata(ChildController); 61 | 62 | expect(result).toEqual(genericMetadata); 63 | }); 64 | 65 | it('should concatenate own and inherited metadata', () => { 66 | class BaseController {} 67 | class ChildController extends BaseController {} 68 | 69 | const ownMetadata = [ 70 | { 71 | key: 'post', 72 | method: 'childMethod', 73 | middleware: [], 74 | path: '/child', 75 | target: ChildController, 76 | }, 77 | ]; 78 | 79 | const genericMetadata = [ 80 | { 81 | key: 'get', 82 | method: 'baseMethod', 83 | middleware: [], 84 | path: '/base', 85 | target: BaseController, 86 | }, 87 | ]; 88 | 89 | Reflect.defineMetadata( 90 | METADATA_KEY.controllerMethod, 91 | ownMetadata, 92 | ChildController, 93 | ); 94 | 95 | Reflect.defineMetadata( 96 | METADATA_KEY.controllerMethod, 97 | genericMetadata, 98 | BaseController, 99 | ); 100 | 101 | const result = getControllerMethodMetadata(ChildController); 102 | 103 | expect(result).toEqual([...ownMetadata, ...genericMetadata]); 104 | }); 105 | 106 | it('should handle undefined metadata correctly', () => { 107 | class TestController {} 108 | const result = getControllerMethodMetadata(TestController); 109 | expect(result).toEqual([]); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { interfaces } from 'inversify'; 2 | 3 | import { METADATA_KEY, NO_CONTROLLERS_FOUND, TYPE } from './constants'; 4 | import type { 5 | Controller, 6 | ControllerMetadata, 7 | ControllerMethodMetadata, 8 | ControllerParameterMetadata, 9 | DecoratorTarget, 10 | IHttpActionResult, 11 | Middleware, 12 | MiddlewareMetaData, 13 | } from './interfaces'; 14 | 15 | export function getControllersFromContainer( 16 | container: interfaces.Container, 17 | forceControllers: boolean, 18 | ): Controller[] { 19 | if (container.isBound(TYPE.Controller)) { 20 | return container.getAll(TYPE.Controller); 21 | } 22 | if (forceControllers) { 23 | throw new Error(NO_CONTROLLERS_FOUND); 24 | } else { 25 | return []; 26 | } 27 | } 28 | 29 | export function getControllersFromMetadata(): DecoratorTarget[] { 30 | const arrayOfControllerMetadata: ControllerMetadata[] = 31 | (Reflect.getMetadata(METADATA_KEY.controller, Reflect) as 32 | | ControllerMetadata[] 33 | | undefined) ?? []; 34 | return arrayOfControllerMetadata.map( 35 | (metadata: ControllerMetadata) => metadata.target, 36 | ); 37 | } 38 | 39 | export function getMiddlewareMetadata( 40 | constructor: DecoratorTarget, 41 | key: string, 42 | ): Middleware[] { 43 | const middlewareMetadata: MiddlewareMetaData = 44 | (Reflect.getMetadata(METADATA_KEY.middleware, constructor) as 45 | | MiddlewareMetaData 46 | | undefined) ?? {}; 47 | 48 | return middlewareMetadata[key] ?? []; 49 | } 50 | 51 | export function getControllerMetadata( 52 | constructor: NewableFunction, 53 | ): ControllerMetadata { 54 | const controllerMetadata: ControllerMetadata = Reflect.getMetadata( 55 | METADATA_KEY.controller, 56 | constructor, 57 | ) as ControllerMetadata; 58 | return controllerMetadata; 59 | } 60 | 61 | export function getControllerMethodMetadata( 62 | constructor: NewableFunction, 63 | ): ControllerMethodMetadata[] { 64 | const methodMetadata: ControllerMethodMetadata[] = Reflect.getOwnMetadata( 65 | METADATA_KEY.controllerMethod, 66 | constructor, 67 | ) as ControllerMethodMetadata[]; 68 | 69 | const genericMetadata: ControllerMethodMetadata[] = Reflect.getMetadata( 70 | METADATA_KEY.controllerMethod, 71 | Reflect.getPrototypeOf(constructor) as NewableFunction, 72 | ) as ControllerMethodMetadata[]; 73 | 74 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 75 | if (genericMetadata === undefined && methodMetadata === undefined) { 76 | return []; 77 | } 78 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 79 | if (genericMetadata !== undefined && methodMetadata !== undefined) { 80 | return methodMetadata.concat(genericMetadata); 81 | } 82 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 83 | if (genericMetadata !== undefined) { 84 | return genericMetadata; 85 | } 86 | return methodMetadata; 87 | } 88 | 89 | export function getControllerParameterMetadata( 90 | constructor: NewableFunction, 91 | ): ControllerParameterMetadata { 92 | const parameterMetadata: ControllerParameterMetadata = Reflect.getOwnMetadata( 93 | METADATA_KEY.controllerParameter, 94 | constructor, 95 | ) as ControllerParameterMetadata; 96 | 97 | const genericMetadata: ControllerParameterMetadata = Reflect.getMetadata( 98 | METADATA_KEY.controllerParameter, 99 | Reflect.getPrototypeOf(constructor) as NewableFunction, 100 | ) as ControllerParameterMetadata; 101 | 102 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 103 | if (genericMetadata !== undefined && parameterMetadata !== undefined) { 104 | return { ...parameterMetadata, ...genericMetadata }; 105 | } 106 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 107 | if (genericMetadata !== undefined) { 108 | return genericMetadata; 109 | } 110 | return parameterMetadata; 111 | } 112 | 113 | export function cleanUpMetadata(): void { 114 | Reflect.defineMetadata(METADATA_KEY.controller, [], Reflect); 115 | } 116 | 117 | export function instanceOfIhttpActionResult( 118 | value: unknown, 119 | ): value is IHttpActionResult { 120 | return ( 121 | value != null && 122 | typeof (value as IHttpActionResult).executeAsync === 'function' 123 | ); 124 | } 125 | 126 | export function getOrCreateMetadata( 127 | key: string, 128 | target: object, 129 | defaultValue: T, 130 | ): T { 131 | if (!Reflect.hasMetadata(key, target)) { 132 | Reflect.defineMetadata(key, defaultValue, target); 133 | return defaultValue; 134 | } 135 | 136 | return Reflect.getMetadata(key, target) as T; 137 | } 138 | -------------------------------------------------------------------------------- /tsconfig.base.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.base.json", 4 | "compilerOptions": { 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.base.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.base.json", 4 | "compilerOptions": { 5 | "module": "ES2022", 6 | "moduleResolution": "Bundler", 7 | "resolveJsonModule": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationMap": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "esModuleInterop": true, 9 | "exactOptionalPropertyTypes": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "lib": ["ES2022"], 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitOverride": true, 14 | "noImplicitReturns": true, 15 | "noPropertyAccessFromIndexSignature": true, 16 | "noUncheckedIndexedAccess": true, 17 | "resolveJsonModule": true, 18 | "skipLibCheck": true, 19 | "sourceMap": true, 20 | "strict": true, 21 | "target": "ES2022" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.base.cjs.json", 4 | "compilerOptions": { 5 | "outDir": "./lib/cjs", 6 | "rootDir": "./src", 7 | "tsBuildInfoFile": "tsconfig.cjs.tsbuildinfo" 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.base.esm.json", 4 | "compilerOptions": { 5 | "outDir": "./lib/esm", 6 | "rootDir": "./src", 7 | "tsBuildInfoFile": "tsconfig.esm.tsbuildinfo" 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.cjs.json" 4 | } 5 | --------------------------------------------------------------------------------