├── .coveralls.yml ├── .npmignore ├── logo.png ├── .husky ├── pre-push └── pre-commit ├── example ├── src │ ├── expressive.js │ ├── index.ts │ ├── middlewares │ │ ├── user-middleware.ts │ │ └── error.ts │ ├── controllers │ │ ├── health.ts │ │ └── users │ │ │ ├── index.ts │ │ │ ├── create-user.ts │ │ │ └── get-user.ts │ ├── router.ts │ └── app.ts ├── package.json └── tsconfig.json ├── .gitignore ├── src ├── middleware │ ├── response.js │ ├── requestId.js │ └── MiddlewareManager.js ├── subroute.js ├── Utils.js ├── Route.js ├── redoc │ ├── registerRedoc.js │ └── redocHtmlTemplate.js ├── index.js ├── SwaggerBuilder.js ├── AuthUtil.js ├── BaseController.js ├── RouteUtil.js ├── ExpressApp.js ├── RouterFactory.js ├── index.d.ts ├── SwaggerUtils.js └── CelebrateUtils.js ├── .prettierrc.json ├── .codebeatignore ├── .codeclimate.yml ├── .travis.yml ├── .vscode ├── tasks.json └── launch.json ├── __tests__ ├── index.test.js ├── app.test.js ├── Models.test.js ├── redoc.test.js ├── ExpressApp.test.js ├── SwaggerBuilder.test.js ├── AuthUtil.test.js ├── RouteUtil.test.js ├── BaseController.test.js ├── RouterFactory.test.js ├── CelebrateUtils.test.js ├── SwaggerUtils.test.js └── Middleware.test.js ├── .github └── workflows │ └── nodejs.yml ├── .eslintrc.js ├── LICENSE ├── README.md ├── package.json └── CODE_OF_CONDUCT.md /.coveralls.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /src/ -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siddiqus/expressive/HEAD/logo.png -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn test 5 | -------------------------------------------------------------------------------- /example/src/expressive.js: -------------------------------------------------------------------------------- 1 | const expressive = require('../../src'); 2 | module.exports = expressive; 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn test && npx lint-staged 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .sonarlint 3 | dist 4 | coverage 5 | .coverage 6 | reports 7 | build 8 | .eslintcache 9 | .stryker-tmp -------------------------------------------------------------------------------- /src/middleware/response.js: -------------------------------------------------------------------------------- 1 | module.exports = (req, res, next) => { 2 | res.setHeader('Content-Type', 'application/json'); 3 | next(); 4 | }; 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "none" 7 | } 8 | -------------------------------------------------------------------------------- /.codebeatignore: -------------------------------------------------------------------------------- 1 | example/ 2 | ts-example/ 3 | node_modules 4 | .sonarlint 5 | dist 6 | coverage 7 | .coverage 8 | reports 9 | build 10 | __tests__/ -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | exclude_patterns: 2 | - "__tests__/" 3 | - "build/" 4 | - "dist/" 5 | - "node_modules/" 6 | - "reports/" 7 | - "example/" 8 | - "ts-example/" 9 | -------------------------------------------------------------------------------- /example/src/index.ts: -------------------------------------------------------------------------------- 1 | import app from "./app"; 2 | 3 | const port = Number(process.env.PORT || 8080); 4 | 5 | app.listen(port, () => console.log('Listening on port ' + port)); 6 | module.exports = app; 7 | -------------------------------------------------------------------------------- /example/src/middlewares/user-middleware.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from "../../../src"; 2 | 3 | export const someMiddleware: Handler = async (req) => { 4 | console.log(`some user middleware`, req.user) 5 | } -------------------------------------------------------------------------------- /example/src/controllers/health.ts: -------------------------------------------------------------------------------- 1 | import { BaseController } from "../../../src"; 2 | 3 | export class HealthController extends BaseController { 4 | async handleRequest() { 5 | this.ok({ 6 | healthy: true 7 | }) 8 | } 9 | } -------------------------------------------------------------------------------- /example/src/middlewares/error.ts: -------------------------------------------------------------------------------- 1 | import { ErrorRequestHandler } from "../../../src"; 2 | 3 | export const errorHandler: ErrorRequestHandler = (err, _req, res, next) => { 4 | res.status(500).json({ 5 | message: err.message 6 | }); 7 | 8 | next() 9 | } -------------------------------------------------------------------------------- /src/subroute.js: -------------------------------------------------------------------------------- 1 | module.exports = ( 2 | path, 3 | router, 4 | { authorizer, middleware, validationSchema } = {} 5 | ) => { 6 | return { 7 | path, 8 | router, 9 | authorizer, 10 | validationSchema, 11 | middleware: middleware || [] 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | git: 4 | depth: 1 5 | 6 | node_js: 7 | - "12.22.7" 8 | 9 | cache: 10 | directories: 11 | - node_modules 12 | 13 | jobs: 14 | include: 15 | - stage: Produce Coverage 16 | node_js: node 17 | script: npm run coveralls 18 | -------------------------------------------------------------------------------- /src/middleware/requestId.js: -------------------------------------------------------------------------------- 1 | const uuid = require('uuid').v4; 2 | const headerName = 'X-Request-Id'; 3 | 4 | module.exports = function requestId() { 5 | return (req, res, next) => { 6 | const oldValue = req.get(headerName); 7 | const id = oldValue || uuid(); 8 | 9 | res.set(headerName, id); 10 | 11 | req.id = id; 12 | 13 | next(); 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /example/src/router.ts: -------------------------------------------------------------------------------- 1 | import { ExpressiveRouter, Route, subroute } from "../../src"; 2 | import { HealthController } from "./controllers/health"; 3 | import { userRouter } from "./controllers/users"; 4 | 5 | export const router: ExpressiveRouter = { 6 | routes: [ 7 | Route.get('/v0/health', new HealthController()), 8 | ], 9 | subroutes: [ 10 | subroute('/v1/users', userRouter) 11 | ] 12 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "build-ts-example", 8 | "type": "typescript", 9 | "tsconfig": "example/tsconfig.json", 10 | "problemMatcher": [ 11 | "$tsc" 12 | ], 13 | "group": "build" 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /example/src/controllers/users/index.ts: -------------------------------------------------------------------------------- 1 | import { ExpressiveRouter, Route } from "../../../../src"; 2 | import { CreateUserController } from "./create-user"; 3 | import { GetUserController, GetUsersController } from "./get-user"; 4 | 5 | // /users 6 | export const userRouter: ExpressiveRouter = { 7 | routes: [ 8 | Route.get('/', new GetUsersController()), 9 | Route.get('/:userId', new GetUserController()), 10 | Route.post('/', new CreateUserController()) 11 | ] 12 | } -------------------------------------------------------------------------------- /src/Utils.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | normalizePathSlashes(path) { 3 | let normalizedPath = path; 4 | if (path && path.charAt(path.length - 1) !== '/') { 5 | normalizedPath = `${path}/`; 6 | } 7 | normalizedPath = normalizedPath.replace(/\/\//g, '/'); 8 | 9 | return normalizedPath; 10 | }, 11 | clearNullValuesInObject(obj) { 12 | Object.keys(obj).forEach( 13 | (key) => (obj[key] === undefined || obj[key] === null) && delete obj[key] 14 | ); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const expressive = require('../src/index'); 2 | 3 | describe('module index', () => { 4 | it('should have ExpressApp', () => { 5 | expect(expressive.ExpressApp).toBeDefined(); 6 | }); 7 | it('should have SwaggerUtils', () => { 8 | expect(expressive.SwaggerUtils).toBeDefined(); 9 | }); 10 | it('should have Route', () => { 11 | expect(expressive.Route).toBeDefined(); 12 | }); 13 | it('should have subroute', () => { 14 | expect(expressive.subroute).toBeDefined(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "tsc -p .", 8 | "start": "NODE_ENV=development nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "@types/multer": "1.4.3", 15 | "@types/node": "13.7.4", 16 | "nodemon": "2.0.15", 17 | "ts-node": "8.8.2", 18 | "typescript": "3.8.2" 19 | }, 20 | "dependencies": { 21 | "multer": "1.4.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - feature/* 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [10.x, 12.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm install 28 | - run: npm test 29 | env: 30 | CI: true 31 | -------------------------------------------------------------------------------- /example/src/controllers/users/create-user.ts: -------------------------------------------------------------------------------- 1 | // import Joi from "@hapi/joi"; 2 | import { BaseController, Handler, Joi, ValidationSchema } from "../../../../src"; 3 | import { someMiddleware } from "../../middlewares/user-middleware"; 4 | 5 | export class CreateUserController extends BaseController { 6 | middleware?: Handler[] | undefined = [someMiddleware] 7 | 8 | validationSchema?: ValidationSchema = { 9 | body: { 10 | firstName: Joi.string().required(), 11 | lastName: Joi.string().required() 12 | } 13 | } 14 | 15 | async handleRequest() { 16 | const { firstName, lastName } = this.getData().body; 17 | 18 | this.ok({ 19 | hello: `Hello ${firstName} ${lastName}!` 20 | }); 21 | } 22 | } -------------------------------------------------------------------------------- /src/Route.js: -------------------------------------------------------------------------------- 1 | function getRouteFn(method) { 2 | return (...args) => { 3 | const [path] = args; 4 | 5 | if (args.length === 2 && args[1].__proto__.constructor.name === 'Object') { 6 | // 2nd is not object 7 | return { 8 | method, 9 | path, 10 | ...args[1] 11 | }; 12 | } 13 | 14 | const [, controller, params = {}] = args; 15 | return { 16 | method, 17 | path, 18 | controller, 19 | ...params 20 | }; 21 | }; 22 | } 23 | 24 | module.exports = { 25 | get: getRouteFn('get'), 26 | post: getRouteFn('post'), 27 | put: getRouteFn('put'), 28 | delete: getRouteFn('delete'), 29 | patch: getRouteFn('patch'), 30 | head: getRouteFn('head'), 31 | options: getRouteFn('options') 32 | }; 33 | -------------------------------------------------------------------------------- /src/redoc/registerRedoc.js: -------------------------------------------------------------------------------- 1 | const basicAuth = require('express-basic-auth'); 2 | const redocHtml = require('./redocHtmlTemplate'); 3 | 4 | module.exports = function registerRedoc({ app, swaggerJson, url, authUser }) { 5 | const specUrl = '/docs/swagger.json'; 6 | 7 | const { user, password } = authUser; 8 | const basicAuthMiddleware = basicAuth({ 9 | challenge: true, 10 | users: { [user]: password } 11 | }); 12 | 13 | app.get(specUrl, basicAuthMiddleware, (_, res) => { 14 | res.status(200).send(swaggerJson); 15 | }); 16 | 17 | app.get(url, basicAuthMiddleware, (_, res) => { 18 | res.type('html'); 19 | res.status(200).send( 20 | redocHtml({ 21 | title: 'ReDoc', 22 | specUrl 23 | }) 24 | ); 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { Joi, isCelebrateError: isValidationError } = require('celebrate'); 3 | const celebrate = require('celebrate'); 4 | const ExpressApp = require('./ExpressApp'); 5 | const RouteUtil = require('./RouteUtil'); 6 | const SwaggerUtils = require('./SwaggerUtils'); 7 | const Route = require('./Route'); 8 | const subroute = require('./subroute'); 9 | const BaseController = require('./BaseController'); 10 | const SwaggerBuilder = require('./SwaggerBuilder'); 11 | 12 | const Expressive = { 13 | ExpressApp, 14 | RouteUtil, 15 | Route, 16 | subroute, 17 | BaseController, 18 | SwaggerUtils, 19 | SwaggerBuilder, 20 | express, 21 | Joi, 22 | isValidationError, 23 | celebrate 24 | }; 25 | 26 | module.exports = Expressive; 27 | -------------------------------------------------------------------------------- /example/src/controllers/users/get-user.ts: -------------------------------------------------------------------------------- 1 | import { BaseController, ValidationSchema, Joi } from "../../../../src"; 2 | 3 | export class GetUserController extends BaseController { 4 | validationSchema?: ValidationSchema = { 5 | params: { 6 | userId: Joi.number().positive().required() 7 | }, 8 | options: { 9 | allowUnknown: true 10 | } 11 | } 12 | async handleRequest() { 13 | const { userId } = this.getData().params; 14 | 15 | this.ok({ 16 | id: userId, 17 | name: 'Sabbir' 18 | }) 19 | } 20 | } 21 | 22 | export class GetUsersController extends BaseController { 23 | async handleRequest() { 24 | this.ok([ 25 | { 26 | id: 1, 27 | name: 'Sabbir' 28 | }, 29 | { 30 | id: 2, 31 | name: 'John' 32 | } 33 | ]) 34 | } 35 | } -------------------------------------------------------------------------------- /example/src/app.ts: -------------------------------------------------------------------------------- 1 | import { ExpressApp } from "../../src"; 2 | import { errorHandler } from "./middlewares/error"; 3 | import { router } from "./router"; 4 | 5 | const swaggerInfo = { 6 | version: '2.0.0', 7 | title: 'Example Expressive App', 8 | contact: { 9 | name: 'Sabbir Siddiqui', 10 | email: 'sabbir.m.siddiqui@gmail.com' 11 | } 12 | }; 13 | 14 | // todo -> add validation schema cascade 15 | // const rootValidationSchema: ValidationSchema = { 16 | // query: { 17 | // q: Joi.string().required().valid('emnei') 18 | // } 19 | // } 20 | 21 | export default new ExpressApp(router, { 22 | basePath: '/', 23 | allowCors: true, 24 | swaggerInfo, 25 | errorHandler, 26 | authorizer: (req, res) => { 27 | console.log(`${req.url}: auth from top`); 28 | res.setHeader('testingAuth', 1234); 29 | }, 30 | requestLoggerOptions: { 31 | disabled: false 32 | } 33 | }).express; -------------------------------------------------------------------------------- /src/redoc/redocHtmlTemplate.js: -------------------------------------------------------------------------------- 1 | const html = ` 2 | 3 | 4 | [[title]] 5 | 6 | 7 | 10 | 16 | 17 | 18 | 19 | 20 | 21 | `; 22 | 23 | function redocHtml( 24 | options = { 25 | title: 'ReDoc', 26 | specUrl: 'http://petstore.swagger.io/v2/swagger.json' 27 | } 28 | ) { 29 | const { title, specUrl } = options; 30 | return html.replace('[[title]]', title).replace('[[spec-url]]', specUrl); 31 | } 32 | 33 | module.exports = redocHtml; 34 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true 5 | }, 6 | extends: ['google', 'plugin:prettier/recommended'], 7 | globals: { 8 | Atomics: 'readonly', 9 | SharedArrayBuffer: 'readonly' 10 | }, 11 | parserOptions: { 12 | ecmaFeatures: { 13 | jsx: true 14 | }, 15 | ecmaVersion: 2018, 16 | sourceType: 'module' 17 | }, 18 | rules: { 19 | 'linebreak-style': [ 20 | 'error', 21 | process.platform === 'win32' ? 'windows' : 'unix' 22 | ], 23 | 'max-len': [1, 100, 4], 24 | 'no-underscore-dangle': 0, 25 | 'no-param-reassign': ['error', { props: false }], 26 | 'prefer-destructuring': 'warn', 27 | 'global-require': 'warn', 28 | // "import/no-dynamic-require": "warn", 29 | 'no-plusplus': 'warn', 30 | indent: ['error', 2], 31 | 'comma-dangle': 0, 32 | 'prefer-rest-params': 0, 33 | quotes: ['error', 'single', { allowTemplateLiterals: true }], 34 | 'class-methods-use-this': 0, 35 | 'require-jsdoc': 0, 36 | 'object-curly-spacing': 0, 37 | 'prettier/prettier': 'error' 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Sabbir Siddiqui 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Expressive Logo](https://raw.githubusercontent.com/siddiqus/expressive/master/logo.png)](https://github.com/siddiqus/expressive/) 2 | 3 | [![Coverage Status](https://coveralls.io/repos/github/siddiqus/expressive/badge.svg?branch=master)](https://coveralls.io/github/siddiqus/expressive?branch=master) 4 | [![Maintainability](https://api.codeclimate.com/v1/badges/533736ee85578f98a732/maintainability)](https://codeclimate.com/github/siddiqus/expressive/maintainability) 5 | [![codebeat badge](https://codebeat.co/badges/6e3ba61c-d5cf-4a05-9aa7-ee7dd1591fe2)](https://codebeat.co/projects/github-com-siddiqus-expressive-master) 6 | [![tested with jest](https://img.shields.io/badge/tested_with-jest-99424f.svg)](https://github.com/facebook/jest) 7 | [![Google Style](https://badgen.net/badge/eslint/google/4a88ef)](https://github.com/google/eslint-config-google) 8 | 9 | # Expressive 10 | 11 | Fast, opinionated, minimalist, and conventional REST API framework for [node](http://nodejs.org). 12 | (With Typescript Support :star:) 13 | 14 | # Introduction 15 | 16 | **Expressive** is a NodeJS REST API framework built on ExpressJs and best practices for smooth development. 17 | 18 | Please visit the [wiki page](https://github.com/siddiqus/expressive/wiki) for the full documentation. -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "name": "vscode-jest-tests", 10 | "request": "launch", 11 | "args": [ 12 | "--runInBand" 13 | ], 14 | "cwd": "${workspaceFolder}", 15 | "console": "integratedTerminal", 16 | "internalConsoleOptions": "neverOpen", 17 | "disableOptimisticBPs": true, 18 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 19 | }, 20 | { 21 | "type": "node", 22 | "request": "launch", 23 | "name": "ts-example", 24 | "program": "${workspaceFolder}/example/src/index.ts", 25 | "preLaunchTask": "build-ts-example", 26 | "outFiles": [ 27 | "${workspaceFolder}/example/dist/**/*.js" 28 | ], 29 | "cwd": "${workspaceFolder}/example/", 30 | "env": { 31 | "NODE_ENV": "development" 32 | } 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /__tests__/app.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const { ExpressApp, BaseController, Route } = require('../src'); 3 | 4 | describe('Express App Tests', () => { 5 | class GetHello extends BaseController { 6 | handleRequest() { 7 | this.ok({ 8 | hello: 'world' 9 | }); 10 | } 11 | } 12 | class PostHello extends BaseController { 13 | handleRequest() { 14 | const { body } = this.getData(); 15 | const { firstName, lastName } = body; 16 | this.ok({ 17 | message: `hello ${firstName} ${lastName}!` 18 | }); 19 | } 20 | } 21 | const router = { 22 | routes: [ 23 | Route.get('/v1/hello', new GetHello()), 24 | Route.post('/v1/hello', new PostHello()) 25 | ] 26 | }; 27 | const app = new ExpressApp(router).express; 28 | 29 | it('should run healthcheck properly', async () => { 30 | const response = await request(app).get('/v1/hello'); 31 | expect(response.statusCode).toBe(200); 32 | expect(response.body).toStrictEqual({ 33 | hello: 'world' 34 | }); 35 | }); 36 | 37 | it('should send post data properly', async () => { 38 | const response = await request(app).post('/v1/hello').send({ 39 | firstName: 'Sabbir', 40 | lastName: 'Siddiqui' 41 | }); 42 | expect(response.statusCode).toBe(200); 43 | expect(response.body).toStrictEqual({ 44 | message: 'hello Sabbir Siddiqui!' 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/SwaggerBuilder.js: -------------------------------------------------------------------------------- 1 | function _addParam( 2 | parameters, 3 | paramType, 4 | { name, description, type, required, schema } 5 | ) { 6 | parameters.push({ 7 | in: paramType, 8 | name, 9 | description, 10 | type, 11 | required, 12 | schema 13 | }); 14 | } 15 | 16 | module.exports = class SwaggerEndpoint { 17 | constructor(summary, description) { 18 | this.summary = summary; 19 | this.description = description; 20 | this.responses = {}; 21 | this.parameters = []; 22 | } 23 | 24 | addResponse({ code, description, schema, produces = ['application/json'] }) { 25 | this.responses[code] = { 26 | produces, 27 | description, 28 | schema 29 | }; 30 | return this; 31 | } 32 | 33 | addPathParam({ name, description, type, required, schema }) { 34 | _addParam(this.parameters, 'path', { 35 | name, 36 | description, 37 | schema, 38 | required, 39 | type 40 | }); 41 | return this; 42 | } 43 | 44 | addRequestBody({ description, required, schema, name = 'body' }) { 45 | _addParam(this.parameters, 'body', { 46 | name, 47 | description, 48 | schema, 49 | required 50 | }); 51 | return this; 52 | } 53 | 54 | addQueryParam({ name, description, required, schema }) { 55 | _addParam(this.parameters, 'query', { 56 | name, 57 | description, 58 | schema, 59 | required 60 | }); 61 | return this; 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /src/AuthUtil.js: -------------------------------------------------------------------------------- 1 | const RouteUtil = require('./RouteUtil'); 2 | 3 | function _getUniqueArray(arr) { 4 | const strArray = arr.map((a) => a.toString()); 5 | const uniqueStrArray = strArray.filter((value, index, self) => { 6 | return self.indexOf(value) === index; 7 | }); 8 | const indicesArray = uniqueStrArray.map((u) => strArray.indexOf(u)); 9 | return indicesArray.map((i) => arr[i]); 10 | } 11 | 12 | module.exports = class AuthUtil { 13 | constructor() { 14 | this.routeUtil = RouteUtil; 15 | } 16 | 17 | getMiddlewareForInjectingAuthProperties(authObject) { 18 | return (req) => { 19 | let { authorizer } = req; 20 | if (!authorizer) authorizer = []; 21 | 22 | authorizer.push(authObject); 23 | // eslint-disable-next-line prefer-spread 24 | authorizer = [].concat.apply([], authorizer); 25 | 26 | authorizer = _getUniqueArray(authorizer); // Ramda.uniqWith(Ramda.eqProps, authorizer); 27 | req.authorizer = authorizer; 28 | }; 29 | } 30 | 31 | getAuthorizerMiddleware(authorizer, authObjectHandler) { 32 | if (!authorizer) return null; 33 | 34 | const isObject = ['object', 'string'].includes(typeof authorizer); 35 | 36 | if (isObject && !authObjectHandler) { 37 | throw new Error( 38 | `'authorizer' object declared, but 'authObjectHandler' ` + 39 | `is not defined in ExpressApp constructor params` 40 | ); 41 | } 42 | 43 | let handlers = []; 44 | if (isObject) { 45 | handlers = [ 46 | this.getMiddlewareForInjectingAuthProperties(authorizer), 47 | authObjectHandler 48 | ]; 49 | } else { 50 | handlers = [authorizer]; 51 | } 52 | 53 | return handlers.map((h) => this.routeUtil.getHandlerWithManagedNextCall(h)); 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@siddiqus/expressive", 3 | "version": "4.0.17", 4 | "description": "Fast, opinionated, minimalist, and conventional REST API framework for NodeJS, built on ExpressJs.", 5 | "main": "src/index.js", 6 | "files": [ 7 | "src" 8 | ], 9 | "scripts": { 10 | "clean": "rimraf dist", 11 | "test": "jest", 12 | "test:watch": "jest --watch", 13 | "coverage": "jest --coverage", 14 | "coveralls": "jest --coverage && cat reports/coverage/lcov.info | coveralls", 15 | "lint": "eslint src -f node_modules/eslint-detailed-reporter/lib/detailed.js -o reports/lint.html || echo Lint report: reports/lint.html", 16 | "lintFix": "eslint --fix src || echo Linting done!", 17 | "dev": "cross-env NODE_ENV=development nodemon -w example -w src --exec node example/index.js", 18 | "dev:ts": "cd ts-example && npm start", 19 | "test:report": "npm test -- --reporters default jest-stare --testResultsProcessor=jest-stare" 20 | }, 21 | "author": "Sabbir Siddiqui ", 22 | "license": "MIT", 23 | "dependencies": { 24 | "@types/cors": "2.8.12", 25 | "@types/express": "4.17.13", 26 | "celebrate": "15.0.0", 27 | "cors": "2.8.5", 28 | "express": "4.17.3", 29 | "express-basic-auth": "1.2.1", 30 | "helmet": "5.0.2", 31 | "morgan-body": "2.6.6", 32 | "swagger-ui-express": "4.3.0", 33 | "uuid": "8.3.2" 34 | }, 35 | "jest-stare": { 36 | "resultDir": "reports/jest-stare", 37 | "coverageLink": "../jest/lcov-report/index.html" 38 | }, 39 | "jest": { 40 | "coverageDirectory": "reports/coverage", 41 | "collectCoverage": true, 42 | "collectCoverageFrom": [ 43 | "src/**/*.{js,jsx}" 44 | ], 45 | "modulePathIgnorePatterns": [ 46 | ".stryker-tmp" 47 | ] 48 | }, 49 | "devDependencies": { 50 | "@types/cors": "2.8.12", 51 | "@types/express": "4.17.13", 52 | "@types/jest": "25.1.4", 53 | "coveralls": "3.1.1", 54 | "cross-env": "5.2.0", 55 | "eslint": "8.10.0", 56 | "eslint-config-google": "0.12.0", 57 | "eslint-config-prettier": "6.10.1", 58 | "eslint-detailed-reporter": "0.8.0", 59 | "eslint-plugin-prettier": "3.1.2", 60 | "husky": "7.0.4", 61 | "jest": "27.4.5", 62 | "lint-staged": "10.1.3", 63 | "nodemon": "2.0.15", 64 | "prettier": "2.0.4", 65 | "supertest": "6.1.6" 66 | }, 67 | "homepage": "https://github.com/siddiqus/expressive#readme", 68 | "bugs": { 69 | "url": "https://github.com/siddiqus/expressive/issues" 70 | }, 71 | "repository": { 72 | "type": "git", 73 | "url": "git+https://github.com/siddiqus/expressive.git" 74 | }, 75 | "keywords": [ 76 | "express", 77 | "framework", 78 | "nodejs", 79 | "server", 80 | "javascript", 81 | "rest api", 82 | "convention based routing" 83 | ], 84 | "publishConfig": { 85 | "registry": "https://registry.npmjs.org/" 86 | }, 87 | "types": "./src/index.d.ts", 88 | "lint-staged": { 89 | "*.js": "eslint --cache --fix" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/BaseController.js: -------------------------------------------------------------------------------- 1 | function _sendJsonResponseWithMessage(res, code, message) { 2 | return _sendJsonResponse(res, code, { message }); 3 | } 4 | 5 | function _sendJsonResponse(res, code, data) { 6 | const wrappedData = BaseController.responseMapper(data); 7 | if (!!data) { 8 | res.status(code); 9 | res.json(wrappedData); 10 | } else { 11 | res.sendStatus(code); 12 | } 13 | } 14 | 15 | class BaseController { 16 | static responseMapper(data) { 17 | return data; 18 | } 19 | 20 | static requestMapper(req) { 21 | return req; 22 | } 23 | 24 | constructor() { 25 | this.req = null; 26 | this.res = null; 27 | this.next = null; 28 | 29 | this.validationSchema = null; 30 | this.authorizer = null; 31 | this.middleware = null; 32 | this.doc = null; 33 | this.pre = null; 34 | 35 | this.resolvedBy = null; 36 | } 37 | 38 | async handleRequest() { 39 | throw new Error( 40 | `'handleRequest' not implemented in ${this.constructor.name}` 41 | ); 42 | } 43 | 44 | getData() { 45 | const { body, query, params, fileUpload, user } = this.req; 46 | return { 47 | body, 48 | query, 49 | params, 50 | fileUpload, 51 | user 52 | }; 53 | } 54 | 55 | getHeaders() { 56 | return this.req.headers; 57 | } 58 | 59 | getCookies() { 60 | return { 61 | cookies: this.req.cookies, 62 | signedCookies: this.req.signedCookies 63 | }; 64 | } 65 | 66 | ok(dto = null) { 67 | this.resolvedBy = 'ok'; 68 | return _sendJsonResponse(this.res, 200, dto); 69 | } 70 | 71 | created(data = null) { 72 | this.resolvedBy = 'created'; 73 | return _sendJsonResponse(this.res, 201, data); 74 | } 75 | 76 | accepted(data = null) { 77 | this.resolvedBy = 'accepted'; 78 | return _sendJsonResponse(this.res, 202, data); 79 | } 80 | 81 | noContent() { 82 | this.resolvedBy = 'noContent'; 83 | return _sendJsonResponse(this.res, 204); 84 | } 85 | 86 | badRequest(message = 'Bad request') { 87 | this.resolvedBy = 'badRequest'; 88 | return _sendJsonResponseWithMessage(this.res, 400, message); 89 | } 90 | 91 | unauthorized(message = 'Unauthorized') { 92 | this.resolvedBy = 'unauthorized'; 93 | return _sendJsonResponseWithMessage(this.res, 401, message); 94 | } 95 | 96 | forbidden(message = 'Forbidden') { 97 | this.resolvedBy = 'forbidden'; 98 | return _sendJsonResponseWithMessage(this.res, 403, message); 99 | } 100 | 101 | notFound(message = 'Not Found') { 102 | this.resolvedBy = 'notFound'; 103 | return _sendJsonResponseWithMessage(this.res, 404, message); 104 | } 105 | 106 | tooMany(message = 'Too many requests') { 107 | this.resolvedBy = 'tooMany'; 108 | return _sendJsonResponseWithMessage(this.res, 429, message); 109 | } 110 | 111 | internalServerError(message = 'Internal server error', body = {}) { 112 | this.resolvedBy = 'internalServerError'; 113 | return _sendJsonResponse(this.res, 500, { 114 | message, 115 | ...body 116 | }); 117 | } 118 | 119 | notImplemented(message = 'Not implemented') { 120 | this.resolvedBy = 'notImplemented'; 121 | return _sendJsonResponseWithMessage(this.res, 501, message); 122 | } 123 | } 124 | 125 | module.exports = BaseController; 126 | -------------------------------------------------------------------------------- /src/RouteUtil.js: -------------------------------------------------------------------------------- 1 | const Utils = require('./Utils'); 2 | // const FUNCTION_STRING = 'function'; 3 | // const CLASS_STRING = 'class'; 4 | // const FUNCTION_STRING_LENGTH = FUNCTION_STRING.length; 5 | 6 | function _addRouteToPaths(paths, parentPath, route) { 7 | const routeData = { 8 | ...route, 9 | path: Utils.normalizePathSlashes(`${parentPath}${route.path}`), 10 | parentPath, 11 | validationSchema: route.controller.validationSchema, 12 | doc: route.controller.doc, 13 | authorizer: route.controller.authorizer, 14 | controllerName: route.controller.constructor.name 15 | }; 16 | 17 | delete routeData.controller; 18 | 19 | Utils.clearNullValuesInObject(routeData); 20 | 21 | paths.push(routeData); 22 | } 23 | 24 | function _getSubroutes(paths, parentPath, subroute) { 25 | return RouteUtil.getRoutesInfo( 26 | subroute.router, 27 | paths, 28 | parentPath + subroute.path 29 | ); 30 | } 31 | 32 | class RouteUtil { 33 | static getRoutesInfo(router, paths = [], parentPath = '') { 34 | if (!router) { 35 | return paths; 36 | } 37 | if (router.routes) { 38 | router.routes.forEach(({ path, method, controller }) => 39 | _addRouteToPaths(paths, parentPath, { 40 | path, 41 | method, 42 | controller 43 | }) 44 | ); 45 | } 46 | 47 | if (router.subroutes) { 48 | router.subroutes.forEach((subroute) => 49 | _getSubroutes(paths, parentPath, subroute) 50 | ); 51 | } 52 | return paths; 53 | } 54 | 55 | static getHandlerWithManagedNextCall(handler) { 56 | return async (req, res, next) => { 57 | try { 58 | await handler(req, res, next); 59 | 60 | if (handler.length !== 3) { 61 | return next(); 62 | } 63 | } catch (error) { 64 | return next(error); 65 | } 66 | }; 67 | } 68 | 69 | static getErrorHandlerWithManagedNextCall(handler) { 70 | return async (err, req, res, next) => { 71 | try { 72 | await handler(err, req, res, next); 73 | 74 | if (handler.length !== 4) { 75 | return next(); 76 | } 77 | } catch (error) { 78 | return next(error); 79 | } 80 | }; 81 | } 82 | 83 | // static isFunction(functionToCheck) { 84 | // const stringPrefix = functionToCheck 85 | // .toString() 86 | // .substring(0, FUNCTION_STRING_LENGTH); 87 | // if (stringPrefix.includes(CLASS_STRING)) return false; 88 | // return ( 89 | // functionToCheck instanceof Function || stringPrefix === FUNCTION_STRING 90 | // ); 91 | // } 92 | 93 | static isUrlPath(string) { 94 | if (typeof string !== 'string') return false; 95 | const regex = /^(\/[a-zA-Z0-9\-:]+)*\/?$/g; 96 | return regex.test(string); 97 | } 98 | 99 | static getDuplicateUrls(expressiveRouter) { 100 | const routeList = RouteUtil.getRoutesInfo(expressiveRouter); 101 | const urlStrings = routeList.map(({ path, method }) => { 102 | const sanitizedPath = Utils.normalizePathSlashes(path); 103 | return `${method} ${sanitizedPath}`; 104 | }); 105 | 106 | const duplicates = urlStrings.filter( 107 | (item, index) => urlStrings.indexOf(item) !== index 108 | ); 109 | return duplicates; 110 | } 111 | } 112 | 113 | module.exports = RouteUtil; 114 | -------------------------------------------------------------------------------- /src/ExpressApp.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const RouterFactory = require('./RouterFactory'); 3 | const RouteUtil = require('./RouteUtil'); 4 | const AuthUtil = require('./AuthUtil'); 5 | const MiddlewareManager = require('./middleware/MiddlewareManager'); 6 | 7 | // const expressStatusMonitor = require('express-status-monitor'); 8 | 9 | module.exports = class ExpressApp { 10 | constructor( 11 | expressiveRouter, 12 | { 13 | basePath = '/', 14 | showSwaggerOnlyInDev = true, 15 | swaggerInfo = undefined, 16 | swaggerDefinitions = undefined, 17 | swaggerSecurityDefinitions = null, 18 | allowCors = false, 19 | corsConfig = null, 20 | middleware = null, 21 | authorizer = null, 22 | errorHandler = null, 23 | bodyLimit = '100kb', 24 | helmetOptions = null, 25 | celebrateErrorHandler = null, 26 | notFoundHandler = null, 27 | authObjectHandler = null, 28 | requestLoggerOptions = undefined 29 | // expressStatusMonitorConfig = {} 30 | } = {} 31 | ) { 32 | this.config = { 33 | swaggerInfo, 34 | showSwaggerOnlyInDev, 35 | swaggerDefinitions, 36 | swaggerSecurityDefinitions, 37 | basePath, 38 | allowCors, 39 | corsConfig, 40 | middleware, 41 | errorHandler, 42 | bodyLimit, 43 | helmetOptions, 44 | authorizer, 45 | celebrateErrorHandler, 46 | notFoundHandler, 47 | authObjectHandler, 48 | requestLoggerOptions 49 | // expressStatusMonitorConfig 50 | }; 51 | this.expressiveRouter = expressiveRouter; 52 | 53 | this._init(); 54 | 55 | this.registerHandlers(); 56 | } 57 | 58 | _init() { 59 | this.routeUtil = RouteUtil; 60 | this.authUtil = new AuthUtil(); 61 | this.routerFactory = new RouterFactory(this.config); 62 | 63 | this.express = express(); 64 | this.listen = this.express.listen.bind(this.express); 65 | this.middlewareManager = new MiddlewareManager(this.config, this.express); 66 | } 67 | 68 | _registerRoutes() { 69 | const expressRouter = this.routerFactory.getExpressRouter( 70 | this.expressiveRouter 71 | ); 72 | this.express.use(this.config.basePath, expressRouter); 73 | } 74 | 75 | _registerErrorHandlers() { 76 | if (!this.config.errorHandler) return; 77 | 78 | let errorHandler; 79 | if (Array.isArray(this.config.errorHandler)) { 80 | errorHandler = this.config.errorHandler.map((e) => 81 | this.routeUtil.getErrorHandlerWithManagedNextCall(e) 82 | ); 83 | } else { 84 | errorHandler = this.routeUtil.getErrorHandlerWithManagedNextCall( 85 | this.config.errorHandler 86 | ); 87 | } 88 | this.express.use(errorHandler); 89 | } 90 | 91 | registerHandlers() { 92 | // this.express.use( 93 | // expressStatusMonitor(this.config.expressStatusMonitorConfig) 94 | // ); 95 | 96 | this.middlewareManager.registerDocs(this.expressiveRouter); 97 | 98 | this.middlewareManager.configureCors(); 99 | 100 | this.middlewareManager.registerBasicMiddleware(); 101 | 102 | this.middlewareManager.registerAuth(); 103 | 104 | this._registerRoutes(); 105 | 106 | this.middlewareManager.registerNotFoundHandler(); 107 | 108 | this.middlewareManager.registerCelebrateErrorMiddleware(); 109 | 110 | this._registerErrorHandlers(); 111 | } 112 | }; 113 | -------------------------------------------------------------------------------- /__tests__/Models.test.js: -------------------------------------------------------------------------------- 1 | const Route = require('../src/Route'); 2 | const subroute = require('../src/subroute'); 3 | 4 | function getRouteObj(method) { 5 | return Route[method]('/somepath', { 6 | controller: 'someController', 7 | validator: 'validatorFunction', 8 | doc: 'docJs' 9 | }); 10 | } 11 | 12 | function testRouteObj(routeObject) { 13 | expect(routeObject.path).toEqual('/somepath'); 14 | expect(routeObject.controller).toEqual('someController'); 15 | expect(routeObject.validator).toEqual('validatorFunction'); 16 | expect(routeObject.doc).toEqual('docJs'); 17 | } 18 | 19 | describe('subroute', () => { 20 | it('should return object', () => { 21 | const someRouter = { some: 'router' }; 22 | const somePath = '/somepath'; 23 | const obj = subroute(somePath, someRouter); 24 | expect(obj.path).toEqual(somePath); 25 | expect(obj.router).toEqual(someRouter); 26 | }); 27 | }); 28 | 29 | describe('Route model', () => { 30 | it('Should have GET function available', () => { 31 | const routeObject = getRouteObj('get'); 32 | expect(routeObject.method).toEqual('get'); 33 | testRouteObj(routeObject); 34 | }); 35 | 36 | it('Should have POST function available', () => { 37 | const routeObject = getRouteObj('post'); 38 | expect(routeObject.method).toEqual('post'); 39 | testRouteObj(routeObject); 40 | }); 41 | 42 | it('Should have PUT function available', () => { 43 | const routeObject = getRouteObj('put'); 44 | expect(routeObject.method).toEqual('put'); 45 | testRouteObj(routeObject); 46 | }); 47 | 48 | it('Should have DELETE function available', () => { 49 | const routeObject = getRouteObj('delete'); 50 | expect(routeObject.method).toEqual('delete'); 51 | testRouteObj(routeObject); 52 | }); 53 | 54 | it('Should have HEAD function available', () => { 55 | const routeObject = getRouteObj('head'); 56 | expect(routeObject.method).toEqual('head'); 57 | testRouteObj(routeObject); 58 | }); 59 | 60 | it('Should have PATCH function available', () => { 61 | const routeObject = getRouteObj('patch'); 62 | expect(routeObject.method).toEqual('patch'); 63 | testRouteObj(routeObject); 64 | }); 65 | 66 | it('Should have OPTIONS function available', () => { 67 | const routeObject = getRouteObj('options'); 68 | expect(routeObject.method).toEqual('options'); 69 | testRouteObj(routeObject); 70 | }); 71 | 72 | it('Should set authorizer if given', () => { 73 | const routeObject = Route.get('/some/path', { 74 | controller: 'someOtherController', 75 | authorizer: 'someAuthorizer' 76 | }); 77 | 78 | expect(routeObject.authorizer).toEqual('someAuthorizer'); 79 | }); 80 | 81 | it('Should set middleware if given', () => { 82 | const routeObject = Route.get('/some/path', { 83 | controller: 'someOtherController', 84 | middleware: 'someMiddleware' 85 | }); 86 | 87 | expect(routeObject.middleware).toEqual('someMiddleware'); 88 | }); 89 | 90 | it('getRouteFn: Should allow route without optional parameters', () => { 91 | expect(Route.get('/', () => {})).toBeDefined(); 92 | }); 93 | 94 | it('getRouteFn: Should work with both 3 and 2 args', () => { 95 | expect(Route.get('/', 'someController').controller).toEqual( 96 | 'someController' 97 | ); 98 | expect( 99 | Route.get('/', { 100 | controller: 'someController' 101 | }).controller 102 | ).toEqual('someController'); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /__tests__/redoc.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-destructuring */ 2 | const registerRedoc = require('../src/redoc/registerRedoc'); 3 | const redocHtmlTemplate = require('../src/redoc/redocHtmlTemplate'); 4 | 5 | describe('Redoc', () => { 6 | describe('registerRedoc', () => { 7 | it('Should register redoc with express app', () => { 8 | const app = { 9 | get: jest.fn() 10 | }; 11 | 12 | registerRedoc({ 13 | app, 14 | swaggerJson: {}, 15 | url: '/docs', 16 | authUser: { 17 | user: 'admin', 18 | password: 'admin' 19 | } 20 | }); 21 | 22 | expect(app.get).toHaveBeenCalledTimes(2); 23 | expect(app.get.mock.calls[0][0]).toEqual('/docs/swagger.json'); 24 | expect(app.get.mock.calls[1][0]).toEqual('/docs'); 25 | }); 26 | 27 | it('Should register first handler properly', () => { 28 | const app = { 29 | get: jest.fn() 30 | }; 31 | 32 | const someSwagger = { 33 | hello: 'world' 34 | }; 35 | 36 | registerRedoc({ 37 | app: app, 38 | swaggerJson: someSwagger, 39 | url: '/docs', 40 | authUser: { 41 | user: 'admin', 42 | password: 'admin' 43 | } 44 | }); 45 | expect(app.get).toHaveBeenCalledTimes(2); 46 | 47 | const mockSend = jest.fn(); 48 | const mockRes = { 49 | type: jest.fn(), 50 | status: jest.fn().mockReturnValue({ 51 | send: mockSend 52 | }) 53 | }; 54 | 55 | const firstHandler = app.get.mock.calls[0][2]; 56 | firstHandler(null, mockRes); 57 | expect(mockRes.status).toHaveBeenCalledWith(200); 58 | expect(mockSend).toHaveBeenCalledWith(someSwagger); 59 | }); 60 | 61 | it('Should register second handler properly', () => { 62 | const app = { 63 | get: jest.fn() 64 | }; 65 | 66 | const someSwagger = { 67 | hello: 'world' 68 | }; 69 | 70 | registerRedoc({ 71 | app: app, 72 | swaggerJson: someSwagger, 73 | url: '/docs', 74 | authUser: { 75 | user: 'admin', 76 | password: 'admin' 77 | } 78 | }); 79 | expect(app.get).toHaveBeenCalledTimes(2); 80 | 81 | const mockSend = jest.fn(); 82 | const mockRes = { 83 | type: jest.fn(), 84 | status: jest.fn().mockReturnValue({ 85 | send: mockSend 86 | }) 87 | }; 88 | 89 | const secondHandler = app.get.mock.calls[1][2]; 90 | secondHandler(null, mockRes); 91 | 92 | const expectedRedoc = redocHtmlTemplate({ 93 | title: 'ReDoc', 94 | specUrl: '/docs/swagger.json' 95 | }); 96 | 97 | expect(mockRes.status).toHaveBeenCalledWith(200); 98 | expect(mockSend).toHaveBeenCalledWith(expectedRedoc); 99 | }); 100 | }); 101 | 102 | describe('redocHtmlTemplate', () => { 103 | it('Should give proper html with defaults', () => { 104 | const html = redocHtmlTemplate(); 105 | expect(html).toContain('ReDoc'); 106 | expect(html).toContain('http://petstore.swagger.io/v2/swagger.json'); 107 | }); 108 | 109 | it('Should give proper html with given params', () => { 110 | const html = redocHtmlTemplate({ 111 | title: 'This is my title', 112 | specUrl: '/some/spec' 113 | }); 114 | expect(html).toContain('This is my title'); 115 | expect(html).toContain('/some/spec'); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at siddiqus@live.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /__tests__/ExpressApp.test.js: -------------------------------------------------------------------------------- 1 | const ExpressApp = require('../src/ExpressApp'); 2 | 3 | describe('ExpressApp', () => { 4 | it('should be defined', () => { 5 | expect(ExpressApp).toBeDefined(); 6 | }); 7 | 8 | describe('constructor', () => { 9 | it('Should init with all defaults', () => { 10 | const app = new ExpressApp({}); 11 | expect(app.express).toBeDefined(); 12 | }); 13 | 14 | it('Should init with all overridden values', () => { 15 | const app = new ExpressApp( 16 | {}, 17 | { 18 | allowCors: true, 19 | middleware: [() => {}], 20 | swaggerDefinitions: {}, 21 | basePath: '/api', 22 | errorHandler: () => {}, 23 | authorizer: true, 24 | authObjectHandler: (req, res) => { 25 | console.log(req.authorizer); 26 | }, 27 | showSwaggerOnlyInDev: false, 28 | swaggerInfo: {} 29 | } 30 | ); 31 | 32 | expect(app.express).toBeDefined(); 33 | }); 34 | 35 | it('constructor with Cors config', () => { 36 | const app = new ExpressApp( 37 | {}, 38 | { 39 | allowCors: true, 40 | corsConfig: { 41 | origin: 'http://somepath' 42 | }, 43 | middleware: [() => {}], 44 | swaggerDefinitions: {}, 45 | basePath: '/api', 46 | errorHandler: () => {}, 47 | showSwaggerOnlyInDev: false, 48 | swaggerInfo: {} 49 | } 50 | ); 51 | 52 | expect(app.express).toBeDefined(); 53 | }); 54 | 55 | it('Should register proper error handler for array', async () => { 56 | const app = new ExpressApp( 57 | {}, 58 | { 59 | allowCors: true, 60 | corsConfig: { 61 | origin: 'http://somepath' 62 | }, 63 | middleware: [() => {}], 64 | swaggerDefinitions: {}, 65 | basePath: '/api', 66 | showSwaggerOnlyInDev: false, 67 | swaggerInfo: {} 68 | } 69 | ); 70 | 71 | app.express = { 72 | use: jest.fn() 73 | }; 74 | 75 | app.config.errorHandler = [ 76 | (err, req, res) => ({ err, req, res, next: 1 }), 77 | (err, req, res, next) => { 78 | return next(); 79 | } 80 | ]; 81 | 82 | app._registerErrorHandlers(); 83 | 84 | expect(app.express.use).toHaveBeenCalledTimes(1); 85 | expect(app.express.use.mock.calls[0][0][0].length).toEqual(4); 86 | expect(app.express.use.mock.calls[0][0][1].length).toEqual(4); 87 | 88 | const mockNext = jest.fn().mockReturnValue(123); 89 | await app.express.use.mock.calls[0][0][0](1, 2, 3, mockNext); 90 | await app.express.use.mock.calls[0][0][1](1, 2, 3, mockNext); 91 | 92 | expect(mockNext).toHaveBeenCalledTimes(2); 93 | }); 94 | 95 | it('Should register proper error handler for single function', async () => { 96 | const app = new ExpressApp( 97 | {}, 98 | { 99 | allowCors: true, 100 | corsConfig: { 101 | origin: 'http://somepath' 102 | }, 103 | middleware: [() => {}], 104 | swaggerDefinitions: {}, 105 | basePath: '/api', 106 | showSwaggerOnlyInDev: false, 107 | swaggerInfo: {} 108 | } 109 | ); 110 | 111 | app.express = { 112 | use: jest.fn() 113 | }; 114 | app.config.errorHandler = (err, req, res) => ({ err, req, res, next: 1 }); 115 | 116 | app._registerErrorHandlers(); 117 | 118 | const mockNext = jest.fn().mockReturnValue(123); 119 | 120 | expect(app.express.use).toHaveBeenCalledTimes(1); 121 | expect(app.express.use.mock.calls[0][0].length).toEqual(4); 122 | const handlerResponse = await app.express.use.mock.calls[0][0]( 123 | 1, 124 | 2, 125 | 3, 126 | mockNext 127 | ); 128 | expect(handlerResponse).toEqual(123); 129 | }); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /__tests__/SwaggerBuilder.test.js: -------------------------------------------------------------------------------- 1 | const SwaggerEndpoint = require('../src/SwaggerBuilder'); 2 | 3 | const expectedOutput = { 4 | summary: 'Get a list of CVs for a given job description', 5 | description: 'Get a list of CVs for a given job description', 6 | parameters: [ 7 | { 8 | name: 'jdId', 9 | in: 'path', 10 | required: true, 11 | type: 'string', 12 | description: 'Job description ID', 13 | schema: { 14 | type: 'object', 15 | properties: { 16 | bucketName: { 17 | type: 'string' 18 | }, 19 | bucketKey: { 20 | type: 'string' 21 | } 22 | } 23 | } 24 | }, 25 | { 26 | name: 'body', 27 | in: 'body', 28 | required: true, 29 | type: 'string', 30 | description: 'CV ID', 31 | schema: { 32 | type: 'object', 33 | properties: { 34 | bucketName: { 35 | type: 'string' 36 | }, 37 | bucketKey: { 38 | type: 'string' 39 | } 40 | } 41 | } 42 | } 43 | ], 44 | responses: { 45 | 200: { 46 | produces: ['application/json'], 47 | description: 'List of CVs for the given job description', 48 | schema: { 49 | type: 'object', 50 | properties: { 51 | url: { 52 | type: 'string' 53 | } 54 | } 55 | } 56 | }, 57 | 400: { 58 | produces: ['application/json'], 59 | description: 'Some error description', 60 | schema: { 61 | type: 'object', 62 | properties: { 63 | errorMessage: { 64 | type: 'string' 65 | } 66 | } 67 | } 68 | } 69 | } 70 | }; 71 | 72 | describe('SwaggerBuilder', () => { 73 | it('Should transform endpoint doc', () => { 74 | const output = new SwaggerEndpoint( 75 | expectedOutput.summary, 76 | expectedOutput.description 77 | ); 78 | 79 | expect(output.summary).toEqual(expectedOutput.summary); 80 | expect(output.description).toEqual(expectedOutput.description); 81 | 82 | output.addResponse({ 83 | code: 200, 84 | description: 'List of CVs for the given job description', 85 | schema: { 86 | type: 'object', 87 | properties: { 88 | url: { 89 | type: 'string' 90 | } 91 | } 92 | } 93 | }); 94 | 95 | expect(output.responses[200]).toEqual(expectedOutput.responses[200]); 96 | 97 | output.addResponse({ 98 | code: 400, 99 | description: 'Some error description', 100 | schema: { 101 | type: 'object', 102 | properties: { 103 | errorMessage: { 104 | type: 'string' 105 | } 106 | } 107 | }, 108 | produces: ['application/json'] 109 | }); 110 | 111 | expect(output.responses[400]).toEqual(expectedOutput.responses[400]); 112 | 113 | output.addPathParam({ 114 | name: 'jdId', 115 | required: true, 116 | type: 'string', 117 | description: 'Job description ID', 118 | schema: { 119 | type: 'object', 120 | properties: { 121 | bucketName: { 122 | type: 'string' 123 | }, 124 | bucketKey: { 125 | type: 'string' 126 | } 127 | } 128 | } 129 | }); 130 | 131 | expect(output.parameters[0]).toEqual(expectedOutput.parameters[0]); 132 | 133 | const requestBody = { 134 | required: true, 135 | description: 'CV ID', 136 | schema: { 137 | type: 'object', 138 | properties: { 139 | bucketName: { 140 | type: 'string' 141 | }, 142 | bucketKey: { 143 | type: 'string' 144 | } 145 | } 146 | } 147 | }; 148 | 149 | output.addRequestBody(requestBody); 150 | 151 | expect(output.parameters[1]).toEqual({ 152 | ...requestBody, 153 | name: 'body', 154 | in: 'body' 155 | }); 156 | 157 | output.addRequestBody({ 158 | ...requestBody, 159 | name: 'someBody' 160 | }); 161 | 162 | expect(output.parameters[2]).toEqual({ 163 | ...requestBody, 164 | name: 'someBody', 165 | in: 'body' 166 | }); 167 | 168 | const queryParam = { 169 | name: 'some query param', 170 | description: 'some query desc', 171 | required: true, 172 | schema: { 173 | type: 'integer' 174 | } 175 | }; 176 | 177 | output.addQueryParam(queryParam); 178 | 179 | expect(output.parameters[3]).toEqual({ 180 | ...queryParam, 181 | in: 'query' 182 | }); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /__tests__/AuthUtil.test.js: -------------------------------------------------------------------------------- 1 | const AuthUtil = require('../src/AuthUtil'); 2 | 3 | describe('AuthUtil', () => { 4 | describe('getMiddlewareForInjectingAuthProperties', () => { 5 | it('should inject auth if none previously', () => { 6 | const authUtil = new AuthUtil(); 7 | 8 | const handler = authUtil.getMiddlewareForInjectingAuthProperties(1); 9 | 10 | const mockReq = {}; 11 | 12 | handler(mockReq); 13 | 14 | expect(mockReq.authorizer).toEqual([1]); 15 | }); 16 | 17 | it('should inject auth with previous auth', () => { 18 | const authUtil = new AuthUtil(); 19 | 20 | const handler = authUtil.getMiddlewareForInjectingAuthProperties(1); 21 | 22 | const mockReq = { 23 | authorizer: ['hehe'] 24 | }; 25 | 26 | handler(mockReq); 27 | 28 | expect(mockReq.authorizer).toEqual(['hehe', 1]); 29 | }); 30 | 31 | it('should maintain unique auth object', () => { 32 | const authUtil = new AuthUtil(); 33 | 34 | const someAuthorizer = { 35 | someAuthPrivileges: ['hello', 'hey'] 36 | }; 37 | const handler = authUtil.getMiddlewareForInjectingAuthProperties( 38 | someAuthorizer 39 | ); 40 | 41 | const mockReq = { 42 | authorizer: [someAuthorizer] 43 | }; 44 | 45 | handler(mockReq); 46 | 47 | expect(mockReq.authorizer).toEqual([someAuthorizer]); 48 | }); 49 | 50 | it('should flatten array for authorizers', () => { 51 | const authUtil = new AuthUtil(); 52 | 53 | const someAuthorizer = ['hehe']; 54 | const handler = authUtil.getMiddlewareForInjectingAuthProperties( 55 | someAuthorizer 56 | ); 57 | 58 | const mockReq = { 59 | authorizer: ['hoho'] 60 | }; 61 | 62 | handler(mockReq); 63 | 64 | expect(mockReq.authorizer).toEqual(['hoho', 'hehe']); 65 | }); 66 | }); 67 | 68 | describe('getAuthorizerMiddleware', () => { 69 | it('Should return null if authorizer is falsy', () => { 70 | // setup 71 | const authUtil = new AuthUtil(); 72 | // execute 73 | const result = authUtil.getAuthorizerMiddleware(null); 74 | // assert 75 | expect(result).toBeNull(); 76 | }); 77 | 78 | it('Should throw error if authorizer is defined but auth object handler is not', () => { 79 | // setup 80 | const authUtil = new AuthUtil(); 81 | // execute 82 | let result; 83 | try { 84 | authUtil.getAuthorizerMiddleware([1, 2, 3]); 85 | } catch (error) { 86 | result = error; 87 | } 88 | // assert 89 | 90 | expect(result.message).toEqual( 91 | `'authorizer' object declared, but 'authObjectHandler' is not defined in ExpressApp constructor params` 92 | ); 93 | }); 94 | 95 | it('Should return proper middleware if authorizer is object', () => { 96 | // setup 97 | const authUtil = new AuthUtil(); 98 | authUtil.routeUtil = { 99 | getHandlerWithManagedNextCall: jest.fn().mockReturnValue(1) 100 | }; 101 | const mockAuthorizer = ['hello']; 102 | const mockAuthObjectHandler = jest.fn(); 103 | // execute 104 | const result = authUtil.getAuthorizerMiddleware( 105 | mockAuthorizer, 106 | mockAuthObjectHandler 107 | ); 108 | // assert 109 | expect(result).not.toBeNull(); 110 | expect( 111 | authUtil.routeUtil.getHandlerWithManagedNextCall 112 | ).toHaveBeenCalledTimes(2); 113 | expect( 114 | authUtil.routeUtil.getHandlerWithManagedNextCall 115 | ).toHaveBeenCalledWith(mockAuthObjectHandler); 116 | }); 117 | 118 | it('Should return proper middleware if authorizer is string', () => { 119 | // setup 120 | const authUtil = new AuthUtil(); 121 | authUtil.routeUtil = { 122 | getHandlerWithManagedNextCall: jest.fn().mockReturnValue(1) 123 | }; 124 | const mockAuthorizer = 'hello'; 125 | const mockAuthObjectHandler = jest.fn(); 126 | // execute 127 | const result = authUtil.getAuthorizerMiddleware( 128 | mockAuthorizer, 129 | mockAuthObjectHandler 130 | ); 131 | // assert 132 | expect(result).not.toBeNull(); 133 | expect( 134 | authUtil.routeUtil.getHandlerWithManagedNextCall 135 | ).toHaveBeenCalledTimes(2); 136 | expect( 137 | authUtil.routeUtil.getHandlerWithManagedNextCall 138 | ).toHaveBeenCalledWith(mockAuthObjectHandler); 139 | }); 140 | 141 | it('Should return proper middleware if authorizer is a function', () => { 142 | // setup 143 | const authUtil = new AuthUtil(); 144 | authUtil.routeUtil = { 145 | getHandlerWithManagedNextCall: jest.fn().mockReturnValue(1) 146 | }; 147 | const mockAuthorizer = (_) => {}; 148 | const mockAuthObjectHandler = jest.fn(); 149 | // execute 150 | const result = authUtil.getAuthorizerMiddleware( 151 | mockAuthorizer, 152 | mockAuthObjectHandler 153 | ); 154 | // assert 155 | expect(result).not.toBeNull(); 156 | expect( 157 | authUtil.routeUtil.getHandlerWithManagedNextCall 158 | ).toHaveBeenCalledTimes(1); 159 | expect( 160 | authUtil.routeUtil.getHandlerWithManagedNextCall 161 | ).toHaveBeenCalledWith(mockAuthorizer); 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "incremental": true, 5 | /* Enable incremental compilation */ 6 | "target": "esnext", 7 | /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 8 | "module": "commonjs", 9 | /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 10 | // "lib": [], /* Specify library files to be included in the compilation. */ 11 | "allowJs": false, 12 | /* Allow javascript files to be compiled. */ 13 | "checkJs": false, 14 | /* Report errors in .js files. */ 15 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 16 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 17 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 18 | "sourceMap": true /* Generates corresponding '.map' file. */, 19 | // "outFile": "./", /* Concatenate and emit output to single file. */ 20 | "outDir": "./dist", 21 | /* Redirect output structure to the directory. */ 22 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 23 | // "composite": true, /* Enable project compilation */ 24 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 25 | // "removeComments": true, /* Do not emit comments to output. */ 26 | // "noEmit": true, /* Do not emit outputs. */ 27 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 28 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 29 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 30 | 31 | /* Strict Type-Checking Options */ 32 | "strict": true, 33 | /* Enable all strict type-checking options. */ 34 | "noImplicitAny": true, 35 | /* Raise error on expressions and declarations with an implied 'any' type. */ 36 | "strictNullChecks": true, 37 | /* Enable strict null checks. */ 38 | "strictFunctionTypes": true, 39 | /* Enable strict checking of function types. */ 40 | "strictBindCallApply": true, 41 | /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 42 | "strictPropertyInitialization": true, 43 | /* Enable strict checking of property initialization in classes. */ 44 | "noImplicitThis": true, 45 | /* Raise error on 'this' expressions with an implied 'any' type. */ 46 | "alwaysStrict": false, 47 | /* Parse in strict mode and emit "use strict" for each source file. */ 48 | 49 | /* Additional Checks */ 50 | // "noUnusedLocals": true, 51 | /* Report errors on unused locals. */ 52 | // "noUnusedParameters": true, 53 | /* Report errors on unused parameters. */ 54 | "noImplicitReturns": true, 55 | /* Report error when not all code paths in function return a value. */ 56 | "noFallthroughCasesInSwitch": true, 57 | /* Report errors for fallthrough cases in switch statement. */ 58 | 59 | /* Module Resolution Options */ 60 | "moduleResolution": "node", 61 | "resolveJsonModule": true, 62 | /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 63 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 64 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 65 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 66 | "typeRoots": [ 67 | "./node_modules/@types" 68 | ], 69 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, 70 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 71 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 72 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 73 | 74 | /* Source Map Options */ 75 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 76 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 77 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 78 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 79 | 80 | /* Experimental Options */ 81 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 82 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */ 83 | }, 84 | "include": ["src/**/*"], 85 | "exclude": ["src/configs", "node_modules"] 86 | } 87 | -------------------------------------------------------------------------------- /src/middleware/MiddlewareManager.js: -------------------------------------------------------------------------------- 1 | const expressModule = require('express'); 2 | const helmet = require('helmet'); 3 | const cors = require('cors'); 4 | const morganBody = require('morgan-body'); 5 | const { errors: celebrateErrors } = require('celebrate'); 6 | const responseMiddleware = require('./response'); 7 | const RouteUtil = require('../RouteUtil'); 8 | const AuthUtil = require('../AuthUtil'); 9 | const SwaggerUtils = require('../SwaggerUtils'); 10 | const registerRedoc = require('../redoc/registerRedoc'); 11 | const addRequestId = require('./requestId'); 12 | 13 | module.exports = class MiddlewareManager { 14 | constructor(expressiveConfig, expressApp) { 15 | this.options = expressiveConfig; 16 | this.express = expressApp; 17 | 18 | this._initDependencies(); 19 | } 20 | 21 | _initDependencies() { 22 | this.expressModule = expressModule; 23 | this.addRequestId = addRequestId; 24 | this.helmet = helmet; 25 | 26 | this.routeUtil = RouteUtil; 27 | this.authUtil = new AuthUtil(); 28 | this.SwaggerUtils = SwaggerUtils; 29 | this.registerRedoc = registerRedoc; 30 | this.celebrateErrors = celebrateErrors; 31 | this.morganBody = morganBody; 32 | } 33 | 34 | defaultNotFoundHandler(req, res) { 35 | res.status(404); 36 | res.json({ 37 | message: `Route '${req.path}' not found` 38 | }); 39 | } 40 | 41 | _registerHelmet() { 42 | const helmet = this.options.helmetOptions 43 | ? this.helmet(this.options.helmetOptions) 44 | : this.helmet(); 45 | this.express.use(helmet); 46 | } 47 | 48 | _registerBodyParser() { 49 | this.express.use( 50 | this.expressModule.json({ 51 | limit: this.options.bodyLimit 52 | }) 53 | ); 54 | this.express.use(this.expressModule.urlencoded({ extended: true })); 55 | } 56 | 57 | _registerMorganBody() { 58 | if ( 59 | this.options.requestLoggerOptions && 60 | this.options.requestLoggerOptions.disabled 61 | ) { 62 | return; 63 | } 64 | 65 | this.morganBody(this.express, { 66 | logRequestId: true, 67 | prettify: false, 68 | skip: (req) => { 69 | return ( 70 | req.originalUrl === '/' || 71 | req.originalUrl.match(/^\/favicon\/*/) || 72 | (this.options.requestLoggerOptions && 73 | this.options.requestLoggerOptions.skip && 74 | this.options.requestLoggerOptions.skip(req)) 75 | ); 76 | }, 77 | includeNewLine: true, 78 | immediateReqLog: true, 79 | ...(this.options.requestLoggerOptions || {}) 80 | }); 81 | } 82 | 83 | registerBasicMiddleware() { 84 | this._registerBodyParser(); 85 | this.express.use(responseMiddleware); 86 | this.express.use(this.addRequestId()); 87 | 88 | this._registerHelmet(); 89 | this._registerMorganBody(); 90 | 91 | const { middleware: userMiddleware } = this.options; 92 | if (userMiddleware && userMiddleware.length > 0) { 93 | const nextManagedMiddlewares = userMiddleware.map((m) => 94 | this.routeUtil.getHandlerWithManagedNextCall(m) 95 | ); 96 | this.express.use(nextManagedMiddlewares); 97 | } 98 | } 99 | 100 | registerNotFoundHandler() { 101 | const { notFoundHandler } = this.options; 102 | if (notFoundHandler) { 103 | this.express.use(notFoundHandler); 104 | } else { 105 | this.express.use(this.defaultNotFoundHandler); 106 | } 107 | } 108 | 109 | registerAuth() { 110 | const { authorizer, authObjectHandler } = this.options; 111 | const authMiddleware = this.authUtil.getAuthorizerMiddleware( 112 | authorizer, 113 | authObjectHandler 114 | ); 115 | 116 | if (authMiddleware) { 117 | this.express.use(authMiddleware); 118 | } 119 | } 120 | 121 | registerCelebrateErrorMiddleware() { 122 | const { celebrateErrorHandler } = this.options; 123 | if (celebrateErrorHandler) { 124 | this.express.use(celebrateErrorHandler); 125 | } else { 126 | this.express.use(this.celebrateErrors()); 127 | } 128 | } 129 | 130 | configureCors() { 131 | if (!this.options.allowCors) return; 132 | 133 | const corsMiddleware = this.options.corsConfig 134 | ? cors(this.options.corsConfig) 135 | : cors(); 136 | this.express.use(corsMiddleware); 137 | } 138 | 139 | registerDocs(expressiveRouter) { 140 | const env = process.env.NODE_ENV || 'development'; 141 | const shouldRegister = 142 | (this.options.showSwaggerOnlyInDev && env === 'development') || 143 | !this.options.showSwaggerOnlyInDev; 144 | 145 | if (!shouldRegister) return; 146 | 147 | const { basePath, swaggerInfo, swaggerSecurityDefinitions } = this.options; 148 | const swaggerHeader = this.SwaggerUtils.getSwaggerHeader({ 149 | basePath, 150 | swaggerInfo, 151 | swaggerSecurityDefinitions 152 | }); 153 | 154 | const swaggerJson = this.SwaggerUtils.convertDocsToSwaggerDoc( 155 | expressiveRouter, 156 | swaggerHeader, 157 | this.options.swaggerDefinitions 158 | ); 159 | 160 | const authUser = { 161 | user: process.env.EXPRESS_SWAGGER_USER || 'admin', 162 | password: process.env.EXPRESS_SWAGGER_PASSWORD || 'admin' 163 | }; 164 | 165 | this.SwaggerUtils.registerExpress({ 166 | app: this.express, 167 | swaggerJson, 168 | url: '/docs/swagger', 169 | authUser 170 | }); 171 | console.log('Swagger doc up and running on /docs'); 172 | 173 | this.registerRedoc({ 174 | app: this.express, 175 | swaggerJson, 176 | url: '/docs/redoc', 177 | authUser 178 | }); 179 | } 180 | }; 181 | -------------------------------------------------------------------------------- /src/RouterFactory.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-invalid-this */ 2 | const { Router } = require('express'); 3 | const { celebrate: celebrateMiddleware } = require('celebrate'); 4 | const RouteUtil = require('./RouteUtil'); 5 | const AuthUtil = require('./AuthUtil'); 6 | const CelebrateUtils = require('./CelebrateUtils'); 7 | const BaseController = require('./BaseController'); 8 | 9 | async function _handleRequestBase(req, res, next) { 10 | this.req = req; 11 | this.res = res; 12 | this.next = next; 13 | 14 | return this.handleRequest(); 15 | } 16 | 17 | module.exports = class RouterFactory { 18 | constructor(expressiveOptions) { 19 | this.expressiveOptions = expressiveOptions; 20 | this.routeUtil = RouteUtil; 21 | this.authUtil = new AuthUtil(); 22 | this.celebrateMiddleware = celebrateMiddleware; 23 | this.CelebrateUtils = CelebrateUtils; 24 | } 25 | 26 | _getWrappedController(controllerInstance) { 27 | return async (req, res, next) => { 28 | try { 29 | const mappedReq = BaseController.requestMapper(req); 30 | await _handleRequestBase.call(controllerInstance, mappedReq, res, next); 31 | if (!controllerInstance.res.headersSent) { 32 | controllerInstance.internalServerError( 33 | 'Server did not send any response' 34 | ); 35 | } 36 | } catch (e) { 37 | return next(e); 38 | } 39 | }; 40 | } 41 | 42 | _registerCelebrateErrorMiddleware(validationSchema, routerArgs) { 43 | if (!validationSchema) return; 44 | 45 | const { options, ...validationSchemaObj } = validationSchema; 46 | 47 | const sanitizedValidationSchema = this.CelebrateUtils.getSanitizedValidationSchema( 48 | validationSchemaObj 49 | ); 50 | 51 | if (sanitizedValidationSchema) { 52 | this.CelebrateUtils.lowercaseHeaderSchemaProperties( 53 | sanitizedValidationSchema 54 | ); 55 | routerArgs.push( 56 | this.celebrateMiddleware(sanitizedValidationSchema, { 57 | abortEarly: false, 58 | ...(options || {}) 59 | }) 60 | ); 61 | } 62 | } 63 | 64 | _setAuthorizerMiddleware(authorizer, routerArgs) { 65 | const authMiddleware = this.authUtil.getAuthorizerMiddleware( 66 | authorizer, 67 | this.expressiveOptions.authObjectHandler 68 | ); 69 | 70 | if (authMiddleware) { 71 | routerArgs.push(authMiddleware); 72 | } 73 | } 74 | 75 | _setFileUploadValidationMiddleware(validationSchema, routerArgs) { 76 | if (!validationSchema || !validationSchema.fileUpload) return; 77 | 78 | routerArgs.push( 79 | this.CelebrateUtils.getCelebrateValidationMiddlewareForFileUpload( 80 | validationSchema.fileUpload 81 | ) 82 | ); 83 | } 84 | 85 | _registerMiddleware( 86 | routerArgs, 87 | { validationSchema, authorizer, middleware } 88 | ) { 89 | this._setAuthorizerMiddleware(authorizer, routerArgs); 90 | this._registerCelebrateErrorMiddleware(validationSchema, routerArgs); 91 | 92 | const nextAdjustedMiddleware = !middleware 93 | ? [] 94 | : middleware.map((m) => this.routeUtil.getHandlerWithManagedNextCall(m)); 95 | 96 | routerArgs.push(...nextAdjustedMiddleware); 97 | 98 | this._setFileUploadValidationMiddleware(validationSchema, routerArgs); 99 | } 100 | 101 | _registerPreHandlers(routerArgs, handler) { 102 | if (!handler) return; 103 | 104 | if (Array.isArray(handler)) { 105 | const nextAdjustedMiddleware = handler.map((m) => 106 | this.routeUtil.getHandlerWithManagedNextCall(m) 107 | ); 108 | routerArgs.push(...nextAdjustedMiddleware); 109 | } else { 110 | routerArgs.push(this.routeUtil.getHandlerWithManagedNextCall(handler)); 111 | } 112 | } 113 | 114 | _registerRoute(router, { method, path, controller }) { 115 | const routerArgs = [path]; 116 | 117 | this._registerPreHandlers(routerArgs, controller.pre); 118 | 119 | this._registerMiddleware(routerArgs, { 120 | validationSchema: controller.validationSchema, 121 | authorizer: controller.authorizer, 122 | middleware: controller.middleware 123 | }); 124 | 125 | routerArgs.push(this._getWrappedController(controller)); 126 | 127 | router[method](...routerArgs); 128 | } 129 | 130 | _registerSubroute( 131 | router, 132 | { 133 | path, 134 | router: subrouter, 135 | authorizer, 136 | validationSchema = null, 137 | middleware = [], 138 | pre = null 139 | } 140 | ) { 141 | const routerArgs = [path]; 142 | 143 | this._registerPreHandlers(routerArgs, pre); 144 | 145 | this._registerMiddleware(routerArgs, { 146 | validationSchema, 147 | authorizer, 148 | middleware 149 | }); 150 | 151 | routerArgs.push(this.getExpressRouter(subrouter)); 152 | 153 | router.use(...routerArgs); 154 | } 155 | 156 | _getRouter() { 157 | return new Router({ 158 | mergeParams: true 159 | }); 160 | } 161 | 162 | _handleDuplicateUrls(expressiveRouter) { 163 | const duplicateUrls = this.routeUtil.getDuplicateUrls(expressiveRouter); 164 | if (duplicateUrls.length > 0) { 165 | throw new Error( 166 | `Duplicate endpoints detected! -> ${duplicateUrls.join(', ')}` 167 | ); 168 | } 169 | } 170 | 171 | getExpressRouter(expressiveRouter) { 172 | this._handleDuplicateUrls(expressiveRouter); 173 | 174 | const router = this._getRouter(); 175 | 176 | if (expressiveRouter.routes) { 177 | expressiveRouter.routes.forEach((routeConf) => { 178 | this._registerRoute(router, routeConf); 179 | }); 180 | } 181 | 182 | if (expressiveRouter.subroutes) { 183 | expressiveRouter.subroutes.forEach((subroute) => { 184 | this._registerSubroute(router, subroute); 185 | }); 186 | } 187 | 188 | return router; 189 | } 190 | }; 191 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { CorsOptions as CorsLibOptions } from 'cors'; 2 | import type { 3 | ErrorRequestHandler as ExpressErrorRequestHandler, 4 | Express as ExpressType, 5 | NextFunction as ExpressNextFunction, 6 | Response as ExpressResponse, 7 | Request as ExpressRequest, 8 | RequestHandler as ExpressHandler 9 | } from 'express'; 10 | 11 | export declare type express = typeof import('express'); 12 | 13 | interface Request extends ExpressRequest { 14 | id?: string 15 | authorizer?: AuthorizerType; 16 | user?: Record; 17 | } 18 | 19 | export type Response = ExpressResponse; 20 | export type NextFunction = ExpressNextFunction; 21 | 22 | export interface Handler { 23 | (req: Request, res: Response, next: NextFunction): void 24 | } 25 | 26 | export type HelmetOptions = typeof import('helmet').HelmetOptions 27 | 28 | export type Express = ExpressType; 29 | export type ErrorRequestHandler = ExpressErrorRequestHandler; 30 | export type CorsOptions = CorsLibOptions; 31 | 32 | export declare const Joi: typeof import('celebrate').Joi; 33 | export declare const isValidationError: typeof import('celebrate').isCelebrateError; 34 | export declare const celebrate: typeof import('celebrate'); 35 | 36 | import { celebrate as CelebrateFn } from 'celebrate'; 37 | export type ValidationOptions = Parameters[1] 38 | 39 | export declare interface SwaggerInfoContact { 40 | name?: string; 41 | email?: string; 42 | } 43 | 44 | export declare interface SwaggerInfo { 45 | version?: string; 46 | title?: string; 47 | contact?: SwaggerInfoContact; 48 | } 49 | 50 | interface BaseController { 51 | validationSchema?: ValidationSchema; 52 | authorizer?: AuthorizerType; 53 | doc?: SwaggerEndpointDoc; 54 | middleware?: Handler[]; 55 | pre?: Handler | Handler[]; 56 | } 57 | 58 | export declare abstract class BaseController { 59 | static responseMapper(data: any): any; 60 | static requestMapper(data: any): any; 61 | 62 | abstract handleRequest(): Promise; 63 | 64 | req: Request; 65 | res: Response; 66 | next: NextFunction; 67 | 68 | getData(): { 69 | body: Record 70 | query: Record 71 | params: Record 72 | fileUpload: { 73 | file: any 74 | files: any 75 | } 76 | user: Record 77 | }; 78 | 79 | ok(data?: any): void; 80 | 81 | created(data?: any): void; 82 | 83 | accepted(data?: any): void; 84 | 85 | noContent(): void; 86 | 87 | badRequest(message?: string): void; 88 | 89 | unauthorized(message?: string): void; 90 | 91 | forbidden(message?: string): void; 92 | 93 | notFound(message?: string): void; 94 | 95 | tooMany(message?: string): void; 96 | 97 | internalServerError(message?: string, body?: any): void; 98 | } 99 | export declare interface ValidationSchema { 100 | body?: object; 101 | params?: object; 102 | query?: object; 103 | headers?: object; 104 | cookies?: object; 105 | signedCookies?: object; 106 | fileUpload?: { 107 | file?: object, 108 | files?: object 109 | } 110 | options?: ValidationOptions 111 | } 112 | 113 | interface SwaggerResponseMap { 114 | [key: number]: object; 115 | } 116 | 117 | export declare interface SwaggerEndpointDoc { 118 | description?: string; 119 | summary?: string; 120 | responses?: SwaggerResponseMap; 121 | tags?: string[]; 122 | } 123 | 124 | type AuthorizerType = Handler | Handler[] | string | string[] | object | object[]; 125 | 126 | export declare type RouteMethod = 127 | | 'get' 128 | | 'post' 129 | | 'put' 130 | | 'delete' 131 | | 'head' 132 | | 'patch' 133 | | 'options'; 134 | 135 | export declare interface Endpoint { 136 | controller: BaseController; 137 | method: RouteMethod; 138 | path: string; 139 | } 140 | 141 | export declare class Route { 142 | static get( 143 | path: string, 144 | controller: BaseController 145 | ): Endpoint; 146 | 147 | static post( 148 | path: string, 149 | controller: BaseController 150 | ): Endpoint; 151 | 152 | static delete( 153 | path: string, 154 | controller: BaseController 155 | ): Endpoint; 156 | 157 | static put( 158 | path: string, 159 | controller: BaseController 160 | ): Endpoint; 161 | 162 | static head( 163 | path: string, 164 | controller: BaseController 165 | ): Endpoint; 166 | 167 | static options( 168 | path: string, 169 | controller: BaseController 170 | ): Endpoint; 171 | 172 | static patch( 173 | path: string, 174 | controller: BaseController 175 | ): Endpoint; 176 | } 177 | 178 | export declare interface Subroute { 179 | path: string; 180 | controller: BaseController; 181 | authorizer?: AuthorizerType; 182 | middleware?: Handler[]; 183 | validationSchema?: ValidationSchema; 184 | pre?: Handler | Handler[]; 185 | } 186 | 187 | export declare function subroute( 188 | path: string, 189 | router: ExpressiveRouter, 190 | options?: Pick 191 | ): Subroute 192 | 193 | export declare interface ExpressiveRouter { 194 | routes?: Endpoint[]; 195 | subroutes?: Subroute[]; 196 | } 197 | 198 | export type SwaggerSecurityDefinitions = { 199 | [key in string]: { 200 | type: 'basic' 201 | } | { 202 | type: 'apiKey' 203 | in: 'header' | 'query' 204 | name: string 205 | } | { 206 | type: 'oauth2' 207 | flow: string 208 | authorizationUrl: string 209 | tokenUrl: string 210 | scopes: { 211 | [key in string]: string 212 | } 213 | } 214 | } 215 | 216 | export interface ExpressiveOptions { 217 | basePath?: string; 218 | showSwaggerOnlyInDev?: boolean; 219 | swaggerInfo?: SwaggerInfo; 220 | swaggerDefinitions?: any; 221 | swaggerBasicAuth?: { 222 | user: string; 223 | password: string; 224 | } 225 | swaggerSecurityDefinitions?: SwaggerSecurityDefinitions; 226 | allowCors?: boolean; 227 | corsConfig?: CorsOptions; 228 | middleware?: Handler[]; 229 | authorizer?: AuthorizerType; 230 | errorHandler?: ErrorRequestHandler | ErrorRequestHandler[]; 231 | bodyLimit?: string; 232 | helmetOptions?: HelmetOptions; 233 | celebrateErrorHandler?: ErrorRequestHandler; 234 | notFoundHandler?: Handler; 235 | authObjectHandler?: Handler; 236 | requestLoggerOptions?: import('morgan-body').IMorganBodyOptions & { 237 | disabled: boolean 238 | } 239 | } 240 | 241 | export declare class ExpressApp { 242 | constructor(router: ExpressiveRouter, options?: ExpressiveOptions); 243 | express: Express; 244 | listen(port: number, cb: Function): void; 245 | } 246 | -------------------------------------------------------------------------------- /src/SwaggerUtils.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const basicAuth = require('express-basic-auth'); 3 | const SwaggerUi = require('swagger-ui-express'); 4 | const RouteUtil = require('./RouteUtil.js'); 5 | const Utils = require('./Utils'); 6 | 7 | const { joiSchemaToSwaggerRequestParameters } = require('./CelebrateUtils'); 8 | 9 | function registerExpress({ app, swaggerJson, url, authUser }) { 10 | const { user, password } = authUser; 11 | const basicAuthMiddleware = basicAuth({ 12 | challenge: true, 13 | users: { [user]: password } 14 | }); 15 | 16 | app.use( 17 | url, 18 | basicAuthMiddleware, 19 | SwaggerUi.serve, 20 | SwaggerUi.setup(swaggerJson, { 21 | explorer: true 22 | }) 23 | ); 24 | 25 | const redirectUrl = 26 | url.charAt(0) === '/' ? url.substring(1, url.length) : url; 27 | app.get('/docs', basicAuthMiddleware, (_, res) => res.redirect(redirectUrl)); 28 | } 29 | 30 | function _sanitizeSwaggerPath(path) { 31 | const normalizedPath = Utils.normalizePathSlashes(path); 32 | if (!normalizedPath.includes(':')) return normalizedPath; 33 | 34 | const split = normalizedPath.split('/'); 35 | 36 | split.forEach((p, index) => { 37 | if (p.includes(':')) { 38 | split[index] = `{${p.replace(':', '')}}`; 39 | } 40 | }); 41 | return split.join('/'); 42 | } 43 | 44 | function _setDocResponses(doc, validationSchema) { 45 | const docResponses = doc.responses || {}; 46 | 47 | if (!docResponses[400] && validationSchema) { 48 | docResponses[400] = { 49 | description: 'Schema validation error response' 50 | }; 51 | } 52 | 53 | if (!docResponses[200]) { 54 | docResponses[200] = { 55 | description: 'Success response' 56 | }; 57 | } 58 | 59 | doc.responses = docResponses; 60 | } 61 | 62 | function _setAuthorizerDocInDescription(doc, authorizer) { 63 | if (!authorizer) return; 64 | const authStr = `Authorized: ${JSON.stringify(authorizer)}`; 65 | if (doc.description) { 66 | doc.description = `${authStr}\n\n${doc.description}`; 67 | } else { 68 | doc.description = authStr; 69 | } 70 | } 71 | 72 | function _setDocParameters(doc, validationSchema) { 73 | doc.parameters = 74 | doc.parameters || joiSchemaToSwaggerRequestParameters(validationSchema); 75 | 76 | if (doc.parameters.find((p) => p.type === 'file')) { 77 | doc.consumes = ['multipart/form-data']; 78 | } 79 | } 80 | 81 | function _getUniqueArray(arr) { 82 | return Array.from(new Set(arr)); 83 | } 84 | 85 | function _capitalize(string) { 86 | if (string.length <= 1) return string.toUpperCase(); 87 | return string.charAt(0).toUpperCase() + string.slice(1); 88 | } 89 | 90 | function _setTagFromPath(path, doc) { 91 | if (!path) return; 92 | const tags = path 93 | .split('/') 94 | .filter(Boolean) 95 | .filter((s) => !s.includes(':')) 96 | .map((s) => s.replace(/-|_/g, ' ').split(' ').map(_capitalize).join(' ')); 97 | if (!tags.length) return; 98 | 99 | if (!doc.tags) doc.tags = []; 100 | doc.tags = _getUniqueArray([...tags, ...doc.tags]); 101 | } 102 | 103 | function _addPathDoc(paths, route, tags) { 104 | let { 105 | path, 106 | validationSchema, 107 | authorizer, 108 | parentPath, 109 | controllerName, 110 | doc = {} 111 | } = route; 112 | 113 | _setTagFromPath(parentPath, doc); 114 | const { method } = route; 115 | path = _sanitizeSwaggerPath(path); 116 | doc.summary = `${doc.summary || ''} [${controllerName}]`.trim(); 117 | 118 | _setAuthorizerDocInDescription(doc, authorizer); 119 | _setDocParameters(doc, validationSchema); 120 | _setDocResponses(doc, validationSchema); 121 | 122 | paths[path] = paths[path] || {}; 123 | paths[path][method] = doc; 124 | 125 | if (doc.tags) tags.push(...doc.tags); 126 | } 127 | 128 | // // _handleRedirects is deprecated in v2 129 | // function _handleRedirects(paths, route) { 130 | // const { method, path } = route; 131 | // if (!route.redirectUrl) return; 132 | 133 | // const redirectUrl = Utils.normalizePathSlashes(route.redirectUrl); 134 | 135 | // const doc = 136 | // paths[redirectUrl] && paths[redirectUrl][method] 137 | // ? { ...paths[redirectUrl][method] } 138 | // : {}; 139 | 140 | // doc.description = `[Redirected to ${redirectUrl}] ${doc.description || ''}`; 141 | // doc.summary = `[Redirected to ${redirectUrl}] ${doc.summary || ''}`; 142 | 143 | // paths[path][method] = doc; 144 | // } 145 | 146 | function convertDocsToSwaggerDoc( 147 | router, 148 | swaggerHeader, 149 | swaggerDefinitions = undefined 150 | ) { 151 | const infoList = RouteUtil.getRoutesInfo(router); 152 | const paths = {}; 153 | let tags = []; 154 | 155 | infoList.forEach((route) => _addPathDoc(paths, route, tags)); 156 | 157 | tags = Array.from(new Set(tags)).map((t) => ({ name: t })); 158 | 159 | const swaggerDoc = Object.assign({}, swaggerHeader); 160 | swaggerDoc.definitions = swaggerDefinitions; 161 | swaggerDoc.tags = tags; 162 | swaggerDoc.paths = paths; 163 | return swaggerDoc; 164 | } 165 | 166 | const sampleSwaggerInfo = { 167 | version: '1.0.0', 168 | title: 'Expressive API', 169 | contact: { 170 | name: 'Author', 171 | email: 'Your email address', 172 | url: '' 173 | } 174 | }; 175 | 176 | function writeSwaggerJson({ 177 | router, 178 | output, 179 | basePath = '/', 180 | swaggerInfo = null, 181 | swaggerSecurityDefinitions = null 182 | }) { 183 | const swaggerHeader = getSwaggerHeader({ 184 | basePath, 185 | swaggerInfo: swaggerInfo || sampleSwaggerInfo, 186 | swaggerSecurityDefinitions 187 | }); 188 | const swaggerJson = convertDocsToSwaggerDoc(router, swaggerHeader); 189 | fs.writeFileSync(output, JSON.stringify(swaggerJson, null, 4)); 190 | } 191 | 192 | function getSwaggerHeader({ 193 | basePath = '/', 194 | swaggerInfo = null, 195 | swaggerSecurityDefinitions = null 196 | }) { 197 | const swaggerHeader = { 198 | swagger: '2.0', 199 | info: swaggerInfo || sampleSwaggerInfo, 200 | basePath: basePath, 201 | schemes: ['https', 'http'], 202 | consumes: ['application/json'], 203 | produces: ['application/json'] 204 | }; 205 | 206 | if (swaggerSecurityDefinitions) { 207 | swaggerHeader.securityDefinitions = swaggerSecurityDefinitions; 208 | swaggerHeader.security = Object.keys(swaggerSecurityDefinitions).map( 209 | (s) => ({ 210 | [s]: [] 211 | }) 212 | ); 213 | } 214 | 215 | return swaggerHeader; 216 | } 217 | 218 | module.exports = { 219 | getSwaggerHeader, 220 | registerExpress, 221 | convertDocsToSwaggerDoc, 222 | writeSwaggerJson, 223 | _sanitizeSwaggerPath 224 | }; 225 | -------------------------------------------------------------------------------- /__tests__/RouteUtil.test.js: -------------------------------------------------------------------------------- 1 | const BaseController = require('../src/BaseController'); 2 | const RouteUtil = require('../src/RouteUtil'); 3 | 4 | const mockSubroutes = [ 5 | { 6 | path: '/users', 7 | router: { 8 | routes: [ 9 | { 10 | path: '/', 11 | method: 'get', 12 | controller: new BaseController() 13 | }, 14 | { 15 | path: '/', 16 | method: 'post', 17 | controller: new BaseController() 18 | } 19 | ], 20 | subroutes: [ 21 | { 22 | path: '/:userId/posts', 23 | router: { 24 | routes: [ 25 | { 26 | path: '/', 27 | method: 'get', 28 | controller: new BaseController() 29 | }, 30 | { 31 | path: '/', 32 | method: 'post', 33 | controller: new BaseController() 34 | } 35 | ] 36 | } 37 | } 38 | ] 39 | } 40 | } 41 | ]; 42 | 43 | describe('RouteUtil', () => { 44 | describe('getRoutesInfo', () => { 45 | it('Should register all routes and subroutes with redirects', () => { 46 | const expectedRoutes = [ 47 | { 48 | path: '/', 49 | parentPath: '', 50 | method: 'get', 51 | doc: 'hello', 52 | controllerName: 'BaseController' 53 | }, 54 | { 55 | path: '/users/', 56 | parentPath: '/users', 57 | method: 'get', 58 | controllerName: 'BaseController' 59 | }, 60 | { 61 | path: '/users/', 62 | parentPath: '/users', 63 | method: 'post', 64 | controllerName: 'BaseController' 65 | }, 66 | { 67 | path: '/users/:userId/posts/', 68 | parentPath: '/users/:userId/posts', 69 | method: 'get', 70 | controllerName: 'BaseController' 71 | }, 72 | { 73 | path: '/users/:userId/posts/', 74 | parentPath: '/users/:userId/posts', 75 | method: 'post', 76 | controllerName: 'BaseController' 77 | } 78 | ]; 79 | 80 | const rootController = new BaseController(); 81 | rootController.doc = 'hello'; 82 | const mockRouter = { 83 | routes: [ 84 | { 85 | path: '/', 86 | method: 'get', 87 | controller: rootController 88 | } 89 | ], 90 | subroutes: mockSubroutes 91 | }; 92 | 93 | const result = RouteUtil.getRoutesInfo(mockRouter); 94 | expect(result).toEqual(expectedRoutes); 95 | }); 96 | 97 | it('Should register all subroutes from top', () => { 98 | const expectedRoutes = [ 99 | { 100 | path: '/users/', 101 | parentPath: '/users', 102 | method: 'get', 103 | controllerName: 'BaseController' 104 | }, 105 | { 106 | path: '/users/', 107 | parentPath: '/users', 108 | method: 'post', 109 | controllerName: 'BaseController' 110 | }, 111 | { 112 | path: '/users/:userId/posts/', 113 | parentPath: '/users/:userId/posts', 114 | method: 'get', 115 | controllerName: 'BaseController' 116 | }, 117 | { 118 | path: '/users/:userId/posts/', 119 | parentPath: '/users/:userId/posts', 120 | method: 'post', 121 | controllerName: 'BaseController' 122 | } 123 | ]; 124 | 125 | const mockRouter = { 126 | subroutes: mockSubroutes 127 | }; 128 | 129 | const result = RouteUtil.getRoutesInfo(mockRouter); 130 | expect(result).toEqual(expectedRoutes); 131 | }); 132 | }); 133 | 134 | describe('getHandlerWithManagedNextCall', () => { 135 | it('Should return handler with next call managed if no next defined', async () => { 136 | const fn = RouteUtil.getHandlerWithManagedNextCall( 137 | async (req, res) => 123 138 | ); 139 | 140 | const mockReq = 1; 141 | const mockRes = 2; 142 | const mockNext = jest.fn(); 143 | 144 | await fn(mockReq, mockRes, mockNext); 145 | 146 | expect(mockNext).toHaveBeenCalled(); 147 | }); 148 | 149 | it('Should return regular handler if 3 args', async () => { 150 | const fn = RouteUtil.getHandlerWithManagedNextCall( 151 | async (req, res, next) => next(123) 152 | ); 153 | 154 | const mockReq = 1; 155 | const mockRes = 2; 156 | const mockNext = jest.fn(); 157 | 158 | await fn(mockReq, mockRes, mockNext); 159 | 160 | expect(mockNext).toHaveBeenCalledWith(123); 161 | }); 162 | 163 | it('Should return handler with proper catch block', async () => { 164 | const someError = new Error('Some error'); 165 | 166 | const fn = RouteUtil.getHandlerWithManagedNextCall( 167 | async (req, res, next) => { 168 | throw someError; 169 | } 170 | ); 171 | 172 | const mockReq = 1; 173 | const mockRes = 2; 174 | const mockNext = jest.fn(); 175 | 176 | await fn(mockReq, mockRes, mockNext); 177 | 178 | expect(mockNext).toHaveBeenCalledWith(someError); 179 | }); 180 | }); 181 | 182 | describe('getErrorHandlerWithManagedNextCall', () => { 183 | it('Should return handler with next call managed if no next defined', async () => { 184 | const fn = RouteUtil.getErrorHandlerWithManagedNextCall( 185 | async (err, req, res) => 123 186 | ); 187 | 188 | const mockReq = 1; 189 | const mockRes = 2; 190 | const mockNext = jest.fn(); 191 | 192 | await fn(null, mockReq, mockRes, mockNext); 193 | 194 | expect(mockNext).toHaveBeenCalled(); 195 | }); 196 | 197 | it('Should return regular handler if 3 args', async () => { 198 | const fn = RouteUtil.getErrorHandlerWithManagedNextCall( 199 | async (err, req, res, next) => next(123) 200 | ); 201 | 202 | const mockReq = 1; 203 | const mockRes = 2; 204 | const mockNext = jest.fn(); 205 | 206 | await fn(null, mockReq, mockRes, mockNext); 207 | 208 | expect(mockNext).toHaveBeenCalledWith(123); 209 | }); 210 | 211 | it('Should return handler with proper catch block', async () => { 212 | const someError = new Error('Some error'); 213 | 214 | const fn = RouteUtil.getErrorHandlerWithManagedNextCall( 215 | async (err, req, res, next) => { 216 | throw someError; 217 | } 218 | ); 219 | 220 | const mockReq = 1; 221 | const mockRes = 2; 222 | const mockNext = jest.fn(); 223 | 224 | await fn(null, mockReq, mockRes, mockNext); 225 | 226 | expect(mockNext).toHaveBeenCalledWith(someError); 227 | }); 228 | }); 229 | 230 | // describe('isFunction', () => { 231 | // it('Should return false if it is a class', () => { 232 | // class SomeClass {} 233 | 234 | // const result = RouteUtil.isFunction(SomeClass); 235 | 236 | // expect(result).toBeFalsy(); 237 | // }); 238 | 239 | // it('Should return true if a named function', () => { 240 | // function someFunc() {} 241 | 242 | // const result = RouteUtil.isFunction(someFunc); 243 | 244 | // expect(result).toBeTruthy(); 245 | // }); 246 | 247 | // it('Should return true if an unnamed function', () => { 248 | // const result = RouteUtil.isFunction(() => {}); 249 | 250 | // expect(result).toBeTruthy(); 251 | // }); 252 | // }); 253 | 254 | describe('isUrlPath', () => { 255 | it('Should return true for valid urls', () => { 256 | ['/hehe/some', '/hehe', '/hehe-blah/:id/ahhs/:someId/hash', '/'].forEach( 257 | (str) => { 258 | expect(RouteUtil.isUrlPath(str)).toBeTruthy(); 259 | } 260 | ); 261 | }); 262 | 263 | it('Should return false for invalid urls', () => { 264 | ['ajdlasjdksad', '10980du90', '/hh*ehe', 'hehe/', '/heheh ooo'].forEach( 265 | (str) => { 266 | expect(RouteUtil.isUrlPath(str)).toBeFalsy(); 267 | } 268 | ); 269 | }); 270 | 271 | it('Should return false if non string input is given', () => { 272 | expect(RouteUtil.isUrlPath(() => {})).toBeFalsy(); 273 | expect(RouteUtil.isUrlPath(class SomeClass {})).toBeFalsy(); 274 | }); 275 | }); 276 | }); 277 | -------------------------------------------------------------------------------- /__tests__/BaseController.test.js: -------------------------------------------------------------------------------- 1 | const BaseController = require('../src/BaseController'); 2 | 3 | describe('BaseController', () => { 4 | it('should be defined and should have handleRequest method', () => { 5 | expect(BaseController).toBeDefined(); 6 | 7 | const base = new BaseController(); 8 | base.handleRequest(); 9 | expect(base.handleRequest).toBeDefined(); 10 | }); 11 | 12 | it('should throw error if handleRequest is not implemented', async () => { 13 | class SomeController extends BaseController {} 14 | 15 | const controller = new SomeController(); 16 | 17 | let result; 18 | try { 19 | await controller.handleRequest(); 20 | } catch (error) { 21 | result = error; 22 | } 23 | 24 | expect(result).toBeInstanceOf(Error); 25 | expect(result.message).toEqual( 26 | `'handleRequest' not implemented in SomeController` 27 | ); 28 | }); 29 | 30 | describe('response', () => { 31 | const controller = new BaseController(); 32 | 33 | const mockRes = { 34 | status: jest.fn(), 35 | send: jest.fn(), 36 | json: jest.fn(), 37 | jsonp: jest.fn(), 38 | sendFile: jest.fn(), 39 | sendStatus: jest.fn(), 40 | end: jest.fn() 41 | }; 42 | 43 | controller.res = mockRes; 44 | 45 | beforeEach(() => { 46 | Object.keys(mockRes).forEach((fn) => { 47 | mockRes[fn].mockClear(); 48 | }); 49 | }); 50 | 51 | it('should handle method ok properly', () => { 52 | controller.ok(); 53 | expect(mockRes.sendStatus).toHaveBeenCalledWith(200); 54 | 55 | controller.ok(123); 56 | expect(mockRes.status).toHaveBeenCalledWith(200); 57 | expect(mockRes.json).toHaveBeenCalledWith(123); 58 | }); 59 | 60 | it('should handle method created properly with dto', () => { 61 | controller.created(); 62 | expect(mockRes.sendStatus).toHaveBeenCalledWith(201); 63 | 64 | controller.created(123); 65 | expect(mockRes.status).toHaveBeenCalledWith(201); 66 | expect(mockRes.json).toHaveBeenCalledWith(123); 67 | }); 68 | 69 | it('should handle method accepted properly with dto', () => { 70 | controller.accepted(); 71 | expect(mockRes.sendStatus).toHaveBeenCalledWith(202); 72 | 73 | controller.accepted(123); 74 | expect(mockRes.status).toHaveBeenCalledWith(202); 75 | expect(mockRes.json).toHaveBeenCalledWith(123); 76 | }); 77 | 78 | it('should handle method noContent properly with dto', () => { 79 | controller.noContent(); 80 | expect(mockRes.sendStatus).toHaveBeenCalledWith(204); 81 | }); 82 | 83 | it('should handle method badRequest properly with message', () => { 84 | controller.badRequest(); 85 | expect(mockRes.status).toHaveBeenCalledWith(400); 86 | expect(mockRes.json).toHaveBeenCalledWith({ 87 | message: 'Bad request' 88 | }); 89 | 90 | controller.badRequest('some message'); 91 | expect(mockRes.status).toHaveBeenCalledWith(400); 92 | expect(mockRes.json).toHaveBeenCalledWith({ 93 | message: 'some message' 94 | }); 95 | }); 96 | 97 | it('should handle method unauthorized properly with message', () => { 98 | controller.unauthorized(); 99 | expect(mockRes.status).toHaveBeenCalledWith(401); 100 | expect(mockRes.json).toHaveBeenCalledWith({ 101 | message: 'Unauthorized' 102 | }); 103 | 104 | controller.unauthorized('some message'); 105 | expect(mockRes.status).toHaveBeenCalledWith(401); 106 | expect(mockRes.json).toHaveBeenCalledWith({ 107 | message: 'some message' 108 | }); 109 | }); 110 | 111 | it('should handle method forbidden properly with message', () => { 112 | controller.forbidden(); 113 | expect(mockRes.status).toHaveBeenCalledWith(403); 114 | expect(mockRes.json).toHaveBeenCalledWith({ 115 | message: 'Forbidden' 116 | }); 117 | 118 | controller.forbidden('some message'); 119 | expect(mockRes.status).toHaveBeenCalledWith(403); 120 | expect(mockRes.json).toHaveBeenCalledWith({ 121 | message: 'some message' 122 | }); 123 | }); 124 | 125 | it('should handle method notFound properly with message', () => { 126 | controller.notFound(); 127 | expect(mockRes.status).toHaveBeenCalledWith(404); 128 | expect(mockRes.json).toHaveBeenCalledWith({ 129 | message: 'Not Found' 130 | }); 131 | 132 | controller.notFound('some message'); 133 | expect(mockRes.status).toHaveBeenCalledWith(404); 134 | expect(mockRes.json).toHaveBeenCalledWith({ 135 | message: 'some message' 136 | }); 137 | }); 138 | 139 | it('should handle method tooMany properly with message', () => { 140 | controller.tooMany(); 141 | expect(mockRes.status).toHaveBeenCalledWith(429); 142 | expect(mockRes.json).toHaveBeenCalledWith({ 143 | message: 'Too many requests' 144 | }); 145 | 146 | controller.tooMany('some message'); 147 | expect(mockRes.status).toHaveBeenCalledWith(429); 148 | expect(mockRes.json).toHaveBeenCalledWith({ 149 | message: 'some message' 150 | }); 151 | }); 152 | 153 | it('Should handle method internalServerError properly', () => { 154 | controller.internalServerError(); 155 | expect(mockRes.status).toHaveBeenCalledWith(500); 156 | expect(mockRes.json).toHaveBeenCalledWith({ 157 | message: 'Internal server error' 158 | }); 159 | 160 | controller.internalServerError('some message'); 161 | expect(mockRes.json).toHaveBeenCalledWith({ 162 | message: 'some message' 163 | }); 164 | 165 | controller.internalServerError('some message', { 166 | some: 'data' 167 | }); 168 | expect(mockRes.json).toHaveBeenCalledWith({ 169 | message: 'some message', 170 | some: 'data' 171 | }); 172 | }); 173 | 174 | it('Should handle method notImplemented properly', () => { 175 | controller.notImplemented(); 176 | expect(mockRes.status).toHaveBeenCalledWith(501); 177 | expect(mockRes.json).toHaveBeenCalledWith({ 178 | message: 'Not implemented' 179 | }); 180 | 181 | controller.notImplemented('some message'); 182 | expect(mockRes.json).toHaveBeenCalledWith({ 183 | message: 'some message' 184 | }); 185 | }); 186 | }); 187 | 188 | it('should return all data for getData given all params have some data', () => { 189 | const req = { 190 | body: { 191 | name: 'john' 192 | }, 193 | query: { 194 | age: 2 195 | }, 196 | params: { 197 | userId: 4 198 | }, 199 | fileUpload: { 200 | file: 1, 201 | files: 2 202 | } 203 | }; 204 | 205 | const controller = new BaseController(); 206 | 207 | controller.req = req; 208 | 209 | expect(controller.getData()).toEqual(req); 210 | }); 211 | 212 | it('should return all data for getData given only body has data', () => { 213 | const req = { 214 | body: { 215 | name: 'john' 216 | } 217 | }; 218 | const controller = new BaseController(); 219 | 220 | controller.req = req; 221 | 222 | expect(controller.getData().body).toEqual({ 223 | name: 'john' 224 | }); 225 | }); 226 | 227 | it('should return empty object for no data from getData', () => { 228 | const req = {}; 229 | const controller = new BaseController(); 230 | 231 | controller.req = req; 232 | 233 | expect(controller.getData()).toEqual({}); 234 | }); 235 | 236 | it('should return headers from getHeaders', () => { 237 | const req = { 238 | headers: { 239 | name: 'john' 240 | } 241 | }; 242 | const controller = new BaseController(); 243 | 244 | controller.req = req; 245 | 246 | expect(controller.getHeaders()).toEqual({ 247 | name: 'john' 248 | }); 249 | }); 250 | 251 | it('should return cookies from getCookies', () => { 252 | const req = { 253 | cookies: { 254 | name: 'john' 255 | }, 256 | signedCookies: { 257 | some: 'otherCookie' 258 | } 259 | }; 260 | const controller = new BaseController(); 261 | 262 | controller.req = req; 263 | 264 | expect(controller.getCookies()).toEqual(req); 265 | }); 266 | 267 | it('should call internal handler if this.ok is called', () => { 268 | class SomeController extends BaseController { 269 | handleRequest() { 270 | this.ok({ some: 'data' }); 271 | } 272 | } 273 | const someRes = { 274 | status: jest.fn(), 275 | json: jest.fn() 276 | }; 277 | const mockHandleInternalError = jest.fn(); 278 | 279 | const controller = new SomeController(); 280 | controller.res = someRes; 281 | 282 | controller.internalServerError = mockHandleInternalError; 283 | 284 | controller.handleRequest(); 285 | 286 | expect(someRes.json).toHaveBeenCalled(); 287 | expect(someRes.status).toHaveBeenCalled(); 288 | expect(mockHandleInternalError).not.toHaveBeenCalled(); 289 | }); 290 | }); 291 | -------------------------------------------------------------------------------- /src/CelebrateUtils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable new-cap */ 2 | const { Joi, CelebrateError, Segments } = require('celebrate'); 3 | const Utils = require('./Utils'); 4 | 5 | function _isJoiObject(obj) { 6 | return Boolean(obj.type === 'object' && obj.$_terms); 7 | } 8 | 9 | function _getJoiObjectKeys(obj) { 10 | return obj.$_terms.keys; 11 | } 12 | 13 | function _getTypeFromjoiSchema(joiSchema) { 14 | const { type } = joiSchema; 15 | if (['string', 'array', 'object', 'boolean'].includes(type)) { 16 | return joiSchema.type; 17 | } 18 | 19 | if (type === 'number') { 20 | return ( 21 | (joiSchema._rules.some((e) => e.name === 'integer') && 'integer') || 22 | 'number' 23 | ); 24 | } 25 | 26 | return 'string'; 27 | } 28 | 29 | function _getMinMaxFromSchemaDefition(joiSchema) { 30 | let min = joiSchema._rules.find((r) => r.name === 'min'); 31 | min = (min && min.args.limit) || null; 32 | 33 | let max = joiSchema._rules.find((r) => r.name === 'max'); 34 | max = (max && max.args.limit) || null; 35 | 36 | return { 37 | min, 38 | max 39 | }; 40 | } 41 | 42 | function _setMinMaxInSwaggerSchema(type, joiSchema, swaggerSchema) { 43 | const { min, max } = _getMinMaxFromSchemaDefition(joiSchema); 44 | 45 | let minKey = 'minimum'; 46 | let maxKey = 'maximum'; 47 | 48 | if (type === 'string') { 49 | minKey = 'minLength'; 50 | maxKey = 'maxLength'; 51 | } else if (type === 'array') { 52 | minKey = 'minItems'; 53 | maxKey = 'maxItems'; 54 | } 55 | 56 | swaggerSchema[minKey] = min; 57 | swaggerSchema[maxKey] = max; 58 | } 59 | 60 | function _setSwaggerPropsForObject(type, joiSchema, swaggerSchema) { 61 | if ( 62 | !( 63 | type === 'object' && 64 | _getJoiObjectKeys(joiSchema) && 65 | _getJoiObjectKeys(joiSchema).length > 0 66 | ) 67 | ) 68 | return; 69 | const requiredProperties = []; 70 | const objectjoiSchemaMap = {}; 71 | 72 | _getJoiObjectKeys(joiSchema).forEach((objectSchema) => { 73 | if (objectSchema.schema._flags.presence === 'required') { 74 | requiredProperties.push(objectSchema.key); 75 | } 76 | objectjoiSchemaMap[objectSchema.key] = _getSchemaDefinitionForSwagger( 77 | objectSchema.schema 78 | ); 79 | }); 80 | swaggerSchema.required = 81 | (requiredProperties.length > 0 && requiredProperties) || null; 82 | swaggerSchema.properties = objectjoiSchemaMap; 83 | } 84 | 85 | function _setSwaggerPropsForArray(type, joiSchema, swaggerSchema) { 86 | if (type !== 'array') return; 87 | 88 | const hasItemSchema = joiSchema.$_terms.items.length > 0; 89 | swaggerSchema.items = 90 | (hasItemSchema && 91 | _getSchemaDefinitionForSwagger(joiSchema.$_terms.items[0])) || 92 | {}; 93 | } 94 | 95 | function _setMultipleOfSwaggerSchema(joiSchema, swaggerSchema) { 96 | const multipleOf = joiSchema._rules.find((r) => r.name === 'multiple'); 97 | swaggerSchema.multipleOf = (multipleOf && multipleOf.args.base) || null; 98 | } 99 | 100 | function _setPatternSwaggerSchema(joiSchema, swaggerSchema) { 101 | const pattern = joiSchema._rules.find((r) => r.name === 'pattern'); 102 | swaggerSchema.pattern = (pattern && String(pattern.args.regex)) || null; 103 | } 104 | 105 | function _setDefaultValueForSwaggerSchema(joiSchema, swaggerSchema) { 106 | const defaultValue = joiSchema._flags.default; 107 | swaggerSchema.default = 108 | (defaultValue && JSON.stringify(defaultValue)) || null; 109 | } 110 | 111 | function _setSwaggerPropsEnums(joiSchema, swaggerSchema) { 112 | if (!joiSchema._valids) return; 113 | const validValues = [...joiSchema._valids._values.values()]; 114 | swaggerSchema.enum = validValues; 115 | } 116 | 117 | function _setSwaggerPropsNullable(swaggerSchema) { 118 | if (swaggerSchema.enum && swaggerSchema.enum.includes(null)) { 119 | swaggerSchema.nullable = true; 120 | } 121 | } 122 | 123 | function _getSchemaDefinitionForSwagger(joiSchema) { 124 | const type = _getTypeFromjoiSchema(joiSchema); 125 | 126 | const swaggerSchema = { 127 | type 128 | }; 129 | 130 | _setDefaultValueForSwaggerSchema(joiSchema, swaggerSchema); 131 | _setMultipleOfSwaggerSchema(joiSchema, swaggerSchema); 132 | _setPatternSwaggerSchema(joiSchema, swaggerSchema); 133 | _setSwaggerPropsEnums(joiSchema, swaggerSchema); 134 | _setSwaggerPropsNullable(swaggerSchema); 135 | _setMinMaxInSwaggerSchema(type, joiSchema, swaggerSchema); 136 | _setSwaggerPropsForObject(type, joiSchema, swaggerSchema); 137 | _setSwaggerPropsForArray(type, joiSchema, swaggerSchema); 138 | 139 | Utils.clearNullValuesInObject(swaggerSchema); 140 | 141 | return swaggerSchema; 142 | } 143 | 144 | function _getAllSwaggerParamsFromValidationSchema(schema, paramIn) { 145 | return Object.keys(schema).map((key) => { 146 | const joiSchema = schema[key]; 147 | const schemaDefinition = _getSchemaDefinitionForSwagger(joiSchema); 148 | const isRequired = joiSchema._flags.presence === 'required'; 149 | const swaggerParams = { 150 | name: key, 151 | in: paramIn, 152 | required: isRequired, 153 | schema: schemaDefinition 154 | }; 155 | 156 | if (paramIn === 'fileUpload') { 157 | swaggerParams.type = 'file'; 158 | swaggerParams.in = 'formData'; 159 | } 160 | return swaggerParams; 161 | }); 162 | } 163 | 164 | function _isJoiAny(schema) { 165 | return Boolean(schema.type === 'any' && schema.$_terms); 166 | } 167 | 168 | function _getSwaggerParamsForBody(bodySchema) { 169 | const joiSchema = 170 | _isJoiObject(bodySchema) || _isJoiAny(bodySchema) 171 | ? bodySchema 172 | : Joi.object(bodySchema); 173 | 174 | const swaggerSchema = { 175 | type: 'object' 176 | }; 177 | 178 | _setSwaggerPropsForObject('object', joiSchema, swaggerSchema); 179 | Utils.clearNullValuesInObject(swaggerSchema); 180 | 181 | return { 182 | name: 'body', 183 | in: 'body', 184 | schema: swaggerSchema 185 | }; 186 | } 187 | 188 | function _getObjectNormalizedSchema(schema) { 189 | if (_isJoiObject(schema)) { 190 | return _getJoiObjectKeys(schema).reduce((map, schemaObj) => { 191 | map[schemaObj.key] = schemaObj.schema; 192 | return map; 193 | }, {}); 194 | } 195 | return schema; 196 | } 197 | 198 | function _lowercaseHeaderSchemaKeys(headerSchema, paramLocation = 'header') { 199 | if (paramLocation === 'header') { 200 | Object.keys(headerSchema).forEach((key) => { 201 | headerSchema[key.toLowerCase()] = headerSchema[key]; 202 | delete headerSchema[key]; 203 | }); 204 | } 205 | } 206 | 207 | function _addSwaggerParamsForNonBodyProps(parameterKeyMap, parameters) { 208 | Object.keys(parameterKeyMap).forEach((paramLocation) => { 209 | const normalizedSchema = _getObjectNormalizedSchema( 210 | parameterKeyMap[paramLocation] 211 | ); 212 | _lowercaseHeaderSchemaKeys(normalizedSchema, paramLocation); 213 | const swaggerParams = _getAllSwaggerParamsFromValidationSchema( 214 | normalizedSchema, 215 | paramLocation 216 | ); 217 | parameters.push(...swaggerParams); 218 | }); 219 | } 220 | 221 | function joiSchemaToSwaggerRequestParameters(validationSchema) { 222 | if (!validationSchema) return []; 223 | 224 | const { 225 | query, 226 | params: path, 227 | headers: header, 228 | body, 229 | fileUpload 230 | } = validationSchema; 231 | 232 | const parameters = []; 233 | if (body && Object.keys(body).length > 0) { 234 | parameters.push(_getSwaggerParamsForBody(body)); 235 | } 236 | 237 | const parameterKeyMap = { query, path, header, fileUpload }; 238 | Utils.clearNullValuesInObject(parameterKeyMap); 239 | _addSwaggerParamsForNonBodyProps(parameterKeyMap, parameters); 240 | 241 | return parameters; 242 | } 243 | 244 | function lowercaseHeaderSchemaProperties(validationSchema) { 245 | if (!validationSchema.headers) return; 246 | 247 | const { headers } = validationSchema; 248 | 249 | if (_isJoiObject(headers)) { 250 | _getJoiObjectKeys(validationSchema.headers).forEach((obj) => { 251 | obj.key = obj.key.toLowerCase(); 252 | }); 253 | 254 | const byKey = validationSchema.headers._ids._byKey; 255 | const keysForById = [...byKey.keys()]; 256 | keysForById.forEach((key) => { 257 | const lowercaseKey = key.toLowerCase(); 258 | const mapValue = byKey.get(key); 259 | mapValue.id = mapValue.id.toLowerCase(); 260 | byKey.set(lowercaseKey, mapValue); 261 | byKey.delete(key); 262 | }); 263 | } else { 264 | _lowercaseHeaderSchemaKeys(validationSchema.headers); 265 | } 266 | } 267 | 268 | function getSanitizedValidationSchema(validationSchema) { 269 | if ( 270 | validationSchema.fileUpload && 271 | Object.keys(validationSchema).length === 1 272 | ) { 273 | return null; 274 | } 275 | 276 | const newValidationSchema = { ...validationSchema }; 277 | delete newValidationSchema.fileUpload; 278 | return newValidationSchema; 279 | } 280 | 281 | function getCelebrateValidationMiddlewareForFileUpload(fileUploadValidation) { 282 | let schema = fileUploadValidation; 283 | if (!_isJoiObject(schema)) { 284 | schema = Joi.object(fileUploadValidation); 285 | } 286 | 287 | return async (req, _, next) => { 288 | const { file, files } = req; 289 | const testObj = { 290 | ...(file && { file }), 291 | ...(files && { files }) 292 | }; 293 | 294 | try { 295 | await schema.validateAsync(testObj); 296 | return next(); 297 | } catch (error) { 298 | error.isJoi = true; 299 | return next( 300 | new CelebrateError(error, Segments.BODY, { celebrated: true }) 301 | ); 302 | } 303 | }; 304 | } 305 | 306 | module.exports = { 307 | joiSchemaToSwaggerRequestParameters, 308 | lowercaseHeaderSchemaProperties, 309 | getCelebrateValidationMiddlewareForFileUpload, 310 | getSanitizedValidationSchema 311 | }; 312 | -------------------------------------------------------------------------------- /__tests__/RouterFactory.test.js: -------------------------------------------------------------------------------- 1 | const RouterFactory = require('../src/RouterFactory'); 2 | const BaseController = require('../src/BaseController'); 3 | const Route = require('../src/Route'); 4 | 5 | class MockControllerThrowsError extends BaseController {} 6 | const mockErrorJestFn = jest.fn().mockImplementation(() => { 7 | throw new Error('mockErrorJestFn'); 8 | }); 9 | MockControllerThrowsError.prototype.handleRequest = mockErrorJestFn; 10 | 11 | const mockSubroutes = [ 12 | { 13 | path: '/users', 14 | router: { 15 | routes: [ 16 | Route.get('/', new BaseController()), 17 | Route.post('/', new BaseController()) 18 | ], 19 | subroutes: [ 20 | { 21 | path: '/:userId/posts', 22 | router: { 23 | routes: [ 24 | Route.get('/', new BaseController()), 25 | Route.post('/', new BaseController()) 26 | ] 27 | } 28 | } 29 | ] 30 | } 31 | } 32 | ]; 33 | 34 | describe('RouterFactory', () => { 35 | beforeEach(() => { 36 | mockErrorJestFn.mockClear(); 37 | }); 38 | 39 | afterEach(() => { 40 | mockErrorJestFn.mockClear(); 41 | }); 42 | 43 | describe('getExpressRouter', () => { 44 | it('Should register all routes and subroutes', () => { 45 | const mockRouter = { 46 | routes: [ 47 | { 48 | path: '/', 49 | method: 'get', 50 | controller: new BaseController() 51 | } 52 | ], 53 | subroutes: mockSubroutes 54 | }; 55 | 56 | const routerFactory = new RouterFactory({}); 57 | const mockExpressRouter = { 58 | get: jest.fn(), 59 | use: jest.fn(), 60 | post: jest.fn() 61 | }; 62 | routerFactory._getRouter = jest.fn().mockReturnValue(mockExpressRouter); 63 | routerFactory.getExpressRouter(mockRouter); 64 | 65 | expect(mockExpressRouter.get).toHaveBeenCalledTimes(3); 66 | expect(mockExpressRouter.get.mock.calls[0][0]).toEqual('/'); 67 | expect(mockExpressRouter.get.mock.calls[1][0]).toEqual('/'); 68 | expect(mockExpressRouter.get.mock.calls[2][0]).toEqual('/'); 69 | 70 | expect(mockExpressRouter.post).toHaveBeenCalledTimes(2); 71 | expect(mockExpressRouter.get.mock.calls[0][0]).toEqual('/'); 72 | expect(mockExpressRouter.get.mock.calls[1][0]).toEqual('/'); 73 | 74 | expect(mockExpressRouter.use).toHaveBeenCalledTimes(2); 75 | expect(mockExpressRouter.use.mock.calls[0][0]).toEqual('/:userId/posts'); 76 | expect(mockExpressRouter.use.mock.calls[1][0]).toEqual('/users'); 77 | }); 78 | 79 | it('Should register all subroutes from top', () => { 80 | const mockRouter = { 81 | subroutes: mockSubroutes 82 | }; 83 | const routerFactory = new RouterFactory({}); 84 | const mockExpressRouter = { 85 | get: jest.fn(), 86 | use: jest.fn(), 87 | post: jest.fn() 88 | }; 89 | routerFactory._getRouter = jest.fn().mockReturnValue(mockExpressRouter); 90 | routerFactory.getExpressRouter(mockRouter); 91 | 92 | expect(mockExpressRouter.get).toHaveBeenCalledTimes(2); 93 | expect(mockExpressRouter.get.mock.calls[0][0]).toEqual('/'); 94 | expect(mockExpressRouter.get.mock.calls[1][0]).toEqual('/'); 95 | 96 | expect(mockExpressRouter.post).toHaveBeenCalledTimes(2); 97 | expect(mockExpressRouter.get.mock.calls[0][0]).toEqual('/'); 98 | expect(mockExpressRouter.get.mock.calls[1][0]).toEqual('/'); 99 | 100 | expect(mockExpressRouter.use).toHaveBeenCalledTimes(2); 101 | expect(mockExpressRouter.use.mock.calls[0][0]).toEqual('/:userId/posts'); 102 | expect(mockExpressRouter.use.mock.calls[1][0]).toEqual('/users'); 103 | }); 104 | }); 105 | 106 | describe('_registerRoute', () => { 107 | it('Should register route with middleware properly', () => { 108 | const factory = new RouterFactory({}); 109 | 110 | const mockExpressRouter = { 111 | get: jest.fn(), 112 | use: jest.fn(), 113 | post: jest.fn() 114 | }; 115 | 116 | factory.routeUtil = { 117 | getDuplicateUrls: jest.fn().mockReturnValue([]), 118 | getHandlerWithManagedNextCall: jest.fn() 119 | }; 120 | 121 | class SomeController extends BaseController {} 122 | const controller = new SomeController(); 123 | controller.router = { 124 | routes: [Route.get('/hello', new BaseController())] 125 | }; 126 | controller.middleware = [(req, res) => 1, (req, res) => 2]; 127 | controller.authorizer = (req, res) => {}; 128 | 129 | factory._registerRoute(mockExpressRouter, { 130 | method: 'get', 131 | path: '/', 132 | controller 133 | }); 134 | 135 | expect( 136 | factory.routeUtil.getHandlerWithManagedNextCall 137 | ).toHaveBeenCalledTimes(2); 138 | expect(mockExpressRouter.get).toHaveBeenCalled(); 139 | }); 140 | 141 | it('Should use celebrate validation schema', () => { 142 | const factory = new RouterFactory({}); 143 | 144 | factory.celebrateMiddleware = jest.fn().mockReturnValue(1); 145 | 146 | const mockExpressRouter = { 147 | get: jest.fn(), 148 | use: jest.fn(), 149 | post: jest.fn() 150 | }; 151 | 152 | factory.routeUtil = { 153 | getHandlerWithManagedNextCall: jest.fn() 154 | }; 155 | 156 | const schema = { 157 | body: { 158 | someId: 1 159 | } 160 | }; 161 | 162 | class SomeController extends BaseController {} 163 | const controller = new SomeController(); 164 | controller.middleware = [(req, res) => 1, (req, res) => 2]; 165 | controller.authorizer = (req, res) => {}; 166 | controller.validationSchema = schema; 167 | 168 | factory._registerRoute(mockExpressRouter, { 169 | method: 'get', 170 | path: '/', 171 | controller 172 | }); 173 | 174 | expect(factory.celebrateMiddleware).toHaveBeenCalledWith(schema, { 175 | abortEarly: false 176 | }); 177 | expect( 178 | factory.routeUtil.getHandlerWithManagedNextCall 179 | ).toHaveBeenCalledTimes(2); 180 | expect(mockExpressRouter.get).toHaveBeenCalled(); 181 | }); 182 | 183 | it('Should not use celebrate validation schema with file upload', () => { 184 | const factory = new RouterFactory({}); 185 | 186 | factory.celebrateMiddleware = jest.fn().mockReturnValue(1); 187 | 188 | const mockExpressRouter = { 189 | get: jest.fn(), 190 | use: jest.fn(), 191 | post: jest.fn() 192 | }; 193 | 194 | factory.routeUtil = { 195 | getHandlerWithManagedNextCall: jest.fn() 196 | }; 197 | 198 | const schema = { 199 | fileUpload: { 200 | someId: 1 201 | } 202 | }; 203 | 204 | class SomeCtr extends BaseController {} 205 | const controller = new SomeCtr(); 206 | controller.middleware = [(req, res) => 1, (req, res) => 2]; 207 | controller.authorizer = (req, res) => {}; 208 | controller.validationSchema = schema; 209 | 210 | factory._registerRoute(mockExpressRouter, { 211 | method: 'get', 212 | path: '/', 213 | controller 214 | }); 215 | 216 | expect(factory.celebrateMiddleware).not.toHaveBeenCalledWith(schema); 217 | expect( 218 | factory.routeUtil.getHandlerWithManagedNextCall 219 | ).toHaveBeenCalledTimes(2); 220 | expect(mockExpressRouter.get).toHaveBeenCalled(); 221 | }); 222 | }); 223 | 224 | describe('_registerPreHandlers', () => { 225 | it('Should not change routerArgs if no pre handlers defined', () => { 226 | const factory = new RouterFactory({}); 227 | 228 | const routerArgs = []; 229 | 230 | factory._registerPreHandlers(routerArgs, null); 231 | 232 | expect(routerArgs).toEqual([]); 233 | }); 234 | 235 | it('Should register handler if single handler defined', () => { 236 | const factory = new RouterFactory({}); 237 | factory.routeUtil = { 238 | getHandlerWithManagedNextCall: jest.fn().mockReturnValue(123) 239 | }; 240 | const routerArgs = []; 241 | const handler = (req, res) => {}; 242 | factory._registerPreHandlers(routerArgs, handler); 243 | 244 | expect(routerArgs).toEqual([123]); 245 | expect( 246 | factory.routeUtil.getHandlerWithManagedNextCall 247 | ).toHaveBeenCalledWith(handler); 248 | }); 249 | 250 | it('Should register handler if array of handlers defined', () => { 251 | const factory = new RouterFactory({}); 252 | factory.routeUtil = { 253 | getHandlerWithManagedNextCall: jest.fn().mockReturnValue(123) 254 | }; 255 | const routerArgs = []; 256 | const handlers = [ 257 | (req, res) => { 258 | return 1; 259 | }, 260 | (req, res) => { 261 | return 2; 262 | } 263 | ]; 264 | factory._registerPreHandlers(routerArgs, handlers); 265 | 266 | expect(routerArgs).toEqual([123, 123]); 267 | expect( 268 | factory.routeUtil.getHandlerWithManagedNextCall 269 | ).toHaveBeenCalledWith(handlers[0]); 270 | expect( 271 | factory.routeUtil.getHandlerWithManagedNextCall 272 | ).toHaveBeenCalledWith(handlers[1]); 273 | }); 274 | }); 275 | 276 | describe('_getWrappedController', () => { 277 | it('should execute given base controller child', async () => { 278 | const factory = new RouterFactory(); 279 | 280 | const mockFn = jest.fn(); 281 | 282 | class SomeController extends BaseController { 283 | handleRequest() { 284 | mockFn(); 285 | } 286 | } 287 | 288 | const controller = new SomeController(); 289 | 290 | const mockHandleInternalError = jest.fn(); 291 | controller.internalServerError = mockHandleInternalError; 292 | 293 | const fn = factory._getWrappedController(controller); 294 | 295 | const someReq = 1; 296 | const someRes = { 297 | status: jest.fn(), 298 | json: jest.fn, 299 | headersSent: false 300 | }; 301 | const someNext = 3; 302 | 303 | await fn(someReq, someRes, someNext); 304 | 305 | expect(mockFn).toHaveBeenCalled(); 306 | expect(mockHandleInternalError).toHaveBeenCalledWith( 307 | 'Server did not send any response' 308 | ); 309 | }); 310 | 311 | it('should pass error to next', async () => { 312 | const factory = new RouterFactory(); 313 | 314 | class SomeController extends BaseController { 315 | handleRequest() { 316 | throw new Error('Some error'); 317 | } 318 | } 319 | 320 | const fn = factory._getWrappedController(new SomeController()); 321 | 322 | const someReq = 1; 323 | const someRes = 2; 324 | const someNext = jest.fn(); 325 | 326 | await fn(someReq, someRes, someNext); 327 | 328 | // expect(mockFn).toHaveBeenCalledWith(someReq, someRes, someNext); 329 | expect(someNext).toHaveBeenCalledWith(new Error('Some error')); 330 | }); 331 | 332 | it('should not call internal if resolvedBy was populated', async () => { 333 | const factory = new RouterFactory(); 334 | class SomeController extends BaseController { 335 | handleRequest() { 336 | this.ok({ some: 'data' }); 337 | } 338 | } 339 | 340 | const controller = new SomeController(); 341 | controller.resolvedBy = 'test'; 342 | controller.internalServerError = jest.fn(); 343 | const fn = factory._getWrappedController(controller); 344 | 345 | const someReq = 1; 346 | const someRes = { 347 | status: jest.fn(), 348 | json: jest.fn(), 349 | headersSent: true 350 | }; 351 | const someNext = jest.fn(); 352 | 353 | await fn(someReq, someRes, someNext); 354 | 355 | expect(someNext).not.toHaveBeenCalled(); 356 | expect(controller.internalServerError).not.toHaveBeenCalled(); 357 | }); 358 | }); 359 | 360 | it('Should get router from method', () => { 361 | const factory = new RouterFactory(); 362 | 363 | const result = factory._getRouter(); 364 | 365 | expect(result).toBeDefined(); 366 | }); 367 | 368 | it('Should throw error if duplicate urls', () => { 369 | let response; 370 | const mockRoutes = { 371 | routes: [ 372 | Route.get('/hello', () => {}), 373 | Route.get('/hello/', () => {}), 374 | Route.get('/v1/hey', () => {}) 375 | ], 376 | subroutes: [ 377 | { 378 | path: '/v1', 379 | router: { 380 | routes: [Route.get('/hey', () => {})] 381 | } 382 | } 383 | ] 384 | }; 385 | try { 386 | new RouterFactory({})._handleDuplicateUrls(mockRoutes); 387 | } catch (error) { 388 | response = error; 389 | } 390 | 391 | expect(response.message).toEqual( 392 | 'Duplicate endpoints detected! -> get /hello/, get /v1/hey/' 393 | ); 394 | }); 395 | }); 396 | -------------------------------------------------------------------------------- /__tests__/CelebrateUtils.test.js: -------------------------------------------------------------------------------- 1 | const { Joi } = jest.requireActual('celebrate'); 2 | const CelebrateUtils = require('../src/CelebrateUtils'); 3 | 4 | describe('CelebrateUtils', () => { 5 | describe('joiSchemaToSwaggerRequestParameters', () => { 6 | it('Should convert validationSchema to swagger parameter json for basic query, params, and headers', () => { 7 | const validationSchema = { 8 | headers: Joi.object({ 9 | 'X-Auth': Joi.string().required() 10 | }), 11 | query: { 12 | page: Joi.number().integer().optional() 13 | }, 14 | params: { 15 | userId: Joi.number().integer().required() 16 | } 17 | }; 18 | 19 | const json = CelebrateUtils.joiSchemaToSwaggerRequestParameters( 20 | validationSchema 21 | ); 22 | 23 | expect(json).toContainEqual({ 24 | name: 'page', 25 | in: 'query', 26 | required: false, 27 | schema: { 28 | type: 'integer' 29 | } 30 | }); 31 | 32 | expect(json).toContainEqual({ 33 | name: 'userId', 34 | in: 'path', 35 | required: true, 36 | schema: { 37 | type: 'integer' 38 | } 39 | }); 40 | 41 | expect(json).toContainEqual({ 42 | name: 'x-auth', 43 | in: 'header', 44 | schema: { 45 | type: 'string' 46 | }, 47 | required: true 48 | }); 49 | }); 50 | 51 | it('Should convert validationSchema to swagger if body is Joi object', () => { 52 | const validationSchema = { 53 | body: Joi.object({ 54 | name: Joi.string().required() 55 | }) 56 | }; 57 | 58 | const json = CelebrateUtils.joiSchemaToSwaggerRequestParameters( 59 | validationSchema 60 | ); 61 | 62 | expect(json).toContainEqual({ 63 | in: 'body', 64 | name: 'body', 65 | schema: { 66 | type: 'object', 67 | properties: { 68 | name: { 69 | type: 'string' 70 | } 71 | }, 72 | required: ['name'] 73 | } 74 | }); 75 | }); 76 | 77 | it('Should convert validationSchema to swagger for property with options - valid, empty, and nullable', () => { 78 | const validationSchema = { 79 | body: Joi.object({ 80 | name: Joi.string().valid('hey', 'you').required(), 81 | someAllow: Joi.string().allow('huh'), 82 | nullable: Joi.string().valid('hey').allow(null) 83 | }) 84 | }; 85 | 86 | const json = CelebrateUtils.joiSchemaToSwaggerRequestParameters( 87 | validationSchema 88 | ); 89 | 90 | expect(json).toContainEqual({ 91 | in: 'body', 92 | name: 'body', 93 | schema: { 94 | type: 'object', 95 | properties: { 96 | name: { 97 | type: 'string', 98 | enum: ['hey', 'you'] 99 | }, 100 | someAllow: { 101 | type: 'string', 102 | enum: ['huh'] 103 | }, 104 | nullable: { 105 | type: 'string', 106 | nullable: true, 107 | enum: ['hey', null] 108 | } 109 | }, 110 | required: ['name'] 111 | } 112 | }); 113 | }); 114 | 115 | it('Should convert validationSchema to swagger parameter json for complex body', () => { 116 | const validationSchema = { 117 | body: { 118 | name: Joi.string().required(), 119 | age: Joi.number().integer().required(), 120 | income: Joi.number().required(), 121 | dob: Joi.date() 122 | .optional() 123 | .default(new Date('2020-01-31T18:00:00.000Z')), 124 | email: Joi.string().email().optional(), 125 | patternString: Joi.string().pattern(/someregex/), 126 | boundedNumber: Joi.number().min(3).max(5), 127 | favoriteString: Joi.string().min(5).max(10), 128 | someMultiple: Joi.number().multiple(5) 129 | } 130 | }; 131 | 132 | const json = CelebrateUtils.joiSchemaToSwaggerRequestParameters( 133 | validationSchema 134 | ); 135 | 136 | expect(json).toContainEqual({ 137 | in: 'body', 138 | name: 'body', 139 | schema: { 140 | type: 'object', 141 | properties: { 142 | name: { 143 | type: 'string' 144 | }, 145 | age: { 146 | type: 'integer' 147 | }, 148 | income: { 149 | type: 'number' 150 | }, 151 | dob: { 152 | type: 'string', 153 | default: '"2020-01-31T18:00:00.000Z"' 154 | }, 155 | email: { 156 | type: 'string' 157 | }, 158 | patternString: { 159 | type: 'string', 160 | pattern: '/someregex/' 161 | }, 162 | boundedNumber: { 163 | type: 'number', 164 | minimum: 3, 165 | maximum: 5 166 | }, 167 | favoriteString: { 168 | type: 'string', 169 | minLength: 5, 170 | maxLength: 10 171 | }, 172 | someMultiple: { 173 | type: 'number', 174 | multipleOf: 5 175 | } 176 | }, 177 | required: ['name', 'age', 'income'] 178 | } 179 | }); 180 | }); 181 | 182 | it('Should convert swagger json for nested object schema', () => { 183 | const validationSchema = { 184 | body: { 185 | address: Joi.object() 186 | .keys({ 187 | street: Joi.string().required(), 188 | house: Joi.string().required(), 189 | extra: Joi.object() 190 | .keys({ 191 | floor: Joi.number(), 192 | flat: Joi.number(), 193 | houseName: Joi.string() 194 | }) 195 | .required(), 196 | zipCode: Joi.number().integer().optional() 197 | }) 198 | .required() 199 | } 200 | }; 201 | 202 | const json = CelebrateUtils.joiSchemaToSwaggerRequestParameters( 203 | validationSchema 204 | ); 205 | 206 | expect(json).toContainEqual({ 207 | in: 'body', 208 | name: 'body', 209 | schema: { 210 | type: 'object', 211 | properties: { 212 | address: { 213 | type: 'object', 214 | properties: { 215 | street: { 216 | type: 'string' 217 | }, 218 | house: { 219 | type: 'string' 220 | }, 221 | zipCode: { 222 | type: 'integer' 223 | }, 224 | extra: { 225 | type: 'object', 226 | properties: { 227 | floor: { 228 | type: 'number' 229 | }, 230 | flat: { 231 | type: 'number' 232 | }, 233 | houseName: { 234 | type: 'string' 235 | } 236 | } 237 | // required: ["flat"] 238 | } 239 | }, 240 | required: ['street', 'house', 'extra'] 241 | } 242 | }, 243 | required: ['address'] 244 | } 245 | }); 246 | }); 247 | 248 | it('Should convert json for array of items', () => { 249 | const validationSchema = { 250 | body: { 251 | blankArray: Joi.array().min(2).max(5), 252 | favoriteNumbers: Joi.array().items(Joi.number()), 253 | favoriteBooks: Joi.array().items( 254 | Joi.object().keys({ 255 | author: Joi.string().required(), 256 | publishDate: Joi.date() 257 | }) 258 | ) 259 | } 260 | }; 261 | 262 | const json = CelebrateUtils.joiSchemaToSwaggerRequestParameters( 263 | validationSchema 264 | ); 265 | 266 | expect(json).toContainEqual({ 267 | in: 'body', 268 | name: 'body', 269 | schema: { 270 | type: 'object', 271 | properties: { 272 | blankArray: { 273 | type: 'array', 274 | items: {}, 275 | maxItems: 5, 276 | minItems: 2 277 | }, 278 | favoriteNumbers: { 279 | type: 'array', 280 | items: { 281 | type: 'number' 282 | } 283 | }, 284 | favoriteBooks: { 285 | type: 'array', 286 | items: { 287 | type: 'object', 288 | properties: { 289 | author: { 290 | type: 'string' 291 | }, 292 | publishDate: { 293 | type: 'string' 294 | } 295 | }, 296 | required: ['author'] 297 | } 298 | } 299 | } 300 | } 301 | }); 302 | }); 303 | 304 | it('Should allow body joi object check', () => { 305 | const validationSchema = { 306 | body: Joi.object().required() 307 | }; 308 | 309 | const json = CelebrateUtils.joiSchemaToSwaggerRequestParameters( 310 | validationSchema 311 | ); 312 | 313 | expect(json).toContainEqual({ 314 | in: 'body', 315 | name: 'body', 316 | schema: { 317 | type: 'object' 318 | } 319 | }); 320 | }); 321 | 322 | it('Should allow body joi any check', () => { 323 | const validationSchema = { 324 | body: Joi.any().required() 325 | }; 326 | 327 | const json = CelebrateUtils.joiSchemaToSwaggerRequestParameters( 328 | validationSchema 329 | ); 330 | 331 | expect(json).toContainEqual({ 332 | in: 'body', 333 | name: 'body', 334 | schema: { 335 | type: 'object' 336 | } 337 | }); 338 | }); 339 | }); 340 | 341 | describe('lowercaseHeaderSchemaProperties', () => { 342 | it('Should do nothing if no headers present', () => { 343 | const validationSchema = { 344 | body: { 345 | some: 'validation' 346 | } 347 | }; 348 | 349 | CelebrateUtils.lowercaseHeaderSchemaProperties(validationSchema); 350 | 351 | expect(validationSchema).toEqual(validationSchema); 352 | }); 353 | 354 | it('Should lowercase if headers object provided', () => { 355 | const validationSchema = { 356 | body: { 357 | some: 'validation' 358 | }, 359 | headers: { 360 | Authorization: 1234, 361 | ContentType: 'json' 362 | } 363 | }; 364 | 365 | CelebrateUtils.lowercaseHeaderSchemaProperties(validationSchema); 366 | 367 | expect(validationSchema).toEqual({ 368 | body: { 369 | some: 'validation' 370 | }, 371 | headers: { 372 | authorization: 1234, 373 | contenttype: 'json' 374 | } 375 | }); 376 | }); 377 | 378 | it('Should lowercase if headers object provided as joi object', () => { 379 | const validationSchema = { 380 | body: { 381 | some: 'validation' 382 | }, 383 | headers: Joi.object({ 384 | Authorization: 1234, 385 | ContentType: 'json' 386 | }) 387 | }; 388 | 389 | CelebrateUtils.lowercaseHeaderSchemaProperties(validationSchema); 390 | 391 | expect(JSON.stringify(validationSchema.headers)).toEqual( 392 | JSON.stringify( 393 | Joi.object({ 394 | authorization: 1234, 395 | contenttype: 'json' 396 | }) 397 | ) 398 | ); 399 | }); 400 | }); 401 | 402 | describe('getCelebrateValidationMiddlewareForFileUpload', () => { 403 | it('should return proper handler with non joi object - no error', async () => { 404 | const fileUpload = { 405 | file: Joi.any().required() 406 | }; 407 | 408 | const handler = CelebrateUtils.getCelebrateValidationMiddlewareForFileUpload( 409 | fileUpload 410 | ); 411 | 412 | const mockNext = jest.fn(); 413 | const mockReq = { 414 | file: 'some file' 415 | }; 416 | await handler(mockReq, null, mockNext); 417 | 418 | expect(mockNext).toHaveBeenCalledWith(); 419 | }); 420 | 421 | it('should return proper handler with non joi object - no error - multiple files', async () => { 422 | const fileUpload = { 423 | files: Joi.any().required() 424 | }; 425 | 426 | const handler = CelebrateUtils.getCelebrateValidationMiddlewareForFileUpload( 427 | fileUpload 428 | ); 429 | 430 | const mockNext = jest.fn(); 431 | const mockReq = { 432 | files: ['some files'] 433 | }; 434 | await handler(mockReq, null, mockNext); 435 | 436 | expect(mockNext).toHaveBeenCalledWith(); 437 | }); 438 | 439 | it('should return proper handler with non joi object - with error', async () => { 440 | const fileUpload = { 441 | file: Joi.any().required() 442 | }; 443 | 444 | const handler = CelebrateUtils.getCelebrateValidationMiddlewareForFileUpload( 445 | fileUpload 446 | ); 447 | 448 | const mockNext = jest.fn(); 449 | const mockReq = {}; 450 | await handler(mockReq, null, mockNext); 451 | 452 | expect(mockNext.mock.calls[0][0].message).toEqual( 453 | `ValidationError: "file" is required` 454 | ); 455 | }); 456 | 457 | it('should return proper handler with joi object - with error', async () => { 458 | const fileUpload = Joi.object({ 459 | file: Joi.any().required() 460 | }); 461 | 462 | const handler = CelebrateUtils.getCelebrateValidationMiddlewareForFileUpload( 463 | fileUpload 464 | ); 465 | 466 | const mockNext = jest.fn(); 467 | const mockReq = {}; 468 | await handler(mockReq, null, mockNext); 469 | 470 | expect(mockNext.mock.calls[0][0].message).toEqual( 471 | `ValidationError: "file" is required` 472 | ); 473 | }); 474 | }); 475 | }); 476 | -------------------------------------------------------------------------------- /__tests__/SwaggerUtils.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | const SwaggerUtils = require('../src/SwaggerUtils'); 4 | const { Joi } = require('celebrate'); 5 | const Route = require('../src/Route'); 6 | const BaseController = require('../src/BaseController'); 7 | const { subroute } = require('../src'); 8 | 9 | class SomeTestController extends BaseController { 10 | constructor() { 11 | super(); 12 | 13 | this.doc = { 14 | tags: ['SomeTag'] 15 | }; 16 | } 17 | } 18 | const mockRouterWithTopRoutes = { 19 | routes: [Route.get('/', new SomeTestController())], 20 | subroutes: [ 21 | { 22 | path: '/users', 23 | router: { 24 | routes: [ 25 | Route.get('/', new SomeTestController()), 26 | Route.post('/', new SomeTestController()) 27 | ], 28 | subroutes: [ 29 | subroute('/:userId/posts', { 30 | routes: [ 31 | Route.get('/', new BaseController()), 32 | Route.post('/', new BaseController()) 33 | ] 34 | }) 35 | ] 36 | } 37 | } 38 | ] 39 | }; 40 | 41 | describe('SwaggerUtils', () => { 42 | describe('registerExpress', () => { 43 | it('Should register both url and redirect', () => { 44 | const mockApp = { 45 | use: jest.fn(), 46 | get: jest.fn() 47 | }; 48 | 49 | SwaggerUtils.registerExpress({ 50 | app: mockApp, 51 | swaggerJson: {}, 52 | url: '/someurl', 53 | authUser: { 54 | user: 'admin', 55 | password: 'admin' 56 | } 57 | }); 58 | 59 | expect(mockApp.use).toHaveBeenCalled(); 60 | expect(mockApp.get).toHaveBeenCalled(); 61 | 62 | // eslint-disable-next-line prefer-destructuring 63 | const [, , mockRedirectHandler] = mockApp.get.mock.calls[0]; 64 | const mockRes = { 65 | redirect: jest.fn() 66 | }; 67 | 68 | mockRedirectHandler(null, mockRes); 69 | 70 | expect(mockRes.redirect).toHaveBeenCalledWith('someurl'); 71 | }); 72 | 73 | it('Should sanitize redirect url', () => { 74 | const mockApp = { 75 | use: jest.fn(), 76 | get: jest.fn() 77 | }; 78 | 79 | SwaggerUtils.registerExpress({ 80 | app: mockApp, 81 | swaggerJson: {}, 82 | url: 'someurl', 83 | authUser: { 84 | user: 'admin', 85 | password: 'admin' 86 | } 87 | }); 88 | 89 | expect(mockApp.use).toHaveBeenCalled(); 90 | expect(mockApp.get).toHaveBeenCalled(); 91 | 92 | // eslint-disable-next-line prefer-destructuring 93 | const [, , mockRedirectHandler] = mockApp.get.mock.calls[0]; 94 | const mockRes = { 95 | redirect: jest.fn() 96 | }; 97 | 98 | mockRedirectHandler(null, mockRes); 99 | 100 | expect(mockRes.redirect).toHaveBeenCalledWith('someurl'); 101 | }); 102 | }); 103 | 104 | describe('_sanitizeSwaggerPath', () => { 105 | it('Should sanitize path parameter at the end of the url', () => { 106 | const result = SwaggerUtils._sanitizeSwaggerPath('some/:path'); 107 | expect(result).toEqual('some/{path}/'); 108 | }); 109 | 110 | it('Should sanitize path parameter at the end of the url with extra slash', () => { 111 | const result = SwaggerUtils._sanitizeSwaggerPath('some/:path/'); 112 | expect(result).toEqual('some/{path}/'); 113 | }); 114 | 115 | it('Should sanitize path parameter in the middle of url', () => { 116 | const result = SwaggerUtils._sanitizeSwaggerPath('/some/:path/other'); 117 | expect(result).toEqual('/some/{path}/other/'); 118 | }); 119 | 120 | it('Should sanitize multiple path parameters', () => { 121 | const result = SwaggerUtils._sanitizeSwaggerPath( 122 | '/some/:path/other/:url/' 123 | ); 124 | expect(result).toEqual('/some/{path}/other/{url}/'); 125 | }); 126 | }); 127 | 128 | describe('convertDocsToSwaggerDoc', () => { 129 | it('Should handle case for parent tags to capitalize', () => { 130 | const routes = { 131 | subroutes: [ 132 | { 133 | path: '/', 134 | router: { 135 | routes: [ 136 | { 137 | path: '/hey', 138 | controller: new BaseController(), 139 | method: 'get' 140 | } 141 | ] 142 | } 143 | }, 144 | { 145 | path: '/v', 146 | router: { 147 | routes: [ 148 | { 149 | path: '/hey', 150 | controller: new BaseController(), 151 | method: 'get' 152 | } 153 | ] 154 | } 155 | }, 156 | { 157 | path: '/hey-there', 158 | router: { 159 | routes: [ 160 | { 161 | path: '/hey', 162 | controller: new BaseController(), 163 | method: 'get' 164 | } 165 | ] 166 | } 167 | }, 168 | { 169 | path: '/hi_there', 170 | router: { 171 | routes: [ 172 | { 173 | path: '/hey', 174 | controller: new BaseController(), 175 | method: 'get' 176 | } 177 | ] 178 | } 179 | } 180 | ] 181 | }; 182 | 183 | const swaggerDoc = SwaggerUtils.convertDocsToSwaggerDoc(routes); 184 | expect(swaggerDoc.tags.map((t) => t.name)).toContain('V'); 185 | expect(swaggerDoc.tags.map((t) => t.name)).toContain('Hey There'); 186 | expect(swaggerDoc.tags.map((t) => t.name)).toContain('Hi There'); 187 | expect(swaggerDoc).toBeDefined(); 188 | }); 189 | 190 | it('Should handle case for no parent tags, and tags with multi word', () => { 191 | const routes = { 192 | subroutes: [ 193 | { 194 | path: '/', 195 | controller: { 196 | routes: [ 197 | { 198 | path: '/hey', 199 | controller: new BaseController(), 200 | method: 'get' 201 | } 202 | ] 203 | } 204 | } 205 | ] 206 | }; 207 | 208 | const swaggerDoc = SwaggerUtils.convertDocsToSwaggerDoc(routes); 209 | expect(swaggerDoc).toBeDefined(); 210 | }); 211 | 212 | it('Should write json for swagger with redirects', () => { 213 | const mockRoutes1 = { ...mockRouterWithTopRoutes }; 214 | mockRoutes1.routes.push( 215 | { 216 | path: '/hey', 217 | controller: new BaseController(), 218 | method: 'get', 219 | doc: { 220 | summary: 'hey route', 221 | responses: { 222 | 200: {}, 223 | 400: {} 224 | } 225 | } 226 | }, 227 | { 228 | path: '/hello', 229 | controller: new BaseController(), 230 | method: 'get', 231 | doc: { 232 | responses: { 233 | 200: {}, 234 | 400: {} 235 | } 236 | } 237 | } 238 | ); 239 | 240 | let swaggerDoc = SwaggerUtils.convertDocsToSwaggerDoc(mockRoutes1); 241 | 242 | mockRoutes1.routes.push({ 243 | path: '/hello', 244 | controller: '/hey/', 245 | method: 'get', 246 | doc: { 247 | responses: {} 248 | }, 249 | authorizer: (req, res) => {} 250 | }); 251 | swaggerDoc = SwaggerUtils.convertDocsToSwaggerDoc(mockRoutes1); 252 | 253 | expect(swaggerDoc).toBeDefined(); 254 | }); 255 | 256 | it('Should write json for swagger with authorizer object', () => { 257 | const mockRoutes1 = { ...mockRouterWithTopRoutes }; 258 | class Controller extends BaseController { 259 | constructor() { 260 | super(); 261 | this.doc = { 262 | summary: 'hey route', 263 | responses: { 264 | 200: {} 265 | } 266 | }; 267 | this.authorizer = ['hello from hey route']; 268 | } 269 | } 270 | mockRoutes1.routes.push({ 271 | path: '/hey', 272 | controller: new Controller(), 273 | method: 'get' 274 | }); 275 | 276 | const swaggerDoc = SwaggerUtils.convertDocsToSwaggerDoc(mockRoutes1); 277 | 278 | expect(swaggerDoc).toBeDefined(); 279 | 280 | const swaggerstr = JSON.stringify(swaggerDoc); 281 | expect( 282 | swaggerstr.includes(`Authorized: [\\"hello from hey route\\"]`) 283 | ).toBeTruthy(); 284 | }); 285 | 286 | it('Should write json for swagger with responses defined', () => { 287 | const mockRoutes1 = { routes: [] }; 288 | 289 | class Controller extends BaseController { 290 | constructor() { 291 | super(); 292 | this.doc = { 293 | summary: 'hey route', 294 | responses: { 295 | 200: {} 296 | } 297 | }; 298 | this.authorizer = ['hello']; 299 | this.validationSchema = { 300 | some: 'schema' 301 | }; 302 | } 303 | } 304 | 305 | mockRoutes1.routes.push({ 306 | path: '/hey', 307 | controller: new Controller(), 308 | method: 'get' 309 | }); 310 | 311 | const swaggerDoc = SwaggerUtils.convertDocsToSwaggerDoc(mockRoutes1); 312 | expect(swaggerDoc).toBeDefined(); 313 | 314 | const swaggerstr = JSON.stringify(swaggerDoc); 315 | expect( 316 | swaggerstr.includes(`Schema validation error response`) 317 | ).toBeTruthy(); 318 | }); 319 | 320 | it('Should write json for swagger with file upload defined', () => { 321 | const mockRoutes1 = { routes: [] }; 322 | class FileUploadController extends BaseController { 323 | constructor() { 324 | super(); 325 | 326 | this.doc = { 327 | summary: 'hey route', 328 | responses: { 329 | 200: {} 330 | } 331 | }; 332 | this.authorizer = ['hello']; 333 | this.validationSchema = { 334 | fileUpload: { 335 | file: Joi.any().required() 336 | } 337 | }; 338 | } 339 | } 340 | 341 | mockRoutes1.routes.push({ 342 | path: '/hey', 343 | controller: new FileUploadController(), 344 | method: 'get' 345 | }); 346 | 347 | const swaggerDoc = SwaggerUtils.convertDocsToSwaggerDoc(mockRoutes1); 348 | expect(swaggerDoc).toBeDefined(); 349 | 350 | const swaggerstr = JSON.stringify(swaggerDoc); 351 | expect(swaggerstr.includes(`multipart/form-data`)).toBeTruthy(); 352 | }); 353 | 354 | it('Should write json for swagger with responses defined for validation', () => { 355 | const mockRoutes1 = { routes: [] }; 356 | class Controller extends BaseController { 357 | constructor() { 358 | super(); 359 | this.doc = { 360 | summary: 'hey route', 361 | description: 'hey route', 362 | responses: { 363 | 200: {}, 364 | 400: { 365 | response: 'this is a response schema' 366 | } 367 | }, 368 | tags: ['hello'] 369 | }; 370 | this.authorizer = ['hello']; 371 | } 372 | } 373 | 374 | mockRoutes1.routes.push({ 375 | path: '/hey', 376 | controller: new Controller(), 377 | method: 'get' 378 | }); 379 | 380 | const swaggerDoc = SwaggerUtils.convertDocsToSwaggerDoc(mockRoutes1); 381 | expect(swaggerDoc).toBeDefined(); 382 | 383 | const swaggerstr = JSON.stringify(swaggerDoc); 384 | expect( 385 | swaggerstr.includes(`Schema validation error response`) 386 | ).toBeFalsy(); 387 | 388 | expect(swaggerstr.includes(`this is a response schema`)).toBeTruthy(); 389 | }); 390 | }); 391 | 392 | describe('writeSwaggerJson', () => { 393 | it('should write json for swagger', () => { 394 | const sampleSwaggerInfo = { 395 | version: '1.0.0', 396 | title: 'Expressive API', 397 | contact: { 398 | name: 'Author', 399 | email: 'Your email address', 400 | url: '' 401 | } 402 | }; 403 | 404 | const outputPath = path.resolve(__dirname, 'output.json'); 405 | SwaggerUtils.writeSwaggerJson({ 406 | router: mockRouterWithTopRoutes, 407 | output: outputPath, 408 | basePath: '/api', 409 | swaggerInfo: sampleSwaggerInfo 410 | }); 411 | 412 | const file = fs.readFileSync(outputPath); 413 | expect(file).toBeDefined(); 414 | fs.unlinkSync(outputPath); 415 | }); 416 | 417 | it('should write json for swagger using defaults', () => { 418 | const outputPath = path.resolve(__dirname, 'output.json'); 419 | SwaggerUtils.writeSwaggerJson({ 420 | router: mockRouterWithTopRoutes, 421 | output: outputPath 422 | }); 423 | 424 | const file = fs.readFileSync(outputPath); 425 | expect(file).toBeDefined(); 426 | fs.unlinkSync(outputPath); 427 | }); 428 | }); 429 | 430 | describe('getSwaggerHeader', () => { 431 | it('works with defaults', () => { 432 | const header = SwaggerUtils.getSwaggerHeader({}); 433 | expect(header).toBeDefined(); 434 | }); 435 | 436 | it('works with non defaults', () => { 437 | const header = SwaggerUtils.getSwaggerHeader({ basePath: '/api' }); 438 | expect(header).toBeDefined(); 439 | }); 440 | 441 | it('should set auth properly for header', () => { 442 | const header = SwaggerUtils.getSwaggerHeader({ 443 | basePath: '/api', 444 | swaggerSecurityDefinitions: { 445 | someAuth: { 446 | type: 'apiKey', 447 | in: 'header', 448 | name: 'X-API-KEY' 449 | } 450 | } 451 | }); 452 | 453 | expect(header).toBeDefined(); 454 | expect(header.securityDefinitions).toEqual({ 455 | someAuth: { 456 | type: 'apiKey', 457 | in: 'header', 458 | name: 'X-API-KEY' 459 | } 460 | }); 461 | expect(header.security).toEqual([ 462 | { 463 | someAuth: [] 464 | } 465 | ]); 466 | }); 467 | }); 468 | }); 469 | -------------------------------------------------------------------------------- /__tests__/Middleware.test.js: -------------------------------------------------------------------------------- 1 | const response = require('../src/middleware/response'); 2 | const MiddlewareManager = require('../src/middleware/MiddlewareManager'); 3 | 4 | const OLD_ENV = process.env; 5 | 6 | describe('response middleware', () => { 7 | it('Should call fn properly', () => { 8 | const mockNext = jest.fn(); 9 | const mockRes = { 10 | setHeader: jest.fn() 11 | }; 12 | response(null, mockRes, mockNext); 13 | 14 | expect(mockNext).toHaveBeenCalled(); 15 | expect(mockRes.setHeader).toHaveBeenCalledWith( 16 | 'Content-Type', 17 | 'application/json' 18 | ); 19 | }); 20 | }); 21 | 22 | describe('Middleware Manager', () => { 23 | describe('registerMiddleware', () => { 24 | it('should skip for morgan body properl with defaults', () => { 25 | const mockExpress = { 26 | use: jest.fn() 27 | }; 28 | const mockUserMiddleware = ['abc']; 29 | const manager = new MiddlewareManager( 30 | { 31 | middleware: mockUserMiddleware 32 | }, 33 | mockExpress 34 | ); 35 | 36 | let morganConfigs = {}; 37 | manager.morganBody = (app, configs) => { 38 | morganConfigs = configs; 39 | }; 40 | 41 | manager._registerMorganBody(); 42 | 43 | expect(morganConfigs.skip).toBeDefined(); 44 | 45 | const mockReq = { 46 | originalUrl: '/' 47 | }; 48 | expect(morganConfigs.skip(mockReq)).toBeTruthy(); 49 | mockReq.originalUrl = '/favicon'; 50 | expect(morganConfigs.skip(mockReq)).toBeTruthy(); 51 | mockReq.originalUrl = '/some/url'; 52 | expect(morganConfigs.skip(mockReq)).toBeFalsy(); 53 | }); 54 | it('should ignore morgan if disabled', () => { 55 | const mockExpress = { 56 | use: jest.fn() 57 | }; 58 | const mockUserMiddleware = ['abc']; 59 | const manager = new MiddlewareManager( 60 | { 61 | middleware: mockUserMiddleware, 62 | requestLoggerOptions: { 63 | disabled: true 64 | } 65 | }, 66 | mockExpress 67 | ); 68 | manager.morganBody = jest.fn(); 69 | manager._registerMorganBody(); 70 | 71 | expect(manager.morganBody).not.toBeCalled(); 72 | }); 73 | it('should skip for morgan body properl with given requestLoggerOptions', () => { 74 | const mockExpress = { 75 | use: jest.fn() 76 | }; 77 | const mockUserMiddleware = ['abc']; 78 | const manager = new MiddlewareManager( 79 | { 80 | middleware: mockUserMiddleware, 81 | requestLoggerOptions: { 82 | skip: (req) => req.originalUrl === '/skip-this' 83 | } 84 | }, 85 | mockExpress 86 | ); 87 | 88 | let morganConfigs = {}; 89 | manager.morganBody = (app, configs) => { 90 | morganConfigs = configs; 91 | }; 92 | 93 | manager._registerMorganBody(); 94 | 95 | expect(morganConfigs.skip).toBeDefined(); 96 | 97 | const mockReq = { 98 | originalUrl: '/skip-this' 99 | }; 100 | expect(morganConfigs.skip(mockReq)).toBeTruthy(); 101 | }); 102 | it('Should register defaults and user middleware', () => { 103 | const mockExpress = { 104 | use: jest.fn() 105 | }; 106 | const mockUserMiddleware = ['abc']; 107 | const manager = new MiddlewareManager( 108 | { 109 | middleware: mockUserMiddleware 110 | }, 111 | mockExpress 112 | ); 113 | manager.expressModule = { 114 | json: jest.fn().mockReturnValue(1), 115 | urlencoded: jest.fn().mockReturnValue(5) 116 | }; 117 | manager.addRequestId = jest.fn().mockReturnValue(2); 118 | manager.helmet = jest.fn().mockReturnValue(3); 119 | manager.routeUtil = { 120 | getHandlerWithManagedNextCall: jest.fn().mockImplementation((d) => d) 121 | }; 122 | manager.morganBody = (app) => app; 123 | manager.registerBasicMiddleware(); 124 | 125 | expect(mockExpress.use).toHaveBeenCalledTimes(6); 126 | 127 | expect(manager.expressModule.json).toHaveBeenCalledWith({ 128 | limit: manager.options.bodyLimit 129 | }); 130 | 131 | expect(mockExpress.use.mock.calls[0][0]).toEqual(1); 132 | expect(mockExpress.use.mock.calls[1][0]).toEqual(5); 133 | expect(mockExpress.use.mock.calls[2][0]).toEqual(response); 134 | expect(mockExpress.use.mock.calls[3][0]).toEqual(2); 135 | expect(mockExpress.use.mock.calls[4][0]).toEqual(3); 136 | expect(mockExpress.use.mock.calls[5][0]).toEqual(mockUserMiddleware); 137 | }); 138 | 139 | it('Should register defaults without user middleware', () => { 140 | const mockExpress = { 141 | use: jest.fn() 142 | }; 143 | const manager = new MiddlewareManager({}, mockExpress); 144 | manager.morganBody = (app) => app; 145 | manager.expressModule = { 146 | json: jest.fn().mockReturnValue(1), 147 | urlencoded: jest.fn().mockReturnValue(2) 148 | }; 149 | manager.addRequestId = jest.fn().mockReturnValue(3); 150 | 151 | manager.registerBasicMiddleware(); 152 | 153 | expect(mockExpress.use).toHaveBeenCalledTimes(5); // registering 4 middleware 154 | 155 | expect(manager.expressModule.json).toHaveBeenCalledWith({ 156 | limit: manager.options.bodyLimit 157 | }); 158 | 159 | expect(mockExpress.use.mock.calls[0][0]).toEqual(1); 160 | expect(mockExpress.use.mock.calls[1][0]).toEqual(2); 161 | expect(mockExpress.use.mock.calls[2][0]).toEqual(response); 162 | expect(mockExpress.use.mock.calls[3][0]).toEqual(3); 163 | }); 164 | }); 165 | 166 | describe('registerNotFoundHandler', () => { 167 | it('Should register given handler', () => { 168 | const mockExpress = { 169 | use: jest.fn() 170 | }; 171 | 172 | const someMockHandler = 'hehe'; 173 | const options = { 174 | notFoundHandler: someMockHandler 175 | }; 176 | const manager = new MiddlewareManager(options, mockExpress); 177 | 178 | manager.registerNotFoundHandler(mockExpress, someMockHandler); 179 | 180 | expect(mockExpress.use).toHaveBeenCalledWith(someMockHandler); 181 | }); 182 | 183 | it('Should run default handler properly', () => { 184 | const manager = new MiddlewareManager(); 185 | const mockReq = { 186 | path: '/some/path' 187 | }; 188 | 189 | const mockRes = { 190 | status: jest.fn(), 191 | json: jest.fn() 192 | }; 193 | 194 | manager.defaultNotFoundHandler(mockReq, mockRes); 195 | 196 | expect(mockRes.status).toHaveBeenCalledWith(404); 197 | expect(mockRes.json).toHaveBeenCalledWith({ 198 | message: `Route \'/some/path\' not found` 199 | }); 200 | }); 201 | }); 202 | 203 | describe('_registerHelmet', () => { 204 | it('Should register helmet with defaults if no config is given', () => { 205 | const mockExpress = { 206 | use: jest.fn() 207 | }; 208 | const middlewareManager = new MiddlewareManager({}, mockExpress); 209 | middlewareManager.helmet = jest.fn().mockReturnValue(123); 210 | 211 | middlewareManager._registerHelmet(); 212 | expect(mockExpress.use).toHaveBeenCalledWith(123); 213 | expect(middlewareManager.helmet).toHaveBeenCalledTimes(1); 214 | expect(middlewareManager.helmet.mock.calls[0].length).toEqual(0); 215 | }); 216 | 217 | it('Should register helmet with given config', () => { 218 | const mockHelmetOptions = { 219 | hello: 'world' 220 | }; 221 | 222 | const mockExpress = { 223 | use: jest.fn() 224 | }; 225 | const middlewareManager = new MiddlewareManager( 226 | { 227 | helmetOptions: mockHelmetOptions 228 | }, 229 | mockExpress 230 | ); 231 | middlewareManager.helmet = jest.fn().mockReturnValue(123); 232 | 233 | middlewareManager._registerHelmet(); 234 | expect(mockExpress.use).toHaveBeenCalledWith(123); 235 | expect(middlewareManager.helmet).toHaveBeenCalledTimes(1); 236 | expect(middlewareManager.helmet).toHaveBeenCalledWith(mockHelmetOptions); 237 | }); 238 | }); 239 | 240 | describe('registerCelebrateErrorMiddleware', () => { 241 | it('Should register custom celebrate error middleware is provided', () => { 242 | const customCelebrateHandler = jest.fn(); 243 | 244 | const mockUse = jest.fn(); 245 | 246 | const mockExpress = { 247 | use: mockUse 248 | }; 249 | 250 | const manager = new MiddlewareManager( 251 | { 252 | celebrateErrorHandler: customCelebrateHandler 253 | }, 254 | mockExpress 255 | ); 256 | manager.registerCelebrateErrorMiddleware(); 257 | 258 | expect(mockUse).toHaveBeenCalledWith(customCelebrateHandler); 259 | }); 260 | 261 | it('Should register default celebrate error middleware if custom is not provided', () => { 262 | const mockUse = jest.fn(); 263 | 264 | const mockExpress = { 265 | use: mockUse 266 | }; 267 | const manager = new MiddlewareManager({}, mockExpress); 268 | 269 | const mockCelebrateErrors = jest.fn().mockReturnValue(1); 270 | manager.celebrateErrors = mockCelebrateErrors; 271 | manager.registerCelebrateErrorMiddleware(); 272 | 273 | expect(mockCelebrateErrors).toHaveBeenCalled(); 274 | expect(mockUse).toHaveBeenCalledWith(1); 275 | }); 276 | }); 277 | 278 | describe('registerDocs', () => { 279 | it('Should register and redoc properly', () => { 280 | const swaggerInfo = { name: 'John Smith' }; 281 | const mockRouter = { 282 | some: 'routes' 283 | }; 284 | const mockSwaggerDefinitions = { 285 | some: 'Definition' 286 | }; 287 | 288 | const options = { 289 | basePath: '/', 290 | swaggerInfo, 291 | swaggerDefinitions: mockSwaggerDefinitions, 292 | showSwaggerOnlyInDev: false 293 | }; 294 | 295 | const mockExpress = { 296 | get: jest.fn() 297 | }; 298 | const manager = new MiddlewareManager(options, mockExpress); 299 | 300 | const mockSwaggerHeader = { some: 'Header' }; 301 | const mockSwaggerJson = { hello: 'world' }; 302 | manager.SwaggerUtils = { 303 | getSwaggerHeader: jest.fn().mockReturnValue(mockSwaggerHeader), 304 | convertDocsToSwaggerDoc: jest.fn().mockReturnValue(mockSwaggerJson), 305 | registerExpress: jest.fn() 306 | }; 307 | manager.registerRedoc = jest.fn(); 308 | 309 | manager.registerDocs(mockRouter); 310 | 311 | expect(manager.registerRedoc).toHaveBeenCalledWith({ 312 | app: mockExpress, 313 | swaggerJson: mockSwaggerJson, 314 | url: '/docs/redoc', 315 | authUser: { 316 | user: 'admin', 317 | password: 'admin' 318 | } 319 | }); 320 | expect(manager.SwaggerUtils.getSwaggerHeader).toHaveBeenCalledWith({ 321 | basePath: '/', 322 | swaggerInfo, 323 | swaggerSecurityDefinitions: undefined 324 | }); 325 | expect(manager.SwaggerUtils.convertDocsToSwaggerDoc).toHaveBeenCalledWith( 326 | mockRouter, 327 | mockSwaggerHeader, 328 | mockSwaggerDefinitions 329 | ); 330 | expect(manager.SwaggerUtils.registerExpress).toHaveBeenCalledWith({ 331 | app: manager.express, 332 | swaggerJson: mockSwaggerJson, 333 | url: '/docs/swagger', 334 | authUser: { 335 | user: 'admin', 336 | password: 'admin' 337 | } 338 | }); 339 | }); 340 | 341 | describe('test with env set username and password for swagger', () => { 342 | beforeEach(() => { 343 | jest.resetModules(); // this is important - it clears the cache 344 | process.env = { 345 | ...OLD_ENV, 346 | EXPRESS_SWAGGER_USER: 'expressive', 347 | EXPRESS_SWAGGER_PASSWORD: 'expressive321' 348 | }; 349 | }); 350 | 351 | afterEach(() => { 352 | process.env = OLD_ENV; 353 | }); 354 | 355 | it('description', () => { 356 | const swaggerInfo = { name: 'John Smith' }; 357 | const mockRouter = { 358 | some: 'routes' 359 | }; 360 | const mockSwaggerDefinitions = { 361 | some: 'Definition' 362 | }; 363 | 364 | const options = { 365 | basePath: '/', 366 | swaggerInfo, 367 | swaggerDefinitions: mockSwaggerDefinitions, 368 | showSwaggerOnlyInDev: false 369 | }; 370 | 371 | const mockExpress = { 372 | get: jest.fn() 373 | }; 374 | const manager = new MiddlewareManager(options, mockExpress); 375 | 376 | const mockSwaggerHeader = { some: 'Header' }; 377 | const mockSwaggerJson = { hello: 'world' }; 378 | manager.SwaggerUtils = { 379 | getSwaggerHeader: jest.fn().mockReturnValue(mockSwaggerHeader), 380 | convertDocsToSwaggerDoc: jest.fn().mockReturnValue(mockSwaggerJson), 381 | registerExpress: jest.fn() 382 | }; 383 | manager.registerRedoc = jest.fn(); 384 | 385 | manager.registerDocs(mockRouter); 386 | 387 | expect(manager.registerRedoc).toHaveBeenCalledWith({ 388 | app: mockExpress, 389 | swaggerJson: mockSwaggerJson, 390 | url: '/docs/redoc', 391 | authUser: { 392 | user: 'expressive', 393 | password: 'expressive321' 394 | } 395 | }); 396 | 397 | expect(manager.SwaggerUtils.registerExpress).toHaveBeenCalledWith({ 398 | app: mockExpress, 399 | swaggerJson: mockSwaggerJson, 400 | url: '/docs/swagger', 401 | authUser: { 402 | user: 'expressive', 403 | password: 'expressive321' 404 | } 405 | }); 406 | }); 407 | }); 408 | 409 | describe('test without NODE_ENV', () => { 410 | beforeEach(() => { 411 | jest.resetModules(); // this is important - it clears the cache 412 | process.env = { ...OLD_ENV }; 413 | delete process.env.NODE_ENV; 414 | }); 415 | 416 | afterEach(() => { 417 | process.env = OLD_ENV; 418 | }); 419 | 420 | it('Should register with default NODE_ENV', () => { 421 | const swaggerInfo = { name: 'John Smith' }; 422 | const mockRouter = { 423 | some: 'routes' 424 | }; 425 | const mockSwaggerDefinitions = { 426 | some: 'Definition' 427 | }; 428 | 429 | const options = { 430 | basePath: '/', 431 | swaggerInfo, 432 | swaggerDefinitions: mockSwaggerDefinitions, 433 | showSwaggerOnlyInDev: false 434 | }; 435 | 436 | const mockExpress = { 437 | get: jest.fn() 438 | }; 439 | const manager = new MiddlewareManager(options, mockExpress); 440 | 441 | const mockSwaggerHeader = { some: 'Header' }; 442 | const mockSwaggerJson = { hello: 'world' }; 443 | manager.SwaggerUtils = { 444 | getSwaggerHeader: jest.fn().mockReturnValue(mockSwaggerHeader), 445 | convertDocsToSwaggerDoc: jest.fn().mockReturnValue(mockSwaggerJson), 446 | registerExpress: jest.fn() 447 | }; 448 | manager.registerRedoc = jest.fn(); 449 | 450 | manager.registerDocs(mockRouter); 451 | 452 | expect(manager.registerRedoc).toHaveBeenCalledWith({ 453 | app: mockExpress, 454 | swaggerJson: mockSwaggerJson, 455 | url: '/docs/redoc', 456 | authUser: { 457 | user: 'admin', 458 | password: 'admin' 459 | } 460 | }); 461 | 462 | expect(manager.SwaggerUtils.registerExpress).toHaveBeenCalledWith({ 463 | app: mockExpress, 464 | swaggerJson: mockSwaggerJson, 465 | url: '/docs/swagger', 466 | authUser: { 467 | user: 'admin', 468 | password: 'admin' 469 | } 470 | }); 471 | }); 472 | }); 473 | }); 474 | 475 | describe('registerAuth', () => { 476 | it('Should throw error if authorizer is declared without authObjectHandler', () => { 477 | let response; 478 | try { 479 | const manager = new MiddlewareManager({ 480 | authorizer: ['hehe'] 481 | }); 482 | 483 | manager.registerAuth(); 484 | } catch (error) { 485 | response = error; 486 | } 487 | 488 | expect(response.message).toEqual( 489 | `'authorizer' object declared, but 'authObjectHandler' is not defined in ExpressApp constructor params` 490 | ); 491 | }); 492 | 493 | it('Should not throw error if authorizer and authObjectHandler both declared', () => { 494 | let response; 495 | const mockExpress = { 496 | use: jest.fn() 497 | }; 498 | const manager = new MiddlewareManager( 499 | { 500 | authorizer: ['hehe'], 501 | authObjectHandler: (req, res) => { 502 | return null; 503 | } 504 | }, 505 | mockExpress 506 | ); 507 | manager.authUtil = { 508 | getAuthorizerMiddleware: jest.fn().mockReturnValue(1234) 509 | }; 510 | 511 | try { 512 | manager.registerAuth(); 513 | } catch (error) { 514 | response = error; 515 | } 516 | 517 | expect(response).not.toBeInstanceOf(Error); 518 | expect(mockExpress.use).toHaveBeenCalledWith(1234); 519 | }); 520 | 521 | it('Should throw error if authObjectHandler body is empty', () => { 522 | let response; 523 | try { 524 | response = new ExpressApp( 525 | {}, 526 | { 527 | authorizer: ['hehe'], 528 | authObjectHandler: (req, res) => {} 529 | } 530 | ); 531 | } catch (error) { 532 | response = error; 533 | } 534 | 535 | expect(response).toBeInstanceOf(Error); 536 | }); 537 | }); 538 | }); 539 | --------------------------------------------------------------------------------