├── .gitignore ├── README.md ├── build ├── routes.ts └── swagger │ └── swagger.json ├── package.json ├── pull_request_template.md ├── src ├── auth.ts ├── config │ ├── ErrorHandler.ts │ ├── Logger.ts │ ├── MongoDbConnection.ts │ ├── SQLDbConnection.ts │ ├── SQLSetupHelper.ts │ ├── Server.ts │ ├── constants.ts │ └── env │ │ ├── .env.development │ │ ├── .env.production │ │ └── .env.test ├── controllers │ ├── UserController.ts │ └── index.ts ├── index.ts ├── ioc.ts ├── models │ ├── BaseFormatter.ts │ ├── PaginationModel.ts │ ├── UserModel.ts │ └── index.ts ├── repositories │ ├── IBaseRepository.ts │ ├── index.ts │ ├── mongo │ │ ├── BaseRepository.ts │ │ └── UserRepository.ts │ └── sql │ │ ├── BaseRepository.ts │ │ ├── UserRepository.ts │ │ └── entities │ │ ├── BaseEntity.ts │ │ ├── UserEntity.ts │ │ └── index.ts ├── services │ ├── BaseService.ts │ ├── UserService.ts │ └── index.ts ├── setup │ ├── migrateSql.ts │ ├── migrations │ │ └── 20180410153203-user.ts │ ├── seedMongo.ts │ └── sql-seeders │ │ ├── seeds.js │ │ └── seeds.ts ├── tests │ ├── IntegrationHelper.ts │ ├── constants.ts │ ├── data │ │ ├── models.ts │ │ └── testfile.jpg │ ├── integration │ │ ├── _setup.spec.ts │ │ └── users.spec.ts │ └── unit │ │ ├── mocks │ │ ├── MockBaseRepository.ts │ │ └── mockSQLEntity.ts │ │ ├── models │ │ └── baseFormatter.spec.ts │ │ ├── other │ │ ├── errorHandler.spec.ts │ │ └── logger.spec.ts │ │ ├── repositories │ │ ├── mongoBaseRepository.spec.ts │ │ └── sqlBaseRepository.spec.ts │ │ ├── services │ │ ├── baseService.spec.ts │ │ └── userService.spec.ts │ │ └── utils │ │ ├── generalUtils.spec.ts │ │ └── immutabilityHelper.spec.ts └── utils │ ├── ImmutabilityHelper.ts │ ├── generalUtils.ts │ └── index.ts ├── tsconfig.json ├── tslint.json ├── tsoa.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /dist-server 6 | /tmp 7 | /out-tsc 8 | 9 | # dependencies 10 | /node_modules 11 | 12 | # IDEs and editors 13 | /.idea 14 | .project 15 | .classpath 16 | .c9/ 17 | *.launch 18 | .settings/ 19 | *.sublime-workspace 20 | 21 | # IDE - VSCode 22 | .vscode/* 23 | !.vscode/settings.json 24 | !.vscode/tasks.json 25 | !.vscode/launch.json 26 | !.vscode/extensions.json 27 | 28 | # misc 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | .nyc_output/ 33 | /libpeerconnection.log 34 | npm-debug.log 35 | testem.log 36 | /typings 37 | 38 | # e2e 39 | /e2e/*.js 40 | /e2e/*.map 41 | 42 | # System Files 43 | .DS_Store 44 | Thumbs.db 45 | 46 | yarn-error.log 47 | /build 48 | /build/* 49 | /logs/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tsoa api 2 | * This project is a seed for building a **node.js** api. It includes the following features: 3 | * * [tsoa](https://www.npmjs.com/package/tsoa) `typescript` 4 | * * [inversify](https://www.npmjs.com/package/inversify) `inversion of controll / dependency injection` 5 | * * [swagger-ui-express](https://www.npmjs.com/package/swagger-ui-express) 6 | * * [mongoose](https://www.npmjs.com/package/mongoose) `MongoDB ORM` 7 | * * [sequelize](https://www.npmjs.com/package/sequelize) `SQL ORM` 8 | * * [mocha](https://www.npmjs.com/package/mocha), [chai](https://www.npmjs.com/package/chai), [supertest](https://www.npmjs.com/package/supertest), [sinon](https://www.npmjs.com/package/sinon) `unit and integration testing` 9 | 10 | ## Swagger 11 | * `/api-docs` 12 | 13 | ## Commands 14 | * **instalation:** `yarn install` 15 | * **dev:** `yarn start` *build tsoa routes, swagger definitions and starts the server on development mode listening to file changes (swagger definition changes will require a manual restart)* 16 | * **test:** `yarn test` *unit and integration tests* 17 | * **build:** `yarn build` *production build* 18 | * **prod:** `yarn start:prod` *starts the server on production mode* 19 | 20 | ## Scaffolding 21 | * config `express server, DB connection, Logger, etc` 22 | * * env `.env files` 23 | * controllers `routes configuration` 24 | * models `classes and interfaces representing entities. They are also used to normalize data` 25 | * respositories `data abstraction layers` 26 | * services `business logic to be used primary by controllers` 27 | * utils 28 | * tests 29 | 30 | ## Code Examples 31 | 32 | ### Controller 33 | * Controllers handle routes configuration including: 34 | * * paths / methods 35 | * * auth roles 36 | * * swagger definitions 37 | ```typescript 38 | import { Route, Controller, Get } from 'tsoa'; 39 | 40 | import { ProvideSingleton } from '../ioc'; 41 | 42 | @Route('ping') 43 | @ProvideSingleton(PingController) 44 | export class PingController extends Controller { 45 | /** The service containing business logic is passed through dependency injection */ 46 | constructor(@inject(UserService) private userService: UserService) { 47 | super(); 48 | } 49 | 50 | /** Simple GET */ 51 | @Get() 52 | public async ping(): Promise { 53 | return 'pong'; 54 | } 55 | 56 | /** Error response definition for swagger */ 57 | @Response(400, 'Bad request') 58 | @Post() 59 | /** Type of security needed to access the method */ 60 | @Security('adminUser') 61 | /** The request's body is accessed through a decorator */ 62 | /** The interface "IUserModel" is also used to build swagger specs and to perform run time validations */ 63 | public async create(@Body() userParams: IUserModel): Promise { 64 | const user = new UserModel(userParams); 65 | return this.userService.create(user); 66 | } 67 | } 68 | ``` 69 | 70 | ### Models and Formatters 71 | * Models and Formatters are used for 4 reasons: 72 | 73 | #### Model 74 | * * **Swagger** definition file 75 | * * Run time validations performed by **tsoa** 76 | * * Static typing advantages 77 | ```typescript 78 | /** An interface used for swagger, run time validations and standar typescript advantages */ 79 | export interface IUserModel { 80 | id?: string; 81 | username: string; 82 | firstName: string; 83 | lastName: string; 84 | } 85 | ``` 86 | 87 | #### Formatter 88 | * * Data normalization 89 | ```typescript 90 | /** A class used to normalize data */ 91 | export class UserFormatter extends BaseFormatter implements IUserModel { 92 | public username: string = undefined; 93 | public firstName: string = undefined; 94 | public lastName: string = undefined; 95 | 96 | constructor(args: any) { 97 | super(); 98 | this.format(args); 99 | } 100 | } 101 | ``` 102 | 103 | ### Auth 104 | * A simple **tsoa** middleware to handle authentication by decorators. 105 | #### Auth on controller 106 | ```typescript 107 | class SomeController { 108 | @Post() 109 | @Security('authRole') 110 | public async method(): Promise { 111 | // ... 112 | } 113 | } 114 | ``` 115 | #### Auth logic implementation 116 | ```typescript 117 | export async function expressAuthentication(request: Request, securityName: string, scopes?: string[]): Promise { 118 | /** Switch to handle security decorators on controllers - @Security('adminUser') */ 119 | switch (securityName) { 120 | case 'authRole': 121 | /** If auth is valid, returns data that might be used on controllers (maybe logged user's data) */ 122 | return null; 123 | } 124 | /** Throws an exception if auth is invalid */ 125 | throw new ApiError('auth', 403, 'invalid credentials'); 126 | } 127 | ``` 128 | 129 | ### Service 130 | * Services encapsulate buisness logic to be used by controllers. This allows the code to stay **DRY** if several controllers rely on similar logic and help to make testing easier. 131 | ```typescript 132 | @ProvideSingleton(UserService) 133 | export class UserService { 134 | /** The repository to access the data persistance layer is passed through dependency injection */ 135 | constructor(@inject(UserRepository) private userRepository: UserRepository) { } 136 | 137 | /** Business logic to get a single item */ 138 | public async getById(_id: string): Promise { 139 | return new UserModel(await this.userRepository.findOne({ _id })); 140 | } 141 | 142 | /** Business logic to get paginated data */ 143 | public async getPaginated(query: IUserModel, pageNumber: number, perPage: number): Promise { 144 | const skip: number = (Math.max(1, pageNumber) - 1) * perPage; 145 | const [count, list] = await Promise.all([ 146 | this.userRepository.count(query), 147 | this.userRepository.find(query, skip, perPage) 148 | ]); 149 | return new PaginationModel({ 150 | count, 151 | pageNumber, 152 | perPage, 153 | list: list.map(item => new UserModel(item)) 154 | }); 155 | } 156 | } 157 | ``` 158 | 159 | ### Repositories 160 | * Repositories handle the access to data layers 161 | 162 | #### Mongo Repository 163 | ```typescript 164 | @ProvideSingleton(UserService) 165 | import { Schema, Model } from 'mongoose'; 166 | 167 | import { BaseRepository } from './BaseRepository'; 168 | import { ProvideSingleton, inject } from '../../ioc'; 169 | import { MongoDbConnection } from '../../config/MongoDbConnection'; 170 | import { ICaseModel, CaseFormatter } from '../../models'; 171 | 172 | @ProvideSingleton(CaseRepository) 173 | export class CaseRepository extends BaseRepository { 174 | protected modelName: string = 'cases'; 175 | protected schema: Schema = new Schema({ 176 | name: { type: String, required: true }, 177 | createdBy: { type: String, required: true } 178 | }); 179 | protected formatter = CaseFormatter; 180 | constructor(@inject(MongoDbConnection) protected dbConnection: MongoDbConnection) { 181 | super(); 182 | super.init(); 183 | } 184 | } 185 | ``` 186 | 187 | #### SQL Repository 188 | ```typescript 189 | import { ProvideSingleton, inject } from '../../ioc'; 190 | import { BaseRepository } from './BaseRepository'; 191 | import { ICaseModel, CaseFormatter } from '../../models'; 192 | import { CaseEntity } from './entities'; 193 | 194 | @ProvideSingleton(CaseRepository) 195 | export class CaseRepository extends BaseRepository { 196 | protected formatter: any = CaseFormatter; 197 | 198 | constructor(@inject(CaseEntity) protected entityModel: CaseEntity) { 199 | super(); 200 | } 201 | } 202 | ``` 203 | 204 | #### SQL Entity 205 | * * Sequelize definition to be used by SQL repositories 206 | ```typescript 207 | import * as Sequelize from 'sequelize'; 208 | 209 | import { ProvideSingleton, inject } from '../../../ioc'; 210 | import { SQLDbConnection } from '../../../config/SQLDbConnection'; 211 | import { BaseEntity } from './BaseEntity'; 212 | 213 | @ProvideSingleton(CaseEntity) 214 | export class CaseEntity extends BaseEntity { 215 | /** table name */ 216 | public entityName: string = 'case'; 217 | /** table definition */ 218 | protected attributes: Sequelize.DefineAttributes = { 219 | _id: { type: Sequelize.UUID, primaryKey: true, defaultValue: Sequelize.UUIDV4 }, 220 | name: { type: Sequelize.STRING, allowNull: false, unique: true }, 221 | createdBy: { type: Sequelize.STRING, allowNull: false } 222 | }; 223 | protected options: Sequelize.DefineOptions = { name: { plural: 'cases' } }; 224 | 225 | constructor(@inject(SQLDbConnection) protected sqlDbConnection: SQLDbConnection) { 226 | super(); 227 | this.initModel(); 228 | } 229 | } 230 | ``` 231 | 232 | #### SQL Migrations 233 | * * When an update on a model is needed, the `Entity` on `src/repositories/sql/entities` will have to be updated and a migration file created and run with the provided npm script `migrate:` to update the already created table. 234 | ```typescript 235 | import * as Sequelize from 'sequelize'; 236 | 237 | export default { 238 | up: async (queryInterface: Sequelize.QueryInterface) => { 239 | return Promise.all([ 240 | // queryInterface.addColumn('users', 'fakeColumn', Sequelize.STRING) 241 | ]); 242 | }, 243 | down: async (queryInterface: Sequelize.QueryInterface) => { 244 | return Promise.all([ 245 | // queryInterface.removeColumn('users', 'fakeColumn') 246 | ]); 247 | } 248 | }; 249 | ``` 250 | 251 | #### Sync 252 | * * To sync all entities when the server/tests start, tou will have to inject their dependencies into `SQLSetupHelper` class localted at `src/config/SQLSetupHelper` 253 | ```typescript 254 | import * as Sequelize from 'sequelize'; 255 | 256 | import constants from './constants'; 257 | import { Logger } from './Logger'; 258 | import { ProvideSingleton, inject } from '../ioc'; 259 | import { SQLDbConnection } from './SQLDbConnection'; 260 | import * as entities from '../repositories/sql/entities'; 261 | 262 | @ProvideSingleton(SQLSetupHelper) 263 | export class SQLSetupHelper { 264 | 265 | constructor( 266 | @inject(SQLDbConnection) private sqlDbConnection: SQLDbConnection, 267 | @inject(entities.UserEntity) private entity1: entities.UserEntity, 268 | @inject(entities.CaseEntity) private entity2: entities.CaseEntity 269 | ) { } 270 | 271 | public async rawQuery(query: string): Promise { 272 | return this.sqlDbConnection.db.query(query, { raw: true }); 273 | } 274 | 275 | public async sync(options?: Sequelize.SyncOptions): Promise { 276 | await this.sqlDbConnection.db.authenticate(); 277 | if (constants.SQL.dialect === 'mysql') await this.rawQuery('SET FOREIGN_KEY_CHECKS = 0'); 278 | Logger.log( 279 | `synchronizing: tables${options ? ` with options: ${JSON.stringify(options)}` : ''}` 280 | ); 281 | await this.sqlDbConnection.db.sync(options); 282 | } 283 | } 284 | ``` 285 | 286 | 287 | ### Test 288 | * Tests include **unit tests** `(utils and services)` and **integration tests**. 289 | ```typescript 290 | import { expect } from 'chai'; 291 | import * as supertest from 'supertest'; 292 | 293 | import { Server } from '../../config/Server'; 294 | 295 | describe('PingController', () => { 296 | const app = supertest(new Server().app); 297 | 298 | it('HTTP GET /api/ping | should return pong', async () => { 299 | const res = await app.get('/api/ping'); 300 | expect(res.status).to.equal(200); 301 | expect(res.body).to.equal('pong'); 302 | }); 303 | }); 304 | 305 | ``` -------------------------------------------------------------------------------- /build/routes.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | import { Controller, ValidateParam, FieldErrors, ValidateError, TsoaRoute } from 'tsoa'; 3 | import { iocContainer } from './../src/ioc'; 4 | import { UserController } from './../src/controllers/UserController'; 5 | import { expressAuthentication } from './../src/auth'; 6 | 7 | const models: TsoaRoute.Models = { 8 | "IUserModel": { 9 | "properties": { 10 | "_id": { "dataType": "string" }, 11 | "id": { "dataType": "string" }, 12 | "email": { "dataType": "string", "required": true }, 13 | "name": { "dataType": "string", "required": true }, 14 | }, 15 | }, 16 | "IPaginationModel": { 17 | "properties": { 18 | "count": { "dataType": "double", "required": true }, 19 | "page": { "dataType": "double", "required": true }, 20 | "limit": { "dataType": "double", "required": true }, 21 | "totalPages": { "dataType": "double", "required": true }, 22 | "docs": { "dataType": "array", "array": { "dataType": "any" }, "required": true }, 23 | }, 24 | }, 25 | }; 26 | 27 | export function RegisterRoutes(app: any) { 28 | app.get('/service/users/:id', 29 | function(request: any, response: any, next: any) { 30 | const args = { 31 | id: { "in": "path", "name": "id", "required": true, "dataType": "string" }, 32 | }; 33 | 34 | let validatedArgs: any[] = []; 35 | try { 36 | validatedArgs = getValidatedArgs(args, request); 37 | } catch (err) { 38 | return next(err); 39 | } 40 | 41 | const controller = iocContainer.get(UserController); 42 | if (typeof controller['setStatus'] === 'function') { 43 | (controller).setStatus(undefined); 44 | } 45 | 46 | 47 | const promise = controller.getById.apply(controller, validatedArgs); 48 | promiseHandler(controller, promise, response, next); 49 | }); 50 | app.get('/service/users', 51 | function(request: any, response: any, next: any) { 52 | const args = { 53 | page: { "in": "query", "name": "page", "required": true, "dataType": "double" }, 54 | limit: { "in": "query", "name": "limit", "required": true, "dataType": "double" }, 55 | fields: { "in": "query", "name": "fields", "dataType": "string" }, 56 | sort: { "in": "query", "name": "sort", "dataType": "string" }, 57 | q: { "in": "query", "name": "q", "dataType": "string" }, 58 | }; 59 | 60 | let validatedArgs: any[] = []; 61 | try { 62 | validatedArgs = getValidatedArgs(args, request); 63 | } catch (err) { 64 | return next(err); 65 | } 66 | 67 | const controller = iocContainer.get(UserController); 68 | if (typeof controller['setStatus'] === 'function') { 69 | (controller).setStatus(undefined); 70 | } 71 | 72 | 73 | const promise = controller.getPaginated.apply(controller, validatedArgs); 74 | promiseHandler(controller, promise, response, next); 75 | }); 76 | app.post('/service/users', 77 | authenticateMiddleware([{ "name": "admin" }]), 78 | function(request: any, response: any, next: any) { 79 | const args = { 80 | body: { "in": "body", "name": "body", "required": true, "ref": "IUserModel" }, 81 | }; 82 | 83 | let validatedArgs: any[] = []; 84 | try { 85 | validatedArgs = getValidatedArgs(args, request); 86 | } catch (err) { 87 | return next(err); 88 | } 89 | 90 | const controller = iocContainer.get(UserController); 91 | if (typeof controller['setStatus'] === 'function') { 92 | (controller).setStatus(undefined); 93 | } 94 | 95 | 96 | const promise = controller.create.apply(controller, validatedArgs); 97 | promiseHandler(controller, promise, response, next); 98 | }); 99 | app.put('/service/users/:id', 100 | authenticateMiddleware([{ "name": "admin" }]), 101 | function(request: any, response: any, next: any) { 102 | const args = { 103 | id: { "in": "path", "name": "id", "required": true, "dataType": "string" }, 104 | body: { "in": "body", "name": "body", "required": true, "ref": "IUserModel" }, 105 | }; 106 | 107 | let validatedArgs: any[] = []; 108 | try { 109 | validatedArgs = getValidatedArgs(args, request); 110 | } catch (err) { 111 | return next(err); 112 | } 113 | 114 | const controller = iocContainer.get(UserController); 115 | if (typeof controller['setStatus'] === 'function') { 116 | (controller).setStatus(undefined); 117 | } 118 | 119 | 120 | const promise = controller.update.apply(controller, validatedArgs); 121 | promiseHandler(controller, promise, response, next); 122 | }); 123 | app.delete('/service/users/:id', 124 | authenticateMiddleware([{ "name": "admin" }]), 125 | function(request: any, response: any, next: any) { 126 | const args = { 127 | id: { "in": "path", "name": "id", "required": true, "dataType": "string" }, 128 | }; 129 | 130 | let validatedArgs: any[] = []; 131 | try { 132 | validatedArgs = getValidatedArgs(args, request); 133 | } catch (err) { 134 | return next(err); 135 | } 136 | 137 | const controller = iocContainer.get(UserController); 138 | if (typeof controller['setStatus'] === 'function') { 139 | (controller).setStatus(undefined); 140 | } 141 | 142 | 143 | const promise = controller.delete.apply(controller, validatedArgs); 144 | promiseHandler(controller, promise, response, next); 145 | }); 146 | 147 | function authenticateMiddleware(security: TsoaRoute.Security[] = []) { 148 | return (request: any, response: any, next: any) => { 149 | let responded = 0; 150 | let success = false; 151 | for (const secMethod of security) { 152 | expressAuthentication(request, secMethod.name, secMethod.scopes).then((user: any) => { 153 | // only need to respond once 154 | if (!success) { 155 | success = true; 156 | responded++; 157 | request['user'] = user; 158 | next(); 159 | } 160 | }) 161 | .catch((error: any) => { 162 | responded++; 163 | if (responded == security.length && !success) { 164 | response.status(401); 165 | next(error) 166 | } 167 | }) 168 | } 169 | } 170 | } 171 | 172 | function promiseHandler(controllerObj: any, promise: any, response: any, next: any) { 173 | return Promise.resolve(promise) 174 | .then((data: any) => { 175 | let statusCode; 176 | if (controllerObj instanceof Controller) { 177 | const controller = controllerObj as Controller 178 | const headers = controller.getHeaders(); 179 | Object.keys(headers).forEach((name: string) => { 180 | response.set(name, headers[name]); 181 | }); 182 | 183 | statusCode = controller.getStatus(); 184 | } 185 | 186 | if (data || data === false) { // === false allows boolean result 187 | response.status(statusCode || 200).json(data); 188 | } else { 189 | response.status(statusCode || 204).end(); 190 | } 191 | }) 192 | .catch((error: any) => next(error)); 193 | } 194 | 195 | function getValidatedArgs(args: any, request: any): any[] { 196 | const fieldErrors: FieldErrors = {}; 197 | const values = Object.keys(args).map((key) => { 198 | const name = args[key].name; 199 | switch (args[key].in) { 200 | case 'request': 201 | return request; 202 | case 'query': 203 | return ValidateParam(args[key], request.query[name], models, name, fieldErrors); 204 | case 'path': 205 | return ValidateParam(args[key], request.params[name], models, name, fieldErrors); 206 | case 'header': 207 | return ValidateParam(args[key], request.header(name), models, name, fieldErrors); 208 | case 'body': 209 | return ValidateParam(args[key], request.body, models, name, fieldErrors, name + '.'); 210 | case 'body-prop': 211 | return ValidateParam(args[key], request.body[name], models, name, fieldErrors, 'body.'); 212 | } 213 | }); 214 | if (Object.keys(fieldErrors).length > 0) { 215 | throw new ValidateError(fieldErrors, ''); 216 | } 217 | return values; 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /build/swagger/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "basePath": "/service", 3 | "consumes": [ 4 | "application/json" 5 | ], 6 | "definitions": { 7 | "IUserModel": { 8 | "properties": { 9 | "_id": { 10 | "type": "string", 11 | "x-nullable": true 12 | }, 13 | "id": { 14 | "type": "string", 15 | "x-nullable": true 16 | }, 17 | "email": { 18 | "type": "string" 19 | }, 20 | "name": { 21 | "type": "string" 22 | } 23 | }, 24 | "required": [ 25 | "email", 26 | "name" 27 | ], 28 | "type": "object" 29 | }, 30 | "IPaginationModel": { 31 | "properties": { 32 | "count": { 33 | "type": "number", 34 | "format": "double" 35 | }, 36 | "page": { 37 | "type": "number", 38 | "format": "double" 39 | }, 40 | "limit": { 41 | "type": "number", 42 | "format": "double" 43 | }, 44 | "totalPages": { 45 | "type": "number", 46 | "format": "double" 47 | }, 48 | "docs": { 49 | "type": "array", 50 | "items": { 51 | "type": "object" 52 | } 53 | } 54 | }, 55 | "required": [ 56 | "count", 57 | "page", 58 | "limit", 59 | "totalPages", 60 | "docs" 61 | ], 62 | "type": "object" 63 | } 64 | }, 65 | "info": { 66 | "title": "opya-backend", 67 | "version": "1.0.0", 68 | "license": { 69 | "name": "ISC" 70 | } 71 | }, 72 | "paths": { 73 | "/users/{id}": { 74 | "get": { 75 | "operationId": "GetById", 76 | "produces": [ 77 | "application/json" 78 | ], 79 | "responses": { 80 | "200": { 81 | "description": "Ok", 82 | "schema": { 83 | "$ref": "#/definitions/IUserModel" 84 | } 85 | } 86 | }, 87 | "tags": [ 88 | "users" 89 | ], 90 | "security": [], 91 | "parameters": [ 92 | { 93 | "in": "path", 94 | "name": "id", 95 | "required": true, 96 | "type": "string" 97 | } 98 | ] 99 | }, 100 | "put": { 101 | "operationId": "Update", 102 | "produces": [ 103 | "application/json" 104 | ], 105 | "responses": { 106 | "200": { 107 | "description": "Ok", 108 | "schema": { 109 | "$ref": "#/definitions/IUserModel" 110 | } 111 | }, 112 | "400": { 113 | "description": "Bad request" 114 | } 115 | }, 116 | "tags": [ 117 | "users" 118 | ], 119 | "security": [ 120 | { 121 | "admin": [] 122 | } 123 | ], 124 | "parameters": [ 125 | { 126 | "in": "path", 127 | "name": "id", 128 | "required": true, 129 | "type": "string" 130 | }, 131 | { 132 | "in": "body", 133 | "name": "body", 134 | "required": true, 135 | "schema": { 136 | "$ref": "#/definitions/IUserModel" 137 | } 138 | } 139 | ] 140 | }, 141 | "delete": { 142 | "operationId": "Delete", 143 | "produces": [ 144 | "application/json" 145 | ], 146 | "responses": { 147 | "204": { 148 | "description": "No content" 149 | } 150 | }, 151 | "tags": [ 152 | "users" 153 | ], 154 | "security": [ 155 | { 156 | "admin": [] 157 | } 158 | ], 159 | "parameters": [ 160 | { 161 | "in": "path", 162 | "name": "id", 163 | "required": true, 164 | "type": "string" 165 | } 166 | ] 167 | } 168 | }, 169 | "/users": { 170 | "get": { 171 | "operationId": "GetPaginated", 172 | "produces": [ 173 | "application/json" 174 | ], 175 | "responses": { 176 | "200": { 177 | "description": "Ok", 178 | "schema": { 179 | "$ref": "#/definitions/IPaginationModel" 180 | } 181 | } 182 | }, 183 | "tags": [ 184 | "users" 185 | ], 186 | "security": [], 187 | "parameters": [ 188 | { 189 | "in": "query", 190 | "name": "page", 191 | "required": true, 192 | "format": "double", 193 | "type": "number" 194 | }, 195 | { 196 | "in": "query", 197 | "name": "limit", 198 | "required": true, 199 | "format": "double", 200 | "type": "number" 201 | }, 202 | { 203 | "in": "query", 204 | "name": "fields", 205 | "required": false, 206 | "type": "string" 207 | }, 208 | { 209 | "in": "query", 210 | "name": "sort", 211 | "required": false, 212 | "type": "string" 213 | }, 214 | { 215 | "in": "query", 216 | "name": "q", 217 | "required": false, 218 | "type": "string" 219 | } 220 | ] 221 | }, 222 | "post": { 223 | "operationId": "Create", 224 | "produces": [ 225 | "application/json" 226 | ], 227 | "responses": { 228 | "200": { 229 | "description": "Ok", 230 | "schema": { 231 | "$ref": "#/definitions/IUserModel" 232 | } 233 | }, 234 | "400": { 235 | "description": "Bad request" 236 | } 237 | }, 238 | "tags": [ 239 | "users" 240 | ], 241 | "security": [ 242 | { 243 | "admin": [] 244 | } 245 | ], 246 | "parameters": [ 247 | { 248 | "in": "body", 249 | "name": "body", 250 | "required": true, 251 | "schema": { 252 | "$ref": "#/definitions/IUserModel" 253 | } 254 | } 255 | ] 256 | } 257 | } 258 | }, 259 | "produces": [ 260 | "application/json" 261 | ], 262 | "swagger": "2.0", 263 | "securityDefinitions": {} 264 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opya-backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "", 6 | "pre-commit": [ 7 | "lint", 8 | "test:unit" 9 | ], 10 | "scripts": { 11 | "test": "yarn lint && yarn test:unit && yarn test:integration", 12 | "test:unit": "yarn env:test nyc mocha --timeout 10000 -r ts-node/register ./src/tests/unit/**/*.spec.ts", 13 | "test:integration": "yarn build:tsoa && yarn env:test mocha --timeout 35000 -r ts-node/register ./src/tests/integration/**/*.spec.ts --exit", 14 | "test:coverage": "yarn test:unit && google-chrome ./coverage/index.html", 15 | "start": "yarn start:dev", 16 | "start:dev": "yarn build:tsoa && yarn env:dev ts-node-dev --respawn ./src", 17 | "start:prod": "yarn env:prod node ./dist/src/index.js", 18 | "build:routes": "tsoa routes", 19 | "build:swagger": "tsoa swagger --basePath /service", 20 | "build:tsoa": "yarn clean && yarn build:routes && yarn build:swagger", 21 | "build:prod": "yarn build:tsoa && tsc && yarn copy", 22 | "build": "yarn build:prod", 23 | "lint": "tslint -c \"./tslint.json\" -p \"./tsconfig.json\"", 24 | "clean": "rm -rf ./dist && rm -rf ./build && mkdir build", 25 | "copy": "yarn copy:swagger && yarn copy:env", 26 | "copy:swagger": "cp -R ./build/swagger ./dist/build/swagger", 27 | "copy:env": "cp -R ./src/config/env ./dist/src/config/env", 28 | "env:dev": "cross-env NODE_ENV=development", 29 | "env:test": "cross-env NODE_ENV=test", 30 | "env:prod": "cross-env NODE_ENV=production", 31 | "migrate:dev": "yarn env:dev cross-env MIGRATION_DIRECTION=up cross-env MIGRATION_ACTION=migrations ts-node ./src/setup/migrateSql.ts --exit", 32 | "migrate:undo:dev": "yarn env:dev cross-env MIGRATION_DIRECTION=down MIGRATION_ACTION=migrations ts-node ./src/setup/migrateSql.ts --exit", 33 | "migrate:prod": "yarn env:prod cross-env MIGRATION_DIRECTION=up MIGRATION_ACTION=migrations ts-node ./src/setup/migrateSql.ts --exit", 34 | "migrate:undo:prod": "yarn env:prod cross-env MIGRATION_DIRECTION=down MIGRATION_ACTION=migrations ts-node ./src/setup/migrateSql.ts --exit", 35 | "seed:sql:dev": "yarn env:dev cross-env MIGRATION_DIRECTION=up cross-env MIGRATION_ACTION=sql-seeders ts-node ./src/setup/migrateSql.ts --exit", 36 | "seed:sql:undo:dev": "yarn env:dev cross-env MIGRATION_DIRECTION=down cross-env MIGRATION_ACTION=sql-seeders ts-node ./src/setup/migrateSql.ts --exit", 37 | "seed:sql:prod": "yarn env:prod cross-env MIGRATION_DIRECTION=up cross-env MIGRATION_ACTION=sql-seeders ts-node ./src/setup/migrateSql.ts --exit", 38 | "seed:sql:undo:prod": "yarn env:prod cross-env MIGRATION_DIRECTION=down cross-env MIGRATION_ACTION=sql-seeders ts-node ./src/setup/migrateSql.ts --exit", 39 | "seed:mongo:dev": "yarn env:dev ts-node ./src/setup/seedMongo.ts --exit", 40 | "seed:mongo:prod": "yarn env:prod ts-node ./src/setup/seedMongo.ts --exit" 41 | }, 42 | "author": "Making Sense ", 43 | "license": "ISC", 44 | "dependencies": { 45 | "auth0": "^2.9.1", 46 | "aws-sdk": "^2.217.1", 47 | "dotenv": "^5.0.1", 48 | "express": "^4.16.3", 49 | "inversify": "^4.11.1", 50 | "inversify-binding-decorators": "^3.2.0", 51 | "mongoose": "^5.0.12", 52 | "mongoose-unique-validator": "^2.0.0", 53 | "morgan": "^1.9.0", 54 | "multer": "^1.3.0", 55 | "mysql": "^2.15.0", 56 | "mysql2": "^1.5.3", 57 | "pg": "^7.4.1", 58 | "pg-hstore": "^2.3.2", 59 | "pubnub": "^4.20.2", 60 | "reflect-metadata": "^0.1.12", 61 | "request-promise": "^4.2.2", 62 | "sequelize": "^4.37.6", 63 | "sequelize-cli": "^4.0.0", 64 | "sinon": "^4.5.0", 65 | "swagger-ui-express": "^3.0.6", 66 | "tsoa": "^2.1.4", 67 | "umzug": "^2.1.0", 68 | "uuid": "^3.2.1", 69 | "winston": "^3.0.0-rc4" 70 | }, 71 | "devDependencies": { 72 | "@types/auth0": "^2.7.1", 73 | "@types/aws-sdk": "^2.7.0", 74 | "@types/chai": "^4.1.2", 75 | "@types/colors": "^1.2.1", 76 | "@types/dotenv": "^4.0.2", 77 | "@types/express": "^4.11.1", 78 | "@types/mocha": "^5.0.0", 79 | "@types/mongoose": "^5.0.2", 80 | "@types/mongoose-unique-validator": "^1.0.1", 81 | "@types/morgan": "^1.7.35", 82 | "@types/multer": "^1.3.6", 83 | "@types/pubnub": "^4.0.2", 84 | "@types/randomstring": "^1.1.6", 85 | "@types/request-promise": "^4.1.41", 86 | "@types/sequelize": "^4.27.13", 87 | "@types/sinon": "^4.3.1", 88 | "@types/supertest": "^2.0.4", 89 | "@types/umzug": "^2.1.1", 90 | "@types/uuid": "^3.4.3", 91 | "chai": "^4.1.2", 92 | "colors": "^1.2.1", 93 | "cross-env": "^5.1.4", 94 | "mocha": "^5.0.5", 95 | "nyc": "^11.6.0", 96 | "pre-commit": "^1.2.2", 97 | "randomstring": "^1.1.5", 98 | "supertest": "^3.0.0", 99 | "ts-node": "^5.0.1", 100 | "ts-node-dev": "^1.0.0-pre.18", 101 | "tslint": "^5.9.1", 102 | "typescript": "^2.8.1", 103 | "yarn": "^1.5.1" 104 | }, 105 | "nyc": { 106 | "include": [ 107 | "src/**/*.ts" 108 | ], 109 | "sourceMap": true, 110 | "instrument": true, 111 | "all": false, 112 | "exclude": [ 113 | "src/tests/**", 114 | "src/ioc.ts", 115 | "src/setup/**", 116 | "src/config/MongoDbConnection.ts", 117 | "src/config/SQLDbConnection.ts", 118 | "src/models/*Model.ts", 119 | "src/repositories/sql/entities/**" 120 | ], 121 | "extension": [ 122 | ".ts" 123 | ], 124 | "require": [ 125 | "ts-node/register" 126 | ], 127 | "reporter": [ 128 | "text", 129 | "text-summary", 130 | "html" 131 | ] 132 | } 133 | } -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Background 2 | _placeholder_ 3 | ## Changes done 4 | * _placeholder_ 5 | ## Pending to be done 6 | * _N/A_ 7 | ## Notes 8 | * _coverage screenshot_ 9 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | 3 | import constants from './config/constants'; 4 | import { ApiError } from './config/ErrorHandler'; 5 | 6 | export type res = { status: number; message: string }; 7 | 8 | export async function expressAuthentication(request: Request, securityName: string, scopes?: string[]): Promise { 9 | switch (securityName) { 10 | case 'admin': 11 | return null; /** everyone is an admin now :D */ 12 | } 13 | throw new ApiError(constants.errorTypes.auth); 14 | } 15 | -------------------------------------------------------------------------------- /src/config/ErrorHandler.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | 3 | import constants from './constants'; 4 | import { Logger } from './Logger'; 5 | 6 | export interface ErrorType { 7 | statusCode: number; 8 | name: string; 9 | message: string; 10 | fields?: { [field: string]: { message: string } }; 11 | } 12 | 13 | export class ApiError extends Error implements ErrorType { 14 | public statusCode: number = 500; 15 | public fields?: { [field: string]: { message: string } }; 16 | 17 | constructor(errorType: ErrorType) { 18 | super(errorType.message); 19 | this.name = errorType.name; 20 | if (errorType.statusCode) this.statusCode = errorType.statusCode; 21 | this.fields = errorType.fields; 22 | } 23 | } 24 | 25 | export class ErrorHandler { 26 | public static handleError(error: ApiError, req: Request, res: Response, next: NextFunction): void { 27 | const normalizedError: ApiError = ErrorHandler.normalizeError(error); 28 | const { name, message, fields, statusCode } = normalizedError; 29 | Logger.error( 30 | `Error: ${statusCode}`, 31 | `Error Name: ${name}`, 32 | `Error Message: ${message}`, 33 | 'Error Fields:', fields || {}, 34 | 'Original Error: ', error 35 | ); 36 | res.status(statusCode).json({ name, message, fields }); 37 | next(); 38 | } 39 | 40 | private static normalizeError(error: ApiError): ApiError { 41 | const normalizedError: ApiError = new ApiError(error); 42 | Object.keys(constants.errorMap).forEach(errorKey => { 43 | if (errorKey === normalizedError.name) Object.assign(normalizedError, constants.errorMap[errorKey]); 44 | }); 45 | Object.keys(constants.errorTypes).forEach(errorTypeKey => { 46 | const errorType = constants.errorTypes[errorTypeKey]; 47 | if (errorType.statusCode === normalizedError.statusCode) normalizedError.name = errorType.name; 48 | }); 49 | return normalizedError; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/config/Logger.ts: -------------------------------------------------------------------------------- 1 | import constants from './constants'; 2 | import * as logger from 'winston'; 3 | 4 | const date = new Date(); 5 | const fileName = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}.log`; 6 | logger.configure({ 7 | level: 'debug', 8 | format: logger.format.combine( 9 | logger.format.colorize(), 10 | logger.format.simple()), 11 | transports: [ 12 | new logger.transports.File({ filename: `logs/${fileName}`, level: 'debug' }), 13 | new logger.transports.Console() 14 | ] 15 | }); 16 | 17 | export class Logger { 18 | public static readonly shouldLog: boolean = constants.environment !== 'test'; 19 | public static readonly console = logger; 20 | 21 | public static log(...args: any[]): void { 22 | if (Logger.shouldLog) Logger.console.debug(Logger.formatArgs(args)); 23 | } 24 | 25 | public static warn(...args: any[]): void { 26 | if (Logger.shouldLog) Logger.console.warn(Logger.formatArgs(args)); 27 | } 28 | 29 | public static error(...args: any[]): void { 30 | if (Logger.shouldLog) Logger.console.error(Logger.formatArgs(args)); 31 | } 32 | 33 | public static info(...args: any[]): void { 34 | if (Logger.shouldLog) Logger.console.info(Logger.formatArgs(args)); 35 | } 36 | 37 | public static verbose(...args: any[]): void { 38 | if (Logger.shouldLog) Logger.console.verbose(Logger.formatArgs(args)); 39 | } 40 | 41 | private static formatArgs(args: any[]): string { 42 | if (args.length <= 1) args = args[0]; 43 | return JSON.stringify(args, null, 4); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/config/MongoDbConnection.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | 3 | import constants from './constants'; 4 | import { Logger } from './Logger'; 5 | import { ProvideSingleton } from '../ioc'; 6 | 7 | mongoose.set('debug', Logger.shouldLog); 8 | 9 | @ProvideSingleton(MongoDbConnection) 10 | export class MongoDbConnection { 11 | public db: mongoose.Connection; 12 | private readonly connectionString: string = constants.mongoConnectionString; 13 | 14 | constructor() { 15 | Logger.log(`connecting to ${constants.environment} MongoDb`); 16 | this.db = mongoose.createConnection(this.connectionString); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/config/SQLDbConnection.ts: -------------------------------------------------------------------------------- 1 | import * as Sequelize from 'sequelize'; 2 | 3 | import constants from './constants'; 4 | import { Logger } from './Logger'; 5 | import { ProvideSingleton } from '../ioc'; 6 | 7 | @ProvideSingleton(SQLDbConnection) 8 | export class SQLDbConnection { 9 | public db: Sequelize.Sequelize; 10 | 11 | constructor() { 12 | Logger.log(`connecting to ${constants.environment} SQL`); 13 | const { SQL: config } = constants; 14 | this.db = new Sequelize(config.db, config.username, config.password, { 15 | port: config.port, 16 | host: config.host, 17 | dialect: config.dialect, 18 | logging: (l) => Logger.verbose(l), 19 | operatorsAliases: Sequelize.Op as any, 20 | define: { charset: 'utf8', collate: 'utf8_general_ci' } 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/config/SQLSetupHelper.ts: -------------------------------------------------------------------------------- 1 | import * as Sequelize from 'sequelize'; 2 | 3 | import constants from './constants'; 4 | import { Logger } from './Logger'; 5 | import { ProvideSingleton, inject } from '../ioc'; 6 | import { SQLDbConnection } from './SQLDbConnection'; 7 | import * as entities from '../repositories/sql/entities'; 8 | 9 | @ProvideSingleton(SQLSetupHelper) 10 | export class SQLSetupHelper { 11 | 12 | constructor( 13 | @inject(SQLDbConnection) private sqlDbConnection: SQLDbConnection, 14 | @inject(entities.UserEntity) private entity1: entities.UserEntity // tslint:disable-line 15 | ) { } 16 | 17 | public async rawQuery(query: string): Promise { 18 | return this.sqlDbConnection.db.query(query, { raw: true }); 19 | } 20 | 21 | public async sync(options?: Sequelize.SyncOptions): Promise { 22 | await this.sqlDbConnection.db.authenticate(); 23 | if (constants.SQL.dialect === 'mysql') await this.rawQuery('SET FOREIGN_KEY_CHECKS = 0'); 24 | Logger.log( 25 | `synchronizing: tables${options ? ` with options: ${JSON.stringify(options)}` : ''}` 26 | ); 27 | await this.sqlDbConnection.db.sync(options); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/config/Server.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as swaggerUi from 'swagger-ui-express'; 3 | import * as bodyParser from 'body-parser'; 4 | import * as morgan from 'morgan'; 5 | import { config as AWSConfig } from 'aws-sdk'; 6 | 7 | import constants from './constants'; 8 | import { ErrorHandler } from './ErrorHandler'; 9 | import { RegisterRoutes } from '../../build/routes'; 10 | import { Logger } from './Logger'; 11 | import { iocContainer } from '../ioc'; 12 | import { SQLSetupHelper } from './SQLSetupHelper'; 13 | import '../controllers'; 14 | 15 | export class Server { 16 | public app: express.Express = express(); 17 | private readonly port: number = constants.port; 18 | 19 | constructor() { 20 | AWSConfig.update({ accessKeyId: constants.AWS.accessKeyId, secretAccessKey: constants.AWS.secretAccessKey }); 21 | this.app.use(this.allowCors); 22 | this.app.use(bodyParser.urlencoded({ extended: true })); 23 | this.app.use(bodyParser.json()); 24 | this.app.use(morgan('dev', { skip: () => !Logger.shouldLog })); 25 | RegisterRoutes(this.app); 26 | this.app.use(ErrorHandler.handleError); 27 | 28 | const swaggerDocument = require('../../build/swagger/swagger.json'); 29 | 30 | this.app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); 31 | } 32 | 33 | public async listen(port: number = this.port) { 34 | process.on('uncaughtException', this.criticalErrorHandler); 35 | process.on('unhandledRejection', this.criticalErrorHandler); 36 | const sqlHelper = iocContainer.get(SQLSetupHelper); 37 | await sqlHelper.sync({ force: false }); 38 | const listen = this.app.listen(this.port); 39 | Logger.info(`${constants.environment} server running on port: ${this.port}`); 40 | return listen; 41 | } 42 | 43 | private criticalErrorHandler(...args) { 44 | Logger.error('Critical Error...', ...args); 45 | process.exit(1); 46 | } 47 | 48 | private allowCors(req: express.Request, res: express.Response, next: express.NextFunction): void { 49 | res.header('Access-Control-Allow-Origin', '*'); 50 | res.header( 51 | 'Access-Control-Allow-Headers', 52 | 'Origin, X-Requested-With, Content-Type, Accept, Authorization, apikey, x-access-token' 53 | ); 54 | next(); 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/config/constants.ts: -------------------------------------------------------------------------------- 1 | import { resolve as pathResolve } from 'path'; 2 | import { config } from 'dotenv'; 3 | 4 | const { env } = process; 5 | config({ path: pathResolve(__dirname, `./env/.env.${env.NODE_ENV}`) }); 6 | 7 | export default { 8 | environment: env.NODE_ENV, 9 | port: Number(env.PORT), 10 | mongoConnectionString: env.MONGO_CONNECTION_STRING, 11 | SQL: { 12 | db: env.SQL_DB, 13 | username: env.SQL_USERNAME, 14 | password: env.SQL_PASSWORD, 15 | host: env.SQL_HOST, 16 | port: Number(env.SQL_PORT), 17 | dialect: env.SQL_DIALECT 18 | }, 19 | AWS: { 20 | accessKeyId: env.AWS_ACCESS_KEY_ID, 21 | secretAccessKey: env.AWS_SECRET_ACCESS_KEY, 22 | mainBucket: env.AWS_MAINBUCKET 23 | }, 24 | auth0: { 25 | domain: env.AUTH0_DOMAIN, 26 | clientId: env.AUTH0_CLIENT_ID, 27 | clientSecret: env.AUTH0_CLIENT_SECRET, 28 | audience: env.AUTH0_AUDIENCE 29 | }, 30 | pubnub: { 31 | publishKey: env.PUBNUB_PUBLISH_KEY, 32 | subscribeKey: env.PUBNUB_SUBSCRIBE_KEY, 33 | secretKey: env.PUBNUB_SECRET_KEY 34 | }, 35 | errorTypes: { 36 | db: { statusCode: 500, name: 'Internal Server Error', message: 'database error' }, 37 | validation: { statusCode: 400, name: 'Bad Request', message: 'validation error' }, 38 | auth: { statusCode: 401, name: 'Unauthorized', message: 'auth error' }, 39 | forbidden: { statusCode: 403, name: 'Forbidden', message: 'forbidden content' }, 40 | notFound: { statusCode: 404, name: 'Not Found', message: 'content not found' }, 41 | entity: { statusCode: 422, name: 'Unprocessable Entity', message: 'entity error' } 42 | }, 43 | get errorMap() { 44 | return { 45 | ValidateError: this.errorTypes.validation, 46 | ValidationError: this.errorTypes.validation, 47 | CastError: this.errorTypes.db 48 | }; 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /src/config/env/.env.development: -------------------------------------------------------------------------------- 1 | PORT=3030 2 | 3 | MONGO_CONNECTION_STRING="mongodb://username:password@ds123929.mlab.com:23929/users" 4 | 5 | SQL_DB="opya-dev" 6 | SQL_USERNAME="root" 7 | SQL_PASSWORD="root" 8 | SQL_HOST="localhost" 9 | SQL_PORT=3306 10 | SQL_DIALECT='mysql' 11 | 12 | AWS_ACCESS_KEY_ID="" 13 | AWS_SECRET_ACCESS_KEY="" 14 | AWS_MAINBUCKET="" 15 | 16 | AUTH0_DOMAIN="" 17 | AUTH0_CLIENT_ID="" 18 | AUTH0_CLIENT_SECRET="" 19 | AUTH0_AUDIENCE="" 20 | 21 | PUBNUB_PUBLISH_KEY="" 22 | PUBNUB_SUBSCRIBE_KEY="" 23 | PUBNUB_SECRET_KEY="" 24 | 25 | EB_BUCKET="" 26 | -------------------------------------------------------------------------------- /src/config/env/.env.production: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | 3 | MONGO_CONNECTION_STRING="" 4 | 5 | SQL_DB="" 6 | # SQL_USERNAME="" 7 | # SQL_PASSWORD="" 8 | SQL_HOST="" 9 | SQL_PORT=3306 10 | SQL_DIALECT='' 11 | 12 | # AWS_ACCESS_KEY_ID="" 13 | # AWS_SECRET_ACCESS_KEY="" 14 | AWS_MAINBUCKET="" 15 | 16 | AUTH0_DOMAIN="" 17 | # AUTH0_CLIENT_ID="" 18 | # AUTH0_CLIENT_SECRET="" 19 | AUTH0_AUDIENCE="" 20 | 21 | # PUBNUB_PUBLISH_KEY="" 22 | # PUBNUB_SUBSCRIBE_KEY="" 23 | # PUBNUB_SECRET_KEY="" 24 | 25 | EB_BUCKET="" -------------------------------------------------------------------------------- /src/config/env/.env.test: -------------------------------------------------------------------------------- 1 | PORT=3030 2 | 3 | MONGO_CONNECTION_STRING="mongodb://username:password@ds123929.mlab.com:23929/users" 4 | 5 | SQL_DB="opya-dev" 6 | SQL_USERNAME="root" 7 | SQL_PASSWORD="root" 8 | SQL_HOST="localhost" 9 | SQL_PORT=3306 10 | SQL_DIALECT='mysql' 11 | 12 | AWS_ACCESS_KEY_ID="" 13 | AWS_SECRET_ACCESS_KEY="" 14 | AWS_MAINBUCKET="" 15 | 16 | AUTH0_DOMAIN="" 17 | AUTH0_CLIENT_ID="" 18 | AUTH0_CLIENT_SECRET="" 19 | AUTH0_AUDIENCE="" 20 | 21 | PUBNUB_PUBLISH_KEY="" 22 | PUBNUB_SUBSCRIBE_KEY="" 23 | PUBNUB_SECRET_KEY="" 24 | 25 | EB_BUCKET="" 26 | -------------------------------------------------------------------------------- /src/controllers/UserController.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Route, 3 | Controller, 4 | Get, 5 | Put, 6 | Post, 7 | Delete, 8 | Security, 9 | Query, 10 | Body, 11 | Response, 12 | Tags 13 | } from 'tsoa'; 14 | 15 | import { ProvideSingleton, inject } from '../ioc'; 16 | import { IUserModel, IPaginationModel } from '../models'; 17 | import { UserService } from '../services'; 18 | 19 | @Tags('users') 20 | @Route('users') 21 | @ProvideSingleton(UserController) 22 | export class UserController extends Controller { 23 | constructor(@inject(UserService) private service: UserService) { 24 | super(); 25 | } 26 | 27 | @Get('{id}') 28 | public async getById(id: string): Promise { 29 | return this.service.getById(id); 30 | } 31 | 32 | @Get() 33 | public async getPaginated( 34 | @Query('page') page: number, 35 | @Query('limit') limit: number, 36 | @Query('fields') fields?: string, 37 | @Query('sort') sort?: string, 38 | @Query('q') q?: string): Promise { 39 | return this.service.getPaginated(page, limit, fields, sort, q); 40 | } 41 | 42 | @Response(400, 'Bad request') 43 | @Security('admin') 44 | @Post() 45 | public async create(@Body() body: IUserModel): Promise { 46 | return this.service.create(body); 47 | } 48 | 49 | @Response(400, 'Bad request') 50 | @Security('admin') 51 | @Put('{id}') 52 | public async update(id: string, @Body() body: IUserModel): Promise { 53 | return this.service.update(id, body); 54 | } 55 | 56 | @Security('admin') 57 | @Delete('{id}') 58 | public async delete(id: string): Promise { 59 | return this.service.delete(id); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './UserController'; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Server } from './config/Server'; 2 | 3 | const server: Server = new Server(); 4 | 5 | server.listen(); 6 | -------------------------------------------------------------------------------- /src/ioc.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from 'tsoa'; 2 | import { Container, inject, interfaces, decorate, injectable } from 'inversify'; 3 | import { autoProvide, makeProvideDecorator, makeFluentProvideDecorator } from 'inversify-binding-decorators'; 4 | import 'reflect-metadata'; 5 | 6 | decorate(injectable(), Controller); 7 | 8 | type Identifier = string | symbol | interfaces.Newable | interfaces.Abstract; 9 | 10 | const iocContainer = new Container(); 11 | 12 | const provide = makeProvideDecorator(iocContainer); 13 | const fluentProvider = makeFluentProvideDecorator(iocContainer); 14 | 15 | const ProvideNamed = (identifier: Identifier, name: string) => fluentProvider(identifier).whenTargetNamed(name).done(); 16 | 17 | const ProvideSingleton = (identifier: Identifier) => fluentProvider(identifier).inSingletonScope().done(); 18 | 19 | export { iocContainer, autoProvide, provide, ProvideSingleton, ProvideNamed, inject, decorate, injectable }; 20 | -------------------------------------------------------------------------------- /src/models/BaseFormatter.ts: -------------------------------------------------------------------------------- 1 | import { ImmutabilityHelper } from '../utils'; 2 | 3 | export abstract class BaseFormatter { 4 | public id: string; 5 | public _id: string; 6 | 7 | protected format(args: any = {}): void { 8 | if (typeof args.toJSON === 'function') args = args.toJSON(); 9 | Object.keys(this).forEach(key => { 10 | if (args[key] !== undefined) this[key] = ImmutabilityHelper.copy(args[key]); 11 | }); 12 | if (args._id) this.id = this._id = args._id; 13 | } 14 | } -------------------------------------------------------------------------------- /src/models/PaginationModel.ts: -------------------------------------------------------------------------------- 1 | export interface IPaginationModel { /** tsoa doesn't like generics */ 2 | count: number; 3 | page: number; 4 | limit: number; 5 | totalPages: number; 6 | docs: any[]; 7 | } 8 | 9 | export class PaginationModel implements IPaginationModel { 10 | public count: number; 11 | public page: number; 12 | public limit: number; 13 | public totalPages: number; 14 | public docs: any[]; 15 | 16 | constructor(args: IPaginationModel) { 17 | Object.assign(this, args); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/models/UserModel.ts: -------------------------------------------------------------------------------- 1 | import { BaseFormatter } from './BaseFormatter'; 2 | 3 | export interface IUserModel { 4 | _id?: string; 5 | id?: string; 6 | email: string; 7 | name: string; 8 | } 9 | 10 | export class UserFormatter extends BaseFormatter implements IUserModel { 11 | public email: string = undefined; 12 | public name: string = undefined; 13 | 14 | constructor(args: any) { 15 | super(); 16 | this.format(args); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './UserModel'; 2 | export * from './PaginationModel'; 3 | -------------------------------------------------------------------------------- /src/repositories/IBaseRepository.ts: -------------------------------------------------------------------------------- 1 | export interface IBaseRepository { 2 | create(model: EntityType): Promise; 3 | update(_id: string, model: EntityType): Promise; 4 | delete(_id: string): Promise<{ n: number }>; 5 | find(skip?: number, limit?: number, sort?: string, query?: any): Promise; 6 | findOne(query: any): Promise; 7 | count(query: any): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /src/repositories/index.ts: -------------------------------------------------------------------------------- 1 | /** MONGO */ 2 | export * from './mongo/UserRepository'; 3 | 4 | /** SQL */ 5 | // export * from './sql/UserRepository'; 6 | -------------------------------------------------------------------------------- /src/repositories/mongo/BaseRepository.ts: -------------------------------------------------------------------------------- 1 | import { decorate, injectable } from 'inversify'; 2 | import { Schema, Model, Document } from 'mongoose'; 3 | import * as uniqueValidator from 'mongoose-unique-validator'; 4 | 5 | import { IBaseRepository } from '../IBaseRepository'; 6 | import { ApiError } from '../../config/ErrorHandler'; 7 | import constants from '../../config/constants'; 8 | import { MongoDbConnection } from '../../config/MongoDbConnection'; 9 | import { cleanQuery } from '../../utils'; 10 | 11 | export abstract class BaseRepository implements IBaseRepository { 12 | protected dbConnection: MongoDbConnection; 13 | protected schema: Schema; 14 | protected documentModel: Model; 15 | protected modelName: string; 16 | protected formatter: any = Object; 17 | private initiated: boolean = false; 18 | 19 | /** this needs to be called after the extended class super is executed */ 20 | protected init(): void { 21 | if (this.initiated) return; 22 | this.documentModel = this.dbConnection.db.model(this.modelName, this.schema); 23 | this.schema.plugin(uniqueValidator); 24 | this.initiated = true; 25 | } 26 | 27 | public async create(model: EntityType): Promise { 28 | const document: Document = await this.documentModel.create(this.cleanToSave(model)); 29 | return new this.formatter(document); 30 | } 31 | 32 | public async update(_id: string, model: EntityType): Promise { 33 | await this.documentModel.updateOne({ _id }, this.cleanToSave(model)); 34 | } 35 | 36 | public async delete(_id: string): Promise<{ n: number }> { 37 | return this.documentModel.deleteOne({ _id }); 38 | } 39 | 40 | public async find( 41 | skip: number = 0, 42 | limit: number = 250, 43 | sort: string, 44 | query: any 45 | ): Promise { 46 | const sortObject = cleanQuery(sort, this.sortQueryFormatter); 47 | return ( 48 | await this.documentModel 49 | .find(this.cleanWhereQuery(query)) 50 | .sort(Object.keys(sortObject).map(key => [key, sortObject[key]])) 51 | .skip(skip) 52 | .limit(limit) 53 | ) 54 | .map(item => new this.formatter(item)); 55 | } 56 | 57 | public async findOne(query: any): Promise { 58 | const document: Document = await this.documentModel.findOne(query); 59 | if (!document) throw new ApiError(constants.errorTypes.notFound); 60 | return new this.formatter(document); 61 | } 62 | 63 | public async count(query: any): Promise { 64 | return this.documentModel.count(this.cleanWhereQuery(query)); 65 | } 66 | 67 | protected cleanToSave(entity: EntityType): EntityType { 68 | const copy: EntityType = new this.formatter(entity); 69 | const loop = (value: any): any => { 70 | if (!value || typeof value !== 'object') return; 71 | /** formatting logic to save goes here */ 72 | Object.keys(value).forEach(key => loop(value[key])); 73 | }; 74 | loop(copy); 75 | return copy; 76 | } 77 | 78 | protected sortQueryFormatter(key: string, value: string): number | undefined { 79 | if (value === 'asc') return 1; 80 | if (value === 'desc') return -1; 81 | return undefined; // just for static typing 82 | } 83 | 84 | protected cleanWhereQuery(query: any): { [key: string]: any } { 85 | if (!query || typeof query === 'string') return cleanQuery(query); 86 | 87 | const newQuery = { $or: [] }; 88 | Object.keys(query).forEach(key => { 89 | let value = query[key]; 90 | if (!(value instanceof Array)) newQuery[key] = value; 91 | else newQuery.$or = newQuery.$or.concat(value.map(item => ({ [key]: item }))); 92 | }); 93 | if (!newQuery.$or.length) delete newQuery.$or; 94 | return newQuery; 95 | } 96 | } 97 | 98 | decorate(injectable(), BaseRepository); 99 | -------------------------------------------------------------------------------- /src/repositories/mongo/UserRepository.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose'; 2 | 3 | import { BaseRepository } from './BaseRepository'; 4 | import { ProvideSingleton, inject } from '../../ioc'; 5 | import { MongoDbConnection } from '../../config/MongoDbConnection'; 6 | import { IUserModel, UserFormatter } from '../../models'; 7 | 8 | @ProvideSingleton(UserRepository) 9 | export class UserRepository extends BaseRepository { 10 | protected modelName: string = 'users'; 11 | protected schema: Schema = new Schema({ 12 | name: { type: String, required: true }, 13 | email: { type: String, required: true, unique: true }, 14 | }); 15 | protected formatter = UserFormatter; 16 | constructor(@inject(MongoDbConnection) protected dbConnection: MongoDbConnection) { 17 | super(); 18 | super.init(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/repositories/sql/BaseRepository.ts: -------------------------------------------------------------------------------- 1 | import * as Sequelize from 'sequelize'; 2 | import { decorate, injectable } from 'inversify'; 3 | 4 | import { IBaseRepository } from '../IBaseRepository'; 5 | import { ApiError } from '../../config/ErrorHandler'; 6 | import constants from '../../config/constants'; 7 | import { cleanQuery, safeParse, isId } from '../../utils'; 8 | import { BaseEntity } from './entities/BaseEntity'; 9 | 10 | export abstract class BaseRepository implements IBaseRepository { 11 | protected formatter: any = Object; 12 | protected entityModel: BaseEntity; 13 | protected getInclude: Sequelize.IncludeOptions[] = []; 14 | protected saveInclude: Sequelize.IncludeOptions[] = []; 15 | 16 | public async create(model: EntityType, include = this.saveInclude): Promise { 17 | const res = await this.entityModel.model.create(this.cleanToSave(model), { include }); 18 | return new this.formatter(res); 19 | } 20 | 21 | public async update(_id: string, model: EntityType, include = this.saveInclude): Promise { 22 | await this.entityModel.model.update(this.cleanToSave(model), { where: { _id } }); 23 | } 24 | 25 | public async delete(_id: string): Promise<{ n: number }> { 26 | const n = await this.entityModel.model.destroy({ where: { _id } }); 27 | return { n }; 28 | } 29 | 30 | public async find( 31 | offset: number = 0, 32 | limit: number = 250, 33 | sort: string, 34 | query: any, 35 | include = this.getInclude 36 | ): Promise { 37 | const sortObject = this.cleanSort(sort); 38 | const order = Object.keys(sortObject).map(key => [key, sortObject[key]]); 39 | const where = this.cleanWhere(query); 40 | const options: Sequelize.FindOptions = { 41 | include, 42 | where, 43 | limit, 44 | offset 45 | }; 46 | if (order) options.order = order; 47 | return (await this.entityModel.model.findAll(options)) 48 | .map(item => new this.formatter(item)); 49 | } 50 | 51 | public async findOne(where: any, include = this.getInclude): Promise { 52 | const res = await this.entityModel.model.findOne({ where, include }); 53 | if (!res) throw new ApiError(constants.errorTypes.notFound); 54 | return new this.formatter(res); 55 | } 56 | 57 | public async count(query: any): Promise { 58 | return this.entityModel.model.count({ where: this.cleanWhere(query) }); 59 | } 60 | 61 | protected cleanToSave(entity: any): any { 62 | const copy: any = new this.formatter(entity); 63 | const loop = (value: any): any => { 64 | if (!value || typeof value !== 'object') return; 65 | /** formatting logic to save goes here */ 66 | Object.keys(value).forEach(key => loop(value[key])); 67 | }; 68 | loop(copy); 69 | return copy; 70 | } 71 | 72 | protected sortQueryFormatter(key: string, value: string): string | undefined { 73 | if (value === 'asc') return 'asc'; 74 | if (value === 'desc') return 'desc'; 75 | return undefined; // just for static typing 76 | } 77 | 78 | protected whereQueryFormatter = (key: string, value: any): any => { 79 | value = safeParse(value, value); 80 | if (value instanceof Array) return { 81 | [Sequelize.Op.or]: value.map(v => this.whereQueryFormatter(key, v)) 82 | }; 83 | else if (isId(key)) return { [Sequelize.Op.eq]: value }; 84 | else if (typeof value === 'string') return { [Sequelize.Op.like]: `%${value}%` }; 85 | else return { [Sequelize.Op.eq]: value }; 86 | } 87 | 88 | protected cleanSort(sort: string): { [key: string]: any } { 89 | return cleanQuery(sort, this.sortQueryFormatter); 90 | } 91 | 92 | protected cleanWhere(query: any): { [key: string]: any } { 93 | return cleanQuery(query, this.whereQueryFormatter); 94 | } 95 | } 96 | 97 | decorate(injectable(), BaseRepository); 98 | -------------------------------------------------------------------------------- /src/repositories/sql/UserRepository.ts: -------------------------------------------------------------------------------- 1 | import { ProvideSingleton, inject } from '../../ioc'; 2 | import { BaseRepository } from './BaseRepository'; 3 | import { IUserModel, UserFormatter } from '../../models'; 4 | import { UserEntity } from './entities'; 5 | 6 | @ProvideSingleton(UserRepository) 7 | export class UserRepository extends BaseRepository { 8 | protected formatter: any = UserFormatter; 9 | 10 | constructor(@inject(UserEntity) protected entityModel: UserEntity) { 11 | super(); 12 | } 13 | 14 | /** for aditional logic (maybe nested entities) */ 15 | public async create(model: IUserModel, include = this.saveInclude): Promise { 16 | return super.create(model, include); 17 | } 18 | 19 | /** for aditional logic (maybe nested entities) */ 20 | public async update(_id: string, model: IUserModel): Promise { 21 | return super.update(_id, model); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/repositories/sql/entities/BaseEntity.ts: -------------------------------------------------------------------------------- 1 | import { decorate, injectable } from 'inversify'; 2 | import * as Sequelize from 'sequelize'; 3 | 4 | import { SQLDbConnection } from '../../../config/SQLDbConnection'; 5 | import { Logger } from '../../../config/Logger'; 6 | 7 | export abstract class BaseEntity { 8 | public entityName: string; 9 | public model: Sequelize.Model; 10 | protected sqlDbConnection: SQLDbConnection; 11 | protected attributes: Sequelize.DefineAttributes; 12 | protected options: Sequelize.DefineOptions; 13 | 14 | protected initModel(): void { 15 | this.model = this.sqlDbConnection.db.define(this.entityName, this.attributes, this.options); 16 | } 17 | 18 | protected sync(options?: Sequelize.SyncOptions): Promise { 19 | Logger.log( 20 | `synchronizing: ${this.entityName}${options ? ` with options: ${JSON.stringify(options)}` : ''}` 21 | ); 22 | return this.model.sync(options) as any; 23 | } 24 | } 25 | 26 | decorate(injectable(), BaseEntity); 27 | -------------------------------------------------------------------------------- /src/repositories/sql/entities/UserEntity.ts: -------------------------------------------------------------------------------- 1 | import * as Sequelize from 'sequelize'; 2 | 3 | import { ProvideSingleton, inject } from '../../../ioc'; 4 | import { SQLDbConnection } from '../../../config/SQLDbConnection'; 5 | import { BaseEntity } from './BaseEntity'; 6 | 7 | @ProvideSingleton(UserEntity) 8 | export class UserEntity extends BaseEntity { 9 | public entityName: string = 'user'; 10 | protected attributes: Sequelize.DefineAttributes = { 11 | _id: { type: Sequelize.UUID, primaryKey: true, defaultValue: Sequelize.UUIDV4 }, 12 | name: { type: Sequelize.STRING, allowNull: false }, 13 | email: { type: Sequelize.STRING, allowNull: false, unique: true } 14 | }; 15 | protected options: Sequelize.DefineOptions = { name: { plural: 'users' } }; 16 | 17 | constructor(@inject(SQLDbConnection) protected sqlDbConnection: SQLDbConnection) { 18 | super(); 19 | this.initModel(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/repositories/sql/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './UserEntity'; 2 | -------------------------------------------------------------------------------- /src/services/BaseService.ts: -------------------------------------------------------------------------------- 1 | import { decorate, injectable } from 'inversify'; 2 | 3 | import constants from '../config/constants'; 4 | import { ApiError } from '../config/ErrorHandler'; 5 | import { PaginationModel } from '../models'; 6 | import { IBaseRepository } from '../repositories/IBaseRepository'; 7 | 8 | export abstract class BaseService { 9 | protected repository: IBaseRepository; 10 | 11 | public async getById(_id: string): Promise { 12 | return this.repository.findOne({ _id }); 13 | } 14 | 15 | public async getPaginated( 16 | page: number, 17 | limit: number, 18 | fields: string, 19 | sort: string, 20 | query: string 21 | ): Promise { 22 | const skip: number = (Math.max(1, page) - 1) * limit; 23 | let [count, docs] = await Promise.all([ 24 | this.repository.count(query), 25 | this.repository.find(skip, limit, sort, query) 26 | ]); 27 | const fieldArray = (fields || '').split(',').map(field => field.trim()).filter(Boolean); 28 | if (fieldArray.length) docs = docs.map(d => { 29 | const attrs: any = {}; 30 | fieldArray.forEach(f => attrs[f] = d[f]); 31 | return attrs; 32 | }); 33 | return new PaginationModel({ 34 | count, 35 | page, 36 | limit, 37 | docs, 38 | totalPages: Math.ceil(count / limit), 39 | }); 40 | } 41 | 42 | public async create(entity: EntityModel): Promise { 43 | const res = await this.repository.create(entity); 44 | return this.getById((res as any)._id); 45 | } 46 | 47 | public async update(id: string, entity: EntityModel): Promise { 48 | await this.repository.update(id, entity); 49 | return this.getById(id); 50 | } 51 | 52 | public async delete(id: string): Promise { 53 | const res = await this.repository.delete(id); 54 | if (!res.n) throw new ApiError(constants.errorTypes.notFound); 55 | } 56 | } 57 | 58 | decorate(injectable(), BaseService); 59 | -------------------------------------------------------------------------------- /src/services/UserService.ts: -------------------------------------------------------------------------------- 1 | import { ProvideSingleton, inject } from '../ioc'; 2 | import { BaseService } from './BaseService'; 3 | import { UserRepository } from '../repositories'; 4 | import { IUserModel } from '../models'; 5 | 6 | @ProvideSingleton(UserService) 7 | export class UserService extends BaseService { 8 | 9 | constructor(@inject(UserRepository) protected repository: UserRepository) { 10 | super(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './UserService'; 2 | -------------------------------------------------------------------------------- /src/setup/migrateSql.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import { readdirSync, unlinkSync } from 'fs'; 3 | import * as ts from 'typescript'; 4 | import * as Umzug from 'umzug'; 5 | 6 | import { iocContainer } from '../ioc'; 7 | import { Logger } from '../config/Logger'; 8 | import constants from '../config/constants'; 9 | import { SQLDbConnection } from '../config/SQLDbConnection'; 10 | import { SQLSetupHelper } from '../config/SQLSetupHelper'; 11 | 12 | (async () => { 13 | try { 14 | const direction: string = process.env.MIGRATION_DIRECTION; 15 | const action: string = process.env.MIGRATION_ACTION; 16 | Logger.log(`starting SQL ${action} **${direction}** on ${constants.environment} environment`); 17 | const { db } = iocContainer.get(SQLDbConnection); 18 | const helper = iocContainer.get(SQLSetupHelper); 19 | 20 | Logger.info('creating tables'); 21 | await helper.sync(); 22 | await db.authenticate(); 23 | 24 | const options = require('../../tsconfig.json'); 25 | const folder = join(__dirname, `./${action}`); 26 | const tsNames: string[] = readdirSync(folder) 27 | .filter(n => /.ts$/.test(n)) 28 | .map(n => `${folder}/${n}`); 29 | 30 | Logger.info('compiling migration files'); 31 | const program = ts.createProgram(tsNames, options); 32 | program.emit(); 33 | 34 | const jsNames: string[] = readdirSync(folder) 35 | .filter(f => /.js$/.test(f)) 36 | .map(n => `${folder}/${n}`); 37 | const umzug = new Umzug({ 38 | storage: 'sequelize', 39 | storageOptions: { 40 | sequelize: db 41 | }, 42 | migrations: { 43 | params: [db.getQueryInterface()], 44 | path: join(__dirname, `./${action}`), 45 | pattern: /.js$/ 46 | } 47 | }); 48 | 49 | Logger.info('running migrations'); 50 | await umzug[direction](); 51 | Logger.info('cleaning up'); 52 | jsNames.forEach(n => unlinkSync(n)); 53 | process.exit(); 54 | } catch (e) { 55 | Logger.error(e); 56 | process.exit(1); 57 | } 58 | })(); 59 | -------------------------------------------------------------------------------- /src/setup/migrations/20180410153203-user.ts: -------------------------------------------------------------------------------- 1 | import * as Sequelize from 'sequelize'; 2 | 3 | export default { 4 | up: async (queryInterface: Sequelize.QueryInterface) => { 5 | return Promise.all([ 6 | // queryInterface.addColumn('users', 'fakeColumn', Sequelize.STRING) 7 | ]); 8 | }, 9 | down: async (queryInterface: Sequelize.QueryInterface) => { 10 | return Promise.all([ 11 | // queryInterface.removeColumn('users', 'fakeColumn') 12 | ]); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/setup/seedMongo.ts: -------------------------------------------------------------------------------- 1 | import { iocContainer } from '../ioc'; 2 | import { Logger } from '../config/Logger'; 3 | import { UserRepository } from '../repositories/mongo/UserRepository'; 4 | 5 | const userRepository = iocContainer.get(UserRepository); 6 | 7 | (async () => { 8 | const users = [ 9 | { 10 | email: 'dgeslin@makingsense.com', 11 | name: 'Daniel', 12 | nickname: 'dgeslin', 13 | avatar: 'https://s.gravatar.com/avatar/eef7ac03735857933c6a32351d1855ae?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fdg.png', // tslint:disable-line 14 | picture: 'https://s.gravatar.com/avatar/eef7ac03735857933c6a32351d1855ae?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fdg.png', // tslint:disable-line 15 | gender: 'male', 16 | firstname: 'Daniel', 17 | lastname: 'Geslin' 18 | } 19 | ]; 20 | 21 | try { 22 | Logger.log('migrating MONGO'); 23 | const prevIds = await userRepository.find(0, 1000, '', { email: users.map(u => u.email) }); 24 | await Promise.all(prevIds.map(p => userRepository.delete(p._id))); 25 | await Promise.all(users.map(u => userRepository.create(u))); 26 | } catch (e) { 27 | Logger.error(e); 28 | process.exit(1); 29 | } 30 | process.exit(); 31 | })(); 32 | -------------------------------------------------------------------------------- /src/setup/sql-seeders/seeds.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | return new (P || (P = Promise))(function (resolve, reject) { 4 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 5 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 6 | function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } 7 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 8 | }); 9 | }; 10 | var __generator = (this && this.__generator) || function (thisArg, body) { 11 | var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; 12 | return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; 13 | function verb(n) { return function (v) { return step([n, v]); }; } 14 | function step(op) { 15 | if (f) throw new TypeError("Generator is already executing."); 16 | while (_) try { 17 | if (f = 1, y && (t = y[op[0] & 2 ? "return" : op[0] ? "throw" : "next"]) && !(t = t.call(y, op[1])).done) return t; 18 | if (y = 0, t) op = [0, t.value]; 19 | switch (op[0]) { 20 | case 0: case 1: t = op; break; 21 | case 4: _.label++; return { value: op[1], done: false }; 22 | case 5: _.label++; y = op[1]; op = [0]; continue; 23 | case 7: op = _.ops.pop(); _.trys.pop(); continue; 24 | default: 25 | if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } 26 | if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } 27 | if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } 28 | if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } 29 | if (t[2]) _.ops.pop(); 30 | _.trys.pop(); continue; 31 | } 32 | op = body.call(thisArg, _); 33 | } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } 34 | if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; 35 | } 36 | }; 37 | var _this = this; 38 | exports.__esModule = true; 39 | var Sequelize = require("sequelize"); 40 | var uuidv4 = require("uuid/v4"); 41 | exports["default"] = { 42 | up: function (queryInterface) { return __awaiter(_this, void 0, void 0, function () { 43 | return __generator(this, function (_a) { 44 | return [2 /*return*/, queryInterface.bulkInsert('users', [ 45 | { 46 | _id: uuidv4(), 47 | email: 'dgeslin@makingsense.com', 48 | name: 'Daniel', 49 | nickname: 'dgeslin', 50 | avatar: 'https://s.gravatar.com/avatar/eef7ac03735857933c6a32351d1855ae?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fdg.png', 51 | picture: 'https://s.gravatar.com/avatar/eef7ac03735857933c6a32351d1855ae?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fdg.png', 52 | gender: 'male', 53 | firstname: 'Daniel', 54 | lastname: 'Geslin', 55 | online: false, 56 | added: new Date(), 57 | updated: new Date(), 58 | createdAt: new Date(), 59 | updatedAt: new Date() 60 | } 61 | ], {})]; 62 | }); 63 | }); }, 64 | down: function (queryInterface) { return __awaiter(_this, void 0, void 0, function () { 65 | var _a; 66 | return __generator(this, function (_b) { 67 | return [2 /*return*/, queryInterface.bulkDelete('users', { 68 | email: (_a = {}, _a[Sequelize.Op.or] = ['dgeslin@makingsense.com'], _a) 69 | }, {})]; 70 | }); 71 | }); } 72 | }; 73 | -------------------------------------------------------------------------------- /src/setup/sql-seeders/seeds.ts: -------------------------------------------------------------------------------- 1 | import * as Sequelize from 'sequelize'; 2 | import * as uuidv4 from 'uuid/v4'; 3 | 4 | export default { 5 | up: async (queryInterface: Sequelize.QueryInterface) => { 6 | return queryInterface.bulkInsert('users', [ 7 | { 8 | _id: uuidv4(), 9 | email: 'dgeslin@makingsense.com', 10 | name: 'Daniel', 11 | nickname: 'dgeslin', 12 | avatar: 'https://s.gravatar.com/avatar/eef7ac03735857933c6a32351d1855ae?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fdg.png', // tslint:disable-line 13 | picture: 'https://s.gravatar.com/avatar/eef7ac03735857933c6a32351d1855ae?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fdg.png', // tslint:disable-line 14 | gender: 'male', 15 | firstname: 'Daniel', 16 | lastname: 'Geslin', 17 | online: false, 18 | added: new Date(), 19 | updated: new Date(), 20 | createdAt: new Date(), 21 | updatedAt: new Date() 22 | } 23 | ], {}); 24 | }, 25 | 26 | down: async (queryInterface: Sequelize.QueryInterface) => { 27 | return queryInterface.bulkDelete('users', { 28 | email: { [Sequelize.Op.or]: ['dgeslin@makingsense.com'] } 29 | }, {}); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/tests/IntegrationHelper.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { SuperTest } from 'supertest'; 3 | 4 | import { iocContainer } from '../ioc'; 5 | import { SQLSetupHelper } from '../config/SQLSetupHelper'; 6 | import { ROOT_PATH } from './constants'; 7 | 8 | export type Response = Promise<{ 9 | status: number; 10 | body: T; 11 | }>; 12 | 13 | export class IntegrationHelper { 14 | public app: SuperTest; 15 | public rootPath: string = ROOT_PATH; 16 | public loginPath: string = `${this.rootPath}/auth/login`; 17 | public userPath: string = `${this.rootPath}/users`; 18 | public channelPath: string = `${this.rootPath}/channels`; 19 | public messagePath: string = `${this.rootPath}/messages`; 20 | 21 | public static setup(): void { 22 | xit('SQL DB', async () => { 23 | const sqlHelper = iocContainer.get(SQLSetupHelper); 24 | await sqlHelper.sync({ force: true }); 25 | expect(1).to.equal(1); 26 | }); 27 | } 28 | 29 | constructor(app: SuperTest) { 30 | this.app = app; 31 | } 32 | 33 | public testPagination(res: any): void { 34 | expect(res.status).to.equal(200); 35 | expect(res.body.count).to.be.greaterThan(0); 36 | expect(res.body.page).to.be.equal(1); 37 | expect(res.body.limit).to.equal(1); 38 | expect(res.body.totalPages).to.be.greaterThan(0); 39 | expect(res.body.docs).to.have.length; // tslint:disable-line 40 | } 41 | } -------------------------------------------------------------------------------- /src/tests/constants.ts: -------------------------------------------------------------------------------- 1 | export const ROOT_PATH: string = '/service'; 2 | -------------------------------------------------------------------------------- /src/tests/data/models.ts: -------------------------------------------------------------------------------- 1 | import { generate } from 'randomstring'; 2 | 3 | import * as Models from '../../models'; 4 | 5 | export const generateMockUUID = (): string => { 6 | return `${generate(8)}-${generate(4)}-${generate(4)}-${generate(4)}-${generate(12)}`; 7 | }; 8 | 9 | export const generateUserModel = (): Models.IUserModel => ({ 10 | email: generate(20), 11 | name: generate(20) 12 | }); 13 | -------------------------------------------------------------------------------- /src/tests/data/testfile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MakingSense/tsoa-api/30457575a83c0097af8391d1e509c76f264c8027/src/tests/data/testfile.jpg -------------------------------------------------------------------------------- /src/tests/integration/_setup.spec.ts: -------------------------------------------------------------------------------- 1 | import { IntegrationHelper } from '../IntegrationHelper'; 2 | 3 | describe('Setup', () => { 4 | IntegrationHelper.setup(); 5 | }); 6 | -------------------------------------------------------------------------------- /src/tests/integration/users.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as supertest from 'supertest'; 3 | 4 | import { ROOT_PATH } from '../constants'; 5 | import { Server } from '../../config/Server'; 6 | import { generateUserModel } from '../data/models'; 7 | import { IntegrationHelper } from '../IntegrationHelper'; 8 | 9 | const route: string = `${ROOT_PATH}/users`; 10 | const entityName: string = 'user'; 11 | 12 | describe(`${route}`, () => { 13 | const app = supertest(new Server().app); 14 | const integrationHelper: IntegrationHelper = new IntegrationHelper(app); 15 | let model = generateUserModel(); 16 | 17 | describe('POST', () => { 18 | it(`should create: ${entityName}`, async () => { 19 | const res = await app.post(route).send(model); 20 | expect(res.status).to.equal(200); 21 | expect(res.body).to.have.property('id'); 22 | expect(res.body).to.have.property('_id'); 23 | expect(res.body).to.have.property('name'); 24 | expect(res.body).to.have.property('email'); 25 | model = res.body; 26 | }); 27 | it(`should FAIL to create: ${entityName}`, async () => { 28 | const res = await app.post(route).send({}); 29 | expect(res.status).to.equal(400); 30 | }); 31 | }); 32 | 33 | describe('PUT /{id}', () => { 34 | it(`should update: ${entityName}`, async () => { 35 | model.name = `${model.name}_edited`; 36 | const res = await app.put(`${route}/${model.id}`).send(model); 37 | expect(res.status).to.equal(200); 38 | model = res.body; 39 | }); 40 | it(`should FAIL to update: ${entityName}`, async () => { 41 | const res = await app.put(`${route}/${model.id}`).send({}); 42 | expect(res.status).to.equal(400); 43 | }); 44 | }); 45 | 46 | describe('GET', () => { 47 | it(`should get paginated: ${entityName}`, async () => { 48 | const res = await app.get( 49 | `${route}?page=1&limit=1&sort={"email":"asc"}&fields=email&q={"email":"${model.email}"}` 50 | ); 51 | integrationHelper.testPagination(res); 52 | }); 53 | it(`should FAIL to get paginated: ${entityName}`, async () => { 54 | const res = await app.get(route); 55 | expect(res.status).to.equal(400); 56 | }); 57 | }); 58 | 59 | describe('GET /{id}', () => { 60 | it(`should get one: ${entityName}`, async () => { 61 | const res = await app.get(`${route}/${model.id}`); 62 | expect(res.status).to.equal(200); 63 | expect(res.body).to.deep.equal(model); 64 | }); 65 | it(`should FAIL to get one: ${entityName}`, async () => { 66 | const res = await app.get(`${route}/11111111-1111-1111-1111-111111111111`); 67 | expect(res.status).to.satisfy(val => val === 404 || val === 500); 68 | }); 69 | }); 70 | 71 | describe('DELETE /{id}', () => { 72 | it(`should delete one: ${entityName}`, async () => { 73 | const res = await app.delete(`${route}/${model.id}`); 74 | expect(res.status).to.equal(204); 75 | }); 76 | it(`should FAIL to delete one: ${entityName}`, async () => { 77 | const res = await app.delete(`${route}/${model.id}`); 78 | expect(res.status).to.equal(404); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/tests/unit/mocks/MockBaseRepository.ts: -------------------------------------------------------------------------------- 1 | import { IBaseRepository } from '../../../repositories/IBaseRepository'; 2 | import { generateMockUUID, generateUserModel } from '../../data/models'; 3 | 4 | export class MockBaseRepository implements IBaseRepository { 5 | constructor(public mock?: any) { } 6 | 7 | public async create(model: any): Promise { 8 | const id = generateMockUUID(); 9 | return this.mock || { ...model, id, _id: id }; 10 | } 11 | 12 | public async update(_id: string, model: any): Promise { 13 | return null; 14 | } 15 | 16 | public async delete(_id: string): Promise<{ n: number }> { 17 | return { n: 1 }; 18 | } 19 | 20 | public async find(skip?: number, limit?: number, sort?: string, query?: any): Promise { 21 | const id = generateMockUUID(); 22 | return [this.mock || { ...generateUserModel(), id, _id: id }]; 23 | } 24 | 25 | public async findOne(query: any): Promise { 26 | const id = generateMockUUID(); 27 | return this.mock || { ...generateUserModel(), id, _id: id }; 28 | } 29 | 30 | public async count(query: any): Promise { 31 | return 1; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/tests/unit/mocks/mockSQLEntity.ts: -------------------------------------------------------------------------------- 1 | import { stub } from 'sinon'; 2 | 3 | export const generateEntity = (mock?: any): any => ({ 4 | model: { 5 | destroy: stub().returns(mock), 6 | create: stub().returns(mock), 7 | update: stub(), 8 | bulkUpdate: stub(), 9 | findAll: stub().returns([mock]), 10 | findOne: stub().returns(mock), 11 | bulkCreate: stub().returns(mock) 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /src/tests/unit/models/baseFormatter.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { BaseFormatter } from '../../../models/BaseFormatter'; 4 | 5 | class Formatter extends BaseFormatter { 6 | public dummy: string = null; 7 | constructor(args: any) { 8 | super(); 9 | this.format(args); 10 | } 11 | } 12 | 13 | describe('BaseFormatter', () => { 14 | it('should format ids', () => { 15 | const formatted = new Formatter({ _id: 'a' }); 16 | expect(formatted.id).to.equal('a'); 17 | expect(formatted._id).to.equal('a'); 18 | }); 19 | 20 | it('should format extention props', () => { 21 | const formatted = new Formatter({ dummy: 'a' }); 22 | expect(formatted.dummy).to.equal('a'); 23 | }); 24 | 25 | it('should format use toJSON if present', () => { 26 | const formatted = new Formatter({ toJSON: () => ({ dummy: 'a' }) }); 27 | expect(formatted.dummy).to.equal('a'); 28 | }); 29 | 30 | it('should ignore undefined args props', () => { 31 | const formatted = new Formatter({}); 32 | expect(formatted.dummy).to.equal(null); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/tests/unit/other/errorHandler.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { stub } from 'sinon'; 3 | 4 | import constants from '../../../config/constants'; 5 | import { ErrorHandler, ApiError } from '../../../config/ErrorHandler'; 6 | 7 | describe('ErrorHandler', () => { 8 | it('should normalize errors', () => { 9 | const error = (ErrorHandler as any).normalizeError({}); 10 | expect(error).to.be.an.instanceof(ApiError); 11 | }); 12 | 13 | it('should handle errors', () => { 14 | const e = constants.errorTypes.notFound; 15 | const jsonStub = stub(); 16 | const statusStub = stub().returns({ json: jsonStub }); 17 | const nextStub = stub(); 18 | ErrorHandler.handleError( 19 | new ApiError(e), 20 | null, 21 | { status: statusStub } as any, 22 | nextStub 23 | ); 24 | expect(statusStub.calledWith(e.statusCode)).to.be.true; // tslint:disable-line 25 | expect(jsonStub.calledWith({ 26 | name: e.name, 27 | message: e.message, 28 | fields: undefined 29 | })).to.be.true; // tslint:disable-line 30 | expect(nextStub.calledOnce).to.be.true; // tslint:disable-line 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/tests/unit/other/logger.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { stub } from 'sinon'; 3 | 4 | import { Logger } from '../../../config/Logger'; 5 | 6 | describe('Logger', () => { 7 | const exampleInput = { a: 1 }; 8 | const exampleOutput = (Logger as any).formatArgs(exampleInput); 9 | const debugStub = Logger.console.debug = stub(); 10 | const warnStub = Logger.console.warn = stub(); 11 | const errorStub = Logger.console.error = stub(); 12 | const infoStub = Logger.console.info = stub(); 13 | const verboseStub = Logger.console.verbose = stub(); 14 | before(() => { 15 | (Logger as any).shouldLog = true; 16 | }); 17 | 18 | it('should log "log"', () => { 19 | Logger.log(exampleInput); 20 | expect(debugStub.calledWith(exampleOutput)).to.be.true; // tslint:disable-line 21 | }); 22 | 23 | it('should log "warn"', () => { 24 | Logger.warn(exampleInput); 25 | expect(warnStub.calledWith(exampleOutput)).to.be.true; // tslint:disable-line 26 | }); 27 | 28 | it('should log "error"', () => { 29 | Logger.error(exampleInput); 30 | expect(errorStub.calledWith(exampleOutput)).to.be.true; // tslint:disable-line 31 | }); 32 | 33 | it('should log "verbose"', () => { 34 | Logger.verbose(exampleInput); 35 | expect(verboseStub.calledWith(exampleOutput)).to.be.true; // tslint:disable-line 36 | }); 37 | 38 | it('should log "info"', () => { 39 | Logger.info(exampleInput); 40 | expect(infoStub.calledWith(exampleOutput)).to.be.true; // tslint:disable-line 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/tests/unit/repositories/mongoBaseRepository.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { spy, SinonSpy, stub, SinonStub } from 'sinon'; 3 | 4 | import * as ioc from '../../../ioc'; 5 | import { generateMockUUID } from '../../data/models'; 6 | import { BaseRepository } from '../../../repositories/mongo/BaseRepository'; 7 | 8 | /** we need some of this stuff on runtime */ 9 | ioc; // tslint:disable-line 10 | 11 | class Formatter extends Object { } 12 | 13 | class BaseRepositoryExtension extends BaseRepository { 14 | public documentModel: any; 15 | public dbConnection: any = { db: { model: stub() } }; 16 | public schema: any = { plugin: stub() }; 17 | protected formatter = Formatter; 18 | constructor(customStub?: SinonStub) { 19 | super(); 20 | this.documentModel = customStub || stub(); 21 | } 22 | } 23 | 24 | describe('Mongo BaseRepository', () => { 25 | let repository: BaseRepositoryExtension; 26 | let cleanToSaveSpy: SinonSpy; 27 | beforeEach(() => { 28 | repository = new BaseRepositoryExtension(); 29 | cleanToSaveSpy = spy(repository, 'cleanToSave' as any); 30 | }); 31 | 32 | it('should init one time', async () => { 33 | (repository as any).init(); 34 | (repository as any).init(); 35 | expect(repository.dbConnection.db.model.calledOnce).to.be.true; // tslint:disable-line 36 | expect(repository.schema.plugin.calledOnce).to.be.true; // tslint:disable-line 37 | }); 38 | 39 | it('should create', async () => { 40 | const createStub = repository.documentModel.create = stub(); 41 | const res = await repository.create({}); 42 | expect(cleanToSaveSpy.calledOnce).to.be.true; // tslint:disable-line 43 | expect(res instanceof Formatter).to.be.true; // tslint:disable-line 44 | expect(createStub.calledOnce).to.be.true; // tslint:disable-line 45 | }); 46 | 47 | it('should update', async () => { 48 | const _id = generateMockUUID(); 49 | const updateStub = repository.documentModel.updateOne = stub(); 50 | const res = await repository.update(_id, {}); 51 | expect(cleanToSaveSpy.calledOnce).to.be.true; // tslint:disable-line 52 | expect(res).to.equal(undefined); 53 | expect(updateStub.calledWith({ _id }, {})).to.be.true; // tslint:disable-line 54 | }); 55 | 56 | it('should delete', async () => { 57 | const _id = generateMockUUID(); 58 | const deleteStub = repository.documentModel.deleteOne = stub(); 59 | await repository.delete(_id); 60 | expect(cleanToSaveSpy.calledOnce).to.be.false; // tslint:disable-line 61 | expect(deleteStub.calledWith({ _id })).to.be.true; // tslint:disable-line 62 | }); 63 | 64 | it('should find', async () => { 65 | const findStub = repository.documentModel.find = stub().returns(repository.documentModel); 66 | const sortStub = repository.documentModel.sort = stub().returns(repository.documentModel); 67 | const skipStub = repository.documentModel.skip = stub().returns(repository.documentModel); 68 | const limitStub = repository.documentModel.limit = stub().returns([{}]); 69 | const res = await repository.find(5, 10, '{"name":"asc"}', '{"name":"example"}'); 70 | expect(findStub.calledWith({ name: new RegExp('example', 'i') })).to.be.true; // tslint:disable-line 71 | expect(sortStub.calledWith([['name', 1]])).to.be.true; // tslint:disable-line 72 | expect(skipStub.calledWith(5)).to.be.true; // tslint:disable-line 73 | expect(limitStub.calledWith(10)).to.be.true; // tslint:disable-line 74 | expect(res[0]).to.be.an.instanceof(Formatter); 75 | }); 76 | 77 | it('should find one', async () => { 78 | const _id = generateMockUUID(); 79 | const findOneStub = repository.documentModel.findOne = stub().returns({}); 80 | const res = await repository.findOne({ _id }); 81 | expect(cleanToSaveSpy.calledOnce).to.be.false; // tslint:disable-line 82 | expect(findOneStub.calledWith({ _id })).to.be.true; // tslint:disable-line 83 | expect(res).to.be.an.instanceof(Formatter); 84 | }); 85 | 86 | it('should FAIL to find one', async () => { 87 | let fails; 88 | try { 89 | await repository.findOne({}); 90 | fails = false; 91 | } catch { 92 | fails = true; 93 | } 94 | expect(fails).to.be.true; // tslint:disable-line 95 | }); 96 | 97 | it('should count', async () => { 98 | const _id = generateMockUUID(); 99 | const countStub = repository.documentModel.count = stub().returns(1); 100 | await repository.count(`{"_id":"${_id}"}`); 101 | expect(countStub.calledWith({ _id })).to.be.true; // tslint:disable-line 102 | }); 103 | 104 | it('should clean to save', async () => { 105 | expect((repository as any).cleanToSave({ a: 1 })).to.be.an.instanceof(Formatter); 106 | }); 107 | 108 | it('should format a sort query', async () => { 109 | expect((repository as any).sortQueryFormatter(null, 'asc')).to.equal(1); 110 | expect((repository as any).sortQueryFormatter(null, 'desc')).to.equal(-1); 111 | }); 112 | 113 | it('should format a where query', async () => { 114 | const fn = (repository as any).cleanWhereQuery; 115 | expect(fn(null)).to.deep.equal({}); 116 | expect(fn('')).to.deep.equal({}); 117 | expect(fn({ name: 'a', desc: ['a'] })).to.deep.equal({ name: 'a', $or: [{ desc: 'a' }] }); 118 | expect(fn({ name: 'a' })).to.deep.equal({ name: 'a' }); 119 | }); 120 | 121 | }); 122 | -------------------------------------------------------------------------------- /src/tests/unit/repositories/sqlBaseRepository.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { spy, SinonSpy, stub, SinonStub } from 'sinon'; 3 | import * as Sequelize from 'sequelize'; 4 | 5 | import * as ioc from '../../../ioc'; 6 | import { generateMockUUID } from '../../data/models'; 7 | import { BaseRepository } from '../../../repositories/sql/BaseRepository'; 8 | 9 | /** we need some of this stuff on runtime */ 10 | ioc; // tslint:disable-line 11 | 12 | class Formatter extends Object { } 13 | 14 | class BaseRepositoryExtension extends BaseRepository { 15 | public entityModel: any; 16 | protected getInclude = []; 17 | protected saveInclude = []; 18 | protected formatter = Formatter; 19 | constructor(customStub?: SinonStub) { 20 | super(); 21 | this.entityModel = { model: customStub || stub() }; 22 | } 23 | } 24 | 25 | describe('SQL BaseRepository', () => { 26 | let repository: BaseRepositoryExtension; 27 | let cleanToSaveSpy: SinonSpy; 28 | beforeEach(() => { 29 | repository = new BaseRepositoryExtension(); 30 | cleanToSaveSpy = spy(repository, 'cleanToSave' as any); 31 | }); 32 | 33 | it('should create', async () => { 34 | const createStub = repository.entityModel.model.create = stub(); 35 | const res = await repository.create({}); 36 | expect(cleanToSaveSpy.calledOnce).to.be.true; // tslint:disable-line 37 | expect(res instanceof Formatter).to.be.true; // tslint:disable-line 38 | expect(createStub.calledOnce).to.be.true; // tslint:disable-line 39 | }); 40 | 41 | it('should update', async () => { 42 | const _id = generateMockUUID(); 43 | const updateStub = repository.entityModel.model.update = stub(); 44 | const res = await repository.update(_id, {}); 45 | expect(cleanToSaveSpy.calledOnce).to.be.true; // tslint:disable-line 46 | expect(res).to.equal(undefined); 47 | expect(updateStub.calledWith({}, { where: { _id } })).to.be.true; // tslint:disable-line 48 | }); 49 | 50 | it('should delete', async () => { 51 | const _id = generateMockUUID(); 52 | const deleteStub = repository.entityModel.model.destroy = stub(); 53 | await repository.delete(_id); 54 | expect(cleanToSaveSpy.calledOnce).to.be.false; // tslint:disable-line 55 | expect(deleteStub.calledWith({ where: { _id } })).to.be.true; // tslint:disable-line 56 | }); 57 | 58 | it('should find', async () => { 59 | const findAllStub = repository.entityModel.model.findAll = stub().returns([{}]); 60 | const options = { 61 | include: [], 62 | where: { name: { [Sequelize.Op.like]: '%example%' } }, 63 | offset: 5, 64 | limit: 10, 65 | order: [['name', 'asc']] 66 | }; 67 | const res = await repository.find(5, 10, '{"name":"asc"}', '{"name":"example"}'); 68 | expect(findAllStub.calledWith(options)).to.be.true; // tslint:disable-line 69 | expect(res[0]).to.be.an.instanceof(Formatter); 70 | }); 71 | 72 | it('should find one', async () => { 73 | const _id = generateMockUUID(); 74 | const findOneStub = repository.entityModel.model.findOne = stub().returns({}); 75 | const res = await repository.findOne({ _id }); 76 | expect(cleanToSaveSpy.calledOnce).to.be.false; // tslint:disable-line 77 | expect(findOneStub.calledWith({ where: { _id }, include: [] })).to.be.true; // tslint:disable-line 78 | expect(res).to.be.an.instanceof(Formatter); 79 | }); 80 | 81 | it('should FAIL to find one', async () => { 82 | let fails; 83 | try { 84 | await repository.findOne({}); 85 | fails = false; 86 | } catch { 87 | fails = true; 88 | } 89 | expect(fails).to.be.true; // tslint:disable-line 90 | }); 91 | 92 | it('should count', async () => { 93 | const _id = generateMockUUID(); 94 | const countStub = repository.entityModel.model.count = stub().returns(1); 95 | await repository.count(`{"_id":"${_id}"}`); 96 | expect(countStub.calledWith({ where: { _id: { [Sequelize.Op.eq]: _id } } })).to.be.true; // tslint:disable-line 97 | }); 98 | 99 | it('should clean to save', async () => { 100 | expect((repository as any).cleanToSave({ a: 1 })).to.be.an.instanceof(Formatter); 101 | }); 102 | 103 | it('should format a sort query', async () => { 104 | expect((repository as any).sortQueryFormatter(null, 'asc')).to.equal('asc'); 105 | expect((repository as any).sortQueryFormatter(null, 'desc')).to.equal('desc'); 106 | expect((repository as any).sortQueryFormatter(null, 'invalid')).to.equal(undefined); 107 | }); 108 | 109 | it('should clean a sort query', async () => { 110 | expect((repository as any).cleanSort('{"name":"asc"}')).to.deep.equal({ name: 'asc' }); 111 | }); 112 | 113 | it('should format a where query', async () => { 114 | const fn = (repository as any).whereQueryFormatter; 115 | expect(fn('a', [])).to.deep.equal({ [Sequelize.Op.or]: [] }); 116 | expect(fn('id', 'a')).to.deep.equal({ [Sequelize.Op.eq]: 'a' }); 117 | expect(fn('a', 'a')).to.deep.equal({ [Sequelize.Op.like]: '%a%' }); 118 | expect(fn('a', 5)).to.deep.equal({ [Sequelize.Op.like]: 5 }); 119 | }); 120 | 121 | it('should clean a where query', async () => { 122 | const fn = (repository as any).cleanWhere.bind(repository); 123 | expect(fn(JSON.stringify({ name: 'a' }))).to.deep.equal({ name: { [Sequelize.Op.like]: '%a%' } }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /src/tests/unit/services/baseService.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import * as ioc from '../../../ioc'; 4 | import { generateMockUUID, generateUserModel } from '../../data/models'; 5 | import { BaseService } from '../../../services/BaseService'; 6 | import { MockBaseRepository } from '../mocks/MockBaseRepository'; 7 | 8 | /** we need some of this stuff on runtime */ 9 | ioc; // tslint:disable-line 10 | 11 | class BaseServiceExtension extends BaseService { 12 | protected repository = new MockBaseRepository(); 13 | } 14 | 15 | describe('BaseService', () => { 16 | let service: BaseService; 17 | beforeEach(() => { 18 | service = new BaseServiceExtension(); 19 | }); 20 | 21 | it('should getPaginated', async () => { 22 | const res = await service.getPaginated(1, 100, null, null, null); 23 | expect(res).to.have.property('count'); 24 | expect(res).to.have.property('page'); 25 | expect(res).to.have.property('limit'); 26 | expect(res).to.have.property('totalPages'); 27 | expect(res).to.have.property('docs'); 28 | expect(res.docs).to.have.length.greaterThan(0); 29 | }); 30 | 31 | it('should getById', async () => { 32 | const res = await service.getById(generateMockUUID()); 33 | expect(res).to.have.property('id'); 34 | expect(res).to.have.property('_id'); 35 | }); 36 | 37 | it('should create', async () => { 38 | const model = generateUserModel(); 39 | const res = await service.create(model); 40 | expect(res).to.have.property('id'); 41 | expect(res).to.have.property('_id'); 42 | }); 43 | 44 | it('should update', async () => { 45 | const res = await service.update(generateMockUUID(), generateUserModel()); 46 | expect(res).to.have.property('id'); 47 | expect(res).to.have.property('_id'); 48 | }); 49 | 50 | it('should delete', async () => { 51 | const res = await service.delete(generateMockUUID()); 52 | expect(res).to.equal(undefined); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/tests/unit/services/userService.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { UserService } from '../../../services'; 4 | import { MockBaseRepository } from '../mocks/MockBaseRepository'; 5 | import { generateUserModel } from '../../data/models'; 6 | 7 | describe('UserService', () => { 8 | let service: UserService; 9 | beforeEach(() => { 10 | // service = new UserService(new MockBaseRepository(generateUserModel()) as any); 11 | }); 12 | 13 | it('should instantiate', async () => { 14 | service = new UserService(new MockBaseRepository(generateUserModel()) as any); 15 | expect(!!service).to.be.true; // tslint:disable-line 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/tests/unit/utils/generalUtils.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { safeParse, cleanQuery, isId, parseMultiPartRequest } from '../../../utils'; 4 | 5 | describe('General utils', () => { 6 | describe('safeParse', () => { 7 | it('should parse a valid json', async () => { 8 | expect(safeParse('{"test":1}')).to.deep.equal({ test: 1 }); 9 | }); 10 | it('should use a default on invalid json', async () => { 11 | expect(safeParse('{syntax error}', { test: 2 })).to.deep.equal({ test: 2 }); 12 | }); 13 | }); 14 | 15 | describe('cleanQuery', () => { 16 | it('should change strings to objects', async () => { 17 | expect(cleanQuery('{"a":1,"b":true}')).to.deep.equal({ a: 1, b: true }); 18 | }); 19 | 20 | it('should change strings to regexs', async () => { 21 | expect(cleanQuery('{"test":"hi"}').test).to.be.an.instanceof(RegExp); 22 | }); 23 | 24 | it('should NOT change id fields type, but change keys to "_id"', async () => { 25 | expect(typeof cleanQuery('{"id":"hi"}').id).to.equal('string'); 26 | expect(typeof cleanQuery('{"_id":"hi"}')._id).to.equal('string'); 27 | }); 28 | 29 | it('should return the same query', async () => { 30 | expect(cleanQuery({ a: 1, b: true })).to.be.an('object'); 31 | }); 32 | 33 | it('sould return an empty object', async () => { 34 | expect(cleanQuery(null)).to.deep.equal({}); 35 | }); 36 | }); 37 | 38 | describe('parseMultiPartRequest', () => { 39 | const mockRequest: any = { 40 | 'headers': { 41 | 'content-type': 'multipart/form-data', 42 | }}; 43 | it('should parse a multipart request and return void', async () => { 44 | parseMultiPartRequest(mockRequest) 45 | .then(data => expect(data).to.be.an('undefined')); 46 | }); 47 | }); 48 | 49 | describe('isId', () => { 50 | it('should identify an "id" key', async () => { 51 | expect(isId('id')).to.be.true; // tslint:disable-line 52 | expect(isId('_id')).to.be.true; // tslint:disable-line 53 | }); 54 | it('should identify a NOT "id" key', async () => { 55 | expect(isId('other')).to.be.false; // tslint:disable-line 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/tests/unit/utils/immutabilityHelper.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { ImmutabilityHelper } from '../../../utils'; 4 | 5 | describe('Immutability Helper', () => { 6 | it('"getType" should get types', async () => { 7 | expect(ImmutabilityHelper.getType('string')).to.equal('string'); 8 | expect(ImmutabilityHelper.getType(true)).to.equal('boolean'); 9 | expect(ImmutabilityHelper.getType(1)).to.equal('number'); 10 | expect(ImmutabilityHelper.getType({})).to.equal('object'); 11 | expect(ImmutabilityHelper.getType([])).to.equal('array'); 12 | expect(ImmutabilityHelper.getType(null)).to.equal('null'); 13 | expect(ImmutabilityHelper.getType(undefined)).to.equal('undefined'); 14 | }); 15 | it('should return an error', async () => { 16 | expect(() => new ImmutabilityHelper()).to.throw(Error, 'just don\'t...'); 17 | }); 18 | it('"immute" should clone a variable', async () => { 19 | const shallow = { a: 1 }; 20 | const copy = ImmutabilityHelper.immute(shallow); 21 | copy.a = 2; 22 | expect(shallow.a).not.to.equal(copy.a); 23 | }); 24 | it('"copy" should deep clone a variable', async () => { 25 | const deep = { a: 1, b: { c: 3, d: [1, 2, 3] } }; 26 | const copy = ImmutabilityHelper.copy(deep); 27 | copy.a = 10; 28 | copy.b.c = 20; 29 | copy.b.d.push(40); 30 | expect(deep).not.to.deep.equal(copy); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/utils/ImmutabilityHelper.ts: -------------------------------------------------------------------------------- 1 | export class ImmutabilityHelper { 2 | public static getType(variable: any): string { 3 | let type: string = typeof variable; 4 | type = variable === null ? 'null' : type; 5 | type = Array.isArray(variable) ? 'array' : type; 6 | return type; 7 | } 8 | 9 | public static immute(variable: any): T { 10 | let copy: T; 11 | const variableType: string = ImmutabilityHelper.getType(variable); 12 | 13 | if (variableType === 'object') copy = { ...variable }; 14 | else if (variableType === 'array') copy = variable.slice(); 15 | else copy = variable; 16 | 17 | return copy as T; 18 | } 19 | 20 | public static copy(variable: any): T { 21 | const result: T = ImmutabilityHelper.immute(variable) as T; 22 | 23 | const loop = (value: any): any => { 24 | const valueType: string = ImmutabilityHelper.getType(value); 25 | const loopable: boolean = !!(valueType === 'object' || valueType === 'array'); 26 | const loopHandler = (index) => { 27 | value[index] = ImmutabilityHelper.immute(value[index]); 28 | if (loopable) loop(value[index]); 29 | }; 30 | 31 | if (valueType === 'object') for (const index in value) loopHandler(index); 32 | if (valueType === 'array') for (let index = 0; index < value.length; index++) loopHandler(index); 33 | }; 34 | 35 | loop(result); 36 | 37 | return result as T; 38 | } 39 | 40 | constructor() { throw new Error('just don\'t...'); } 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/generalUtils.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import * as multer from 'multer'; 3 | 4 | export const safeParse = (str: string, fallback: any = undefined) => { 5 | try { 6 | return JSON.parse(str); 7 | } catch { 8 | return fallback; 9 | } 10 | }; 11 | 12 | export const isId = (key: string): boolean => key === 'id' || key === '_id' || /Id$/.test(key); 13 | 14 | export const cleanQuery = ( 15 | query: string | any = '', 16 | customFormatter?: (key: string, value: any) => any 17 | ): { [key: string]: any } => { 18 | if (typeof query !== 'string') return query instanceof Object ? query : {}; 19 | 20 | const defaultFormatter = (key: string, value: any) => { 21 | if (isId(key)) return value; 22 | value = safeParse(value, value); 23 | if (typeof value === 'string') return new RegExp(value, 'i'); 24 | return value; 25 | }; 26 | 27 | const parsedQuery = safeParse(query, {}); 28 | 29 | return Object.keys(parsedQuery) 30 | .map(key => [key, parsedQuery[key]]) 31 | .reduce((fullQuery, queryChunk) => { 32 | const key: string = queryChunk[0]; 33 | const value: any = (customFormatter || defaultFormatter)(key, queryChunk[1]); 34 | 35 | if (key && value !== undefined) fullQuery[key] = value; 36 | 37 | return fullQuery; 38 | }, {}); 39 | }; 40 | 41 | export const parseMultiPartRequest = async (request: Request): Promise => { 42 | return new Promise((resolve, reject) => { 43 | multer().any()(request, undefined, async (error) => { 44 | if (error) reject(error); 45 | resolve(); 46 | }); 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ImmutabilityHelper'; 2 | export * from './generalUtils'; 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "sourceMap": true, 7 | "removeComments": false, 8 | "noImplicitReturns": true, 9 | "noImplicitAny": false, 10 | "preserveConstEnums": true, 11 | "experimentalDecorators": true, 12 | "outDir": "./dist", 13 | "rootDirs": [ 14 | "./src", 15 | "./build" 16 | ] 17 | }, 18 | "exclude": [ 19 | "logs", 20 | "node_modules" 21 | ] 22 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "member-access": [ 4 | true, 5 | "check-accessor" 6 | ], 7 | "align": [ 8 | false, 9 | "parameters", 10 | "arguments", 11 | "statements" 12 | ], 13 | "ban": false, 14 | "class-name": true, 15 | "comment-format": [ 16 | true, 17 | "check-space" 18 | ], 19 | "curly": false, 20 | "eofline": false, 21 | "forin": true, 22 | "indent": [ 23 | true, 24 | "spaces" 25 | ], 26 | "interface-name": [ 27 | false, 28 | "never-prefix" 29 | ], 30 | "jsdoc-format": true, 31 | "jsx-no-lambda": false, 32 | "jsx-no-multiline-js": false, 33 | "label-position": true, 34 | "max-line-length": [ 35 | true, 36 | 120 37 | ], 38 | "member-ordering": [ 39 | true, 40 | "public-before-private", 41 | "static-before-instance", 42 | "variables-before-functions" 43 | ], 44 | "no-any": false, 45 | "no-arg": true, 46 | "no-bitwise": true, 47 | "no-console": [ 48 | false, 49 | "log", 50 | "error", 51 | "debug", 52 | "info", 53 | "time", 54 | "timeEnd", 55 | "trace" 56 | ], 57 | "no-consecutive-blank-lines": true, 58 | "no-construct": true, 59 | "no-debugger": false, 60 | "no-duplicate-variable": true, 61 | "no-empty": true, 62 | "no-eval": true, 63 | "no-shadowed-variable": true, 64 | "no-string-literal": true, 65 | "no-switch-case-fall-through": true, 66 | "no-trailing-whitespace": false, 67 | "no-unused-expression": true, 68 | "no-unused-variable": true, 69 | "no-use-before-declare": true, 70 | "one-line": [ 71 | true, 72 | "check-catch", 73 | "check-else", 74 | "check-open-brace", 75 | "check-whitespace" 76 | ], 77 | "quotemark": [ 78 | true, 79 | "single", 80 | "jsx-double" 81 | ], 82 | "radix": true, 83 | "semicolon": [ 84 | true, 85 | "always" 86 | ], 87 | "switch-default": false, 88 | "trailing-comma": [ 89 | false 90 | ], 91 | "triple-equals": [ 92 | true, 93 | "allow-null-check" 94 | ], 95 | "typedef": [ 96 | false, 97 | "call-signature", 98 | "parameter", 99 | "member-variable-declaration" 100 | ], 101 | "typedef-whitespace": [ 102 | true, 103 | { 104 | "call-signature": "nospace", 105 | "index-signature": "nospace", 106 | "parameter": "nospace", 107 | "property-declaration": "nospace", 108 | "variable-declaration": "nospace" 109 | } 110 | ], 111 | "variable-name": [ 112 | true, 113 | "ban-keywords", 114 | "check-format", 115 | "allow-leading-underscore", 116 | "allow-pascal-case" 117 | ], 118 | "whitespace": [ 119 | true, 120 | "check-branch", 121 | "check-decl", 122 | "check-module", 123 | "check-operator", 124 | "check-separator", 125 | "check-type", 126 | "check-typecast" 127 | ] 128 | } 129 | } -------------------------------------------------------------------------------- /tsoa.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": { 3 | "entryFile": "./build/routes.ts", 4 | "outputDirectory": "./build/swagger", 5 | "specMerging": "recursive", 6 | "spec": {} 7 | }, 8 | "routes": { 9 | "entryFile": "./src/controllers/index.ts", 10 | "routesDir": "./build", 11 | "basePath": "/service/", 12 | "authenticationModule": "./src/auth.ts", 13 | "middleware": "express", 14 | "iocModule": "./src/ioc" 15 | } 16 | } --------------------------------------------------------------------------------