├── .bettercodehub.yml ├── .github └── workflows │ ├── master.yml │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── authenticator │ └── passport.ts ├── decorators │ ├── methods.ts │ ├── parameters.ts │ └── services.ts ├── server │ ├── config.ts │ ├── model │ │ ├── errors.ts │ │ ├── metadata.ts │ │ ├── return-types.ts │ │ └── server-types.ts │ ├── parameter-processor.ts │ ├── server-container.ts │ ├── server.ts │ └── service-invoker.ts └── typescript-rest.ts ├── test ├── data │ ├── apis.ts │ └── swagger.yaml ├── integration │ ├── authenticator.spec.ts │ ├── datatypes.spec.ts │ ├── errors.spec.ts │ ├── ignore-middlewares.spec.ts │ ├── ioc.spec.ts │ ├── paths.spec.ts │ ├── postprocessor.spec.ts │ ├── preprocessor.spec.ts │ ├── server.spec.ts │ └── swagger.spec.ts ├── jest.config-integration.js ├── jest.config-unit.js ├── jest.config.js ├── tsconfig.json └── unit │ ├── decorators.spec.ts │ ├── passport-authenticator.spec.ts │ ├── server-config.spec.ts │ ├── server-errors.spec.ts │ └── server.spec.ts ├── tsconfig.json └── tslint.json /.bettercodehub.yml: -------------------------------------------------------------------------------- 1 | component_depth: 2 2 | languages: 3 | - typescript -------------------------------------------------------------------------------- /.github/workflows/master.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: Master Workflow 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: [10.x, 12.x, 14.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: npm ci 26 | - run: npm test 27 | - name: Upload Code coverage report 28 | uses: codecov/codecov-action@v1 29 | with: 30 | token: ${{ secrets.codecov }} 31 | file: ./reports/coverage/*.json 32 | 33 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Publish Workflow 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | publish-npm: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | registry-url: https://registry.npmjs.org/ 19 | - run: npm ci 20 | - run: npm test 21 | - run: npm publish 22 | env: 23 | NODE_AUTH_TOKEN: ${{secrets.npm}} 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .vscode 4 | reports 5 | .nyc_output 6 | .DS_Store 7 | *.log 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !dist/ 2 | .travis.yml 3 | *.log 4 | npm-debug.log* 5 | tsconfig.json 6 | tslint.json 7 | test 8 | stryker.conf.json 9 | .bettercodehub.yml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2016 Thiago da Rosa de Bustamante 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://badge.fury.io/js/typescript-rest.svg)](https://badge.fury.io/js/typescript-rest) 2 | ![Master Workflow](https://github.com/thiagobustamante/typescript-rest/workflows/Master%20Workflow/badge.svg) 3 | [![Coverage Status](https://codecov.io/gh/thiagobustamante/typescript-rest/branch/master/graph/badge.svg)](https://codecov.io/gh/thiagobustamante/typescript-rest) 4 | [![Known Vulnerabilities](https://snyk.io/test/github/thiagobustamante/typescript-rest/badge.svg?targetFile=package.json)](https://snyk.io/test/github/thiagobustamante/typescript-rest?targetFile=package.json) 5 | [![BCH compliance](https://bettercodehub.com/edge/badge/thiagobustamante/typescript-rest?branch=master)](https://bettercodehub.com/) 6 | 7 | # REST Services for Typescript 8 | This is a lightweight annotation-based [expressjs](http://expressjs.com/) extension for typescript. 9 | 10 | It can be used to define your APIs using decorators. 11 | 12 | **Table of Contents** 13 | 14 | - [REST Services for Typescript](#) 15 | - [Installation](#installation) 16 | - [Configuration](#configuration) 17 | - [Basic Usage](#basic-usage) 18 | - [Using with an IoC Container](#using-with-an-ioc-container) 19 | - [Documentation](https://github.com/thiagobustamante/typescript-rest/wiki) 20 | - [Boilerplate Project](#boilerplate-project) 21 | 22 | ## Installation 23 | 24 | This library only works with typescript. Ensure it is installed: 25 | 26 | ```bash 27 | npm install typescript -g 28 | ``` 29 | 30 | To install typescript-rest: 31 | 32 | ```bash 33 | npm install typescript-rest --save 34 | ``` 35 | 36 | ## Configuration 37 | 38 | Typescript-rest requires the following TypeScript compilation options in your tsconfig.json file: 39 | 40 | ```typescript 41 | { 42 | "compilerOptions": { 43 | "experimentalDecorators": true, 44 | "emitDecoratorMetadata": true, 45 | "target": "es6" // or anything newer like esnext 46 | } 47 | } 48 | ``` 49 | 50 | ## Basic Usage 51 | 52 | ```typescript 53 | import * as express from "express"; 54 | import {Server, Path, GET, PathParam} from "typescript-rest"; 55 | 56 | @Path("/hello") 57 | class HelloService { 58 | @Path(":name") 59 | @GET 60 | sayHello( @PathParam('name') name: string ): string { 61 | return "Hello " + name; 62 | } 63 | } 64 | 65 | let app: express.Application = express(); 66 | Server.buildServices(app); 67 | 68 | app.listen(3000, function() { 69 | console.log('Rest Server listening on port 3000!'); 70 | }); 71 | 72 | ``` 73 | 74 | That's it. You can just call now: 75 | 76 | ``` 77 | GET http://localhost:3000/hello/joe 78 | ``` 79 | 80 | ## Using with an IoC Container 81 | 82 | Install the IoC container and the serviceFactory for the IoC Container 83 | 84 | ```bash 85 | npm install typescript-rest --save 86 | npm install typescript-ioc --save 87 | npm install typescript-rest-ioc --save 88 | ``` 89 | 90 | Then add a rest.config file in the root of your project: 91 | 92 | ```json 93 | { 94 | "serviceFactory": "typescript-rest-ioc" 95 | } 96 | ``` 97 | 98 | And you can use Injections, Request scopes and all the features of the IoC Container. It is possible to use it with any other IoC Container, like Inversify. 99 | 100 | Example: 101 | 102 | ```typescript 103 | class HelloService { 104 | sayHello(name: string) { 105 | return "Hello " + name; 106 | } 107 | } 108 | 109 | @Path("/hello") 110 | class HelloRestService { 111 | @Inject 112 | private helloService: HelloService; 113 | 114 | @Path(":name") 115 | @GET 116 | sayHello( @PathParam('name') name: string): string { 117 | return this.sayHello(name); 118 | } 119 | } 120 | ``` 121 | 122 | ## Complete Guide 123 | 124 | Check our [documentation](https://github.com/thiagobustamante/typescript-rest/wiki). 125 | 126 | ## Boilerplate Project 127 | 128 | You can check [this project](https://github.com/vrudikov/typescript-rest-boilerplate) to get started. 129 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-rest", 3 | "version": "3.0.4", 4 | "description": "A Library to create RESTFul APIs with Typescript", 5 | "author": "Thiago da Rosa de Bustamante ", 6 | "keywords": [ 7 | "API", 8 | "REST", 9 | "RESTFul", 10 | "service", 11 | "microservice", 12 | "typescript", 13 | "node server" 14 | ], 15 | "main": "./dist/typescript-rest.js", 16 | "typings": "./dist/typescript-rest.d.ts", 17 | "license": "MIT", 18 | "scripts": { 19 | "start": "tsc -w", 20 | "build": "npm run clean && tsc", 21 | "clean": "rimraf dist", 22 | "prepare": "rimraf dist && tsc", 23 | "lint": "tslint ./src/**/*.ts ./test/**/*.ts", 24 | "lint:fix": "tslint --fix ./src/**/*.ts ./test/**/*.ts -t verbose", 25 | "pretest": "cross-env NODE_ENV=test npm run build && npm run lint", 26 | "test": "cross-env NODE_ENV=test jest --config ./test/jest.config.js --coverage --runInBand", 27 | "test:unit": "cross-env NODE_ENV=test jest --config ./test/jest.config-unit.js", 28 | "test:integration": "cross-env NODE_ENV=test jest --config ./test/jest.config-integration.js --runInBand", 29 | "tsc": "tsc" 30 | }, 31 | "nyc": { 32 | "include": [ 33 | "src/**/*.ts" 34 | ], 35 | "extension": [ 36 | ".ts" 37 | ], 38 | "require": [ 39 | "ts-node/register" 40 | ], 41 | "reporter": [ 42 | "text-summary", 43 | "json", 44 | "html" 45 | ], 46 | "report-dir": "./reports/coverage", 47 | "sourceMap": true, 48 | "instrument": true 49 | }, 50 | "dependencies": { 51 | "@types/body-parser": "1.19.0", 52 | "@types/cookie-parser": "^1.4.2", 53 | "@types/express": "^4.17.12", 54 | "@types/multer": "1.4.5", 55 | "@types/passport": "^1.0.6", 56 | "@types/serve-static": "^1.13.9", 57 | "body-parser": "^1.19.0", 58 | "cookie-parser": "^1.4.5", 59 | "express": "^4.17.1", 60 | "fs-extra": "^10.0.0", 61 | "lodash": "^4.17.21", 62 | "multer": "^1.4.2", 63 | "passport": "^0.4.1", 64 | "path": "^0.12.7", 65 | "reflect-metadata": "^0.1.13", 66 | "require-glob": "^4.0.0", 67 | "swagger-ui-express": "^4.1.6", 68 | "yamljs": "^0.3.0" 69 | }, 70 | "devDependencies": { 71 | "@types/debug": "^4.1.5", 72 | "@types/express-serve-static-core": "^4.17.18", 73 | "@types/fs-extra": "9.0.11", 74 | "@types/jest": "^26.0.23", 75 | "@types/jsonwebtoken": "^8.5.1", 76 | "@types/lodash": "^4.14.149", 77 | "@types/passport-jwt": "^3.0.3", 78 | "@types/proxyquire": "^1.3.28", 79 | "@types/request": "^2.48.4", 80 | "@types/yamljs": "^0.2.30", 81 | "cross-env": "^7.0.3", 82 | "jest": "^27.0.4", 83 | "jsonwebtoken": "^8.5.1", 84 | "passport-jwt": "^4.0.0", 85 | "request": "^2.88.0", 86 | "rimraf": "^3.0.2", 87 | "source-map-support": "^0.5.16", 88 | "test-wait": "^1.1.3", 89 | "ts-jest": "^27.0.3", 90 | "ts-node": "^10.0.0", 91 | "tslint": "^6.1.3", 92 | "tslint-config-prettier": "^1.17.0", 93 | "typescript": "^4.1.3", 94 | "typescript-ioc": "^3.0.3", 95 | "typescript-rest-ioc": "^1.0.0" 96 | }, 97 | "repository": { 98 | "type": "git", 99 | "url": "https://github.com/thiagobustamante/typescript-rest.git" 100 | }, 101 | "bugs": { 102 | "url": "https://github.com/thiagobustamante/typescript-rest/issues" 103 | }, 104 | "directories": { 105 | "lib": "dist", 106 | "doc": "doc" 107 | }, 108 | "engines": { 109 | "node": ">=6.0.0" 110 | }, 111 | "engineStrict": true 112 | } 113 | -------------------------------------------------------------------------------- /src/authenticator/passport.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import * as express from 'express'; 3 | import * as _ from 'lodash'; 4 | import * as passport from 'passport'; 5 | import { ServiceAuthenticator } from '../server/model/server-types'; 6 | 7 | export interface PassportAuthenticatorOptions { 8 | authOptions?: passport.AuthenticateOptions; 9 | rolesKey?: string; 10 | strategyName?: string; 11 | serializeUser?: (user: any) => string | Promise; 12 | deserializeUser?: (user: string) => any; 13 | } 14 | 15 | export class PassportAuthenticator implements ServiceAuthenticator { 16 | private authenticator: express.Handler; 17 | private options: PassportAuthenticatorOptions; 18 | 19 | constructor(strategy: passport.Strategy, options: PassportAuthenticatorOptions = {}) { 20 | this.options = options; 21 | const authStrategy = options.strategyName || strategy.name || 'default_strategy'; 22 | passport.use(authStrategy, strategy); 23 | this.authenticator = passport.authenticate(authStrategy, options.authOptions || {}); 24 | } 25 | 26 | public getMiddleware(): express.RequestHandler { 27 | return this.authenticator; 28 | } 29 | 30 | public getRoles(req: express.Request): Array { 31 | const roleKey = this.options.rolesKey || 'roles'; 32 | return _.castArray(_.get(req.user, roleKey, [])); 33 | } 34 | 35 | public initialize(router: express.Router): void { 36 | router.use(passport.initialize()); 37 | const useSession = _.get(this.options, 'authOptions.session', true); 38 | if (useSession) { 39 | router.use(passport.session()); 40 | if (this.options.serializeUser && this.options.deserializeUser) { 41 | passport.serializeUser((user: any, done: (a: any, b: string) => void) => { 42 | Promise.resolve(this.options.serializeUser(user)) 43 | .then((result: string) => { 44 | done(null, result); 45 | }).catch((err: Error) => { 46 | done(err, null); 47 | }); 48 | }); 49 | passport.deserializeUser((user: string, done: (a: any, b: any) => void) => { 50 | Promise.resolve(this.options.deserializeUser(user)) 51 | .then((result: any) => { 52 | done(null, result); 53 | }).catch((err: Error) => { 54 | done(err, null); 55 | }); 56 | }); 57 | } 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/decorators/methods.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as _ from 'lodash'; 4 | import 'reflect-metadata'; 5 | import { FileParam, MethodParam, ParamType, ServiceMethod } from '../server/model/metadata'; 6 | import { HttpMethod } from '../server/model/server-types'; 7 | import { ServerContainer } from '../server/server-container'; 8 | 9 | 10 | /** 11 | * A decorator to tell the [[Server]] that a method 12 | * should be called to process HTTP GET requests. 13 | * 14 | * For example: 15 | * 16 | * ``` 17 | * @ Path('people') 18 | * class PeopleService { 19 | * @ GET 20 | * getPeople() { 21 | * // ... 22 | * } 23 | * } 24 | * ``` 25 | * 26 | * Will create a service that listen for requests like: 27 | * 28 | * ``` 29 | * GET http://mydomain/people 30 | * ``` 31 | */ 32 | export function GET(target: any, propertyKey: string) { 33 | new MethodDecorator(HttpMethod.GET).decorateMethod(target, propertyKey); 34 | } 35 | 36 | /** 37 | * A decorator to tell the [[Server]] that a method 38 | * should be called to process HTTP POST requests. 39 | * 40 | * For example: 41 | * 42 | * ``` 43 | * @ Path('people') 44 | * class PeopleService { 45 | * @ POST 46 | * addPerson() { 47 | * // ... 48 | * } 49 | * } 50 | * ``` 51 | * 52 | * Will create a service that listen for requests like: 53 | * 54 | * ``` 55 | * POST http://mydomain/people 56 | * ``` 57 | */ 58 | export function POST(target: any, propertyKey: string) { 59 | new MethodDecorator(HttpMethod.POST).decorateMethod(target, propertyKey); 60 | } 61 | 62 | /** 63 | * A decorator to tell the [[Server]] that a method 64 | * should be called to process HTTP PUT requests. 65 | * 66 | * For example: 67 | * 68 | * ``` 69 | * @ Path('people') 70 | * class PeopleService { 71 | * @ PUT 72 | * @ Path(':id') 73 | * savePerson(person: Person) { 74 | * // ... 75 | * } 76 | * } 77 | * ``` 78 | * 79 | * Will create a service that listen for requests like: 80 | * 81 | * ``` 82 | * PUT http://mydomain/people/123 83 | * ``` 84 | */ 85 | export function PUT(target: any, propertyKey: string) { 86 | new MethodDecorator(HttpMethod.PUT).decorateMethod(target, propertyKey); 87 | } 88 | 89 | /** 90 | * A decorator to tell the [[Server]] that a method 91 | * should be called to process HTTP DELETE requests. 92 | * 93 | * For example: 94 | * 95 | * ``` 96 | * @ Path('people') 97 | * class PeopleService { 98 | * @ DELETE 99 | * @ Path(':id') 100 | * removePerson(@ PathParam('id')id: string) { 101 | * // ... 102 | * } 103 | * } 104 | * ``` 105 | * 106 | * Will create a service that listen for requests like: 107 | * 108 | * ``` 109 | * PUT http://mydomain/people/123 110 | * ``` 111 | */ 112 | export function DELETE(target: any, propertyKey: string) { 113 | new MethodDecorator(HttpMethod.DELETE).decorateMethod(target, propertyKey); 114 | } 115 | 116 | /** 117 | * A decorator to tell the [[Server]] that a method 118 | * should be called to process HTTP HEAD requests. 119 | * 120 | * For example: 121 | * 122 | * ``` 123 | * @ Path('people') 124 | * class PeopleService { 125 | * @ HEAD 126 | * headPerson() { 127 | * // ... 128 | * } 129 | * } 130 | * ``` 131 | * 132 | * Will create a service that listen for requests like: 133 | * 134 | * ``` 135 | * HEAD http://mydomain/people/123 136 | * ``` 137 | */ 138 | export function HEAD(target: any, propertyKey: string) { 139 | new MethodDecorator(HttpMethod.HEAD).decorateMethod(target, propertyKey); 140 | } 141 | 142 | /** 143 | * A decorator to tell the [[Server]] that a method 144 | * should be called to process HTTP OPTIONS requests. 145 | * 146 | * For example: 147 | * 148 | * ``` 149 | * @ Path('people') 150 | * class PeopleService { 151 | * @ OPTIONS 152 | * optionsPerson() { 153 | * // ... 154 | * } 155 | * } 156 | * ``` 157 | * 158 | * Will create a service that listen for requests like: 159 | * 160 | * ``` 161 | * OPTIONS http://mydomain/people/123 162 | * ``` 163 | */ 164 | export function OPTIONS(target: any, propertyKey: string) { 165 | new MethodDecorator(HttpMethod.OPTIONS).decorateMethod(target, propertyKey); 166 | } 167 | 168 | /** 169 | * A decorator to tell the [[Server]] that a method 170 | * should be called to process HTTP PATCH requests. 171 | * 172 | * For example: 173 | * 174 | * ``` 175 | * @ Path('people') 176 | * class PeopleService { 177 | * @ PATCH 178 | * @ Path(':id') 179 | * savePerson(person: Person) { 180 | * // ... 181 | * } 182 | * } 183 | * ``` 184 | * 185 | * Will create a service that listen for requests like: 186 | * 187 | * ``` 188 | * PATCH http://mydomain/people/123 189 | * ``` 190 | */ 191 | export function PATCH(target: any, propertyKey: string) { 192 | new MethodDecorator(HttpMethod.PATCH).decorateMethod(target, propertyKey); 193 | } 194 | 195 | class MethodDecorator { 196 | private static PROCESSORS = getParameterProcessors(); 197 | 198 | private httpMethod: HttpMethod; 199 | 200 | constructor(httpMethod: HttpMethod) { 201 | this.httpMethod = httpMethod; 202 | } 203 | 204 | public decorateMethod(target: Function, propertyKey: string) { 205 | const serviceMethod: ServiceMethod = ServerContainer.get().registerServiceMethod(target.constructor, propertyKey); 206 | if (serviceMethod) { // does not intercept constructor 207 | if (!serviceMethod.httpMethod) { 208 | serviceMethod.httpMethod = this.httpMethod; 209 | this.processServiceMethod(target, propertyKey, serviceMethod); 210 | } else if (serviceMethod.httpMethod !== this.httpMethod) { 211 | throw new Error('Method is already annotated with @' + 212 | HttpMethod[serviceMethod.httpMethod] + 213 | '. You can only map a method to one HTTP verb.'); 214 | } 215 | } 216 | } 217 | 218 | /** 219 | * Extract metadata for rest methods 220 | */ 221 | private processServiceMethod(target: any, propertyKey: string, serviceMethod: ServiceMethod) { 222 | serviceMethod.name = propertyKey; 223 | const paramTypes = Reflect.getOwnMetadata('design:paramtypes', target, propertyKey); 224 | this.registerUndecoratedParameters(paramTypes, serviceMethod); 225 | 226 | serviceMethod.parameters.forEach(param => { 227 | const processor = MethodDecorator.PROCESSORS.get(param.paramType); 228 | if (processor) { 229 | processor(serviceMethod, param); 230 | } 231 | }); 232 | } 233 | 234 | private registerUndecoratedParameters(paramTypes: any, serviceMethod: ServiceMethod) { 235 | while (paramTypes && paramTypes.length > serviceMethod.parameters.length) { 236 | serviceMethod.parameters.push(new MethodParam(null, paramTypes[serviceMethod.parameters.length], ParamType.body)); 237 | } 238 | } 239 | } 240 | 241 | type ParamProcessor = (serviceMethod: ServiceMethod, param?: MethodParam) => void; 242 | function getParameterProcessors() { 243 | const result = new Map(); 244 | result.set(ParamType.cookie, (serviceMethod) => { 245 | serviceMethod.mustParseCookies = true; 246 | }); 247 | result.set(ParamType.file, (serviceMethod, param) => { 248 | serviceMethod.files.push(new FileParam(param.name, true)); 249 | }); 250 | result.set(ParamType.files, (serviceMethod, param) => { 251 | serviceMethod.files.push(new FileParam(param.name, false)); 252 | }); 253 | result.set(ParamType.param, (serviceMethod) => { 254 | serviceMethod.acceptMultiTypedParam = true; 255 | }); 256 | result.set(ParamType.form, (serviceMethod) => { 257 | if (serviceMethod.mustParseBody) { 258 | throw Error('Can not use form parameters with a body parameter on the same method.'); 259 | } 260 | serviceMethod.mustParseForms = true; 261 | }); 262 | result.set(ParamType.body, (serviceMethod) => { 263 | if (serviceMethod.mustParseForms) { 264 | throw Error('Can not use form parameters with a body parameter on the same method.'); 265 | } 266 | if (serviceMethod.mustParseBody) { 267 | throw Error('Can not use more than one body parameter on the same method.'); 268 | } 269 | serviceMethod.mustParseBody = true; 270 | }); 271 | return result; 272 | } 273 | -------------------------------------------------------------------------------- /src/decorators/parameters.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as _ from 'lodash'; 4 | import 'reflect-metadata'; 5 | import { MethodParam, ParamType, ServiceClass, ServiceMethod } from '../server/model/metadata'; 6 | import { ServerContainer } from '../server/server-container'; 7 | 8 | 9 | /** 10 | * A decorator to be used on class properties or on service method arguments 11 | * to inform that the decorated property or argument should be bound to the 12 | * [[ServiceContext]] object associated to the current request. 13 | * 14 | * For example: 15 | * 16 | * ``` 17 | * @ Path('context') 18 | * class TestService { 19 | * @ Context 20 | * context: ServiceContext; 21 | * // ... 22 | * } 23 | * ``` 24 | * 25 | * The field context on the above class will point to the current 26 | * [[ServiceContext]] instance. 27 | */ 28 | export function Context(...args: Array) { 29 | return new ParameterDecorator('Context').withType(ParamType.context) 30 | .decorateParameterOrProperty(args); 31 | } 32 | 33 | /** 34 | * A decorator to be used on class properties or on service method arguments 35 | * to inform that the decorated property or argument should be bound to the 36 | * the current request. 37 | * 38 | * For example: 39 | * 40 | * ``` 41 | * @ Path('context') 42 | * class TestService { 43 | * @ ContextRequest 44 | * request: express.Request; 45 | * // ... 46 | * } 47 | * ``` 48 | * 49 | * The field request on the above class will point to the current 50 | * request. 51 | */ 52 | export function ContextRequest(...args: Array) { 53 | return new ParameterDecorator('ContextRequest').withType(ParamType.context_request) 54 | .decorateParameterOrProperty(args); 55 | } 56 | 57 | /** 58 | * A decorator to be used on class properties or on service method arguments 59 | * to inform that the decorated property or argument should be bound to the 60 | * the current response object. 61 | * 62 | * For example: 63 | * 64 | * ``` 65 | * @ Path('context') 66 | * class TestService { 67 | * @ ContextResponse 68 | * response: express.Response; 69 | * // ... 70 | * } 71 | * ``` 72 | * 73 | * The field response on the above class will point to the current 74 | * response object. 75 | */ 76 | export function ContextResponse(...args: Array) { 77 | return new ParameterDecorator('ContextResponse').withType(ParamType.context_response) 78 | .decorateParameterOrProperty(args); 79 | } 80 | 81 | /** 82 | * A decorator to be used on class properties or on service method arguments 83 | * to inform that the decorated property or argument should be bound to the 84 | * the next function. 85 | * 86 | * For example: 87 | * 88 | * ``` 89 | * @ Path('context') 90 | * class TestService { 91 | * @ ContextNext 92 | * next: express.NextFunction 93 | * // ... 94 | * } 95 | * ``` 96 | * 97 | * The next function can be used to delegate to the next registered 98 | * middleware the current request processing. 99 | */ 100 | export function ContextNext(...args: Array) { 101 | return new ParameterDecorator('ContextNext').withType(ParamType.context_next) 102 | .decorateParameterOrProperty(args); 103 | } 104 | 105 | /** 106 | * A decorator to be used on class properties or on service method arguments 107 | * to inform that the decorated property or argument should be bound to the 108 | * the current context language. 109 | * 110 | * For example: 111 | * 112 | * ``` 113 | * @ Path('context') 114 | * class TestService { 115 | * @ ContextLanguage 116 | * language: string 117 | * // ... 118 | * } 119 | * ``` 120 | */ 121 | export function ContextLanguage(...args: Array) { 122 | return new ParameterDecorator('ContextLanguage') 123 | .withType(ParamType.context_accept_language) 124 | .decorateParameterOrProperty(args); 125 | } 126 | 127 | /** 128 | * A decorator to be used on class properties or on service method arguments 129 | * to inform that the decorated property or argument should be bound to the 130 | * the preferred media type for the current request. 131 | * 132 | * For example: 133 | * 134 | * ``` 135 | * @ Path('context') 136 | * class TestService { 137 | * @ ContextAccept 138 | * media: string 139 | * // ... 140 | * } 141 | * ``` 142 | */ 143 | export function ContextAccept(...args: Array) { 144 | return new ParameterDecorator('ContextAccept').withType(ParamType.context_accept) 145 | .decorateParameterOrProperty(args); 146 | } 147 | 148 | 149 | /** 150 | * Creates a mapping between a fragment of the requested path and 151 | * a method argument. 152 | * 153 | * For example: 154 | * 155 | * ``` 156 | * @ Path('people') 157 | * class PeopleService { 158 | * @ GET 159 | * @ Path(':id') 160 | * getPerson(@ PathParam('id') id: string) { 161 | * // ... 162 | * } 163 | * } 164 | * ``` 165 | * 166 | * Will create a service that listen for requests like: 167 | * 168 | * ``` 169 | * GET http://mydomain/people/123 170 | * ``` 171 | * 172 | * And pass 123 as the id argument on getPerson method's call. 173 | */ 174 | export function PathParam(name: string) { 175 | return new ParameterDecorator('PathParam').withType(ParamType.path).withName(name) 176 | .decorateNamedParameterOrProperty(); 177 | } 178 | 179 | /** 180 | * Creates a mapping between a file on a multipart request and a method 181 | * argument. 182 | * 183 | * For example: 184 | * 185 | * ``` 186 | * @ Path('people') 187 | * class PeopleService { 188 | * @ POST 189 | * @ Path('id') 190 | * addAvatar(@ PathParam('id') id: string, 191 | * @ FileParam('avatar') file: Express.Multer.File) { 192 | * // ... 193 | * } 194 | * } 195 | * ``` 196 | * 197 | * Will create a service that listen for requests and bind the 198 | * file with name 'avatar' on the requested form to the file 199 | * argument on addAvatar method's call. 200 | */ 201 | export function FileParam(name: string) { 202 | return new ParameterDecorator('FileParam').withType(ParamType.file).withName(name) 203 | .decorateNamedParameterOrProperty(); 204 | } 205 | 206 | /** 207 | * Creates a mapping between a list of files on a multipart request and a method 208 | * argument. 209 | * 210 | * For example: 211 | * 212 | * ``` 213 | * @ Path('people') 214 | * class PeopleService { 215 | * @ POST 216 | * @ Path('id') 217 | * addAvatar(@ PathParam('id') id: string, 218 | * @ FilesParam('avatar[]') files: Array) { 219 | * // ... 220 | * } 221 | * } 222 | * ``` 223 | * 224 | * Will create a service that listen for requests and bind the 225 | * files with name 'avatar' on the request form to the file 226 | * argument on addAvatar method's call. 227 | */ 228 | export function FilesParam(name: string) { 229 | return new ParameterDecorator('FilesParam').withType(ParamType.files).withName(name) 230 | .decorateNamedParameterOrProperty(); 231 | } 232 | 233 | /** 234 | * Creates a mapping between a query parameter on request and a method 235 | * argument. 236 | * 237 | * For example: 238 | * 239 | * ``` 240 | * @ Path('people') 241 | * class PeopleService { 242 | * @ GET 243 | * getPeople(@ QueryParam('name') name: string) { 244 | * // ... 245 | * } 246 | * } 247 | * ``` 248 | * 249 | * Will create a service that listen for requests like: 250 | * 251 | * ``` 252 | * GET http://mydomain/people?name=joe 253 | * ``` 254 | * 255 | * And pass 'joe' as the name argument on getPerson method's call. 256 | */ 257 | export function QueryParam(name: string) { 258 | return new ParameterDecorator('QueryParam').withType(ParamType.query).withName(name) 259 | .decorateNamedParameterOrProperty(); 260 | } 261 | 262 | /** 263 | * Creates a mapping between a header on request and a method 264 | * argument. 265 | * 266 | * For example: 267 | * 268 | * ``` 269 | * @ Path('people') 270 | * class PeopleService { 271 | * @ GET 272 | * getPeople(@ HeaderParam('header') header: string) { 273 | * // ... 274 | * } 275 | * } 276 | * ``` 277 | * 278 | * Will create a service that listen for requests and bind the 279 | * header called 'header' to the header argument on getPerson method's call. 280 | */ 281 | export function HeaderParam(name: string) { 282 | return new ParameterDecorator('HeaderParam').withType(ParamType.header).withName(name) 283 | .decorateNamedParameterOrProperty(); 284 | } 285 | 286 | /** 287 | * Creates a mapping between a cookie on request and a method 288 | * argument. 289 | * 290 | * For example: 291 | * 292 | * ``` 293 | * @ Path('people') 294 | * class PeopleService { 295 | * @ GET 296 | * getPeople(@ CookieParam('cookie') cookie: string) { 297 | * // ... 298 | * } 299 | * } 300 | * ``` 301 | * 302 | * Will create a service that listen for requests and bind the 303 | * cookie called 'cookie' to the cookie argument on getPerson method's call. 304 | */ 305 | export function CookieParam(name: string) { 306 | return new ParameterDecorator('CookieParam').withType(ParamType.cookie).withName(name) 307 | .decorateNamedParameterOrProperty(); 308 | } 309 | 310 | /** 311 | * Creates a mapping between a form parameter on request and a method 312 | * argument. 313 | * 314 | * For example: 315 | * 316 | * ``` 317 | * @ Path('people') 318 | * class PeopleService { 319 | * @ GET 320 | * getPeople(@ FormParam('name') name: string) { 321 | * // ... 322 | * } 323 | * } 324 | * ``` 325 | * 326 | * Will create a service that listen for requests and bind the 327 | * request paramenter called 'name' to the name argument on getPerson 328 | * method's call. 329 | */ 330 | export function FormParam(name: string) { 331 | return new ParameterDecorator('FormParam').withType(ParamType.form).withName(name) 332 | .decorateNamedParameterOrProperty(); 333 | } 334 | 335 | /** 336 | * Creates a mapping between a parameter on request and a method 337 | * argument. 338 | * 339 | * For example: 340 | * 341 | * ``` 342 | * @ Path('people') 343 | * class PeopleService { 344 | * @ GET 345 | * getPeople(@ Param('name') name: string) { 346 | * // ... 347 | * } 348 | * } 349 | * ``` 350 | * 351 | * Will create a service that listen for requests and bind the 352 | * request paramenter called 'name' to the name argument on getPerson 353 | * method's call. It will work to query parameters or form parameters 354 | * received in the current request. 355 | */ 356 | export function Param(name: string) { 357 | return new ParameterDecorator('Param').withType(ParamType.param).withName(name) 358 | .decorateNamedParameterOrProperty(); 359 | } 360 | 361 | class ParameterDecorator { 362 | private decorator: string; 363 | private paramType: ParamType; 364 | private nameRequired: boolean = false; 365 | private name: string = null; 366 | 367 | constructor(decorator: string) { 368 | this.decorator = decorator; 369 | } 370 | 371 | public withType(paramType: ParamType) { 372 | this.paramType = paramType; 373 | return this; 374 | } 375 | 376 | public withName(name: string) { 377 | this.nameRequired = true; 378 | this.name = name ? name.trim() : ''; 379 | return this; 380 | } 381 | 382 | public decorateParameterOrProperty(args: Array) { 383 | if (!this.nameRequired || this.name) { 384 | args = _.without(args, undefined); 385 | if (args.length < 3 || typeof args[2] === 'undefined') { 386 | return this.decorateProperty(args[0], args[1]); 387 | } else if (args.length === 3 && typeof args[2] === 'number') { 388 | return this.decorateParameter(args[0], args[1], args[2]); 389 | } 390 | } 391 | 392 | throw new Error(`Invalid @${this.decorator} Decorator declaration.`); 393 | } 394 | 395 | public decorateNamedParameterOrProperty() { 396 | return (...args: Array) => { 397 | return this.decorateParameterOrProperty(args); 398 | }; 399 | } 400 | 401 | private decorateParameter(target: Object, propertyKey: string, parameterIndex: number) { 402 | const serviceMethod: ServiceMethod = ServerContainer.get().registerServiceMethod(target.constructor, propertyKey); 403 | if (serviceMethod) { // does not intercept constructor 404 | const paramTypes = Reflect.getOwnMetadata('design:paramtypes', target, propertyKey); 405 | 406 | while (paramTypes && serviceMethod.parameters.length < paramTypes.length) { 407 | serviceMethod.parameters.push(new MethodParam(null, 408 | paramTypes[serviceMethod.parameters.length], ParamType.body)); 409 | } 410 | serviceMethod.parameters[parameterIndex] = 411 | new MethodParam(this.name, paramTypes[parameterIndex], this.paramType); 412 | } 413 | } 414 | 415 | private decorateProperty(target: Function, key: string) { 416 | const classData: ServiceClass = ServerContainer.get().registerServiceClass(target.constructor); 417 | const propertyType = Reflect.getMetadata('design:type', target, key); 418 | classData.addProperty(key, { 419 | name: this.name, 420 | propertyType: propertyType, 421 | type: this.paramType 422 | }); 423 | } 424 | } 425 | 426 | 427 | 428 | 429 | -------------------------------------------------------------------------------- /src/decorators/services.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as _ from 'lodash'; 4 | import 'reflect-metadata'; 5 | import { ServiceClass, ServiceMethod } from '../server/model/metadata'; 6 | import { ParserType, ServiceProcessor } from '../server/model/server-types'; 7 | import { ServerContainer } from '../server/server-container'; 8 | 9 | /** 10 | * A decorator to tell the [[Server]] that a class or a method 11 | * should be bound to a given path. 12 | * 13 | * For example: 14 | * 15 | * ``` 16 | * @ Path('people') 17 | * class PeopleService { 18 | * @ PUT 19 | * @ Path(':id') 20 | * savePerson(person:Person) { 21 | * // ... 22 | * } 23 | * 24 | * @ GET 25 | * @ Path(':id') 26 | * getPerson():Person { 27 | * // ... 28 | * } 29 | * } 30 | * ``` 31 | * 32 | * Will create services that listen for requests like: 33 | * 34 | * ``` 35 | * PUT http://mydomain/people/123 or 36 | * GET http://mydomain/people/123 37 | * ``` 38 | */ 39 | export function Path(path: string) { 40 | return new ServiceDecorator('Path').withProperty('path', path) 41 | .createDecorator(); 42 | } 43 | 44 | /** 45 | * A decorator to tell the [[Server]] that a class or a method 46 | * should include a determined role 47 | * or all authorized users (token) using passport 48 | * 49 | * For example: 50 | * 51 | * ``` 52 | * @ Path('people') 53 | * @ Security() 54 | * class PeopleService { 55 | * @ PUT 56 | * @ Path(':id', true) 57 | * @ Security(['ROLE_ADMIN']) 58 | * savePerson(person:Person) { 59 | * // ... 60 | * } 61 | * 62 | * @ GET 63 | * @ Path(':id', true) 64 | * getPerson():Person { 65 | * // ... 66 | * } 67 | * } 68 | * ``` 69 | * 70 | * Will create services that listen for requests like: 71 | * 72 | * ``` 73 | * PUT http://mydomain/people/123 (Only for ADMIN roles) or 74 | * GET http://mydomain/people/123 (For all authorized users) 75 | * ``` 76 | */ 77 | export function Security(roles?: string | Array, name?: string) { 78 | roles = _.castArray(roles || '*'); 79 | return new SecurityServiceDecorator('Security') 80 | .withObjectProperty('authenticator', name || 'default', roles) 81 | .createDecorator(); 82 | } 83 | 84 | /** 85 | * A decorator to tell the [[Server]] that a class or a method 86 | * should include a pre-processor in its request pipelines. 87 | * 88 | * For example: 89 | * ``` 90 | * function validator(req: express.Request): express.Request { 91 | * if (!req.body.userId) { 92 | * throw new Errors.BadRequestError("userId not present"); 93 | * } 94 | * } 95 | * ``` 96 | * And: 97 | * 98 | * ``` 99 | * @ Path('people') 100 | * class PeopleService { 101 | * @ PUT 102 | * @ Path(':id') 103 | * @ PreProcessor(validator) 104 | * savePerson(person:Person) { 105 | * // ... 106 | * } 107 | * } 108 | * ``` 109 | */ 110 | export function PreProcessor(preprocessor: ServiceProcessor) { 111 | return new ProcessorServiceDecorator('PreProcessor') 112 | .withArrayProperty('preProcessors', preprocessor, true) 113 | .createDecorator(); 114 | } 115 | 116 | /** 117 | * A decorator to tell the [[Server]] that a class or a method 118 | * should include a post-processor in its request pipelines. 119 | * 120 | * For example: 121 | * ``` 122 | * function processor(req: express.Request): express.Request { 123 | * if (!req.body.userId) { 124 | * throw new Errors.BadRequestError("userId not present"); 125 | * } 126 | * } 127 | * ``` 128 | * And: 129 | * 130 | * ``` 131 | * @ Path('people') 132 | * class PeopleService { 133 | * @ PUT 134 | * @ Path(':id') 135 | * @ PostProcessor(validator) 136 | * savePerson(person:Person) { 137 | * // ... 138 | * } 139 | * } 140 | * ``` 141 | */ 142 | export function PostProcessor(postprocessor: ServiceProcessor) { 143 | return new ProcessorServiceDecorator('PostProcessor') 144 | .withArrayProperty('postProcessors', postprocessor, true) 145 | .createDecorator(); 146 | } 147 | 148 | /** 149 | * A decorator to tell the [[Server]] that a class or a method 150 | * should only accept requests from clients that accepts one of 151 | * the supported languages. 152 | * 153 | * For example: 154 | * 155 | * ``` 156 | * @ Path('accept') 157 | * @ AcceptLanguage('en', 'pt-BR') 158 | * class TestAcceptService { 159 | * // ... 160 | * } 161 | * ``` 162 | * 163 | * Will reject requests that only accepts languages that are not 164 | * English or Brazilian portuguese 165 | * 166 | * If the language requested is not supported, a status code 406 returned 167 | */ 168 | export function AcceptLanguage(...languages: Array) { 169 | languages = _.compact(languages); 170 | return new AcceptServiceDecorator('AcceptLanguage').withArrayProperty('languages', languages, true) 171 | .createDecorator(); 172 | } 173 | 174 | /** 175 | * A decorator to tell the [[Server]] that a class or a method 176 | * should only accept requests from clients that accepts one of 177 | * the supported mime types. 178 | * 179 | * For example: 180 | * 181 | * ``` 182 | * @ Path('accept') 183 | * @ Accept('application/json') 184 | * class TestAcceptService { 185 | * // ... 186 | * } 187 | * ``` 188 | * 189 | * Will reject requests that only accepts mime types that are not 190 | * 'application/json' 191 | * 192 | * If the mime type requested is not supported, a status code 406 returned 193 | */ 194 | export function Accept(...accepts: Array) { 195 | accepts = _.compact(accepts); 196 | return new AcceptServiceDecorator('Accept').withArrayProperty('accepts', accepts, true) 197 | .createDecorator(); 198 | } 199 | 200 | /** 201 | * A decorator to inform options to pe passed to bodyParser. 202 | * You can inform any property accepted by 203 | * [[bodyParser]](https://www.npmjs.com/package/body-parser) 204 | */ 205 | export function BodyOptions(options: any) { 206 | return new ServiceDecorator('BodyOptions').withProperty('bodyParserOptions', options) 207 | .createDecorator(); 208 | } 209 | 210 | /** 211 | * A decorator to inform the type of parser to be used to parse the body. 212 | * The default type is json. 213 | */ 214 | export function BodyType(type: ParserType) { 215 | return new ServiceDecorator('BodyType').withProperty('bodyParserType', type) 216 | .createDecorator(); 217 | } 218 | 219 | /** 220 | * A decorator to inform that server should ignore other middlewares. 221 | * It makes server does not call next function after service invocation. 222 | */ 223 | export function IgnoreNextMiddlewares(...args: Array) { 224 | return new ServiceDecorator('IgnoreNextMiddlewares').withProperty('ignoreNextMiddlewares', true) 225 | .decorateTypeOrMethod(args); 226 | } 227 | 228 | /** 229 | * Mark the annotated service class as an abstract service. Abstract services has none of its 230 | * methods exposed as rest enpoints, even if the class is in the services list to be exposed. 231 | * 232 | * For example: 233 | * 234 | * ``` 235 | * @ Abstract 236 | * abstract class PeopleService { 237 | * @ GET 238 | * getPeople(@ Param('name') name: string) { 239 | * // ... 240 | * } 241 | * } 242 | * ``` 243 | * 244 | * No endpoint will be registered for PeopleService. It is useful if you only plain that subclasses of 245 | * PeopleService exposes the getPeople method. 246 | */ 247 | export function Abstract(...args: Array) { 248 | args = _.without(args, undefined); 249 | if (args.length === 1) { 250 | const classData: ServiceClass = ServerContainer.get().registerServiceClass(args[0]); 251 | classData.isAbstract = true; 252 | } 253 | else { 254 | throw new Error('Invalid @Abstract Decorator declaration.'); 255 | } 256 | } 257 | 258 | interface DecoratorProperty { 259 | property: string; 260 | value: any; 261 | required: boolean; 262 | process: (target: any) => void; 263 | checkRequired: () => boolean; 264 | } 265 | 266 | class ServiceDecorator { 267 | protected decorator: string; 268 | protected properties: Array = []; 269 | 270 | constructor(decorator: string) { 271 | this.decorator = decorator; 272 | } 273 | 274 | public withProperty(property: string, value: any, required: boolean = false) { 275 | this.properties.push({ 276 | checkRequired: () => required && !value, 277 | process: (target: any) => { 278 | target[property] = value; 279 | }, 280 | property: property, 281 | required: required, 282 | value: value 283 | }); 284 | return this; 285 | } 286 | 287 | public createDecorator() { 288 | return (...args: Array) => { 289 | this.checkRequiredValue(); 290 | this.decorateTypeOrMethod(args); 291 | }; 292 | } 293 | 294 | public decorateTypeOrMethod(args: Array) { 295 | args = _.without(args, undefined); 296 | if (args.length === 1) { 297 | this.decorateType(args[0]); 298 | } else if (args.length === 3 && typeof args[2] === 'object') { 299 | this.decorateMethod(args[0], args[1]); 300 | } else { 301 | throw new Error(`Invalid @${this.decorator} Decorator declaration.`); 302 | } 303 | } 304 | 305 | protected checkRequiredValue() { 306 | this.properties.forEach(property => { 307 | if (property.checkRequired()) { 308 | throw new Error(`Invalid @${this.decorator} Decorator declaration.`); 309 | } 310 | }); 311 | } 312 | 313 | protected decorateType(target: Function) { 314 | const classData: ServiceClass = ServerContainer.get().registerServiceClass(target); 315 | if (classData) { 316 | this.updateClassMetadata(classData); 317 | } 318 | } 319 | 320 | protected decorateMethod(target: Function, propertyKey: string) { 321 | const serviceMethod: ServiceMethod = ServerContainer.get().registerServiceMethod(target.constructor, propertyKey); 322 | if (serviceMethod) { // does not intercept constructor 323 | this.updateMethodMetadada(serviceMethod); 324 | } 325 | } 326 | 327 | protected updateClassMetadata(classData: ServiceClass) { 328 | this.properties.forEach(property => { 329 | property.process(classData); 330 | }); 331 | } 332 | 333 | protected updateMethodMetadada(serviceMethod: ServiceMethod) { 334 | this.properties.forEach(property => { 335 | property.process(serviceMethod); 336 | }); 337 | } 338 | } 339 | 340 | class SecurityServiceDecorator extends ServiceDecorator { 341 | public withObjectProperty(property: string, subtext: string, value: any, required: boolean = false) { 342 | this.properties.push({ 343 | checkRequired: () => required && !value, 344 | process: (target: any) => { 345 | if (!target[property]) { 346 | target[property] = {}; 347 | } 348 | target[property][subtext] = value; 349 | }, 350 | property: property, 351 | required: required, 352 | value: value 353 | }); 354 | return this; 355 | } 356 | } 357 | 358 | class ProcessorServiceDecorator extends ServiceDecorator { 359 | public withArrayProperty(property: string, value: any, required: boolean = false) { 360 | this.properties.push({ 361 | checkRequired: () => required && !value, 362 | process: (target: any) => { 363 | if (!target[property]) { 364 | target[property] = []; 365 | } 366 | target[property].unshift(value); 367 | }, 368 | property: property, 369 | required: required, 370 | value: value 371 | }); 372 | return this; 373 | } 374 | } 375 | 376 | class AcceptServiceDecorator extends ServiceDecorator { 377 | public withArrayProperty(property: string, value: any, required: boolean = false) { 378 | this.properties.push({ 379 | checkRequired: () => required && (!value || !value.length), 380 | process: (target: any) => { 381 | target[property] = _.union(target[property], value); 382 | }, 383 | property: property, 384 | required: required, 385 | value: value 386 | }); 387 | return this; 388 | } 389 | } -------------------------------------------------------------------------------- /src/server/config.ts: -------------------------------------------------------------------------------- 1 | import * as debug from 'debug'; 2 | import * as fs from 'fs-extra'; 3 | import * as path from 'path'; 4 | import { Server } from './server'; 5 | 6 | const serverDebugger = debug('typescript-rest:server:config:build'); 7 | 8 | export class ServerConfig { 9 | public static configure() { 10 | try { 11 | const CONFIG_FILE = this.searchConfigFile(); 12 | if (CONFIG_FILE && fs.existsSync(CONFIG_FILE)) { 13 | const config = fs.readJSONSync(CONFIG_FILE); 14 | serverDebugger('rest.config file found: %j', config); 15 | if (config.serviceFactory) { 16 | if (config.serviceFactory.indexOf('.') === 0) { 17 | config.serviceFactory = path.join(process.cwd(), config.serviceFactory); 18 | } 19 | Server.registerServiceFactory(config.serviceFactory); 20 | } 21 | } 22 | } catch (e) { 23 | // tslint:disable-next-line:no-console 24 | console.error(e); 25 | } 26 | } 27 | 28 | public static searchConfigFile() { 29 | serverDebugger('Searching for rest.config file'); 30 | let configFile = path.join(__dirname, 'rest.config'); 31 | while (!fs.existsSync(configFile)) { 32 | const fileOnParent = path.normalize(path.join(path.dirname(configFile), '..', 'rest.config')); 33 | if (configFile === fileOnParent) { 34 | return null; 35 | } 36 | configFile = fileOnParent; 37 | } 38 | return configFile; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/server/model/errors.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * The Base class for all HTTP errors 5 | */ 6 | export abstract class HttpError extends Error { 7 | public statusCode: number; 8 | 9 | constructor(name: string, 10 | public message: string) { 11 | super(message); 12 | this.name = name; 13 | } 14 | } 15 | 16 | /** 17 | * Represents a BAD REQUEST error. The request could not be understood by the 18 | * server due to malformed syntax. The client SHOULD NOT repeat the request 19 | * without modifications. 20 | */ 21 | export class BadRequestError extends HttpError { 22 | constructor(message?: string) { 23 | super('BadRequestError', message || 'Bad Request'); 24 | Object.setPrototypeOf(this, BadRequestError.prototype); 25 | this.statusCode = 400; 26 | } 27 | } 28 | 29 | /** 30 | * Represents an UNAUTHORIZED error. The request requires user authentication. The response 31 | * MUST include a WWW-Authenticate header field containing a challenge applicable to the 32 | * requested resource. 33 | */ 34 | export class UnauthorizedError extends HttpError { 35 | constructor(message?: string) { 36 | super('UnauthorizedError', message || 'Unauthorized'); 37 | Object.setPrototypeOf(this, UnauthorizedError.prototype); 38 | this.statusCode = 401; 39 | } 40 | } 41 | 42 | /** 43 | * Represents a FORBIDDEN error. The server understood the request, but is refusing to 44 | * fulfill it. Authorization will not help and the request SHOULD NOT be repeated. 45 | */ 46 | export class ForbiddenError extends HttpError { 47 | constructor(message?: string) { 48 | super('ForbiddenError', message || 'Forbidden'); 49 | Object.setPrototypeOf(this, ForbiddenError.prototype); 50 | this.statusCode = 403; 51 | } 52 | } 53 | 54 | /** 55 | * Represents a NOT FOUND error. The server has not found anything matching 56 | * the Request-URI. No indication is given of whether the condition is temporary 57 | * or permanent. The 410 (GoneError) status code SHOULD be used if the server knows, 58 | * through some internally configurable mechanism, that an old resource is permanently 59 | * unavailable and has no forwarding address. 60 | * 61 | * This error is commonly used when 62 | * the server does not wish to reveal exactly why the request has been refused, 63 | * or when no other response is applicable. 64 | */ 65 | export class NotFoundError extends HttpError { 66 | constructor(message?: string) { 67 | super('NotFoundError', message || 'Not Found'); 68 | Object.setPrototypeOf(this, NotFoundError.prototype); 69 | this.statusCode = 404; 70 | } 71 | } 72 | 73 | /** 74 | * Represents a METHOD NOT ALLOWED error. The method specified in the Request-Line is not allowed for 75 | * the resource identified by the Request-URI. The response MUST include an Allow header 76 | * containing a list of valid methods for the requested resource. 77 | */ 78 | export class MethodNotAllowedError extends HttpError { 79 | constructor(message?: string) { 80 | super('MethodNotAllowedError', message || 'Method Not Allowed'); 81 | Object.setPrototypeOf(this, MethodNotAllowedError.prototype); 82 | this.statusCode = 405; 83 | } 84 | } 85 | 86 | /** 87 | * Represents a NOT ACCEPTABLE error. The resource identified by the request is only capable of 88 | * generating response entities which have content characteristics not acceptable according 89 | * to the accept headers sent in the request. 90 | */ 91 | export class NotAcceptableError extends HttpError { 92 | constructor(message?: string) { 93 | super('NotAcceptableError', message || 'Not Acceptable'); 94 | Object.setPrototypeOf(this, NotAcceptableError.prototype); 95 | this.statusCode = 406; 96 | } 97 | } 98 | /** 99 | * Represents a CONFLICT error. The request could not be completed due to a 100 | * conflict with the current state of the resource. 101 | */ 102 | export class ConflictError extends HttpError { 103 | constructor(message?: string) { 104 | super('ConflictError', message || 'Conflict'); 105 | Object.setPrototypeOf(this, ConflictError.prototype); 106 | this.statusCode = 409; 107 | } 108 | } 109 | 110 | /** 111 | * Represents a GONE error. The requested resource is no longer available at the server 112 | * and no forwarding address is known. This condition is expected to be considered 113 | * permanent. Clients with link editing capabilities SHOULD delete references to 114 | * the Request-URI after user approval. If the server does not know, or has 115 | * no facility to determine, whether or not the condition is permanent, the 116 | * error 404 (NotFoundError) SHOULD be used instead. This response is 117 | * cacheable unless indicated otherwise. 118 | */ 119 | export class GoneError extends HttpError { 120 | constructor(message?: string) { 121 | super('GoneError', message || 'Gone'); 122 | Object.setPrototypeOf(this, GoneError.prototype); 123 | this.statusCode = 410; 124 | } 125 | } 126 | 127 | /** 128 | * Represents an UNSUPPORTED MEDIA TYPE error. The server is refusing to service the request 129 | * because the entity of the request is in a format not supported by the requested resource 130 | * for the requested method. 131 | */ 132 | export class UnsupportedMediaTypeError extends HttpError { 133 | constructor(message?: string) { 134 | super('UnsupportedMediaTypeError', message || 'Unsupported Media Type'); 135 | Object.setPrototypeOf(this, UnsupportedMediaTypeError.prototype); 136 | this.statusCode = 415; 137 | } 138 | } 139 | 140 | /** 141 | * Represents a UNPROCESSABLE ENTITY error. The server understands the content type of the request entity 142 | * (hence a 415 Unsupported Media Type status code is inappropriate), and the syntax of the request entity is correct 143 | * (thus a 400 Bad Request status code is inappropriate) but was unable to process the contained instructions. 144 | */ 145 | export class UnprocessableEntityError extends HttpError { 146 | constructor(message?: string) { 147 | super('UnprocessableEntityError', message || 'Unprocessable Entity'); 148 | Object.setPrototypeOf(this, UnprocessableEntityError.prototype); 149 | this.statusCode = 422; 150 | } 151 | } 152 | 153 | /** 154 | * Represents an INTERNAL SERVER error. The server encountered an unexpected condition 155 | * which prevented it from fulfilling the request. 156 | */ 157 | export class InternalServerError extends HttpError { 158 | constructor(message?: string) { 159 | super('InternalServerError', message || 'Internal Server Error'); 160 | Object.setPrototypeOf(this, InternalServerError.prototype); 161 | this.statusCode = 500; 162 | } 163 | } 164 | 165 | /** 166 | * Represents a NOT IMPLEMENTED error. The server does not support the functionality required 167 | * to fulfill the request. This is the appropriate response when the server does not recognize 168 | * the request method and is not capable of supporting it for any resource. 169 | */ 170 | export class NotImplementedError extends HttpError { 171 | constructor(message?: string) { 172 | super('NotImplementedError', message || 'Not Implemented'); 173 | Object.setPrototypeOf(this, NotImplementedError.prototype); 174 | this.statusCode = 501; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/server/model/metadata.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { HttpMethod, ParserType, ServiceProcessor } from './server-types'; 4 | 5 | export interface ServiceProperty { 6 | type: ParamType; 7 | name: string; 8 | propertyType: any; 9 | } 10 | 11 | /** 12 | * Metadata for REST service classes 13 | */ 14 | export class ServiceClass { 15 | [key: string]: any; 16 | 17 | public targetClass: any; 18 | public path: string; 19 | public authenticator: Record>; 20 | public preProcessors: Array; 21 | public postProcessors: Array; 22 | public methods: Map; 23 | public bodyParserOptions: any; 24 | public bodyParserType: ParserType; 25 | public languages: Array; 26 | public accepts: Array; 27 | public properties: Map; 28 | public isAbstract: boolean = false; 29 | public ignoreNextMiddlewares: boolean = false; 30 | constructor(targetClass: any) { 31 | this.targetClass = targetClass; 32 | this.methods = new Map(); 33 | this.properties = new Map(); 34 | } 35 | 36 | public addProperty(key: string, property: ServiceProperty) { 37 | this.properties.set(key, property); 38 | } 39 | 40 | public hasProperties(): boolean { 41 | return (this.properties && this.properties.size > 0); 42 | } 43 | } 44 | 45 | /** 46 | * Metadata for REST service methods 47 | */ 48 | export class ServiceMethod { 49 | [key: string]: any; 50 | 51 | public name: string; 52 | public path: string; 53 | public authenticator: Record>; 54 | public resolvedPath: string; 55 | public httpMethod: HttpMethod; 56 | public parameters: Array = new Array(); 57 | public mustParseCookies: boolean = false; 58 | public files: Array = new Array(); 59 | public mustParseBody: boolean = false; 60 | public bodyParserOptions: any; 61 | public bodyParserType: ParserType; 62 | public mustParseForms: boolean = false; 63 | public acceptMultiTypedParam: boolean = false; 64 | public languages: Array; 65 | public accepts: Array; 66 | public resolvedLanguages: Array; 67 | public resolvedAccepts: Array; 68 | public preProcessors: Array; 69 | public postProcessors: Array; 70 | public ignoreNextMiddlewares: boolean = false; 71 | } 72 | 73 | /** 74 | * Metadata for File parameters on REST methods 75 | */ 76 | export class FileParam { 77 | 78 | public name: string; 79 | public singleFile: boolean; 80 | constructor(name: string, singleFile: boolean) { 81 | this.name = name; 82 | this.singleFile = singleFile; 83 | } 84 | } 85 | 86 | /** 87 | * Metadata for REST service method parameters 88 | */ 89 | export class MethodParam { 90 | 91 | public name: string; 92 | public type: Function; 93 | public paramType: ParamType; 94 | constructor(name: string, type: Function, paramType: ParamType) { 95 | this.name = name; 96 | this.type = type; 97 | this.paramType = paramType; 98 | } 99 | } 100 | 101 | /** 102 | * Enumeration of accepted parameter types 103 | */ 104 | export enum ParamType { 105 | path = 'path', 106 | query = 'query', 107 | header = 'header', 108 | cookie = 'cookie', 109 | form = 'form', 110 | body = 'body', 111 | param = 'param', 112 | file = 'file', 113 | files = 'files', 114 | context = 'context', 115 | context_request = 'context_request', 116 | context_response = 'context_response', 117 | context_next = 'context_next', 118 | context_accept = 'context_accept', 119 | context_accept_language = 'context_accept_language' 120 | } 121 | -------------------------------------------------------------------------------- /src/server/model/return-types.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { ReferencedResource } from './server-types'; 4 | 5 | /** 6 | * Inform that a new resource was created. Server will 7 | * add a Location header and set status to 201 8 | */ 9 | export class NewResource extends ReferencedResource { 10 | /** 11 | * Constructor. Receives the location of the new resource created. 12 | * @param location To be added to the Location header on response 13 | * @param body To be added to the response body 14 | */ 15 | constructor(location: string, body?: T) { 16 | super(location, 201); 17 | this.body = body; 18 | } 19 | } 20 | 21 | /** 22 | * Inform that the request was accepted but is not completed. 23 | * A Location header should inform the location where the user 24 | * can monitor his request processing status. 25 | */ 26 | export class RequestAccepted extends ReferencedResource { 27 | /** 28 | * Constructor. Receives the location where information about the 29 | * request processing can be found. 30 | * @param location To be added to the Location header on response 31 | * @param body To be added to the response body 32 | */ 33 | constructor(location: string, body?: T) { 34 | super(location, 202); 35 | this.body = body; 36 | } 37 | } 38 | 39 | /** 40 | * Inform that the resource has permanently 41 | * moved to a new location, and that future references should use a 42 | * new URI with their requests. 43 | */ 44 | export class MovedPermanently extends ReferencedResource { 45 | /** 46 | * Constructor. Receives the location where the resource can be found. 47 | * @param location To be added to the Location header on response 48 | * @param body To be added to the response body 49 | */ 50 | constructor(location: string, body?: T) { 51 | super(location, 301); 52 | this.body = body; 53 | } 54 | } 55 | 56 | /** 57 | * Inform that the resource has temporarily 58 | * moved to another location, but that future references should 59 | * still use the original URI to access the resource. 60 | */ 61 | export class MovedTemporarily extends ReferencedResource { 62 | /** 63 | * Constructor. Receives the location where the resource can be found. 64 | * @param location To be added to the Location header on response 65 | * @param body To be added to the response body 66 | */ 67 | constructor(location: string, body?: T) { 68 | super(location, 302); 69 | this.body = body; 70 | } 71 | } 72 | 73 | /** 74 | * Used to download a resource. 75 | */ 76 | export class DownloadResource { 77 | /** 78 | * Constructor. 79 | * @param filePath The file path to download. 80 | * @param fileName The file name 81 | */ 82 | constructor(public filePath: string, public fileName: string) { } 83 | } 84 | 85 | /** 86 | * Used to download binary data as a file. 87 | */ 88 | export class DownloadBinaryData { 89 | /** 90 | * Constructor. Receives the location of the resource. 91 | * @param content The binary data to be downloaded as a file. 92 | * @param mimeType The mime-type to be passed on Content-Type header. 93 | * @param fileName The file name 94 | */ 95 | constructor(public content: Buffer, public mimeType: string, public fileName?: string) { } 96 | } 97 | 98 | /** 99 | * If returned by a service, no response will be sent to client. Use it 100 | * if you want to send the response by yourself. 101 | */ 102 | export const NoResponse = {}; 103 | -------------------------------------------------------------------------------- /src/server/model/server-types.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as express from 'express'; 4 | 5 | /** 6 | * Limits for file uploads 7 | */ 8 | export interface FileLimits { 9 | /** Max field name size (Default: 100 bytes) */ 10 | fieldNameSize?: number; 11 | /** Max field value size (Default: 1MB) */ 12 | fieldSize?: number; 13 | /** Max number of non- file fields (Default: Infinity) */ 14 | fields?: number; 15 | /** For multipart forms, the max file size (in bytes)(Default: Infinity) */ 16 | fileSize?: number; 17 | /** For multipart forms, the max number of file fields (Default: Infinity) */ 18 | files?: number; 19 | /** For multipart forms, the max number of parts (fields + files)(Default: Infinity) */ 20 | parts?: number; 21 | /** For multipart forms, the max number of header key=> value pairs to parse Default: 2000(same as node's http). */ 22 | headerPairs?: number; 23 | } 24 | 25 | /** 26 | * The supported HTTP methods. 27 | */ 28 | export enum HttpMethod { 29 | GET = 'GET', 30 | POST = 'POST', 31 | PUT = 'PUT', 32 | DELETE = 'DELETE', 33 | HEAD = 'HEAD', 34 | OPTIONS = 'OPTIONS', 35 | PATCH = 'PATCH' 36 | } 37 | 38 | /** 39 | * Represents the current context of the request being handled. 40 | */ 41 | export class ServiceContext { 42 | /** 43 | * The resolved language to be used in the current request handling. 44 | */ 45 | public language: string; 46 | /** 47 | * The preferred media type to be used in the current request handling. 48 | */ 49 | public accept: string; 50 | /** 51 | * The request object. 52 | */ 53 | public request: express.Request; 54 | /** 55 | * The response object 56 | */ 57 | public response: express.Response; 58 | /** 59 | * The next function. It can be used to delegate to the next middleware 60 | * registered the processing of the current request. 61 | */ 62 | public next: express.NextFunction; 63 | } 64 | 65 | /** 66 | * Used to create a reference to a resource. 67 | */ 68 | export abstract class ReferencedResource { 69 | /** 70 | * the body to be sent 71 | */ 72 | public body: T; 73 | 74 | /** 75 | * Constructor. Receives the location of the resource. 76 | * @param location To be added to the Location header on response 77 | * @param statusCode the response status code to be sent 78 | */ 79 | constructor(public location: string, public statusCode: number) { } 80 | } 81 | 82 | /** 83 | * The factory used to instantiate the object services 84 | */ 85 | export interface ServiceFactory { 86 | /** 87 | * Create a new service object. Called before each request handling. 88 | */ 89 | create: (serviceClass: Function, context: ServiceContext) => any; 90 | /** 91 | * Return the type used to handle requests to the target service. 92 | * By default, returns the serviceClass received, but you can use this 93 | * to implement IoC integrations, once some frameworks like typescript-ioc or 94 | * Inversify can override constructors for injectable types. 95 | */ 96 | getTargetClass: (serviceClass: Function) => FunctionConstructor; 97 | } 98 | 99 | /** 100 | * An optional authenticator for rest services 101 | */ 102 | export interface ServiceAuthenticator { 103 | /** 104 | * Get the user list of roles. 105 | */ 106 | getRoles: (req: express.Request, res: express.Response) => Array; 107 | /** 108 | * Initialize the authenticator 109 | */ 110 | initialize(router: express.Router): void; 111 | /** 112 | * Retrieve the middleware used to authenticate users. 113 | */ 114 | getMiddleware(): express.RequestHandler; 115 | } 116 | 117 | export type ServiceProcessor = (req: express.Request, res?: express.Response) => void; 118 | export type ParameterConverter = (paramValue: any) => any; 119 | 120 | /** 121 | * The types of parsers to parse the message body 122 | */ 123 | export enum ParserType { 124 | json = 'json', 125 | text = 'text', 126 | raw = 'raw' 127 | } 128 | -------------------------------------------------------------------------------- /src/server/parameter-processor.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as debug from 'debug'; 4 | import { Errors } from '../typescript-rest'; 5 | import { ParamType, ServiceProperty } from './model/metadata'; 6 | import { ParameterConverter, ServiceContext } from './model/server-types'; 7 | import { ServerContainer } from './server-container'; 8 | 9 | type ParameterContextMapper = (context: ServiceContext, property: ServiceProperty) => any; 10 | 11 | export class ParameterProcessor { 12 | public static get() { 13 | return ParameterProcessor.instance; 14 | } 15 | private static instance = new ParameterProcessor(); 16 | private static defaultParamConverter: ParameterConverter = (p: any) => p; 17 | 18 | private parameterMapper: Map; 19 | private debugger = { 20 | build: debug('typescript-rest:parameter-processor:build'), 21 | runtime: debug('typescript-rest:parameter-processor:runtime') 22 | }; 23 | 24 | private constructor() { 25 | this.parameterMapper = this.initializeParameterMappers(); 26 | } 27 | 28 | public processParameter(context: ServiceContext, property: ServiceProperty) { 29 | const processor = this.parameterMapper.get(property.type); 30 | if (!processor) { 31 | throw new Errors.BadRequestError('Invalid parameter type'); 32 | } 33 | return processor(context, property); 34 | } 35 | 36 | private initializeParameterMappers() { 37 | this.debugger.build('Initializing parameters processors'); 38 | const parameterMapper: Map = new Map(); 39 | 40 | parameterMapper.set(ParamType.path, (context, property) => this.convertType(context.request.params[property.name], property.propertyType)); 41 | parameterMapper.set(ParamType.query, (context, property) => this.convertType(context.request.query[property.name] as string, property.propertyType)); 42 | parameterMapper.set(ParamType.header, (context, property) => this.convertType(context.request.header(property.name), property.propertyType)); 43 | parameterMapper.set(ParamType.cookie, (context, property) => this.convertType(context.request.cookies[property.name], property.propertyType)); 44 | parameterMapper.set(ParamType.body, (context, property) => this.convertType(context.request.body, property.propertyType)); 45 | parameterMapper.set(ParamType.file, (context, property) => { 46 | this.debugger.runtime('Processing file parameter'); 47 | // @ts-ignore 48 | const files: Array = context.request.files ? context.request.files[property.name] : null; 49 | if (files && files.length > 0) { 50 | return files[0]; 51 | } 52 | return null; 53 | }); 54 | parameterMapper.set(ParamType.files, (context, property) => { 55 | this.debugger.runtime('Processing files parameter'); 56 | // @ts-ignore 57 | return context.request.files[property.name]; 58 | }); 59 | parameterMapper.set(ParamType.form, (context, property) => this.convertType(context.request.body[property.name], property.propertyType)); 60 | parameterMapper.set(ParamType.param, (context, property) => { 61 | const paramValue = context.request.body[property.name] || 62 | context.request.query[property.name]; 63 | return this.convertType(paramValue, property.propertyType); 64 | }); 65 | parameterMapper.set(ParamType.context, (context) => context); 66 | parameterMapper.set(ParamType.context_request, (context) => context.request); 67 | parameterMapper.set(ParamType.context_response, (context) => context.response); 68 | parameterMapper.set(ParamType.context_next, (context) => context.next); 69 | parameterMapper.set(ParamType.context_accept, (context) => context.accept); 70 | parameterMapper.set(ParamType.context_accept_language, (context) => context.language); 71 | 72 | return parameterMapper; 73 | } 74 | 75 | private convertType(paramValue: string | boolean, paramType: Function): any { 76 | const serializedType = paramType['name']; 77 | this.debugger.runtime('Processing parameter. received type: %s, received value:', serializedType, paramValue); 78 | switch (serializedType) { 79 | case 'Number': 80 | return paramValue === undefined ? paramValue : parseFloat(paramValue as string); 81 | case 'Boolean': 82 | return paramValue === undefined ? paramValue : paramValue === 'true' || paramValue === true; 83 | default: 84 | let converter = ServerContainer.get().paramConverters.get(paramType); 85 | if (!converter) { 86 | converter = ParameterProcessor.defaultParamConverter; 87 | } 88 | 89 | return converter(paramValue); 90 | } 91 | } 92 | } 93 | 94 | -------------------------------------------------------------------------------- /src/server/server-container.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as bodyParser from 'body-parser'; 4 | import * as cookieParser from 'cookie-parser'; 5 | import * as debug from 'debug'; 6 | import * as express from 'express'; 7 | import { NextFunction, Request, Response } from 'express'; 8 | import * as _ from 'lodash'; 9 | import * as multer from 'multer'; 10 | import * as Errors from './model/errors'; 11 | import { ServiceClass, ServiceMethod } from './model/metadata'; 12 | import { 13 | FileLimits, HttpMethod, ParameterConverter, 14 | ParserType, ServiceAuthenticator, ServiceContext, ServiceFactory 15 | } from './model/server-types'; 16 | import { ServiceInvoker } from './service-invoker'; 17 | 18 | export class DefaultServiceFactory implements ServiceFactory { 19 | public create(serviceClass: any) { 20 | return new serviceClass(); 21 | } 22 | public getTargetClass(serviceClass: Function) { 23 | return serviceClass as FunctionConstructor; 24 | } 25 | } 26 | 27 | export class ServerContainer { 28 | public static get(): ServerContainer { 29 | return ServerContainer.instance; 30 | } 31 | 32 | private static instance: ServerContainer = new ServerContainer(); 33 | 34 | public cookiesSecret: string; 35 | public cookiesDecoder: (val: string) => string; 36 | public fileDest: string; 37 | public fileFilter: (req: Express.Request, file: Express.Multer.File, callback: (error: Error, acceptFile: boolean) => void) => void; 38 | public fileLimits: FileLimits; 39 | public ignoreNextMiddlewares: boolean = false; 40 | public authenticator: Map = new Map(); 41 | public serviceFactory: ServiceFactory = new DefaultServiceFactory(); 42 | public paramConverters: Map = new Map(); 43 | public router: express.Router; 44 | 45 | private debugger = { 46 | build: debug('typescript-rest:server-container:build'), 47 | runtime: debug('typescript-rest:server-container:runtime') 48 | }; 49 | private upload: multer.Multer; 50 | private serverClasses: Map = new Map(); 51 | private paths: Map> = new Map>(); 52 | private pathsResolved: boolean = false; 53 | 54 | private constructor() { } 55 | 56 | public registerServiceClass(target: Function): ServiceClass { 57 | this.pathsResolved = false; 58 | target = this.serviceFactory.getTargetClass(target); 59 | if (!this.serverClasses.has(target)) { 60 | this.debugger.build('Registering a new service class, %o', target); 61 | this.serverClasses.set(target, new ServiceClass(target)); 62 | this.inheritParentClass(target); 63 | } 64 | const serviceClass: ServiceClass = this.serverClasses.get(target); 65 | return serviceClass; 66 | } 67 | 68 | public registerServiceMethod(target: Function, methodName: string): ServiceMethod { 69 | if (methodName) { 70 | this.pathsResolved = false; 71 | const classData: ServiceClass = this.registerServiceClass(target); 72 | if (!classData.methods.has(methodName)) { 73 | this.debugger.build('Registering the rest method <%s> for the service class, %o', methodName, target); 74 | classData.methods.set(methodName, new ServiceMethod()); 75 | } 76 | const serviceMethod: ServiceMethod = classData.methods.get(methodName); 77 | return serviceMethod; 78 | } 79 | return null; 80 | } 81 | 82 | public getPaths(): Set { 83 | this.resolveAllPaths(); 84 | const result = new Set(); 85 | this.paths.forEach((value, key) => { 86 | result.add(key); 87 | }); 88 | return result; 89 | } 90 | 91 | public getHttpMethods(path: string): Set { 92 | this.resolveAllPaths(); 93 | const methods: Set = this.paths.get(path); 94 | return methods || new Set(); 95 | } 96 | 97 | public buildServices(types?: Array) { 98 | if (types) { 99 | types = types.map(type => this.serviceFactory.getTargetClass(type)); 100 | } 101 | this.debugger.build('Creating service endpoints for types: %o', types); 102 | if (this.authenticator) { 103 | this.authenticator.forEach((auth, name) => { 104 | this.debugger.build('Initializing authenticator: %s', name); 105 | auth.initialize(this.router); 106 | }); 107 | } 108 | this.serverClasses.forEach(classData => { 109 | if (!classData.isAbstract) { 110 | classData.methods.forEach(method => { 111 | if (this.validateTargetType(classData.targetClass, types)) { 112 | this.buildService(classData, method); 113 | } 114 | }); 115 | } 116 | }); 117 | this.pathsResolved = true; 118 | this.handleNotAllowedMethods(); 119 | } 120 | 121 | private inheritParentClass(target: Function) { 122 | const classData: ServiceClass = this.serverClasses.get(target); 123 | const parent = Object.getPrototypeOf(classData.targetClass.prototype).constructor; 124 | const parentClassData: ServiceClass = this.getServiceClass(parent); 125 | if (parentClassData) { 126 | if (parentClassData.methods) { 127 | parentClassData.methods.forEach((value, key) => { 128 | classData.methods.set(key, _.cloneDeep(value)); 129 | }); 130 | } 131 | 132 | if (parentClassData.properties) { 133 | parentClassData.properties.forEach((value, key) => { 134 | classData.properties.set(key, _.cloneDeep(value)); 135 | }); 136 | } 137 | 138 | if (parentClassData.languages) { 139 | classData.languages = _.union(classData.languages, parentClassData.languages); 140 | } 141 | 142 | if (parentClassData.accepts) { 143 | classData.accepts = _.union(classData.accepts, parentClassData.accepts); 144 | } 145 | } 146 | this.debugger.build('Service class registered with the given metadata: %o', classData); 147 | } 148 | 149 | private buildService(serviceClass: ServiceClass, serviceMethod: ServiceMethod) { 150 | this.debugger.build('Creating service endpoint for method: %o', serviceMethod); 151 | if (!serviceMethod.resolvedPath) { 152 | this.resolveProperties(serviceClass, serviceMethod); 153 | } 154 | 155 | let args: Array = [serviceMethod.resolvedPath]; 156 | args = args.concat(this.buildSecurityMiddlewares(serviceClass, serviceMethod)); 157 | args = args.concat(this.buildParserMiddlewares(serviceClass, serviceMethod)); 158 | args.push(this.buildServiceMiddleware(serviceMethod, serviceClass)); 159 | switch (serviceMethod.httpMethod) { 160 | case HttpMethod.GET: 161 | this.router.get.apply(this.router, args); 162 | break; 163 | case HttpMethod.POST: 164 | this.router.post.apply(this.router, args); 165 | break; 166 | case HttpMethod.PUT: 167 | this.router.put.apply(this.router, args); 168 | break; 169 | case HttpMethod.DELETE: 170 | this.router.delete.apply(this.router, args); 171 | break; 172 | case HttpMethod.HEAD: 173 | this.router.head.apply(this.router, args); 174 | break; 175 | case HttpMethod.OPTIONS: 176 | this.router.options.apply(this.router, args); 177 | break; 178 | case HttpMethod.PATCH: 179 | this.router.patch.apply(this.router, args); 180 | break; 181 | 182 | default: 183 | throw Error(`Invalid http method for service [${serviceMethod.resolvedPath}]`); 184 | } 185 | } 186 | 187 | private resolveAllPaths() { 188 | if (!this.pathsResolved) { 189 | this.debugger.build('Building the server list of paths'); 190 | this.paths.clear(); 191 | this.serverClasses.forEach(classData => { 192 | classData.methods.forEach(method => { 193 | if (!method.resolvedPath) { 194 | this.resolveProperties(classData, method); 195 | } 196 | }); 197 | }); 198 | this.pathsResolved = true; 199 | } 200 | } 201 | 202 | private getServiceClass(target: Function): ServiceClass { 203 | target = this.serviceFactory.getTargetClass(target); 204 | return this.serverClasses.get(target) || null; 205 | } 206 | 207 | private resolveProperties(serviceClass: ServiceClass, 208 | serviceMethod: ServiceMethod): void { 209 | this.resolveLanguages(serviceClass, serviceMethod); 210 | this.resolveAccepts(serviceClass, serviceMethod); 211 | this.resolvePath(serviceClass, serviceMethod); 212 | } 213 | 214 | private resolveLanguages(serviceClass: ServiceClass, 215 | serviceMethod: ServiceMethod): void { 216 | this.debugger.build('Resolving the list of acceptable languages for method %s', serviceMethod.name); 217 | 218 | const resolvedLanguages = _.union(serviceClass.languages, serviceMethod.languages); 219 | if (resolvedLanguages.length > 0) { 220 | serviceMethod.resolvedLanguages = resolvedLanguages; 221 | } 222 | } 223 | 224 | private resolveAccepts(serviceClass: ServiceClass, 225 | serviceMethod: ServiceMethod): void { 226 | 227 | this.debugger.build('Resolving the list of acceptable types for method %s', serviceMethod.name); 228 | const resolvedAccepts = _.union(serviceClass.accepts, serviceMethod.accepts); 229 | if (resolvedAccepts.length > 0) { 230 | serviceMethod.resolvedAccepts = resolvedAccepts; 231 | } 232 | } 233 | 234 | private resolvePath(serviceClass: ServiceClass, 235 | serviceMethod: ServiceMethod): void { 236 | 237 | this.debugger.build('Resolving the path for method %s', serviceMethod.name); 238 | 239 | const classPath: string = serviceClass.path ? serviceClass.path.trim() : ''; 240 | let resolvedPath = _.startsWith(classPath, '/') ? classPath : '/' + classPath; 241 | if (_.endsWith(resolvedPath, '/')) { 242 | resolvedPath = resolvedPath.slice(0, resolvedPath.length - 1); 243 | } 244 | 245 | if (serviceMethod.path) { 246 | const methodPath: string = serviceMethod.path.trim(); 247 | resolvedPath = resolvedPath + (_.startsWith(methodPath, '/') ? methodPath : '/' + methodPath); 248 | } 249 | 250 | let declaredHttpMethods: Set = this.paths.get(resolvedPath); 251 | if (!declaredHttpMethods) { 252 | declaredHttpMethods = new Set(); 253 | this.paths.set(resolvedPath, declaredHttpMethods); 254 | } 255 | if (declaredHttpMethods.has(serviceMethod.httpMethod)) { 256 | throw Error(`Duplicated declaration for path [${resolvedPath}], method [${serviceMethod.httpMethod}].`); 257 | } 258 | declaredHttpMethods.add(serviceMethod.httpMethod); 259 | serviceMethod.resolvedPath = resolvedPath; 260 | } 261 | 262 | private validateTargetType(targetClass: Function, types: Array): boolean { 263 | if (types && types.length > 0) { 264 | return (types.indexOf(targetClass) > -1); 265 | } 266 | return true; 267 | } 268 | 269 | private handleNotAllowedMethods() { 270 | this.debugger.build('Creating middleware to handle not allowed methods'); 271 | const paths: Set = this.getPaths(); 272 | paths.forEach((path) => { 273 | const supported: Set = this.getHttpMethods(path); 274 | const allowedMethods: Array = new Array(); 275 | supported.forEach((method: HttpMethod) => { 276 | allowedMethods.push(HttpMethod[method]); 277 | }); 278 | const allowed: string = allowedMethods.join(', '); 279 | this.debugger.build('Registering middleware to validate allowed HTTP methods for path %s.', path); 280 | this.debugger.build('Allowed HTTP methods [%s].', allowed); 281 | this.router.all(path, (req: express.Request, res: express.Response, next: express.NextFunction) => { 282 | if (res.headersSent || allowedMethods.indexOf(req.method) > -1) { 283 | next(); 284 | } else { 285 | res.set('Allow', allowed); 286 | throw new Errors.MethodNotAllowedError(); 287 | } 288 | }); 289 | }); 290 | } 291 | 292 | private getUploader(): multer.Multer { 293 | if (!this.upload) { 294 | const options: multer.Options = {}; 295 | if (this.fileDest) { 296 | options.dest = this.fileDest; 297 | } 298 | if (this.fileFilter) { 299 | options.fileFilter = this.fileFilter; 300 | } 301 | if (this.fileLimits) { 302 | options.limits = this.fileLimits; 303 | } 304 | if (options.dest) { 305 | this.debugger.build('Creating a file Uploader with options: %o.', options); 306 | this.upload = multer(options); 307 | } else { 308 | this.debugger.build('Creating a file Uploader with the default options.'); 309 | this.upload = multer(); 310 | } 311 | } 312 | return this.upload; 313 | } 314 | 315 | private buildServiceMiddleware(serviceMethod: ServiceMethod, serviceClass: ServiceClass) { 316 | const serviceInvoker = new ServiceInvoker(serviceClass, serviceMethod); 317 | this.debugger.build('Creating the service middleware for method <%s>.', serviceMethod.name); 318 | return async (req: express.Request, res: express.Response, next: express.NextFunction) => { 319 | const context: ServiceContext = new ServiceContext(); 320 | context.request = req; 321 | context.response = res; 322 | context.next = next; 323 | await serviceInvoker.callService(context); 324 | }; 325 | } 326 | 327 | private buildSecurityMiddlewares(serviceClass: ServiceClass, serviceMethod: ServiceMethod) { 328 | const result: Array = new Array(); 329 | const authenticatorMap: Record> | undefined = serviceMethod.authenticator || serviceClass.authenticator; 330 | if (this.authenticator && authenticatorMap) { 331 | const authenticatorNames: Array = Object.keys(authenticatorMap); 332 | for (const authenticatorName of authenticatorNames) { 333 | let roles: Array = authenticatorMap[authenticatorName]; 334 | this.debugger.build('Registering an authenticator middleware <%s> for method <%s>.', authenticatorName, serviceMethod.name); 335 | const authenticator = this.getAuthenticator(authenticatorName); 336 | result.push(authenticator.getMiddleware()); 337 | roles = roles.filter((role) => role !== '*'); 338 | if (roles.length) { 339 | this.debugger.build('Registering a role validator middleware <%s> for method <%s>.', authenticatorName, serviceMethod.name); 340 | this.debugger.build('Roles: <%j>.', roles); 341 | result.push(this.buildAuthMiddleware(authenticator, roles)); 342 | } 343 | } 344 | } 345 | 346 | return result; 347 | } 348 | 349 | private getAuthenticator(authenticatorName: string) { 350 | if (!this.authenticator.has(authenticatorName)) { 351 | throw new Error(`Invalid authenticator name ${authenticatorName}`); 352 | } 353 | return this.authenticator.get(authenticatorName); 354 | } 355 | 356 | private buildAuthMiddleware(authenticator: ServiceAuthenticator, roles: Array): express.RequestHandler { 357 | return (req: Request, res: Response, next: NextFunction) => { 358 | const requestRoles = authenticator.getRoles(req, res); 359 | if (this.debugger.runtime.enabled) { 360 | this.debugger.runtime('Validating authentication roles: <%j>.', requestRoles); 361 | } 362 | if (requestRoles.some((role: string) => roles.indexOf(role) >= 0)) { 363 | next(); 364 | } 365 | else { 366 | throw new Errors.ForbiddenError(); 367 | } 368 | }; 369 | } 370 | 371 | private buildParserMiddlewares(serviceClass: ServiceClass, serviceMethod: ServiceMethod): Array { 372 | const result: Array = new Array(); 373 | const bodyParserOptions = serviceMethod.bodyParserOptions || serviceClass.bodyParserOptions; 374 | 375 | if (serviceMethod.mustParseCookies) { 376 | this.debugger.build('Registering cookie parser middleware for method <%s>.', serviceMethod.name); 377 | result.push(this.buildCookieParserMiddleware()); 378 | } 379 | if (serviceMethod.mustParseBody) { 380 | const bodyParserType = serviceMethod.bodyParserType || serviceClass.bodyParserType || ParserType.json; 381 | this.debugger.build('Registering body %s parser middleware for method <%s>' + 382 | ' with options: %j.', ParserType[bodyParserType], serviceMethod.name, bodyParserOptions); 383 | result.push(this.buildBodyParserMiddleware(serviceMethod, bodyParserOptions, bodyParserType)); 384 | } 385 | if (serviceMethod.mustParseForms || serviceMethod.acceptMultiTypedParam) { 386 | this.debugger.build('Registering body form parser middleware for method <%s>' + 387 | ' with options: %j.', serviceMethod.name, bodyParserOptions); 388 | result.push(this.buildFormParserMiddleware(bodyParserOptions)); 389 | } 390 | if (serviceMethod.files.length > 0) { 391 | this.debugger.build('Registering file parser middleware for method <%s>.', serviceMethod.name); 392 | result.push(this.buildFilesParserMiddleware(serviceMethod)); 393 | } 394 | 395 | return result; 396 | } 397 | 398 | private buildBodyParserMiddleware(serviceMethod: ServiceMethod, bodyParserOptions: any, bodyParserType: ParserType) { 399 | switch (bodyParserType) { 400 | case ParserType.text: 401 | return this.buildTextBodyParserMiddleware(bodyParserOptions); 402 | case ParserType.raw: 403 | return this.buildRawBodyParserMiddleware(bodyParserOptions); 404 | default: 405 | return this.buildJsonBodyParserMiddleware(bodyParserOptions); 406 | } 407 | } 408 | 409 | private buildFilesParserMiddleware(serviceMethod: ServiceMethod) { 410 | const options: Array = new Array(); 411 | serviceMethod.files.forEach(fileData => { 412 | if (fileData.singleFile) { 413 | options.push({ 'name': fileData.name, 'maxCount': 1 }); 414 | } 415 | else { 416 | options.push({ 'name': fileData.name }); 417 | } 418 | }); 419 | this.debugger.build('Creating file parser with options %j.', options); 420 | return this.getUploader().fields(options); 421 | } 422 | 423 | private buildFormParserMiddleware(bodyParserOptions: any) { 424 | let middleware: express.RequestHandler; 425 | if (!bodyParserOptions) { 426 | bodyParserOptions = { extended: true }; 427 | } 428 | this.debugger.build('Creating form body parser with options %j.', bodyParserOptions); 429 | middleware = bodyParser.urlencoded(bodyParserOptions); 430 | return middleware; 431 | } 432 | 433 | private buildJsonBodyParserMiddleware(bodyParserOptions: any) { 434 | let middleware: express.RequestHandler; 435 | this.debugger.build('Creating json body parser with options %j.', bodyParserOptions || {}); 436 | if (bodyParserOptions) { 437 | middleware = bodyParser.json(bodyParserOptions); 438 | } 439 | else { 440 | middleware = bodyParser.json(); 441 | } 442 | return middleware; 443 | } 444 | 445 | private buildTextBodyParserMiddleware(bodyParserOptions: any) { 446 | let middleware: express.RequestHandler; 447 | this.debugger.build('Creating text body parser with options %j.', bodyParserOptions || {}); 448 | if (bodyParserOptions) { 449 | middleware = bodyParser.text(bodyParserOptions); 450 | } 451 | else { 452 | middleware = bodyParser.text(); 453 | } 454 | return middleware; 455 | } 456 | 457 | private buildRawBodyParserMiddleware(bodyParserOptions: any) { 458 | let middleware: express.RequestHandler; 459 | this.debugger.build('Creating raw body parser with options %j.', bodyParserOptions || {}); 460 | if (bodyParserOptions) { 461 | middleware = bodyParser.raw(bodyParserOptions); 462 | } 463 | else { 464 | middleware = bodyParser.raw(); 465 | } 466 | return middleware; 467 | } 468 | 469 | private buildCookieParserMiddleware() { 470 | const args = []; 471 | if (this.cookiesSecret) { 472 | args.push(this.cookiesSecret); 473 | } 474 | if (this.cookiesDecoder) { 475 | args.push({ decode: this.cookiesDecoder }); 476 | } 477 | this.debugger.build('Creating cookie parser with options %j.', args); 478 | const middleware = cookieParser.apply(this, args); 479 | return middleware; 480 | } 481 | } 482 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as debug from 'debug'; 4 | import * as express from 'express'; 5 | import * as fs from 'fs-extra'; 6 | import * as _ from 'lodash'; 7 | import 'multer'; 8 | import * as path from 'path'; 9 | import * as YAML from 'yamljs'; 10 | import { 11 | FileLimits, HttpMethod, ParameterConverter, 12 | ServiceAuthenticator, ServiceFactory 13 | } from './model/server-types'; 14 | import { ServerContainer } from './server-container'; 15 | 16 | const serverDebugger = debug('typescript-rest:server:build'); 17 | 18 | /** 19 | * The Http server main class. 20 | */ 21 | export class Server { 22 | /** 23 | * Create the routes for all classes decorated with our decorators 24 | */ 25 | public static buildServices(router: express.Router, ...types: Array) { 26 | if (!Server.locked) { 27 | serverDebugger('Creating typescript-rest services handlers'); 28 | const serverContainer = ServerContainer.get(); 29 | serverContainer.router = router; 30 | serverContainer.buildServices(types); 31 | } 32 | } 33 | 34 | /** 35 | * An alias for Server.loadServices() 36 | */ 37 | public static loadControllers(router: express.Router, patterns: string | Array, baseDir?: string) { 38 | Server.loadServices(router, patterns, baseDir); 39 | } 40 | 41 | /** 42 | * Load all services from the files that matches the patterns provided 43 | */ 44 | public static loadServices(router: express.Router, patterns: string | Array, baseDir?: string) { 45 | if (!Server.locked) { 46 | serverDebugger('Loading typescript-rest services %j. BaseDir: %s', patterns, baseDir); 47 | const importedTypes: Array = []; 48 | const requireGlob = require('require-glob'); 49 | baseDir = baseDir || process.cwd(); 50 | const loadedModules: Array = requireGlob.sync(patterns, { 51 | cwd: baseDir 52 | }); 53 | 54 | _.values(loadedModules).forEach(serviceModule => { 55 | _.values(serviceModule) 56 | .filter((service: Function) => typeof service === 'function') 57 | .forEach((service: Function) => { 58 | importedTypes.push(service); 59 | }); 60 | }); 61 | 62 | try { 63 | Server.buildServices(router, ...importedTypes); 64 | } catch (e) { 65 | serverDebugger('Error loading services for pattern: %j. Error: %o', patterns, e); 66 | serverDebugger('ImportedTypes: %o', importedTypes); 67 | throw new TypeError(`Error loading services for pattern: ${JSON.stringify(patterns)}. Error: ${e.message}`); 68 | } 69 | } 70 | } 71 | 72 | /** 73 | * Makes the server immutable. Any configuration change request to the Server 74 | * is ignored when immutable is true 75 | * @param value true to make immutable 76 | */ 77 | public static immutable(value: boolean) { 78 | Server.locked = value; 79 | } 80 | 81 | /** 82 | * Return true if the server is immutable. Any configuration change request to the Server 83 | * is ignored when immutable is true 84 | */ 85 | public static isImmutable() { 86 | return Server.locked; 87 | } 88 | 89 | /** 90 | * Retrieve the express router that serves the rest endpoints 91 | */ 92 | public static server() { 93 | return ServerContainer.get().router; 94 | } 95 | 96 | /** 97 | * Return all paths accepted by the Server 98 | */ 99 | public static getPaths(): Array { 100 | const result = new Array(); 101 | ServerContainer.get().getPaths().forEach(value => { 102 | result.push(value); 103 | }); 104 | 105 | return result; 106 | } 107 | 108 | /** 109 | * Register a custom serviceFactory. It will be used to instantiate the service Objects 110 | * If You plan to use a custom serviceFactory, You must ensure to call this method before any typescript-rest service declaration. 111 | */ 112 | public static registerServiceFactory(serviceFactory: ServiceFactory | string) { 113 | if (!Server.locked) { 114 | let factory: ServiceFactory; 115 | if (typeof serviceFactory === 'string') { 116 | const mod = require(serviceFactory); 117 | factory = mod.default ? mod.default : mod; 118 | } else { 119 | factory = serviceFactory as ServiceFactory; 120 | } 121 | 122 | serverDebugger('Registering a new serviceFactory'); 123 | ServerContainer.get().serviceFactory = factory; 124 | } 125 | } 126 | 127 | /** 128 | * Register a service authenticator. It will be used to authenticate users before the service method 129 | * invocations occurs. 130 | */ 131 | public static registerAuthenticator(authenticator: ServiceAuthenticator, name: string = 'default') { 132 | if (!Server.locked) { 133 | serverDebugger('Registering a new authenticator with name %s', name); 134 | ServerContainer.get().authenticator.set(name, authenticator); 135 | } 136 | } 137 | 138 | /** 139 | * Return the set oh HTTP verbs configured for the given path 140 | * @param servicePath The path to search HTTP verbs 141 | */ 142 | public static getHttpMethods(servicePath: string): Array { 143 | const result = new Array(); 144 | ServerContainer.get().getHttpMethods(servicePath).forEach(value => { 145 | result.push(value); 146 | }); 147 | 148 | return result; 149 | } 150 | 151 | /** 152 | * A string used for signing cookies. This is optional and if not specified, 153 | * will not parse signed cookies. 154 | * @param secret the secret used to sign 155 | */ 156 | public static setCookiesSecret(secret: string) { 157 | if (!Server.locked) { 158 | serverDebugger('Setting a new secret for cookies: %s', secret); 159 | ServerContainer.get().cookiesSecret = secret; 160 | } 161 | } 162 | 163 | /** 164 | * Specifies a function that will be used to decode a cookie's value. 165 | * This function can be used to decode a previously-encoded cookie value 166 | * into a JavaScript string. 167 | * The default function is the global decodeURIComponent, which will decode 168 | * any URL-encoded sequences into their byte representations. 169 | * 170 | * NOTE: if an error is thrown from this function, the original, non-decoded 171 | * cookie value will be returned as the cookie's value. 172 | * @param decoder The decoder function 173 | */ 174 | public static setCookiesDecoder(decoder: (val: string) => string) { 175 | if (!Server.locked) { 176 | serverDebugger('Setting a new secret decoder'); 177 | ServerContainer.get().cookiesDecoder = decoder; 178 | } 179 | } 180 | 181 | /** 182 | * Set where to store the uploaded files 183 | * @param dest Destination folder 184 | */ 185 | public static setFileDest(dest: string) { 186 | if (!Server.locked) { 187 | serverDebugger('Setting a new destination for files: %s', dest); 188 | ServerContainer.get().fileDest = dest; 189 | } 190 | } 191 | 192 | /** 193 | * Set a Function to control which files are accepted to upload 194 | * @param filter The filter function 195 | */ 196 | public static setFileFilter(filter: (req: Express.Request, file: Express.Multer.File, 197 | callback: (error: Error, acceptFile: boolean) => void) => void) { 198 | if (!Server.locked) { 199 | serverDebugger('Setting a new filter for files'); 200 | ServerContainer.get().fileFilter = filter; 201 | } 202 | } 203 | 204 | /** 205 | * Set the limits of uploaded data 206 | * @param limit The data limit 207 | */ 208 | public static setFileLimits(limit: FileLimits) { 209 | if (!Server.locked) { 210 | serverDebugger('Setting a new fileLimits: %j', limit); 211 | ServerContainer.get().fileLimits = limit; 212 | } 213 | } 214 | 215 | /** 216 | * Adds a converter for param values to have an ability to intercept the type that actually will be passed to service 217 | * @param converter The converter 218 | * @param type The target type that needs to be converted 219 | */ 220 | public static addParameterConverter(converter: ParameterConverter, type: Function): void { 221 | if (!Server.locked) { 222 | serverDebugger('Adding a new parameter converter'); 223 | ServerContainer.get().paramConverters.set(type, converter); 224 | } 225 | } 226 | 227 | /** 228 | * Remove the converter associated with the given type. 229 | * @param type The target type that needs to be converted 230 | */ 231 | public static removeParameterConverter(type: Function): void { 232 | if (!Server.locked) { 233 | serverDebugger('Removing a parameter converter'); 234 | ServerContainer.get().paramConverters.delete(type); 235 | } 236 | } 237 | 238 | /** 239 | * Makes the server ignore next middlewares for all endpoints. 240 | * It has the same effect than add @IgnoreNextMiddlewares to all 241 | * services. 242 | * @param value - true to ignore next middlewares. 243 | */ 244 | public static ignoreNextMiddlewares(value: boolean) { 245 | if (!Server.locked) { 246 | serverDebugger('Ignoring next middlewares: %b', value); 247 | ServerContainer.get().ignoreNextMiddlewares = value; 248 | } 249 | } 250 | 251 | /** 252 | * Creates and endpoint to publish the swagger documentation. 253 | * @param router Express router 254 | * @param options Options for swagger endpoint 255 | */ 256 | public static swagger(router: express.Router, options?: SwaggerOptions) { 257 | if (!Server.locked) { 258 | const swaggerUi = require('swagger-ui-express'); 259 | options = Server.getOptions(options); 260 | serverDebugger('Configuring open api documentation endpoints for options: %j', options); 261 | 262 | const swaggerDocument: any = Server.loadSwaggerDocument(options); 263 | 264 | if (options.host) { 265 | swaggerDocument.host = options.host; 266 | } 267 | if (options.schemes) { 268 | swaggerDocument.schemes = options.schemes; 269 | } 270 | 271 | router.get(path.posix.join('/', options.endpoint, 'json'), (req, res, next) => { 272 | res.send(swaggerDocument); 273 | }); 274 | router.get(path.posix.join('/', options.endpoint, 'yaml'), (req, res, next) => { 275 | res.set('Content-Type', 'text/vnd.yaml'); 276 | res.send(YAML.stringify(swaggerDocument, 1000)); 277 | }); 278 | router.use(path.posix.join('/', options.endpoint), swaggerUi.serve, swaggerUi.setup(swaggerDocument, options.swaggerUiOptions)); 279 | } 280 | } 281 | 282 | private static locked = false; 283 | 284 | private static loadSwaggerDocument(options: SwaggerOptions) { 285 | let swaggerDocument: any; 286 | if (_.endsWith(options.filePath, '.yml') || _.endsWith(options.filePath, '.yaml')) { 287 | swaggerDocument = YAML.load(options.filePath); 288 | } 289 | else { 290 | swaggerDocument = fs.readJSONSync(options.filePath); 291 | } 292 | serverDebugger('Loaded swagger configurations: %j', swaggerDocument); 293 | return swaggerDocument; 294 | } 295 | 296 | private static getOptions(options: SwaggerOptions) { 297 | options = _.defaults(options, { 298 | endpoint: 'api-docs', 299 | filePath: './swagger.json' 300 | }); 301 | if (_.startsWith(options.filePath, '.')) { 302 | options.filePath = path.join(process.cwd(), options.filePath); 303 | } 304 | return options; 305 | } 306 | } 307 | 308 | export interface SwaggerOptions { 309 | /** 310 | * The path to a swagger file (json or yaml) 311 | */ 312 | filePath?: string; 313 | /** 314 | * Where to publish the docs 315 | */ 316 | endpoint?: string; 317 | /** 318 | * The hostname of the service 319 | */ 320 | host?: string; 321 | /** 322 | * The schemes used by the server 323 | */ 324 | schemes?: Array; 325 | /** 326 | * Options to send to swagger-ui 327 | */ 328 | swaggerUiOptions?: object; 329 | } 330 | -------------------------------------------------------------------------------- /src/server/service-invoker.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as debug from 'debug'; 4 | import * as express from 'express'; 5 | import * as _ from 'lodash'; 6 | import { Errors } from '../typescript-rest'; 7 | import { ServiceClass, ServiceMethod, ServiceProperty } from './model/metadata'; 8 | import { DownloadBinaryData, DownloadResource, NoResponse } from './model/return-types'; 9 | import { HttpMethod, ReferencedResource, ServiceContext, ServiceProcessor } from './model/server-types'; 10 | import { ParameterProcessor } from './parameter-processor'; 11 | import { ServerContainer } from './server-container'; 12 | 13 | export class ServiceInvoker { 14 | private serviceClass: ServiceClass; 15 | private serviceMethod: ServiceMethod; 16 | private preProcessors: Array; 17 | private postProcessors: Array; 18 | private debugger = debug('typescript-rest:service-invoker:runtime'); 19 | 20 | constructor(serviceClass: ServiceClass, serviceMethod: ServiceMethod) { 21 | this.serviceClass = serviceClass; 22 | this.serviceMethod = serviceMethod; 23 | this.preProcessors = _.union(serviceMethod.preProcessors, serviceClass.preProcessors); 24 | this.postProcessors = _.union(serviceMethod.postProcessors, serviceClass.postProcessors); 25 | } 26 | 27 | public async callService(context: ServiceContext) { 28 | try { 29 | await this.callTargetEndPoint(context); 30 | if (this.mustCallNext()) { 31 | context.next(); 32 | } else if (this.debugger.enabled) { 33 | this.debugger('Ignoring next middlewares'); 34 | } 35 | } 36 | catch (err) { 37 | context.next(err); 38 | } 39 | } 40 | 41 | private mustCallNext() { 42 | return !ServerContainer.get().ignoreNextMiddlewares && 43 | !this.serviceMethod.ignoreNextMiddlewares && !this.serviceClass.ignoreNextMiddlewares; 44 | } 45 | 46 | private async runPreProcessors(context: ServiceContext): Promise { 47 | this.debugger('Running preprocessors'); 48 | for (const processor of this.preProcessors) { 49 | await Promise.resolve(processor(context.request, context.response)); 50 | } 51 | } 52 | 53 | private async runPostProcessors(context: ServiceContext): Promise { 54 | this.debugger('Running postprocessors'); 55 | for (const processor of this.postProcessors) { 56 | await Promise.resolve(processor(context.request, context.response)); 57 | } 58 | } 59 | 60 | private async callTargetEndPoint(context: ServiceContext) { 61 | this.debugger('Calling targetEndpoint %s', this.serviceMethod.resolvedPath); 62 | this.checkAcceptance(context); 63 | if (this.preProcessors.length) { 64 | await this.runPreProcessors(context); 65 | } 66 | const serviceObject = this.createService(context); 67 | const args = this.buildArgumentsList(context); 68 | const toCall = this.getMethodToCall(); 69 | if (this.debugger.enabled) { 70 | this.debugger('Invoking service method <%s> with params: %j', this.serviceMethod.name, args); 71 | } 72 | const result = await toCall.apply(serviceObject, args); 73 | if (this.postProcessors.length) { 74 | await this.runPostProcessors(context); 75 | } 76 | this.processResponseHeaders(context); 77 | await this.sendValue(result, context); 78 | } 79 | 80 | private getMethodToCall() { 81 | return this.serviceClass.targetClass.prototype[this.serviceMethod.name] 82 | || this.serviceClass.targetClass[this.serviceMethod.name]; 83 | } 84 | 85 | private checkAcceptance(context: ServiceContext): void { 86 | this.debugger('Verifying accept headers'); 87 | this.identifyAcceptedLanguage(context); 88 | this.identifyAcceptedType(context); 89 | 90 | if (!context.accept) { 91 | throw new Errors.NotAcceptableError('Accept'); 92 | } 93 | if (!context.language) { 94 | throw new Errors.NotAcceptableError('Accept-Language'); 95 | } 96 | } 97 | 98 | private identifyAcceptedLanguage(context: ServiceContext) { 99 | if (this.serviceMethod.resolvedLanguages) { 100 | const lang: any = context.request.acceptsLanguages(this.serviceMethod.resolvedLanguages); 101 | if (lang) { 102 | context.language = lang as string; 103 | } 104 | } else { 105 | const languages: Array = context.request.acceptsLanguages(); 106 | if (languages && languages.length > 0) { 107 | context.language = languages[0]; 108 | } 109 | } 110 | this.debugger('Identified the preferable language accepted by server: %s', context.language); 111 | } 112 | 113 | private identifyAcceptedType(context: ServiceContext) { 114 | if (this.serviceMethod.resolvedAccepts) { 115 | context.accept = context.request.accepts(this.serviceMethod.resolvedAccepts) as string; 116 | } else { 117 | const accepts: Array = context.request.accepts(); 118 | if (accepts && accepts.length > 0) { 119 | context.accept = accepts[0]; 120 | } 121 | } 122 | this.debugger('Identified the preferable media type accepted by server: %s', context.accept); 123 | } 124 | 125 | private createService(context: ServiceContext) { 126 | const serviceObject = ServerContainer.get().serviceFactory.create(this.serviceClass.targetClass, context); 127 | this.debugger('Creating service object'); 128 | if (this.serviceClass.hasProperties()) { 129 | this.serviceClass.properties.forEach((property, key) => { 130 | this.debugger('Setting service property %s', key); 131 | serviceObject[key] = this.processParameter(context, property); 132 | }); 133 | } 134 | return serviceObject; 135 | } 136 | 137 | private buildArgumentsList(context: ServiceContext) { 138 | const result: Array = new Array(); 139 | 140 | this.serviceMethod.parameters.forEach(param => { 141 | this.debugger('Processing service parameter [%s]', param.name || 'body'); 142 | result.push(this.processParameter(context, { 143 | name: param.name, 144 | propertyType: param.type, 145 | type: param.paramType 146 | })); 147 | }); 148 | 149 | return result; 150 | } 151 | 152 | private processParameter(context: ServiceContext, property: ServiceProperty) { 153 | return ParameterProcessor.get().processParameter(context, property); 154 | } 155 | 156 | private processResponseHeaders(context: ServiceContext) { 157 | if (this.serviceMethod.resolvedLanguages) { 158 | if (this.serviceMethod.httpMethod === HttpMethod.GET) { 159 | this.debugger('Adding response header vary: Accept-Language'); 160 | context.response.vary('Accept-Language'); 161 | } 162 | this.debugger('Adding response header Content-Language: %s', context.language); 163 | context.response.set('Content-Language', context.language); 164 | } 165 | if (this.serviceMethod.resolvedAccepts) { 166 | if (this.serviceMethod.httpMethod === HttpMethod.GET) { 167 | this.debugger('Adding response header vary: Accept'); 168 | context.response.vary('Accept'); 169 | } 170 | } 171 | } 172 | 173 | private async sendValue(value: any, context: ServiceContext) { 174 | if (value !== NoResponse) { 175 | this.debugger('Sending response value: %o', value); 176 | switch (typeof value) { 177 | case 'number': 178 | context.response.send(value.toString()); 179 | break; 180 | case 'string': 181 | context.response.send(value); 182 | break; 183 | case 'boolean': 184 | context.response.send(value.toString()); 185 | break; 186 | case 'undefined': 187 | if (!context.response.headersSent) { 188 | context.response.sendStatus(204); 189 | } 190 | break; 191 | default: 192 | value === null 193 | ? context.response.send(value) 194 | : await this.sendComplexValue(context, value); 195 | } 196 | } else { 197 | this.debugger('Do not send any response value'); 198 | } 199 | } 200 | 201 | private async sendComplexValue(context: ServiceContext, value: any) { 202 | if (value.filePath && value instanceof DownloadResource) { 203 | await this.downloadResToPromise(context.response, value); 204 | } 205 | else if (value instanceof DownloadBinaryData) { 206 | this.sendFile(context, value); 207 | } 208 | else if (value.location && value instanceof ReferencedResource) { 209 | await this.sendReferencedResource(context, value); 210 | } 211 | else if (value.then && value.catch) { 212 | const val = await value; 213 | await this.sendValue(val, context); 214 | } 215 | else { 216 | this.debugger('Sending a json value: %j', value); 217 | context.response.json(value); 218 | } 219 | } 220 | 221 | private async sendReferencedResource(context: ServiceContext, value: ReferencedResource) { 222 | this.debugger('Setting the header Location: %s', value.location); 223 | this.debugger('Sendinf status code: %d', value.statusCode); 224 | context.response.set('Location', value.location); 225 | if (value.body) { 226 | context.response.status(value.statusCode); 227 | await this.sendValue(value.body, context); 228 | } 229 | else { 230 | context.response.sendStatus(value.statusCode); 231 | } 232 | } 233 | 234 | private sendFile(context: ServiceContext, value: DownloadBinaryData) { 235 | this.debugger('Sending file as response'); 236 | if (value.fileName) { 237 | context.response.writeHead(200, { 238 | 'Content-Length': value.content.length, 239 | 'Content-Type': value.mimeType, 240 | 'Content-disposition': 'attachment;filename=' + value.fileName 241 | }); 242 | } 243 | else { 244 | context.response.writeHead(200, { 245 | 'Content-Length': value.content.length, 246 | 'Content-Type': value.mimeType 247 | }); 248 | } 249 | context.response.end(value.content); 250 | } 251 | 252 | private downloadResToPromise(res: express.Response, value: DownloadResource) { 253 | this.debugger('Sending a resource to download. Path: %s', value.filePath); 254 | return new Promise((resolve, reject) => { 255 | res.download(value.filePath, value.fileName || value.filePath, (err) => { 256 | if (err) { 257 | reject(err); 258 | } else { 259 | resolve(undefined); 260 | } 261 | }); 262 | }); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/typescript-rest.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { ServerConfig } from './server/config'; 4 | import * as Errors from './server/model/errors'; 5 | import * as Return from './server/model/return-types'; 6 | 7 | export * from './decorators/parameters'; 8 | export * from './decorators/methods'; 9 | export * from './decorators/services'; 10 | export * from './server/model/server-types'; 11 | export * from './server/server'; 12 | export * from './authenticator/passport'; 13 | 14 | export { Return }; 15 | export { Errors }; 16 | export { DefaultServiceFactory } from './server/server-container'; 17 | 18 | ServerConfig.configure(); 19 | -------------------------------------------------------------------------------- /test/data/apis.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as _ from 'lodash'; 4 | 5 | import { GET, Path } from '../../src/typescript-rest'; 6 | 7 | 8 | @Path('simplepath') 9 | export class SimpleService { 10 | @GET 11 | public test(): string { 12 | return 'simpleservice'; 13 | } 14 | 15 | @GET 16 | @Path('secondpath') 17 | public test2(): string { 18 | return 'simpleservice'; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/data/swagger.yaml: -------------------------------------------------------------------------------- 1 | basePath: /v1 2 | consumes: 3 | - application/json 4 | definitions: 5 | Address: 6 | description: "" 7 | properties: 8 | street: 9 | type: string 10 | description: "" 11 | required: 12 | - street 13 | type: object 14 | Person: 15 | description: "" 16 | properties: 17 | name: 18 | type: string 19 | description: "" 20 | address: 21 | $ref: '#/definitions/Address' 22 | required: 23 | - name 24 | type: object 25 | info: 26 | description: 'a description' 27 | license: 28 | name: MIT 29 | title: 'Typescript-rest Test API' 30 | version: '1.0' 31 | paths: 32 | /mypath: 33 | get: 34 | operationId: MyServiceTest 35 | produces: 36 | - application/json 37 | responses: 38 | '200': 39 | description: Ok 40 | schema: 41 | type: string 42 | description: "" 43 | parameters: [] 44 | /mypath/secondpath: 45 | get: 46 | operationId: MyServiceTest2 47 | produces: 48 | - application/json 49 | responses: 50 | '200': 51 | description: Ok 52 | schema: 53 | $ref: '#/definitions/Person' 54 | description: 'Esta eh a da classe' 55 | parameters: 56 | - 57 | description: 'Esta eh a description do param teste' 58 | in: query 59 | name: testParam 60 | required: false 61 | type: string 62 | /promise: 63 | get: 64 | operationId: PromiseServiceTest 65 | produces: 66 | - application/json 67 | responses: 68 | '200': 69 | description: Ok 70 | schema: 71 | $ref: '#/definitions/Person' 72 | description: 'Esta eh a da classe' 73 | parameters: 74 | - 75 | description: 'Esta eh a description do param teste' 76 | in: query 77 | name: testParam 78 | required: false 79 | type: string 80 | post: 81 | operationId: PromiseServiceTestPost 82 | produces: 83 | - application/json 84 | responses: 85 | '201': 86 | description: Ok 87 | schema: 88 | $ref: '#/definitions/Person' 89 | description: "" 90 | parameters: 91 | - 92 | description: "" 93 | in: body 94 | name: obj 95 | required: true 96 | schema: 97 | $ref: '#/definitions/Person' 98 | produces: 99 | - application/json 100 | swagger: '2.0' 101 | securityDefinitions: 102 | api_key: 103 | type: apiKey 104 | name: access_token 105 | in: query 106 | host: 'localhost:3000' 107 | -------------------------------------------------------------------------------- /test/integration/authenticator.spec.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as jwt from 'jsonwebtoken'; 3 | import * as _ from 'lodash'; 4 | import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt'; 5 | import * as request from 'request'; 6 | import { Context, GET, PassportAuthenticator, Path, POST, PUT, Security, Server, ServiceContext } from '../../src/typescript-rest'; 7 | 8 | 9 | @Path('authorization') 10 | @Security() 11 | export class AuthenticatePath { 12 | @Context 13 | public context: ServiceContext; 14 | 15 | @GET 16 | public test(): Express.User { 17 | return this.context.request.user; 18 | } 19 | } 20 | 21 | @Path('authorization/with/role') 22 | @Security('ROLE_ADMIN') 23 | export class AuthenticateRole { 24 | @Context 25 | public context: ServiceContext; 26 | 27 | @GET 28 | public test(): Express.User { 29 | return this.context.request.user; 30 | } 31 | } 32 | 33 | @Path('authorization/secondAuthenticator') 34 | @Security('ROLE_ADMIN', 'secondAuthenticator') 35 | export class MultipleAuthenticateRole { 36 | @Context 37 | public context: ServiceContext; 38 | 39 | @GET 40 | public test(): Express.User { 41 | return this.context.request.user; 42 | } 43 | } 44 | 45 | @Path('authorization/without/role') 46 | @Security('ROLE_NOT_EXISTING') 47 | export class AuthenticateWithoutRole { 48 | @Context 49 | public context: ServiceContext; 50 | 51 | @GET 52 | public test(): Express.User { 53 | return this.context.request.user; 54 | } 55 | } 56 | 57 | 58 | @Path('/authorization/methods') 59 | export class AuthenticateMethods { 60 | @Context 61 | public context: ServiceContext; 62 | 63 | @GET 64 | @Path('public') 65 | public test(): string { 66 | return 'OK'; 67 | } 68 | 69 | @POST 70 | @Path('profile') 71 | @Security(['ROLE_ADMIN', 'ROLE_USER']) 72 | public test3(): Express.User { 73 | return this.context.request.user; 74 | } 75 | 76 | @GET 77 | @Path('profile') 78 | public test2(): string { 79 | return 'OK'; 80 | } 81 | 82 | @PUT 83 | @Path('profile') 84 | @Security('ROLE_NOT_EXISTING') 85 | public test4(): Express.User { 86 | return this.context.request.user; 87 | } 88 | } 89 | 90 | describe('Authenticator Tests', () => { 91 | beforeAll(() => { 92 | return startApi(); 93 | }); 94 | 95 | afterAll(function () { 96 | stopApi(); 97 | }); 98 | 99 | describe('Authorization', () => { 100 | it('should not authorize without header', (done) => { 101 | request('http://localhost:5674/authorization', (error, response, body) => { 102 | expect(response.statusCode).toEqual(401); 103 | expect(body).toEqual('Unauthorized'); 104 | done(); 105 | }); 106 | }); 107 | it('should not authorize with wrong token', (done) => { 108 | request('http://localhost:5674/authorization', { 109 | headers: { 110 | 'Authorization': 'Bearer xx' 111 | } 112 | }, (error, response, body) => { 113 | expect(response.statusCode).toEqual(401); 114 | expect(body).toEqual('Unauthorized'); 115 | done(); 116 | }); 117 | }); 118 | it('should authorize with header', (done) => { 119 | request('http://localhost:5674/authorization', { 120 | headers: { 121 | 'Authorization': `Bearer ${generateJwt()}` 122 | } 123 | }, (error, response, body) => { 124 | expect(response.statusCode).toEqual(200); 125 | expect(JSON.parse(body)).toMatchObject({ username: 'admin' }); 126 | done(); 127 | }); 128 | }); 129 | }); 130 | 131 | describe('Authorization with role', () => { 132 | it('should not authorize without header', (done) => { 133 | request('http://localhost:5674/authorization/with/role', (error, response, body) => { 134 | expect(response.statusCode).toEqual(401); 135 | expect(body).toEqual('Unauthorized'); 136 | done(); 137 | }); 138 | }); 139 | it('should not authorize with wrong token', (done) => { 140 | request('http://localhost:5674/authorization/with/role', { 141 | headers: { 142 | 'Authorization': 'Bearer xx' 143 | } 144 | }, (error, response, body) => { 145 | expect(response.statusCode).toEqual(401); 146 | expect(body).toEqual('Unauthorized'); 147 | done(); 148 | }); 149 | }); 150 | it('should authorize with header', (done) => { 151 | request('http://localhost:5674/authorization/with/role', { 152 | headers: { 153 | 'Authorization': `Bearer ${generateJwt()}` 154 | } 155 | }, (error, response, body) => { 156 | expect(response.statusCode).toEqual(200); 157 | const user = JSON.parse(body); 158 | expect(user).toMatchObject({ username: 'admin' }); 159 | expect(user).toMatchObject({ strategy: 'default' }); 160 | done(); 161 | }); 162 | }); 163 | }); 164 | 165 | describe('Multiple Authorizations registered', () => { 166 | it('should authorize with the correct autorization', (done) => { 167 | request('http://localhost:5674/authorization/secondAuthenticator', { 168 | headers: { 169 | 'Authorization': `Bearer ${generateJwt()}` 170 | } 171 | }, (error, response, body) => { 172 | expect(response.statusCode).toEqual(200); 173 | const user = JSON.parse(body); 174 | expect(user).toMatchObject({ username: 'admin' }); 175 | expect(user).toMatchObject({ strategy: 'second' }); 176 | done(); 177 | }); 178 | }); 179 | }); 180 | 181 | describe('Authorization without role', () => { 182 | it('should not authorize without header', (done) => { 183 | request('http://localhost:5674/authorization/without/role', (error, response, body) => { 184 | expect(response.statusCode).toEqual(401); 185 | expect(body).toEqual('Unauthorized'); 186 | done(); 187 | }); 188 | }); 189 | it('should not authorize with wrong token', (done) => { 190 | request('http://localhost:5674/authorization/without/role', { 191 | headers: { 192 | 'Authorization': 'Bearer xx' 193 | } 194 | }, (error, response, body) => { 195 | expect(response.statusCode).toEqual(401); 196 | expect(body).toEqual('Unauthorized'); 197 | done(); 198 | }); 199 | }); 200 | it('should not authorize with header and without appropiate role', (done) => { 201 | request('http://localhost:5674/authorization/without/role', { 202 | headers: { 203 | 'Authorization': `Bearer ${generateJwt()}` 204 | } 205 | }, (error, response, body) => { 206 | expect(response.statusCode).toEqual(403); 207 | done(); 208 | }); 209 | }); 210 | }); 211 | 212 | describe('Authorization for methods', () => { 213 | it('should work in "public" methods', (done) => { 214 | request('http://localhost:5674/authorization/methods/public', (error, response, body) => { 215 | expect(response.statusCode).toEqual(200); 216 | expect(body).toEqual('OK'); 217 | done(); 218 | }); 219 | }); 220 | it('should not authorize without header', (done) => { 221 | request.post('http://localhost:5674/authorization/methods/profile', 222 | (error, response, body) => { 223 | expect(response.statusCode).toEqual(401); 224 | done(); 225 | }); 226 | }); 227 | it('should not authorize with wrong token', (done) => { 228 | request.post('http://localhost:5674/authorization/methods/profile', { 229 | headers: { 230 | 'Authorization': 'Bearer xx' 231 | } 232 | }, (error, response, body) => { 233 | expect(response.statusCode).toEqual(401); 234 | done(); 235 | }); 236 | }); 237 | it('should authorize with header', (done) => { 238 | request.post('http://localhost:5674/authorization/methods/profile', { 239 | headers: { 240 | 'Authorization': `Bearer ${generateJwt()}` 241 | } 242 | }, (error, response, body) => { 243 | expect(response.statusCode).toEqual(200); 244 | expect(JSON.parse(body)).toMatchObject({ username: 'admin' }); 245 | done(); 246 | }); 247 | }); 248 | it('should authorize in GET method', (done) => { 249 | request('http://localhost:5674/authorization/methods/profile', (error, response, body) => { 250 | expect(response.statusCode).toEqual(200); 251 | expect(body).toEqual('OK'); 252 | done(); 253 | }); 254 | }); 255 | it('should not authorize in PUT method', (done) => { 256 | request.put('http://localhost:5674/authorization/methods/profile', { 257 | headers: { 258 | 'Authorization': `Bearer ${generateJwt()}` 259 | } 260 | }, (error, response, body) => { 261 | expect(response.statusCode).toEqual(403); 262 | done(); 263 | }); 264 | }); 265 | }); 266 | }); 267 | 268 | const JWT_SECRET: string = 'some-jwt-secret'; 269 | 270 | const jwtConfig: StrategyOptions = { 271 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 272 | secretOrKey: Buffer.from(JWT_SECRET, 'base64'), 273 | }; 274 | 275 | interface JwtUser { 276 | username: string; 277 | roles: Array; 278 | strategy: string; 279 | } 280 | 281 | interface JwtUserPayload { 282 | sub: string; 283 | auth: string; 284 | } 285 | 286 | function configureAuthenticator() { 287 | const strategy = new Strategy(jwtConfig, (payload: JwtUserPayload, done: (a: null, b: JwtUser) => void) => { 288 | const user: JwtUser = { 289 | roles: payload.auth.split(','), 290 | strategy: 'default', 291 | username: payload.sub 292 | }; 293 | done(null, user); 294 | }); 295 | 296 | const secondStrategy = new Strategy(jwtConfig, (payload: JwtUserPayload, done: (a: null, b: JwtUser) => void) => { 297 | const user: JwtUser = { 298 | roles: payload.auth.split(','), 299 | strategy: 'second', 300 | username: payload.sub 301 | }; 302 | done(null, user); 303 | }); 304 | 305 | Server.registerAuthenticator(new PassportAuthenticator(strategy, { 306 | deserializeUser: (user: string) => JSON.parse(user), 307 | serializeUser: (user: JwtUser) => { 308 | return JSON.stringify(user); 309 | } 310 | })); 311 | Server.registerAuthenticator(new PassportAuthenticator(secondStrategy, { 312 | deserializeUser: (user: string) => JSON.parse(user), 313 | serializeUser: (user: JwtUser) => { 314 | return JSON.stringify(user); 315 | }, 316 | strategyName: 'secondAuthenticator' 317 | }), 'secondAuthenticator'); 318 | } 319 | 320 | function generateJwt() { 321 | const user = { sub: 'admin', auth: 'ROLE_ADMIN,ROLE_USER' }; 322 | return jwt.sign(user, Buffer.from(JWT_SECRET, 'base64'), { algorithm: 'HS512' }); 323 | } 324 | 325 | let server: any; 326 | 327 | function startApi(): Promise { 328 | return new Promise((resolve, reject) => { 329 | const app: express.Application = express(); 330 | app.set('env', 'test'); 331 | configureAuthenticator(); 332 | Server.buildServices(app, AuthenticatePath, AuthenticateRole, 333 | AuthenticateWithoutRole, AuthenticateMethods, MultipleAuthenticateRole); 334 | server = app.listen(5674, (err?: any) => { 335 | if (err) { 336 | return reject(err); 337 | } 338 | resolve(); 339 | }); 340 | }); 341 | } 342 | 343 | export function stopApi() { 344 | if (server) { 345 | server.close(); 346 | } 347 | } -------------------------------------------------------------------------------- /test/integration/datatypes.spec.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as express from 'express'; 4 | import * as fs from 'fs'; 5 | import * as _ from 'lodash'; 6 | import * as request from 'request'; 7 | import { Container } from 'typescript-ioc'; 8 | import { 9 | BodyOptions, BodyType, Context, ContextNext, 10 | ContextRequest, ContextResponse, CookieParam, FileParam, FormParam, 11 | GET, HeaderParam, Param, ParserType, Path, PathParam, POST, PUT, QueryParam, Return, Server, ServiceContext 12 | } from '../../src/typescript-rest'; 13 | 14 | export class Person { 15 | public id: number; 16 | public name: string; 17 | public age: number; 18 | public salary: number; 19 | constructor(id: number, name: string, age: number, salary: number = age * 1000) { 20 | this.id = id; 21 | this.name = name; 22 | this.age = age; 23 | this.salary = salary; 24 | } 25 | } 26 | export interface DataParam { 27 | param1: string; 28 | param2: Date; 29 | } 30 | 31 | @Path("testparams") 32 | export class TestParamsService { 33 | @Context 34 | public context: ServiceContext; 35 | 36 | @HeaderParam('my-header') 37 | private myHeader: ServiceContext; 38 | 39 | @Path('people/:id') 40 | @GET 41 | public getPerson(@PathParam('id') id: number): Promise { 42 | return new Promise(function (resolve, reject) { 43 | resolve(new Person(id, `This is the person with ID = ${id}`, 35)); 44 | }); 45 | } 46 | 47 | @PUT 48 | @Path('/people/:id') 49 | public setPerson(person: Person): string { 50 | return JSON.stringify(person); 51 | } 52 | 53 | @POST 54 | @Path('/people') 55 | @BodyOptions({ limit: '50b' }) 56 | public addPerson(@ContextRequest req: express.Request, person: Person): Return.NewResource<{ id: number }> { 57 | return new Return.NewResource<{ id: number }>(req.url + '/' + person.id, { id: person.id }); 58 | } 59 | 60 | @POST 61 | @Path('/date') 62 | @BodyOptions({ 63 | reviver: (key: string, value: any) => { 64 | if (key === 'param2') { 65 | return new Date(value); 66 | } 67 | return value; 68 | } 69 | }) 70 | public testData(param: DataParam) { 71 | if ((param.param2 instanceof Date) && (param.param2.toString() === param.param1)) { 72 | return 'OK'; 73 | } 74 | return 'NOT OK'; 75 | } 76 | 77 | 78 | @GET 79 | @Path('/people') 80 | public getAll(@QueryParam('start') start: number, 81 | @QueryParam('size') size: number): Array { 82 | const result: Array = new Array(); 83 | 84 | for (let i: number = start; i < (start + size); i++) { 85 | result.push(new Person(i, `This is the person with ID = ${i}`, 35)); 86 | } 87 | return result; 88 | } 89 | 90 | @GET 91 | @Path('myheader') 92 | public testMyHeader(): string { 93 | return 'header: ' + this.myHeader; 94 | } 95 | 96 | @GET 97 | @Path('headers') 98 | public testHeaders(@HeaderParam('my-header') header: string, 99 | @CookieParam('my-cookie') cookie: string): string { 100 | return 'cookie: ' + cookie + '|header: ' + header; 101 | } 102 | 103 | @POST 104 | @Path('multi-param') 105 | public testMultiParam(@Param('param') param: string): string { 106 | return param; 107 | } 108 | 109 | @GET 110 | @Path('context') 111 | public testContext(@QueryParam('q') q: string, 112 | @ContextRequest req: express.Request, 113 | @ContextResponse response: express.Response, 114 | @ContextNext next: express.NextFunction): void { 115 | 116 | if (req && response && next) { 117 | response.status(201); 118 | if (q === '123') { 119 | response.send(true); 120 | } 121 | else { 122 | response.send(false); 123 | } 124 | } 125 | } 126 | 127 | @GET 128 | @Path('default-query') 129 | public testDefaultQuery(@QueryParam('limit') limit: number = 20, 130 | @QueryParam('prefix') prefix: string = 'default', 131 | @QueryParam('expand') expand: boolean = true): string { 132 | return `limit:${limit}|prefix:${prefix}|expand:${expand}`; 133 | } 134 | 135 | @GET 136 | @Path('optional-query') 137 | public testOptionalQuery(@QueryParam('limit') limit?: number, 138 | @QueryParam('prefix') prefix?: string, 139 | @QueryParam('expand') expand?: boolean): string { 140 | return `limit:${limit}|prefix:${prefix}|expand:${expand}`; 141 | } 142 | 143 | @POST 144 | @Path('upload') 145 | public testUploadFile(@FileParam('myFile') file: Express.Multer.File, 146 | @FormParam('myField') myField: string): boolean { 147 | return (file 148 | && (_.startsWith(file.buffer.toString(), '\'use strict\';')) 149 | && (myField === 'my_value')); 150 | } 151 | 152 | @GET 153 | @Path('download') 154 | public testDownloadFile(): Promise { 155 | return new Promise((resolve, reject) => { 156 | fs.readFile(__dirname + '/datatypes.spec.ts', (err, data) => { 157 | if (err) { 158 | return reject(err); 159 | } 160 | return resolve(new Return.DownloadBinaryData(data, 'application/javascript', 'test-rest.spec.js')); 161 | }); 162 | }); 163 | } 164 | 165 | @Path('download/ref') 166 | @GET 167 | public testDownloadFile2(): Promise { 168 | return new Promise((resolve, reject) => { 169 | resolve(new Return.DownloadResource(__dirname + '/datatypes.spec.ts', 'test-rest.spec.js')); 170 | }); 171 | } 172 | 173 | @Path('stringbody') 174 | @POST 175 | @BodyType(ParserType.text) 176 | public async testStringBody(data: string) { 177 | return data; 178 | } 179 | 180 | @Path('stringbodytype') 181 | @POST 182 | @BodyType(ParserType.text) 183 | @BodyOptions({ type: 'text/myformat' }) 184 | public async testStringWithTypeBody(data: string) { 185 | return data; 186 | } 187 | 188 | @Path('rawbody') 189 | @POST 190 | @BodyType(ParserType.raw) 191 | @BodyOptions({ type: 'text/plain' }) 192 | public async testRawBody(data: Buffer) { 193 | return Buffer.isBuffer(data); 194 | } 195 | } 196 | 197 | @Path("testreturn") 198 | export class TestReturnService { 199 | 200 | @GET 201 | @Path('noresponse') 202 | public testNoResponse() { 203 | return Return.NoResponse; 204 | } 205 | 206 | @GET 207 | @Path('empty') 208 | public testEmptyObjectResponse() { 209 | return {}; 210 | } 211 | 212 | @POST 213 | @Path('/externalmodule') 214 | public testExternal(@ContextRequest req: express.Request): Return.NewResource { 215 | const result = new Return.NewResource(req.url + '/123'); 216 | result.body = new Container(); 217 | return result; 218 | } 219 | } 220 | 221 | describe('Data Types Tests', () => { 222 | 223 | beforeAll(() => { 224 | return startApi(); 225 | }); 226 | 227 | afterAll(() => { 228 | stopApi(); 229 | }); 230 | 231 | describe('Services that handle Objects', () => { 232 | it('should be able to return Objects as JSON', (done) => { 233 | request('http://localhost:5674/testparams/people/123', (error, response, body) => { 234 | const result: Person = JSON.parse(body); 235 | expect(result.id).toEqual(123); 236 | done(); 237 | }); 238 | }); 239 | 240 | it('should be able to receive parametes as Objects', (done) => { 241 | const person = new Person(123, 'Person 123', 35); 242 | request.put({ 243 | body: JSON.stringify(person), 244 | headers: { 'content-type': 'application/json' }, 245 | url: 'http://localhost:5674/testparams/people/123' 246 | }, (error, response, body) => { 247 | const receivedPerson = JSON.parse(body); 248 | expect(receivedPerson).toEqual(person); 249 | done(); 250 | }); 251 | }); 252 | 253 | it('should be able to return an array of Objects', (done) => { 254 | request('http://localhost:5674/testparams/people?start=0&size=3', (error, response, body) => { 255 | const result: Array = JSON.parse(body); 256 | expect(result.length).toEqual(3); 257 | done(); 258 | }); 259 | }); 260 | 261 | it('should be able to receive objects that follow size constraints', (done) => { 262 | request.post({ 263 | body: JSON.stringify(new Person(123, 'person', 35)), 264 | headers: { 'content-type': 'application/json' }, 265 | url: 'http://localhost:5674/testparams/people' 266 | }, function (error, response, body) { 267 | expect(response.statusCode).toEqual(201); 268 | expect(response.headers['location']).toEqual('/testparams/people/123'); 269 | const result: Person = JSON.parse(body); 270 | expect(result.id).toEqual(123); 271 | done(); 272 | }); 273 | }); 274 | 275 | it('should be able to reject objects that do not follow size constraints', (done) => { 276 | request.post({ 277 | body: JSON.stringify(new Person(123, 278 | 'this is a very large payload that should be rejected', 35)), 279 | headers: { 'content-type': 'application/json' }, 280 | url: 'http://localhost:5674/testparams/people' 281 | }, function (error, response, body) { 282 | expect(response.statusCode).toEqual(413); 283 | done(); 284 | }); 285 | }); 286 | 287 | it('should be able to send a Date into a json object ', (done) => { 288 | const date = new Date(); 289 | request.post({ 290 | body: { 291 | param1: date.toString(), 292 | param2: date 293 | }, 294 | json: true, 295 | url: 'http://localhost:5674/testparams/date' 296 | }, (error, response, body) => { 297 | expect(body).toEqual('OK'); 298 | done(); 299 | }); 300 | }); 301 | }); 302 | 303 | describe('A rest Service', () => { 304 | it('should parse header and cookies correclty', (done) => { 305 | request({ 306 | headers: { 'my-header': 'header value', 'Cookie': 'my-cookie=cookie value' }, 307 | url: 'http://localhost:5674/testparams/headers' 308 | }, (error, response, body) => { 309 | expect(body).toEqual('cookie: cookie value|header: header value'); 310 | done(); 311 | }); 312 | }); 313 | 314 | it('should read parameters as class property', (done) => { 315 | request({ 316 | headers: { 'my-header': 'header value' }, 317 | url: 'http://localhost:5674/testparams/myheader' 318 | }, (error, response, body) => { 319 | expect(body).toEqual('header: header value'); 320 | done(); 321 | }); 322 | }); 323 | 324 | it('should parse multi param as query param', (done) => { 325 | request.post({ 326 | url: 'http://localhost:5674/testparams/multi-param?param=myQueryValue' 327 | }, (error, response, body) => { 328 | expect(body).toEqual('myQueryValue'); 329 | done(); 330 | }); 331 | }); 332 | 333 | it('should parse multi param as form param', (done) => { 334 | const form = { 335 | 'param': 'formParam' 336 | }; 337 | request.post({ 338 | 'form': form, 339 | 'url': 'http://localhost:5674/testparams/multi-param' 340 | }, (error, response, body) => { 341 | expect(body).toEqual('formParam'); 342 | expect(response.statusCode).toEqual(200); 343 | done(); 344 | }); 345 | }); 346 | 347 | it('should accept Context parameters', (done) => { 348 | request({ 349 | url: 'http://localhost:5674/testparams/context?q=123' 350 | }, (error, response, body) => { 351 | expect(body).toEqual('true'); 352 | expect(response.statusCode).toEqual(201); 353 | done(); 354 | }); 355 | }); 356 | 357 | it('should accept file parameters', (done) => { 358 | const req = request.post('http://localhost:5674/testparams/upload', (error, response, body) => { 359 | expect(body).toEqual('true'); 360 | expect(response.statusCode).toEqual(200); 361 | done(); 362 | }); 363 | const form = req.form(); 364 | form.append('myField', 'my_value'); 365 | form.append('myFile', fs.createReadStream(__dirname + '/datatypes.spec.ts'), 'test-rest.spec.ts'); 366 | }); 367 | 368 | it('should use sent value for query param that defines a default', (done) => { 369 | request({ 370 | url: 'http://localhost:5674/testparams/default-query?limit=5&prefix=test&expand=false' 371 | }, (error, response, body) => { 372 | expect(body).toEqual('limit:5|prefix:test|expand:false'); 373 | done(); 374 | }); 375 | }); 376 | 377 | it('should use provided default value for missing query param', (done) => { 378 | request({ 379 | url: 'http://localhost:5674/testparams/default-query' 380 | }, (error, response, body) => { 381 | expect(body).toEqual('limit:20|prefix:default|expand:true'); 382 | done(); 383 | }); 384 | }); 385 | 386 | it('should handle empty string value for default parameter', (done) => { 387 | request({ 388 | url: 'http://localhost:5674/testparams/default-query?limit=&prefix=&expand=' 389 | }, (error, response, body) => { 390 | expect(body).toEqual('limit:NaN|prefix:|expand:false'); 391 | done(); 392 | }); 393 | }); 394 | 395 | it('should use sent value for optional query param', (done) => { 396 | request({ 397 | url: 'http://localhost:5674/testparams/optional-query?limit=5&prefix=test&expand=false' 398 | }, (error, response, body) => { 399 | expect(body).toEqual('limit:5|prefix:test|expand:false'); 400 | done(); 401 | }); 402 | }); 403 | 404 | it('should use undefined as value for missing optional query param', (done) => { 405 | request({ 406 | url: 'http://localhost:5674/testparams/optional-query' 407 | }, (error, response, body) => { 408 | expect(body).toEqual('limit:undefined|prefix:undefined|expand:undefined'); 409 | done(); 410 | }); 411 | }); 412 | 413 | it('should handle empty string value for optional parameter', (done) => { 414 | request({ 415 | url: 'http://localhost:5674/testparams/optional-query?limit=&prefix=&expand=' 416 | }, (error, response, body) => { 417 | expect(body).toEqual('limit:NaN|prefix:|expand:false'); 418 | done(); 419 | }); 420 | }); 421 | }); 422 | describe('Download Service', () => { 423 | it('should return a file', (done) => { 424 | request({ 425 | url: 'http://localhost:5674/testparams/download' 426 | }, (error, response, body) => { 427 | expect(response.headers['content-type']).toEqual('application/javascript'); 428 | expect(_.startsWith(body.toString(), '\'use strict\';')).toEqual(true); 429 | done(); 430 | }); 431 | }); 432 | it('should return a referenced file', (done) => { 433 | request({ 434 | url: 'http://localhost:5674/testparams/download/ref' 435 | }, (error, response, body) => { 436 | expect(_.startsWith(body.toString(), '\'use strict\';')).toEqual(true); 437 | done(); 438 | }); 439 | }); 440 | }); 441 | 442 | describe('Raw Body Service', () => { 443 | it('should accept a string as a body', (done) => { 444 | const data = '1;2;3;4;\n5;6;7;8;\n9;10;11;12;'; 445 | request.post({ 446 | body: data, 447 | headers: { 'content-type': 'text/plain' }, 448 | url: 'http://localhost:5674/testparams/stringbody' 449 | }, (error, response, body) => { 450 | expect(body).toEqual(data); 451 | done(); 452 | }); 453 | }); 454 | 455 | it('should accept a buffer as a body', (done) => { 456 | const data = Buffer.from('1;2;3;4;\n5;6;7;8;\n9;10;11;12;'); 457 | request.post({ 458 | body: data, 459 | headers: { 'content-type': 'text/plain' }, 460 | url: 'http://localhost:5674/testparams/rawbody' 461 | }, (error, response, body) => { 462 | expect(body).toEqual('true'); 463 | done(); 464 | }); 465 | }); 466 | 467 | it('should accept a string as a body with custom mediatype', (done) => { 468 | const data = '1;2;3;4;\n5;6;7;8;\n9;10;11;12;'; 469 | request.post({ 470 | body: data, 471 | headers: { 'content-type': 'text/myformat' }, 472 | url: 'http://localhost:5674/testparams/stringbodytype' 473 | }, (error, response, body) => { 474 | expect(body).toEqual(data); 475 | done(); 476 | }); 477 | }); 478 | 479 | it('should accept a string as a body with custom mediatype', (done) => { 480 | const data = '1;2;3;4;\n5;6;7;8;\n9;10;11;12;'; 481 | request.post({ 482 | body: data, 483 | headers: { 'content-type': 'text/plain' }, 484 | url: 'http://localhost:5674/testparams/stringbodytype' 485 | }, (error, response, body) => { 486 | expect(body).toEqual('{}'); 487 | done(); 488 | }); 489 | }); 490 | }); 491 | 492 | describe('No Response Service', () => { 493 | it('should not send a value when NoResponse is returned', (done) => { 494 | request({ 495 | url: 'http://localhost:5674/testreturn/noresponse' 496 | }, (error, response, body) => { 497 | expect(body).toEqual('handled by middleware'); 498 | done(); 499 | }); 500 | }); 501 | it('should not be handled as an empty object', (done) => { 502 | request({ 503 | url: 'http://localhost:5674/testreturn/empty' 504 | }, (error, response, body) => { 505 | const val = JSON.parse(body); 506 | expect(Object.keys(val)).toHaveLength(0); 507 | done(); 508 | }); 509 | }); 510 | }); 511 | 512 | describe('NewResource return type', () => { 513 | it('should handle types referenced from other modules', (done) => { 514 | request.post({ 515 | url: 'http://localhost:5674/testreturn/externalmodule' 516 | }, (error, response, body) => { 517 | expect(response.statusCode).toEqual(201); 518 | expect(response.headers.location).toEqual('/testreturn/externalmodule/123'); 519 | done(); 520 | }); 521 | }); 522 | }); 523 | 524 | describe('Param Converters', () => { 525 | it('should intercept parameters', (done) => { 526 | Server.addParameterConverter((param: Person) => { 527 | if (param.salary === 424242) { 528 | param.salary = 434343; 529 | } 530 | return param; 531 | }, Person); 532 | const person = new Person(123, 'Person 123', 35, 424242); 533 | request.put({ 534 | body: JSON.stringify(person), 535 | headers: { 'content-type': 'application/json' }, 536 | url: 'http://localhost:5674/testparams/people/123' 537 | }, (error, response, body) => { 538 | const receivedPerson = JSON.parse(body); 539 | expect(receivedPerson.salary).toEqual(434343); 540 | Server.removeParameterConverter(Person); 541 | done(); 542 | }); 543 | }); 544 | }); 545 | 546 | 547 | 548 | }); 549 | 550 | let server: any; 551 | 552 | export function startApi(): Promise { 553 | return new Promise((resolve, reject) => { 554 | const app: express.Application = express(); 555 | app.set('env', 'test'); 556 | Server.buildServices(app, TestParamsService, TestReturnService); 557 | app.use('/testreturn', (req, res, next) => { 558 | if (!res.headersSent) { 559 | res.send('handled by middleware'); 560 | } 561 | }); 562 | server = app.listen(5674, (err?: any) => { 563 | if (err) { 564 | return reject(err); 565 | } 566 | resolve(); 567 | }); 568 | }); 569 | } 570 | 571 | export function stopApi() { 572 | if (server) { 573 | server.close(); 574 | } 575 | } -------------------------------------------------------------------------------- /test/integration/errors.spec.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as _ from 'lodash'; 3 | import * as request from 'request'; 4 | import { Errors, GET, Path, Server } from '../../src/typescript-rest'; 5 | 6 | @Path('errors') 7 | export class ErrorService { 8 | @Path('badrequest') 9 | @GET 10 | public test1(p: string): Promise { 11 | return new Promise(function (resolve, reject) { 12 | reject(new Errors.BadRequestError()); 13 | }); 14 | } 15 | 16 | @Path('conflict') 17 | @GET 18 | public test2(p: string): Promise { 19 | return new Promise(function (resolve, reject) { 20 | reject(new Errors.ConflictError()); 21 | }); 22 | } 23 | 24 | @Path('forbiden') 25 | @GET 26 | public test3(p: string): Promise { 27 | return new Promise(function (resolve, reject) { 28 | reject(new Errors.ForbiddenError()); 29 | }); 30 | } 31 | 32 | @Path('gone') 33 | @GET 34 | public test4(p: string): Promise { 35 | return new Promise(function (resolve, reject) { 36 | reject(new Errors.GoneError()); 37 | }); 38 | } 39 | 40 | @Path('internal') 41 | @GET 42 | public test5(p: string): Promise { 43 | return new Promise(function (resolve, reject) { 44 | reject(new Errors.InternalServerError()); 45 | }); 46 | } 47 | 48 | @Path('method') 49 | @GET 50 | public test6(p: string): Promise { 51 | return new Promise(function (resolve, reject) { 52 | reject(new Errors.MethodNotAllowedError()); 53 | }); 54 | } 55 | 56 | @Path('notacceptable') 57 | @GET 58 | public test7(p: string): Promise { 59 | return new Promise(function (resolve, reject) { 60 | reject(new Errors.NotAcceptableError()); 61 | }); 62 | } 63 | 64 | @Path('notfound') 65 | @GET 66 | public test8(p: string): Promise { 67 | return new Promise(function (resolve, reject) { 68 | reject(new Errors.NotFoundError()); 69 | }); 70 | } 71 | 72 | @Path('notimplemented') 73 | @GET 74 | public test9(p: string): Promise { 75 | return new Promise(function (resolve, reject) { 76 | reject(new Errors.NotImplementedError()); 77 | }); 78 | } 79 | 80 | @Path('unauthorized') 81 | @GET 82 | public test10(p: string): Promise { 83 | return new Promise(function (resolve, reject) { 84 | reject(new Errors.UnauthorizedError()); 85 | }); 86 | } 87 | 88 | @Path('unsupportedmedia') 89 | @GET 90 | public test11(p: string): Promise { 91 | return new Promise(function (resolve, reject) { 92 | reject(new Errors.UnsupportedMediaTypeError()); 93 | }); 94 | } 95 | 96 | @Path('unprocessableentity') 97 | @GET 98 | public test12(p: string): Promise { 99 | return new Promise(function (resolve, reject) { 100 | reject(new Errors.UnprocessableEntityError()); 101 | }); 102 | } 103 | 104 | @GET 105 | @Path('sync/badrequest') 106 | public test13(p: string): Promise { 107 | throw new Errors.BadRequestError(); 108 | } 109 | } 110 | 111 | describe('Errors Tests', () => { 112 | 113 | beforeAll(() => { 114 | return startApi(); 115 | }); 116 | 117 | afterAll(() => { 118 | stopApi(); 119 | }); 120 | 121 | describe('Error Service', () => { 122 | it('should be able to send 400', (done) => { 123 | request.get('http://localhost:5674/errors/badrequest', (error, response, body) => { 124 | expect(response.statusCode).toEqual(400); 125 | done(); 126 | }); 127 | }); 128 | it('should be able to send 400', (done) => { 129 | request.get('http://localhost:5674/errors/sync/badrequest', (error, response, body) => { 130 | expect(response.statusCode).toEqual(400); 131 | done(); 132 | }); 133 | }); 134 | it('should be able to send 409', (done) => { 135 | request.get('http://localhost:5674/errors/conflict', (error, response, body) => { 136 | expect(response.statusCode).toEqual(409); 137 | done(); 138 | }); 139 | }); 140 | it('should be able to send 403', (done) => { 141 | request.get('http://localhost:5674/errors/forbiden', (error, response, body) => { 142 | expect(response.statusCode).toEqual(403); 143 | done(); 144 | }); 145 | }); 146 | it('should be able to send 410', (done) => { 147 | request.get('http://localhost:5674/errors/gone', (error, response, body) => { 148 | expect(response.statusCode).toEqual(410); 149 | done(); 150 | }); 151 | }); 152 | it('should be able to send 500', (done) => { 153 | request.get('http://localhost:5674/errors/internal', (error, response, body) => { 154 | expect(response.statusCode).toEqual(500); 155 | done(); 156 | }); 157 | }); 158 | it('should be able to send 405', (done) => { 159 | request.get('http://localhost:5674/errors/method', (error, response, body) => { 160 | expect(response.statusCode).toEqual(405); 161 | done(); 162 | }); 163 | }); 164 | it('should be able to send 406', (done) => { 165 | request.get('http://localhost:5674/errors/notacceptable', (error, response, body) => { 166 | expect(response.statusCode).toEqual(406); 167 | done(); 168 | }); 169 | }); 170 | it('should be able to send 404', (done) => { 171 | request.get('http://localhost:5674/errors/notfound', (error, response, body) => { 172 | expect(response.statusCode).toEqual(404); 173 | done(); 174 | }); 175 | }); 176 | it('should be able to send 501', (done) => { 177 | request.get('http://localhost:5674/errors/notimplemented', (error, response, body) => { 178 | expect(response.statusCode).toEqual(501); 179 | done(); 180 | }); 181 | }); 182 | it('should be able to send 401', (done) => { 183 | request.get('http://localhost:5674/errors/unauthorized', (error, response, body) => { 184 | expect(response.statusCode).toEqual(401); 185 | done(); 186 | }); 187 | }); 188 | it('should be able to send 415', (done) => { 189 | request.get('http://localhost:5674/errors/unsupportedmedia', (error, response, body) => { 190 | expect(response.statusCode).toEqual(415); 191 | done(); 192 | }); 193 | }); 194 | 195 | it('should be able to send 422', (done) => { 196 | request.get('http://localhost:5674/errors/unprocessableentity', (error, response, body) => { 197 | expect(response.statusCode).toEqual(422); 198 | done(); 199 | }); 200 | }); 201 | }); 202 | }); 203 | 204 | let server: any; 205 | 206 | export function startApi(): Promise { 207 | return new Promise((resolve, reject) => { 208 | const app: express.Application = express(); 209 | app.set('env', 'test'); 210 | Server.buildServices(app, ErrorService); 211 | server = app.listen(5674, (err?: any) => { 212 | if (err) { 213 | return reject(err); 214 | } 215 | resolve(); 216 | }); 217 | }); 218 | } 219 | 220 | export function stopApi() { 221 | if (server) { 222 | server.close(); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /test/integration/ignore-middlewares.spec.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as _ from 'lodash'; 3 | import * as request from 'request'; 4 | import { GET, IgnoreNextMiddlewares, Path, Server } from '../../src/typescript-rest'; 5 | 6 | @Path('/ignoreEndpoint') 7 | export class EndpointTestService { 8 | @GET 9 | @Path('/withoutMiddlewares') 10 | @IgnoreNextMiddlewares 11 | public test(): string { 12 | return 'OK'; 13 | } 14 | 15 | @GET 16 | @Path('/withMiddlewares') 17 | public testWithAllMiddlewares(): string { 18 | return 'OK'; 19 | } 20 | } 21 | 22 | let middlewareCalled: boolean; 23 | describe('Customized Endpoint Tests', () => { 24 | 25 | beforeAll(() => { 26 | return startApi(); 27 | }); 28 | 29 | afterAll(() => { 30 | stopApi(); 31 | }); 32 | 33 | beforeEach(() => { 34 | middlewareCalled = false; 35 | }); 36 | 37 | describe('@IgnoreNexts Decorator', () => { 38 | it('should make the server ignore next middlewares (does not call next())', (done) => { 39 | request('http://localhost:5674/ignoreEndpoint/withoutMiddlewares', (error, response, body) => { 40 | expect(body).toEqual('OK'); 41 | expect(middlewareCalled).toBeFalsy(); 42 | done(); 43 | }); 44 | }); 45 | 46 | it('should not prevent the server to call next middlewares for sibbling methods', (done) => { 47 | request('http://localhost:5674/ignoreEndpoint/withMiddlewares', (error, response, body) => { 48 | expect(body).toEqual('OK'); 49 | expect(middlewareCalled).toBeTruthy(); 50 | done(); 51 | }); 52 | }); 53 | }); 54 | 55 | describe('Server.ignoreNextMiddlewares', () => { 56 | beforeAll(() => { 57 | Server.ignoreNextMiddlewares(true); 58 | }); 59 | 60 | afterAll(() => { 61 | Server.ignoreNextMiddlewares(false); 62 | }); 63 | 64 | it('should make the server ignore next middlewares for all services', (done) => { 65 | request('http://localhost:5674/ignoreEndpoint/withMiddlewares', (error, response, body) => { 66 | expect(body).toEqual('OK'); 67 | expect(middlewareCalled).toBeFalsy(); 68 | done(); 69 | }); 70 | }); 71 | }); 72 | 73 | }); 74 | 75 | let server: any; 76 | export function startApi(): Promise { 77 | return new Promise((resolve, reject) => { 78 | const app: express.Application = express(); 79 | app.set('env', 'test'); 80 | Server.buildServices(app, EndpointTestService); 81 | 82 | app.use((req, res, next) => { 83 | middlewareCalled = true; 84 | next(); 85 | }); 86 | server = app.listen(5674, (err?: any) => { 87 | if (err) { 88 | return reject(err); 89 | } 90 | resolve(); 91 | }); 92 | }); 93 | } 94 | 95 | export function stopApi() { 96 | if (server) { 97 | server.close(); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /test/integration/ioc.spec.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as _ from 'lodash'; 3 | import * as request from 'request'; 4 | import { Inject, OnlyInstantiableByContainer } from 'typescript-ioc'; 5 | import { DefaultServiceFactory, GET, Path, Server } from '../../src/typescript-rest'; 6 | 7 | Server.registerServiceFactory('typescript-rest-ioc'); 8 | 9 | @OnlyInstantiableByContainer 10 | export class InjectableObject { } 11 | 12 | @OnlyInstantiableByContainer 13 | @Path('ioctest') 14 | export class IoCService { 15 | @Inject 16 | private injectedObject: InjectableObject; 17 | 18 | @GET 19 | public test(): string { 20 | return (this.injectedObject) ? 'OK' : 'NOT OK'; 21 | } 22 | } 23 | 24 | @Path('ioctest2') 25 | @OnlyInstantiableByContainer 26 | export class IoCService2 { 27 | @Inject 28 | private injectedObject: InjectableObject; 29 | 30 | @GET 31 | public test(): string { 32 | return (this.injectedObject) ? 'OK' : 'NOT OK'; 33 | } 34 | } 35 | 36 | @Path('ioctest3') 37 | @OnlyInstantiableByContainer 38 | export class IoCService3 { 39 | private injectedObject: InjectableObject; 40 | 41 | constructor(@Inject injectedObject: InjectableObject) { 42 | this.injectedObject = injectedObject; 43 | } 44 | 45 | @GET 46 | public test(): string { 47 | return (this.injectedObject) ? 'OK' : 'NOT OK'; 48 | } 49 | } 50 | 51 | @Path('ioctest4') 52 | @OnlyInstantiableByContainer 53 | export class IoCService4 extends IoCService2 { 54 | } 55 | 56 | describe('IoC Tests', () => { 57 | 58 | beforeAll(() => { 59 | return startApi(); 60 | }); 61 | 62 | afterAll(() => { 63 | stopApi(); 64 | }); 65 | 66 | describe('Server integrated with typescript-ioc', () => { 67 | it('should use IoC container to instantiate the services', (done) => { 68 | request('http://localhost:5674/ioctest', (error, response, body) => { 69 | expect(body).toEqual('OK'); 70 | done(); 71 | }); 72 | }); 73 | it('should use IoC container to instantiate the services, does not carrying about the decorators order', (done) => { 74 | request('http://localhost:5674/ioctest2', (error, response, body) => { 75 | expect(body).toEqual('OK'); 76 | done(); 77 | }); 78 | }); 79 | it('should use IoC container to instantiate the services with injected params on constructor', (done) => { 80 | request('http://localhost:5674/ioctest3', (error, response, body) => { 81 | expect(body).toEqual('OK'); 82 | done(); 83 | }); 84 | }); 85 | it('should use IoC container to instantiate the services with superclasses', (done) => { 86 | request('http://localhost:5674/ioctest4', (error, response, body) => { 87 | expect(body).toEqual('OK'); 88 | done(); 89 | }); 90 | }); 91 | }); 92 | }); 93 | 94 | let server: any; 95 | 96 | function startApi(): Promise { 97 | return new Promise((resolve, reject) => { 98 | const app: express.Application = express(); 99 | app.set('env', 'test'); 100 | Server.buildServices(app, IoCService, IoCService2, IoCService3, IoCService4); 101 | server = app.listen(5674, (err?: any) => { 102 | if (err) { 103 | return reject(err); 104 | } 105 | resolve(); 106 | }); 107 | }); 108 | } 109 | 110 | function stopApi() { 111 | if (server) { 112 | Server.registerServiceFactory(new DefaultServiceFactory()); 113 | server.close(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /test/integration/paths.spec.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as _ from 'lodash'; 3 | import * as request from 'request'; 4 | import { Abstract, Context, GET, HttpMethod, Path, PathParam, PUT, Server, ServiceContext } from '../../src/typescript-rest'; 5 | 6 | @Path('/pathtest') 7 | export class PathTestService { 8 | @GET 9 | public test(): string { 10 | return 'OK'; 11 | } 12 | } 13 | export class PathOnlyOnMethodTestService { 14 | @GET 15 | @Path('methodpath') 16 | public test(): string { 17 | return 'OK'; 18 | } 19 | } 20 | 21 | @Path('pathtest2') 22 | export class SubPathTestService { 23 | @GET 24 | public test(): string { 25 | return 'OK'; 26 | } 27 | 28 | @GET 29 | @Path('secondpath') 30 | public test2(): string { 31 | return 'OK'; 32 | } 33 | } 34 | @Abstract 35 | export abstract class BaseApi { 36 | @Context 37 | protected context: ServiceContext; 38 | 39 | @GET 40 | @Path(':id') 41 | public testCrudGet(@PathParam('id') id: string) { 42 | if (this.context) { 43 | return 'OK_' + id; 44 | } 45 | return 'false'; 46 | } 47 | 48 | @Path('overload/:id') 49 | @GET 50 | public testOverloadGet(@PathParam('id') id: string) { 51 | if (this.context) { 52 | return 'OK_' + id; 53 | } 54 | return 'false'; 55 | } 56 | 57 | @PUT 58 | @Path('overload/:id') 59 | public testOverloadPut(@PathParam('id') id: string) { 60 | if (this.context) { 61 | return 'OK_' + id; 62 | } 63 | return 'false'; 64 | } 65 | } 66 | @Path('superclasspath') 67 | export class SuperClassService extends BaseApi { 68 | @GET 69 | @Path('overload/:id') 70 | public testOverloadGet(@PathParam('id') id: string) { 71 | if (this.context) { 72 | return 'superclass_OK_' + id; 73 | } 74 | return 'false'; 75 | } 76 | 77 | @Path('overload/:id') 78 | @PUT 79 | public testOverloadPut(@PathParam('id') id: string) { 80 | if (this.context) { 81 | return 'superclass_OK_' + id; 82 | } 83 | return 'false'; 84 | } 85 | } 86 | 87 | describe('Paths Tests', () => { 88 | 89 | beforeAll(() => { 90 | return startApi(); 91 | }); 92 | 93 | afterAll(() => { 94 | stopApi(); 95 | }); 96 | 97 | describe('Server', () => { 98 | it('should provide a catalog containing the exposed paths', () => { 99 | expect(Server.getPaths()).toContain('/pathtest'); 100 | expect(Server.getPaths()).toContain('/pathtest2'); 101 | expect(Server.getPaths()).toContain('/methodpath'); 102 | expect(Server.getPaths()).toContain('/pathtest2/secondpath'); 103 | expect(Server.getPaths()).toContain('/superclasspath/overload/:id'); 104 | expect(Server.getPaths()).toContain('/pathtest'); 105 | expect(Server.getPaths()).not.toContain('/overload/:id'); 106 | expect(Server.getHttpMethods('/pathtest')).toContain(HttpMethod.GET); 107 | expect(Server.getHttpMethods('/pathtest2/secondpath')).toContain(HttpMethod.GET); 108 | expect(Server.getHttpMethods('/superclasspath/overload/:id')).toContain(HttpMethod.GET); 109 | expect(Server.getHttpMethods('/superclasspath/overload/:id')).toContain(HttpMethod.PUT); 110 | }); 111 | }); 112 | 113 | describe('Path Annotation', () => { 114 | it('should configure a path', (done) => { 115 | request('http://localhost:5674/pathtest', function (error, response, body) { 116 | expect(body).toEqual('OK'); 117 | done(); 118 | }); 119 | }); 120 | it('should configure a path without an initial /', (done) => { 121 | request('http://localhost:5674/pathtest2', function (error, response, body) { 122 | expect(body).toEqual('OK'); 123 | done(); 124 | }); 125 | }); 126 | it('should be able to build a composed path bwetween class and method', (done) => { 127 | request('http://localhost:5674/pathtest2/secondpath', function (error, response, body) { 128 | expect(body).toEqual('OK'); 129 | done(); 130 | }); 131 | }); 132 | it('should be able to register services with present only on methods of a class', (done) => { 133 | request('http://localhost:5674/methodpath', function (error, response, body) { 134 | expect(body).toEqual('OK'); 135 | done(); 136 | }); 137 | }); 138 | }); 139 | 140 | describe('Service on Subclass', () => { 141 | it('should return OK when calling a method of its super class', (done) => { 142 | request('http://localhost:5674/superclasspath/123', function (error, response, body) { 143 | expect(body).toEqual('OK_' + 123); 144 | done(); 145 | }); 146 | }); 147 | 148 | it('should return OK when calling an overloaded method of its super class', (done) => { 149 | request('http://localhost:5674/superclasspath/overload/123', function (error, response, body) { 150 | expect(body).toEqual('superclass_OK_' + 123); 151 | done(); 152 | }); 153 | }); 154 | it('should return OK when calling an overloaded PUT method of its super class', (done) => { 155 | request.put('http://localhost:5674/superclasspath/overload/123', function (error, response, body) { 156 | expect(body).toEqual('superclass_OK_' + 123); 157 | done(); 158 | }); 159 | }); 160 | }); 161 | }); 162 | 163 | let server: any; 164 | export function startApi(): Promise { 165 | return new Promise((resolve, reject) => { 166 | const app: express.Application = express(); 167 | app.set('env', 'test'); 168 | Server.buildServices(app, PathTestService, PathOnlyOnMethodTestService, 169 | SubPathTestService, SuperClassService); 170 | server = app.listen(5674, (err?: any) => { 171 | if (err) { 172 | return reject(err); 173 | } 174 | resolve(); 175 | }); 176 | }); 177 | } 178 | 179 | export function stopApi() { 180 | if (server) { 181 | server.close(); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /test/integration/postprocessor.spec.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as _ from 'lodash'; 3 | import * as request from 'request'; 4 | import { Path, POST, PostProcessor, Server } from '../../src/typescript-rest'; 5 | 6 | @Path('postprocessor') 7 | @PostProcessor(postprocessor1) 8 | export class PostProcessedService { 9 | @Path('test') 10 | @POST 11 | @PostProcessor(postprocessor2) 12 | public test() { 13 | return 'OK'; 14 | } 15 | 16 | @Path('asynctest') 17 | @POST 18 | @PostProcessor(asyncPostprocessor1) 19 | @PostProcessor(asyncPostprocessor2) // multiple postprocessors needed to test async 20 | public asynctest() { 21 | return 'OK'; 22 | } 23 | } 24 | 25 | function postprocessor1(req: express.Request, res: express.Response) { 26 | res.setHeader('x-postprocessor1', '1'); 27 | } 28 | 29 | function postprocessor2(req: express.Request, res: express.Response) { 30 | res.setHeader('x-postprocessor2', '1'); 31 | } 32 | 33 | async function asyncPostprocessor1(req: express.Request, res: express.Response) { 34 | res.setHeader('x-asyncpostprocessor1', '1'); 35 | } 36 | 37 | async function asyncPostprocessor2(req: express.Request, res: express.Response) { 38 | res.setHeader('x-asyncpostprocessor2', '1'); 39 | } 40 | 41 | describe('Postprocessor Tests', () => { 42 | 43 | beforeAll(() => { 44 | return startApi(); 45 | }); 46 | 47 | afterAll(() => { 48 | stopApi(); 49 | }); 50 | 51 | describe('Synchronous Postrocessors', () => { 52 | it('should run after handling the request', (done) => { 53 | request.post({ 54 | headers: { 'content-type': 'application/json' }, 55 | url: 'http://localhost:5674/postprocessor/test' 56 | }, (error, response, body) => { 57 | expect(response.headers['x-postprocessor1']).toEqual('1'); 58 | expect(response.headers['x-postprocessor2']).toEqual('1'); 59 | done(); 60 | }); 61 | }); 62 | }); 63 | 64 | describe('Assynchronous Postprocessors', () => { 65 | it('should run after handling the request', (done) => { 66 | request.post({ 67 | headers: { 'content-type': 'application/json' }, 68 | url: 'http://localhost:5674/postprocessor/asynctest' 69 | }, (error, response, body) => { 70 | expect(response.headers['x-postprocessor1']).toEqual('1'); 71 | expect(response.headers['x-asyncpostprocessor1']).toEqual('1'); 72 | expect(response.headers['x-asyncpostprocessor2']).toEqual('1'); 73 | done(); 74 | }); 75 | }); 76 | }); 77 | }); 78 | 79 | let server: any; 80 | 81 | function startApi(): Promise { 82 | return new Promise((resolve, reject) => { 83 | const app: express.Application = express(); 84 | app.set('env', 'test'); 85 | Server.buildServices(app, PostProcessedService); 86 | server = app.listen(5674, (err?: any) => { 87 | if (err) { 88 | return reject(err); 89 | } 90 | resolve(); 91 | }); 92 | }); 93 | } 94 | 95 | function stopApi() { 96 | if (server) { 97 | server.close(); 98 | } 99 | } -------------------------------------------------------------------------------- /test/integration/preprocessor.spec.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as _ from 'lodash'; 3 | import * as request from 'request'; 4 | import { ContextRequest, Errors, Path, POST, PreProcessor, Server } from '../../src/typescript-rest'; 5 | 6 | @Path('preprocessor') 7 | @PreProcessor(preprocessor1) 8 | export class PreprocessedService { 9 | @ContextRequest 10 | public request: PreprocessedRequest; 11 | 12 | @Path('test') 13 | @POST 14 | @PreProcessor(preprocessor2) 15 | public test(body: any) { 16 | return this.request.preprocessor1 && this.request.preprocessor2; 17 | } 18 | 19 | @Path('asynctest') 20 | @POST 21 | @PreProcessor(asyncPreprocessor1) 22 | @PreProcessor(asyncPreprocessor2) // multiple preprocessors needed to test async 23 | public asynctest(body: any) { 24 | return this.request.preprocessor1 && (!this.request.preprocessor2) && 25 | this.request.asyncPreproocessor1 && this.request.asyncPreproocessor2; 26 | } 27 | } 28 | 29 | function preprocessor1(req: PreprocessedRequest) { 30 | if (!req.body.valid) { 31 | throw new Errors.BadRequestError(); 32 | } 33 | req.preprocessor1 = true; 34 | } 35 | 36 | function preprocessor2(req: PreprocessedRequest) { 37 | req.preprocessor2 = true; 38 | } 39 | 40 | async function asyncPreprocessor1(req: PreprocessedRequest) { 41 | if (!req.body.asyncValid) { 42 | throw new Errors.BadRequestError(); 43 | } 44 | req.asyncPreproocessor1 = true; 45 | } 46 | 47 | async function asyncPreprocessor2(req: PreprocessedRequest) { 48 | req.asyncPreproocessor2 = true; 49 | } 50 | 51 | interface PreprocessedRequest extends express.Request { 52 | preprocessor1: boolean; 53 | preprocessor2: boolean; 54 | asyncPreproocessor1: boolean; 55 | asyncPreproocessor2: boolean; 56 | } 57 | 58 | describe('Preprocessor Tests', () => { 59 | 60 | beforeAll(() => { 61 | return startApi(); 62 | }); 63 | 64 | afterAll(() => { 65 | stopApi(); 66 | }); 67 | 68 | describe('Synchronous Preprocessors', () => { 69 | it('should validate before handling the request', (done) => { 70 | request.post({ 71 | body: JSON.stringify({ valid: true }), 72 | headers: { 'content-type': 'application/json' }, 73 | url: 'http://localhost:5674/preprocessor/test' 74 | }, (error, response, body) => { 75 | expect(body).toEqual('true'); 76 | done(); 77 | }); 78 | }); 79 | it('should fail validation when body is invalid', (done) => { 80 | request.post({ 81 | body: JSON.stringify({}), 82 | headers: { 'content-type': 'application/json' }, 83 | url: 'http://localhost:5674/preprocessor/test' 84 | }, (error, response, body) => { 85 | expect(response.statusCode).toEqual(400); 86 | done(); 87 | }); 88 | }); 89 | }); 90 | 91 | describe('Assynchronous Preprocessors', () => { 92 | it('should validate before handling the request', (done) => { 93 | request.post({ 94 | body: JSON.stringify({ valid: true, asyncValid: true }), 95 | headers: { 'content-type': 'application/json' }, 96 | url: 'http://localhost:5674/preprocessor/asynctest' 97 | }, (error, response, body) => { 98 | expect(body).toEqual('true'); 99 | done(); 100 | }); 101 | }); 102 | it('should fail validation when body is invalid', (done) => { 103 | request.post({ 104 | body: JSON.stringify({ valid: true }), 105 | headers: { 'content-type': 'application/json' }, 106 | url: 'http://localhost:5674/preprocessor/asynctest' 107 | }, (error, response, body) => { 108 | expect(response.statusCode).toEqual(400); 109 | done(); 110 | }); 111 | }); 112 | }); 113 | }); 114 | 115 | let server: any; 116 | 117 | function startApi(): Promise { 118 | return new Promise((resolve, reject) => { 119 | const app: express.Application = express(); 120 | app.set('env', 'test'); 121 | Server.buildServices(app, PreprocessedService); 122 | server = app.listen(5674, (err?: any) => { 123 | if (err) { 124 | return reject(err); 125 | } 126 | resolve(); 127 | }); 128 | }); 129 | } 130 | 131 | function stopApi() { 132 | if (server) { 133 | server.close(); 134 | } 135 | } -------------------------------------------------------------------------------- /test/integration/server.spec.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as _ from 'lodash'; 3 | import * as request from 'request'; 4 | import { 5 | Accept, AcceptLanguage, ContextAccept, ContextLanguage, GET, 6 | Path, POST, PUT, Return, Server 7 | } from '../../src/typescript-rest'; 8 | 9 | export class Person { 10 | public id: number; 11 | public name: string; 12 | public age: number; 13 | public salary: number; 14 | constructor(id: number, name: string, age: number, salary: number = age * 1000) { 15 | this.id = id; 16 | this.name = name; 17 | this.age = age; 18 | this.salary = salary; 19 | } 20 | } 21 | 22 | @Path('/accept') 23 | @AcceptLanguage('en', 'pt-BR') 24 | export class AcceptServiceTest { 25 | 26 | @GET 27 | public testLanguage(@ContextLanguage language: string): string { 28 | if (language === 'en') { 29 | return 'accepted'; 30 | } 31 | return 'aceito'; 32 | } 33 | 34 | @PUT 35 | public testLanguageChange(@ContextLanguage language: string): void { 36 | return; 37 | } 38 | 39 | @GET 40 | @AcceptLanguage('fr') 41 | @Path('fr') 42 | public testLanguageFr(@ContextLanguage language: string): string { 43 | if (language === 'fr') { 44 | return 'OK'; 45 | } 46 | return 'NOT OK'; 47 | } 48 | 49 | @GET 50 | @Path('types') 51 | @Accept('application/json') 52 | public testAccepts(@ContextAccept type: string): string { 53 | if (type === 'application/json') { 54 | return 'accepted'; 55 | } 56 | return 'not accepted'; 57 | } 58 | } 59 | 60 | @Path('/reference') 61 | export class ReferenceServiceTest { 62 | @Path('accepted') 63 | @POST 64 | public testAccepted(p: Person): Promise> { 65 | return new Promise>(function (resolve, reject) { 66 | resolve(new Return.RequestAccepted('' + p.id)); 67 | }); 68 | } 69 | 70 | @Path('moved') 71 | @POST 72 | public testMoved(p: Person): Promise> { 73 | return new Promise>(function (resolve, reject) { 74 | resolve(new Return.MovedPermanently('' + p.id)); 75 | }); 76 | } 77 | 78 | @Path('movedtemp') 79 | @POST 80 | public testMovedTemp(p: Person): Promise> { 81 | return new Promise>(function (resolve, reject) { 82 | resolve(new Return.MovedTemporarily('' + p.id)); 83 | }); 84 | } 85 | } 86 | 87 | @Path('async/test') 88 | export class AsyncServiceTest { 89 | @GET 90 | public async test() { 91 | const result = await this.aPromiseMethod(); 92 | return result; 93 | } 94 | 95 | private aPromiseMethod() { 96 | return new Promise((resolve, reject) => { 97 | setTimeout(() => { 98 | resolve('OK'); 99 | }, 10); 100 | }); 101 | } 102 | } 103 | 104 | @Path('othersimplepath') 105 | export class SimpleService { 106 | @GET 107 | public test(): string { 108 | return 'othersimpleservice'; 109 | } 110 | } 111 | 112 | describe('Server Tests', () => { 113 | 114 | beforeAll(() => { 115 | return startApi(); 116 | }); 117 | 118 | afterAll(() => { 119 | stopApi(); 120 | }); 121 | 122 | // describe('Server', () => { 123 | // it('should provide a catalog containing the exposed paths', (done) => { 124 | // expect(Server.getPaths()).to.include.members(['/mypath', '/mypath2/secondpath', 125 | // '/asubpath/person/:id', '/headers', '/multi-param', '/context', '/upload', 126 | // '/download', '/download/ref', '/accept', '/accept/conflict', '/async/test']); 127 | // expect(Server.getHttpMethods('/asubpath/person/:id')).to.have.members([HttpMethod.GET, HttpMethod.PUT]); 128 | // expect(Server.getHttpMethods('/mypath2/secondpath')).to.have.members([HttpMethod.GET, HttpMethod.DELETE]); 129 | // done(); 130 | // }); 131 | // }); 132 | 133 | describe('Server', () => { 134 | it('should choose language correctly', (done) => { 135 | request({ 136 | headers: { 'Accept-Language': 'pt-BR' }, 137 | url: 'http://localhost:5674/accept' 138 | }, (error, response, body) => { 139 | expect(body).toEqual('aceito'); 140 | done(); 141 | }); 142 | }); 143 | 144 | it('should choose language correctly, when declared on methods', (done) => { 145 | request({ 146 | headers: { 'Accept-Language': 'fr' }, 147 | url: 'http://localhost:5674/accept/fr' 148 | }, (error, response, body) => { 149 | expect(body).toEqual('OK'); 150 | done(); 151 | }); 152 | }); 153 | 154 | it('should reject unacceptable languages', (done) => { 155 | request({ 156 | headers: { 'Accept-Language': 'fr' }, 157 | url: 'http://localhost:5674/accept' 158 | }, (error, response, body) => { 159 | expect(response.statusCode).toEqual(406); 160 | done(); 161 | }); 162 | }); 163 | 164 | it('should use default language if none specified', (done) => { 165 | request({ 166 | url: 'http://localhost:5674/accept' 167 | }, (error, response, body) => { 168 | expect(body).toEqual('accepted'); 169 | done(); 170 | }); 171 | }); 172 | 173 | it('should use default media type if none specified', (done) => { 174 | request({ 175 | url: 'http://localhost:5674/accept/types' 176 | }, (error, response, body) => { 177 | expect(body).toEqual('accepted'); 178 | done(); 179 | }); 180 | }); 181 | it('should reject unacceptable media types', (done) => { 182 | request({ 183 | headers: { 'Accept': 'text/html' }, 184 | url: 'http://localhost:5674/accept/types' 185 | }, (error, response, body) => { 186 | expect(response.statusCode).toEqual(406); 187 | done(); 188 | }); 189 | }); 190 | 191 | it('should return 404 when unmapped resources are requested', (done) => { 192 | request({ 193 | url: 'http://localhost:5674/unmapped/resource' 194 | }, (error, response, body) => { 195 | expect(response.statusCode).toEqual(404); 196 | done(); 197 | }); 198 | }); 199 | 200 | it('should return 405 when a not supported method is requeted to a mapped resource', (done) => { 201 | request.post({ 202 | url: 'http://localhost:5674/accept' 203 | }, (error, response, body) => { 204 | expect(response.statusCode).toEqual(405); 205 | const allowed: string | Array = response.headers['allow']; 206 | expect(allowed).toContain('GET'); 207 | expect(allowed).toContain('PUT'); 208 | done(); 209 | }); 210 | }); 211 | it('should support async and await on REST methods', (done) => { 212 | request('http://localhost:5674/async/test', (error, response, body) => { 213 | expect(body).toEqual('OK'); 214 | done(); 215 | }); 216 | }); 217 | }); 218 | 219 | describe('Services that use referenced types', () => { 220 | it('should return 202 for POST on path: /accepted', (done) => { 221 | request.post({ 222 | body: JSON.stringify(new Person(123, 'person 123', 35)), 223 | headers: { 'content-type': 'application/json' }, 224 | url: 'http://localhost:5674/reference/accepted' 225 | }, (error, response, body) => { 226 | expect(response.statusCode).toEqual(202); 227 | expect(response.headers['location']).toEqual('123'); 228 | done(); 229 | }); 230 | }); 231 | 232 | it('should return 301 for POST on path: /moved', (done) => { 233 | request.post({ 234 | body: JSON.stringify(new Person(123, 'person 123', 35)), 235 | headers: { 'content-type': 'application/json' }, 236 | url: 'http://localhost:5674/reference/moved' 237 | }, (error, response, body) => { 238 | expect(response.statusCode).toEqual(301); 239 | expect(response.headers['location']).toEqual('123'); 240 | done(); 241 | }); 242 | }); 243 | 244 | it('should return 302 for POST on path: /movedtemp', (done) => { 245 | request.post({ 246 | body: JSON.stringify(new Person(123, 'person 123', 35)), 247 | headers: { 'content-type': 'application/json' }, 248 | url: 'http://localhost:5674/reference/movedtemp' 249 | }, (error, response, body) => { 250 | expect(response.statusCode).toEqual(302); 251 | expect(response.headers['location']).toEqual('123'); 252 | done(); 253 | }); 254 | }); 255 | }); 256 | 257 | describe('Service classes with same name', () => { 258 | it('should should work when imported via loadServices', (done) => { 259 | request.get({ 260 | url: 'http://localhost:5674/simplepath' 261 | }, (error, response, body) => { 262 | expect(response.statusCode).toEqual(200); 263 | expect(body).toEqual('simpleservice'); 264 | done(); 265 | }); 266 | }); 267 | it('should should work when imported via buildServices', (done) => { 268 | request.get({ 269 | url: 'http://localhost:5674/othersimplepath' 270 | }, (error, response, body) => { 271 | expect(response.statusCode).toEqual(200); 272 | expect(body).toEqual('othersimpleservice'); 273 | done(); 274 | }); 275 | }); 276 | }); 277 | 278 | }); 279 | 280 | let server: any; 281 | 282 | export function startApi(): Promise { 283 | return new Promise((resolve, reject) => { 284 | const app: express.Application = express(); 285 | app.set('env', 'test'); 286 | // Server.setFileLimits({ 287 | // fieldSize: 1024 * 1024 288 | // }); 289 | Server.loadControllers(app, ['test/data/*', '!**/*.yaml'], `${__dirname}/../..`); 290 | Server.buildServices(app, AcceptServiceTest, ReferenceServiceTest, 291 | AsyncServiceTest, SimpleService); 292 | server = app.listen(5674, (err?: any) => { 293 | if (err) { 294 | return reject(err); 295 | } 296 | resolve(); 297 | }); 298 | }); 299 | } 300 | 301 | export function stopApi() { 302 | if (server) { 303 | server.close(); 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /test/integration/swagger.spec.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as _ from 'lodash'; 3 | import * as request from 'request'; 4 | import * as YAML from 'yamljs'; 5 | import { Server } from '../../src/typescript-rest'; 6 | 7 | let server: any; 8 | let swaggerFile: any; 9 | 10 | describe('Swagger Tests', () => { 11 | 12 | beforeAll(() => { 13 | return startApi(); 14 | }); 15 | 16 | afterAll(() => { 17 | stopApi(); 18 | }); 19 | 20 | describe('Api Docs', () => { 21 | it('should be able to send the YAML API swagger file', (done) => { 22 | request.get('http://localhost:5674/api-docs/yaml', (error, response, body) => { 23 | const swaggerDocument: any = YAML.parse(body); 24 | const expectedSwagger = _.cloneDeep(swaggerFile); 25 | expectedSwagger.host = 'localhost:5674'; 26 | expectedSwagger.schemes = ["http"]; 27 | expect(expectedSwagger).toEqual(swaggerDocument); 28 | done(); 29 | }); 30 | }); 31 | it('should be able to send the JSON API swagger file', (done) => { 32 | request.get('http://localhost:5674/api-docs/json', (error, response, body) => { 33 | const swaggerDocument: any = JSON.parse(body); 34 | expect(swaggerDocument.basePath).toEqual('/v1'); 35 | done(); 36 | }); 37 | }); 38 | }); 39 | }); 40 | 41 | export function startApi(): Promise { 42 | return new Promise((resolve, reject) => { 43 | const app: express.Application = express(); 44 | app.set('env', 'test'); 45 | swaggerFile = YAML.load('./test/data/swagger.yaml'); 46 | Server.swagger(app, { 47 | endpoint: 'api-docs', 48 | filePath: './test/data/swagger.yaml', 49 | host: 'localhost:5674', 50 | schemes: ['http'] 51 | }); 52 | server = app.listen(5674, (err?: any) => { 53 | if (err) { 54 | return reject(err); 55 | } 56 | resolve(); 57 | }); 58 | }); 59 | } 60 | 61 | export function stopApi() { 62 | if (server) { 63 | server.close(); 64 | } 65 | } -------------------------------------------------------------------------------- /test/jest.config-integration.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest' 5 | }, 6 | moduleFileExtensions: [ 7 | 'ts', 8 | 'tsx', 9 | 'js', 10 | 'jsx', 11 | 'json', 12 | 'node', 13 | ], 14 | testMatch: ['**/test/integration/**/*.spec.ts'], 15 | coverageDirectory: 'reports/coverage', 16 | collectCoverageFrom: [ 17 | 'src/**/*.{ts,tsx,js,jsx}', 18 | '!src/**/*.d.ts', 19 | ], 20 | rootDir: '../' 21 | }; 22 | -------------------------------------------------------------------------------- /test/jest.config-unit.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest' 5 | }, 6 | moduleFileExtensions: [ 7 | 'ts', 8 | 'tsx', 9 | 'js', 10 | 'jsx', 11 | 'json', 12 | 'node', 13 | ], 14 | testMatch: ['**/test/unit/**/*.spec.ts'], 15 | coverageDirectory: 'reports/coverage', 16 | collectCoverageFrom: [ 17 | 'src/**/*.{ts,tsx,js,jsx}', 18 | '!src/**/*.d.ts', 19 | ], 20 | coverageThreshold: { 21 | global: { 22 | branches: 82, 23 | functions: 90, 24 | lines: 90, 25 | statements: 90 26 | } 27 | }, 28 | rootDir: '../' 29 | }; 30 | -------------------------------------------------------------------------------- /test/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest' 5 | }, 6 | moduleFileExtensions: [ 7 | 'ts', 8 | 'tsx', 9 | 'js', 10 | 'jsx', 11 | 'json', 12 | 'node', 13 | ], 14 | testMatch: ['**/test/unit/**/*.spec.ts', '**/test/integration/**/*.spec.ts'], 15 | coverageDirectory: 'reports/coverage', 16 | collectCoverageFrom: [ 17 | 'src/**/*.{ts,tsx,js,jsx}', 18 | '!src/**/*.d.ts', 19 | ], 20 | coverageThreshold: { 21 | global: { 22 | branches: 82, 23 | functions: 90, 24 | lines: 90, 25 | statements: 90 26 | } 27 | }, 28 | rootDir: '../' 29 | }; 30 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "experimentalDecorators": true, 5 | "emitDecoratorMetadata": true, 6 | "lib": [ 7 | "es6", 8 | "dom" 9 | ], 10 | "module": "commonjs", 11 | "newLine": "LF", 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitAny": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": false, 16 | "noUnusedParameters": false, 17 | "noUnusedLocals": true, 18 | "sourceMap": true, 19 | "strictNullChecks": false, 20 | "target": "es5" 21 | }, 22 | "include": [ 23 | "**/*.ts" 24 | ] 25 | } -------------------------------------------------------------------------------- /test/unit/passport-authenticator.spec.ts: -------------------------------------------------------------------------------- 1 | jest.mock('passport'); 2 | 3 | import * as _ from 'lodash'; 4 | import { PassportAuthenticator } from '../../src/authenticator/passport'; 5 | import { wait } from 'test-wait'; 6 | import * as passport from 'passport'; 7 | 8 | const expressStub: any = 9 | { 10 | use: jest.fn() 11 | }; 12 | const authenticate = passport.authenticate as jest.Mock; 13 | const deserializeUser = passport.deserializeUser as jest.Mock; 14 | const initialize = passport.initialize as jest.Mock; 15 | const serializeUser = passport.serializeUser as jest.Mock; 16 | const session = passport.session as jest.Mock; 17 | const use = passport.use as jest.Mock; 18 | 19 | describe('PassportAuthenticator', () => { 20 | const testStrategy: any = { name: 'test-strategy' }; 21 | const authenticator = jest.fn(); 22 | const initializer = jest.fn(); 23 | const sessionHandler = jest.fn(); 24 | 25 | beforeEach(() => { 26 | 27 | authenticate.mockReturnValue(authenticator); 28 | initialize.mockReturnValue(initializer); 29 | session.mockReturnValue(sessionHandler); 30 | }); 31 | 32 | afterEach(() => { 33 | authenticate.mockClear(); 34 | deserializeUser.mockClear(); 35 | initialize.mockClear(); 36 | serializeUser.mockClear(); 37 | session.mockClear(); 38 | use.mockClear(); 39 | expressStub.use.mockClear(); 40 | }); 41 | 42 | it('should be able to create a simple authenticator with a given passport strategy', async () => { 43 | const auth: any = new PassportAuthenticator(testStrategy); 44 | 45 | expect(Object.keys(auth.options)).toHaveLength(0); 46 | expect(use).toBeCalledWith(testStrategy.name, testStrategy); 47 | expect(use).toBeCalledTimes(1); 48 | expect(authenticate).toBeCalledWith(testStrategy.name, expect.anything()); 49 | expect(auth.getMiddleware()).toEqual(authenticator); 50 | }); 51 | 52 | it('should be able to create a simple authenticator with default strategy name', async () => { 53 | const strategy: any = {}; 54 | const auth = new PassportAuthenticator(strategy); 55 | 56 | expect(auth).toBeDefined(); 57 | expect(use).toBeCalledWith('default_strategy', strategy); 58 | expect(use).toBeCalledTimes(1); 59 | expect(authenticate).toBeCalledWith('default_strategy', expect.anything()); 60 | expect(authenticate).toBeCalledTimes(1); 61 | }); 62 | 63 | it('should be able to create a simple authenticator with custom auth options', async () => { 64 | const options = { 65 | authOptions: { 66 | session: false 67 | }, 68 | strategyName: 'my-custom-strategy' 69 | }; 70 | const auth: any = new PassportAuthenticator(testStrategy, options); 71 | 72 | expect(auth.options).toEqual(options); 73 | expect(authenticate).toBeCalledWith(options.strategyName, options.authOptions); 74 | expect(authenticate).toBeCalledTimes(1); 75 | }); 76 | 77 | it('should be able to initialize a sessionless authenticator', async () => { 78 | const options = { 79 | authOptions: { 80 | session: false 81 | } 82 | }; 83 | const auth = new PassportAuthenticator(testStrategy, options); 84 | auth.initialize(expressStub); 85 | 86 | expect(initialize).toBeCalledTimes(1); 87 | expect(expressStub.use).toBeCalledTimes(1); 88 | expect(expressStub.use).toBeCalledWith(initializer); 89 | expect(session).toBeCalledTimes(0); 90 | }); 91 | 92 | describe('Session tests', () => { 93 | const serializationCallbackStub = jest.fn(); 94 | const deserializationCallbackStub = jest.fn(); 95 | const options = { 96 | deserializeUser: jest.fn(), 97 | serializeUser: jest.fn() 98 | }; 99 | 100 | afterEach(() => { 101 | options.deserializeUser.mockClear(); 102 | options.serializeUser.mockClear(); 103 | deserializationCallbackStub.mockClear(); 104 | serializationCallbackStub.mockClear(); 105 | }); 106 | 107 | it('should be able to initialize an authenticator with session', async () => { 108 | const user = { 'id': '123', 'name': 'Joe' }; 109 | const serialization = JSON.stringify(user); 110 | options.serializeUser.mockReturnValue(serialization); 111 | options.deserializeUser.mockReturnValue(user); 112 | 113 | serializeUser.mockImplementation((callback) => { 114 | callback(user, serializationCallbackStub); 115 | }); 116 | deserializeUser.mockImplementation((callback) => { 117 | callback(serialization, deserializationCallbackStub); 118 | }); 119 | const auth = new PassportAuthenticator(testStrategy, options); 120 | auth.initialize(expressStub); 121 | await wait(1); 122 | expect(initialize).toBeCalledTimes(1); 123 | expect(expressStub.use).toBeCalledTimes(2); 124 | expect(expressStub.use).toBeCalledWith(initializer); 125 | expect(session).toBeCalledTimes(1); 126 | expect(expressStub.use).toBeCalledWith(sessionHandler); 127 | expect(serializeUser).toBeCalledTimes(1); 128 | expect(deserializeUser).toBeCalledTimes(1); 129 | expect(serializationCallbackStub).toBeCalledWith(null, serialization); 130 | expect(serializationCallbackStub).toBeCalledTimes(1); 131 | expect(deserializationCallbackStub).toBeCalledWith(null, user); 132 | expect(deserializationCallbackStub).toBeCalledTimes(1); 133 | }); 134 | 135 | it('should be able to fail when serialization fail', async () => { 136 | const user = { 'id': '123', 'name': 'Joe' }; 137 | const serialization = JSON.stringify(user); 138 | const error = new Error('any error'); 139 | options.serializeUser.mockReturnValue(Promise.reject(error)); 140 | options.deserializeUser.mockReturnValue(Promise.reject(error)); 141 | 142 | serializeUser.mockImplementation((callback) => { 143 | callback(user, serializationCallbackStub); 144 | }); 145 | deserializeUser.mockImplementation((callback) => { 146 | callback(serialization, deserializationCallbackStub); 147 | }); 148 | const auth = new PassportAuthenticator(testStrategy, options); 149 | auth.initialize(expressStub); 150 | await wait(1); 151 | expect(serializationCallbackStub).toBeCalledWith(error, null); 152 | expect(serializationCallbackStub).toBeCalledTimes(1); 153 | expect(deserializationCallbackStub).toBeCalledWith(error, null); 154 | expect(deserializationCallbackStub).toBeCalledTimes(1); 155 | }); 156 | }); 157 | 158 | }); -------------------------------------------------------------------------------- /test/unit/server-config.spec.ts: -------------------------------------------------------------------------------- 1 | jest.mock('fs-extra'); 2 | jest.mock('../../src/server/server'); 3 | 4 | import * as _ from 'lodash'; 5 | import * as path from 'path'; 6 | import { ServerConfig } from '../../src/server/config'; 7 | import { Server } from '../../src/server/server'; 8 | import * as fs from 'fs-extra'; 9 | 10 | const registerServiceFactory = Server.registerServiceFactory as jest.Mock; 11 | const existsSync = fs.existsSync as jest.Mock; 12 | const readJSONSync = fs.readJSONSync as jest.Mock; 13 | 14 | describe('ServerConfig', () => { 15 | afterEach(() => { 16 | existsSync.mockClear(); 17 | readJSONSync.mockClear(); 18 | registerServiceFactory.mockClear(); 19 | }); 20 | 21 | it('should use a custom service factory if configured', async () => { 22 | const config = { 23 | serviceFactory: 'myCustomFactory' 24 | }; 25 | 26 | existsSync.mockReturnValueOnce(false); 27 | existsSync.mockReturnValueOnce(true); 28 | existsSync.mockReturnValueOnce(true); 29 | readJSONSync.mockReturnValue(config); 30 | ServerConfig.configure(); 31 | 32 | expect(registerServiceFactory).toBeCalledWith(config.serviceFactory); 33 | expect(registerServiceFactory).toBeCalledTimes(1); 34 | }); 35 | 36 | it('should use a custom service factory configured with relative path', async () => { 37 | const config = { 38 | serviceFactory: './myCustomFactory' 39 | }; 40 | const expectedServicePath = path.join(process.cwd(), config.serviceFactory); 41 | 42 | existsSync.mockReturnValueOnce(false); 43 | existsSync.mockReturnValueOnce(true); 44 | existsSync.mockReturnValueOnce(true); 45 | readJSONSync.mockReturnValue(config); 46 | ServerConfig.configure(); 47 | 48 | expect(registerServiceFactory).toBeCalledWith(expectedServicePath); 49 | expect(registerServiceFactory).toBeCalledTimes(1); 50 | }); 51 | 52 | it('should not use ioc if an error occur while searching for config file', async () => { 53 | const consoleError = jest.spyOn(console, "error"); 54 | try { 55 | const error = new Error("Some error"); 56 | existsSync.mockImplementation(() => { throw error; }); 57 | ServerConfig.configure(); 58 | 59 | expect(registerServiceFactory).toBeCalledTimes(0); 60 | expect(consoleError).toBeCalledWith(error); 61 | } finally { 62 | consoleError.mockReset(); 63 | } 64 | }); 65 | }); -------------------------------------------------------------------------------- /test/unit/server-errors.spec.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { Errors } from '../../src/typescript-rest'; 3 | 4 | describe('Server Errors', () => { 5 | 6 | it('should correct default message for BadRequestError', async () => { 7 | const error = new Errors.BadRequestError(); 8 | expect(error.statusCode).toEqual(400); 9 | expect(error.message).toEqual('Bad Request'); 10 | }); 11 | 12 | it('should correct default message for UnauthorizedError', async () => { 13 | const error = new Errors.UnauthorizedError(); 14 | expect(error.statusCode).toEqual(401); 15 | expect(error.message).toEqual('Unauthorized'); 16 | }); 17 | 18 | it('should correct default message for ForbiddenError', async () => { 19 | const error = new Errors.ForbiddenError(); 20 | expect(error.statusCode).toEqual(403); 21 | expect(error.message).toEqual('Forbidden'); 22 | }); 23 | 24 | it('should correct default message for NotFoundError', async () => { 25 | const error = new Errors.NotFoundError(); 26 | expect(error.statusCode).toEqual(404); 27 | expect(error.message).toEqual('Not Found'); 28 | }); 29 | 30 | it('should correct default message for MethodNotAllowedError', async () => { 31 | const error = new Errors.MethodNotAllowedError(); 32 | expect(error.statusCode).toEqual(405); 33 | expect(error.message).toEqual('Method Not Allowed'); 34 | }); 35 | 36 | it('should correct default message for NotAcceptableError', async () => { 37 | const error = new Errors.NotAcceptableError(); 38 | expect(error.statusCode).toEqual(406); 39 | expect(error.message).toEqual('Not Acceptable'); 40 | }); 41 | 42 | it('should correct default message for ConflictError', async () => { 43 | const error = new Errors.ConflictError(); 44 | expect(error.statusCode).toEqual(409); 45 | expect(error.message).toEqual('Conflict'); 46 | }); 47 | 48 | it('should correct default message for GoneError', async () => { 49 | const error = new Errors.GoneError(); 50 | expect(error.statusCode).toEqual(410); 51 | expect(error.message).toEqual('Gone'); 52 | }); 53 | 54 | it('should correct default message for UnsupportedMediaTypeError', async () => { 55 | const error = new Errors.UnsupportedMediaTypeError(); 56 | expect(error.statusCode).toEqual(415); 57 | expect(error.message).toEqual('Unsupported Media Type'); 58 | }); 59 | 60 | it('should correct default message for UnprocessableEntityError', async () => { 61 | const error = new Errors.UnprocessableEntityError(); 62 | expect(error.statusCode).toEqual(422); 63 | expect(error.message).toEqual('Unprocessable Entity'); 64 | }); 65 | 66 | it('should correct default message for InternalServerError', async () => { 67 | const error = new Errors.InternalServerError(); 68 | expect(error.statusCode).toEqual(500); 69 | expect(error.message).toEqual('Internal Server Error'); 70 | }); 71 | 72 | it('should correct default message for NotImplementedError', async () => { 73 | const error = new Errors.NotImplementedError(); 74 | expect(error.statusCode).toEqual(501); 75 | expect(error.message).toEqual('Not Implemented'); 76 | }); 77 | 78 | it('should support custom message for BadRequestError', async () => { 79 | const error = new Errors.BadRequestError('Custom Message'); 80 | expect(error.message).toEqual('Custom Message'); 81 | }); 82 | 83 | it('should support custom message for UnauthorizedError', async () => { 84 | const error = new Errors.UnauthorizedError('Custom Message'); 85 | expect(error.message).toEqual('Custom Message'); 86 | }); 87 | 88 | it('should support custom message for ForbiddenError', async () => { 89 | const error = new Errors.ForbiddenError('Custom Message'); 90 | expect(error.message).toEqual('Custom Message'); 91 | }); 92 | 93 | it('should support custom message for NotFoundError', async () => { 94 | const error = new Errors.NotFoundError('Custom Message'); 95 | expect(error.message).toEqual('Custom Message'); 96 | }); 97 | 98 | it('should support custom message for MethodNotAllowedError', async () => { 99 | const error = new Errors.MethodNotAllowedError('Custom Message'); 100 | expect(error.message).toEqual('Custom Message'); 101 | }); 102 | 103 | it('should support custom message for NotAcceptableError', async () => { 104 | const error = new Errors.NotAcceptableError('Custom Message'); 105 | expect(error.message).toEqual('Custom Message'); 106 | }); 107 | 108 | it('should support custom message for ConflictError', async () => { 109 | const error = new Errors.ConflictError('Custom Message'); 110 | expect(error.message).toEqual('Custom Message'); 111 | }); 112 | 113 | it('should support custom message for GoneError', async () => { 114 | const error = new Errors.GoneError('Custom Message'); 115 | expect(error.message).toEqual('Custom Message'); 116 | }); 117 | 118 | it('should support custom message for UnsupportedMediaTypeError', async () => { 119 | const error = new Errors.UnsupportedMediaTypeError('Custom Message'); 120 | expect(error.message).toEqual('Custom Message'); 121 | }); 122 | 123 | it('should support custom message for UnprocessableEntityError', async () => { 124 | const error = new Errors.UnprocessableEntityError('Custom Message'); 125 | expect(error.message).toEqual('Custom Message'); 126 | }); 127 | 128 | it('should support custom message for InternalServerError', async () => { 129 | const error = new Errors.InternalServerError('Custom Message'); 130 | expect(error.message).toEqual('Custom Message'); 131 | }); 132 | 133 | it('should support custom message for NotImplementedError', async () => { 134 | const error = new Errors.NotImplementedError('Custom Message'); 135 | expect(error.message).toEqual('Custom Message'); 136 | }); 137 | }); -------------------------------------------------------------------------------- /test/unit/server.spec.ts: -------------------------------------------------------------------------------- 1 | jest.mock('../../src/server/server-container'); 2 | 3 | import * as _ from 'lodash'; 4 | import { Server } from '../../src/server/server'; 5 | import { ServerContainer } from '../../src/server/server-container'; 6 | 7 | const server: any = {}; 8 | const get = ServerContainer.get as jest.Mock; 9 | 10 | describe('Server', () => { 11 | beforeAll(() => { 12 | get.mockReturnValue(server); 13 | }); 14 | 15 | afterEach(() => { 16 | get.mockClear(); 17 | Server.immutable(false); 18 | }); 19 | 20 | it('should be able to define a custom cookie secret', async () => { 21 | const secret = 'my-secret'; 22 | Server.setCookiesSecret(secret); 23 | 24 | expect(get).toBeCalledTimes(1); 25 | expect(server.cookiesSecret).toEqual(secret); 26 | }); 27 | 28 | it('should be able to define a custom cookie decoder', async () => { 29 | const decoder = jest.fn(); 30 | Server.setCookiesDecoder(decoder); 31 | 32 | expect(get).toBeCalledTimes(1); 33 | expect(server.cookiesDecoder).toEqual(decoder); 34 | }); 35 | 36 | it('should be able to define a custom destination folder for uploaded files', async () => { 37 | const target = './target-dir'; 38 | Server.setFileDest(target); 39 | 40 | expect(get).toBeCalledTimes(1); 41 | expect(server.fileDest).toEqual(target); 42 | }); 43 | 44 | it('should be able to define a custom filter for uploaded files', async () => { 45 | const filter = jest.fn(); 46 | Server.setFileFilter(filter); 47 | 48 | expect(get).toBeCalledTimes(1); 49 | expect(server.fileFilter).toEqual(filter); 50 | }); 51 | 52 | it('should be able to define a custom limit for uploaded files', async () => { 53 | const limits = { 54 | fieldNameSize: 100, 55 | fieldSize: 1024, 56 | fields: 3000, 57 | fileSize: 3000, 58 | files: 1000, 59 | headerPairs: 30, 60 | parts: 100 61 | }; 62 | Server.setFileLimits(limits); 63 | 64 | expect(get).toBeCalledTimes(1); 65 | expect(server.fileLimits).toEqual(limits); 66 | }); 67 | 68 | 69 | it('should ignore change requests when immutable', async () => { 70 | Server.immutable(true); 71 | Server.setCookiesSecret(null); 72 | Server.registerAuthenticator(null); 73 | Server.registerServiceFactory('test'); 74 | Server.setCookiesDecoder(null); 75 | Server.setFileDest('test'); 76 | Server.setFileFilter(null); 77 | Server.setFileLimits(null); 78 | Server.addParameterConverter(null, null); 79 | Server.removeParameterConverter(null); 80 | Server.ignoreNextMiddlewares(false); 81 | expect(get).toBeCalledTimes(0); 82 | expect(Server.isImmutable()).toBeTruthy(); 83 | }); 84 | 85 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "experimentalDecorators": true, 5 | "emitDecoratorMetadata": true, 6 | "lib": [ 7 | "es6", 8 | "dom" 9 | ], 10 | "module": "commonjs", 11 | "newLine": "LF", 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitAny": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": false, 16 | "noUnusedParameters": false, 17 | "noUnusedLocals": true, 18 | "outDir": "dist", 19 | "sourceMap": true, 20 | "strictNullChecks": false, 21 | "target": "es6" 22 | }, 23 | "include": [ 24 | "src/**/*.ts" 25 | ] 26 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended", 4 | "tslint-config-prettier" 5 | ], 6 | "rules": { 7 | "array-type": [ 8 | true, 9 | "generic" 10 | ], 11 | "no-string-literal": false, 12 | "object-literal-shorthand": [ 13 | true, 14 | "never" 15 | ], 16 | "only-arrow-functions": false, 17 | "interface-name": false, 18 | "max-classes-per-file": false, 19 | "no-var-requires": false, 20 | "ban-types": false, 21 | "no-unused-expression": true, 22 | "semicolon": [ 23 | true, 24 | "always" 25 | ] 26 | } 27 | } --------------------------------------------------------------------------------