├── .env ├── .gitignore ├── .prettierrc.json ├── .snyk ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── commitlint.config.js ├── data └── index.ts ├── docker-compose.yml ├── package-lock.json ├── package.json ├── src ├── api │ └── users │ │ ├── controller.ts │ │ ├── resolver.ts │ │ ├── routes.ts │ │ └── validate.ts ├── common │ ├── base-repository.ts │ ├── base-resolver.ts │ ├── base-service.ts │ └── crud-controller.ts ├── config.ts ├── helper │ ├── logger.ts │ ├── response.ts │ └── route.ts ├── index.ts ├── main.d.ts ├── model │ └── user.ts ├── plugin │ └── index.ts ├── router.ts └── server.ts ├── test ├── helpers.ts └── users.spec.ts ├── tsconfig.json └── tslint.json /.env: -------------------------------------------------------------------------------- 1 | HOST = localhost 2 | PORT = 8080 3 | NODE_ENV = development 4 | LOG_LEVEL = info 5 | PROJECT_DIR = /usr/app -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | dist 4 | logs/*.log 5 | .nyc_output/ 6 | coverage/ 7 | build 8 | yarn.lock 9 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.13.5 3 | ignore: {} 4 | # patches apply the minimum changes required to fix a vulnerability 5 | patch: 6 | SNYK-JS-LODASH-450202: 7 | - winston > async > lodash: 8 | patched: '2019-07-04T01:41:56.163Z' 9 | - hapi-swagger > handlebars > async > lodash: 10 | patched: '2019-07-04T01:41:56.163Z' 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.1.2-alpine 2 | 3 | MAINTAINER BlackBox Vision 4 | 5 | RUN rm -rf PROJECT_DIR/node_modules 6 | 7 | ADD ./src PROJECT_DIR 8 | ADD ./logs PROJECT_DIR 9 | ADD ./data PROJECT_DIR 10 | 11 | ADD ./package.json PROJECT_DIR 12 | ADD ./package-lock.json PROJECT_DIR 13 | ADD ./tsconfig.json PROJECT_DIR 14 | 15 | WORKDIR PROJECT_DIR 16 | 17 | EXPOSE PORT 18 | 19 | RUN npm prune 20 | RUN npm install -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 BlackBox Vision 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. -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | # This file is for Heroku deployments 2 | 3 | web: node dist/src/index.js 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeScript + Hapi = <3 2 | 3 | This is a super simple starter kit to develop APIs with HapiJS + TypeScript 4 | 5 | ## What currently supports? 6 | 7 | This starter kit comes with the following features: 8 | 9 | - **Swagger-UI** 10 | - **Status Monitor** 11 | - **.env files support** 12 | - **nodemon for hot-reload** 13 | - **Pretty Console Logger with Winston** 14 | - **Work with Yarn or NPM 6 as dependency resolvers** 15 | - **Code formatting with Prettier as hook for Pre-commit** 16 | - **Dockerfile + docker-compose for development** 17 | - **Basic Test Suite with Tape** 18 | - **Coverage Report** 19 | - **Supports Heroku Deployment** 20 | - **Supports Prettier for code formating** 21 | - **Supports commitlint via husky to have standarized commit messages** 22 | 23 | ## Requirements 24 | 25 | - NodeJS > 12.x 26 | - NPM > 6.x 27 | 28 | ## How to use it? 29 | 30 | 1. Download this project as a zip. 31 | 2. Run `npm install` 32 | 3. Run `npm run nodemon:start` 33 | 4. Visit [http://localhost:8080/documentation](http://localhost:8080/documentation) to view swagger docs. 34 | 5. Visit [http://localhost:8080/api/users](http://localhost:8080/api/users) to test the REST API. 35 | 6. Visit [http://localhost:8080/status](http://localhost:8080/status) to view the status monitor. 36 | 37 | UPDATED: Now there's a CLI that currently support creating a new project from this repo: [create-typescript-api](https://github.com/BlackBoxVision/create-typescript-api) 38 | 39 | ## TODO 40 | 41 | This is not finished, there's still a lot of things to improve. Here you got some: 42 | 43 | - [x] Simple test suite - added by the help of [@jcloutz](https://github.com/jcloutz) 44 | - [x] Add support for test coverage - added by the help of [@jcloutz](https://github.com/jcloutz) 45 | - [ ] Add GraphQL support 46 | - [ ] Add support for Auth with JWT or Sessions 47 | - [ ] Add support for TypeORM/Mongoose 48 | - [ ] Add support for Jenkins pipeline 49 | 50 | ## Documentation 51 | 52 | ### What are the package.json scripts for? 53 | 54 | - `build-ts`: Compiles typescript based on config set in tsconfig.json. 55 | - `start`: Starts node with the compiled typescript. Used by eg. Heroku. 56 | - `docker:logs`: View Docker logs 57 | - `docker:ps`: List Docker containers 58 | - `docker:start`: Start Docker container based on docker-compose.yml file. 59 | - `docker:stop`: Stop Docker container 60 | - `nodemon:build`: Starts the Nodemon using ts-node. No need to compile beforehand. 61 | - `nodemon:start`: Same as nodemon:build 62 | - `format:lint`: Runs tslint on the typescipt files, based on tslint.js settings. 63 | - `format:prettier`: Runs prettier on all ts-files. 64 | - `postinstall`: Runs build-ts script. This is used by eg. Heroku automatically. 65 | - `test`: Runs tests using nyc, and creates coverage report. 66 | 67 | ## Issues 68 | 69 | If you found a bug, or you have an answer, or whatever. Please, raise an [issue](https://github.com/BlackBoxVision/typescript-hapi-starter/issues/new). 70 | 71 | ## Contributing 72 | 73 | Of course, if you see something that you want to upgrade from this library, or a bug that needs to be solved, PRs are welcome! 74 | 75 | ## License 76 | 77 | Distributed under the **MIT license**. See [LICENSE](https://github.com/BlackBoxVision/typescript-hapi-starter/blob/master/LICENSE) for more information. 78 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /data/index.ts: -------------------------------------------------------------------------------- 1 | export default []; 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | web: 4 | container_name: web-api 5 | build: . 6 | command: npm run nodemon:start 7 | volumes: 8 | - .:/usr/app/ 9 | environment: 10 | - PORT=8080 11 | - HOST=0.0.0.0 12 | - PROJECT_DIR=/usr/app/ 13 | - NODE_ENV=development 14 | - LOG_LEVEL=debug 15 | ports: 16 | - "localhost:8080:8080" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "BlackBox Vision", 3 | "contributors": [ 4 | "Jonatan E. Salas ", 5 | "Lauri Larjo " 6 | ], 7 | "description": "Starter for building APIs with Hapi + Typescript!", 8 | "license": "MIT", 9 | "name": "typescript-hapi-starter", 10 | "version": "1.0.0", 11 | "engines": { 12 | "node": ">=10.0" 13 | }, 14 | "scripts": { 15 | "dev": "npm run nodemon:build", 16 | "build": "tsc", 17 | "start": "node dist/src/index.js", 18 | "docker:logs": "docker-compose logs", 19 | "docker:ps": "docker-compose ps", 20 | "docker:start": "docker-compose up", 21 | "docker:stop": "docker-compose -f docker-compose.yml down -v --remove-orphans", 22 | "nodemon:build": "nodemon --exec ./node_modules/.bin/ts-node -- ./src/index.ts", 23 | "format:lint": "./node_modules/.bin/tslint -c tslint.json 'src/**/*.ts'", 24 | "format:prettier": "./node_modules/.bin/prettier --tab-width 4 --print-width 120 --single-quote --trailing-comma all --write 'src/**/*.ts'", 25 | "postinstall": "npm run build", 26 | "test": "NODE_ENV=test nyc --reporter=lcov -r tsconfig-paths/register -r ts-node/register tape test/**/*.spec.{ts,js} | tap-spec", 27 | "tap": "tap", 28 | "snyk-protect": "snyk protect", 29 | "prepublish": "npm run snyk-protect" 30 | }, 31 | "nyc": { 32 | "include": [ 33 | "src/**/*.ts" 34 | ], 35 | "extension": [ 36 | ".ts" 37 | ] 38 | }, 39 | "husky": { 40 | "hooks": { 41 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 42 | "pre-commit": "lint-staged" 43 | } 44 | }, 45 | "lint-staged": { 46 | "*.{js,json,css,md}": [ 47 | "prettier --write", 48 | "git add" 49 | ], 50 | "*.{js,ts,tsx}": [ 51 | "git add" 52 | ] 53 | }, 54 | "dependencies": { 55 | "@hapi/boom": "^9.1.0", 56 | "@hapi/hapi": "^19.2.0", 57 | "@hapi/inert": "^6.0.1", 58 | "@hapi/joi": "^17.1.1", 59 | "@hapi/vision": "^6.0.0", 60 | "@types/mime-db": "^1.43.0", 61 | "dotenv": "^8.0.0", 62 | "hapi-swagger": "^13.0.2", 63 | "hapijs-status-monitor": "github:ziyasal/hapijs-status-monitor", 64 | "nedb": "^1.8.0", 65 | "snyk": "^1.189.0", 66 | "winston": "^3.1.0" 67 | }, 68 | "devDependencies": { 69 | "@commitlint/cli": "^9.1.2", 70 | "@commitlint/config-conventional": "^8.1.0", 71 | "@types/code": "^4.0.5", 72 | "@types/dotenv": "^6.1.1", 73 | "@types/hapi__boom": "^9.0.1", 74 | "@types/hapi__hapi": "^19.0.3", 75 | "@types/hapi__inert": "^5.2.0", 76 | "@types/hapi__joi": "^17.1.4", 77 | "@types/hapi__vision": "^5.5.1", 78 | "@types/nedb": "^1.8.8", 79 | "@types/node": "^12.6.8", 80 | "@types/tape": "^4.2.33", 81 | "husky": "^3.0.1", 82 | "lint-staged": "^9.2.1", 83 | "nodemon": "^1.19.4", 84 | "nyc": "^14.1.1", 85 | "prettier": "^1.18.2", 86 | "tap": "^14.10.8", 87 | "tap-spec": "^5.0.0", 88 | "tape": "^4.8.0", 89 | "ts-node": "^8.3.0", 90 | "tslint": "^5.4.3", 91 | "typescript": "^3.2.2" 92 | }, 93 | "keywords": [ 94 | "api", 95 | "nodejs", 96 | "hapi", 97 | "typescript", 98 | "swagger" 99 | ], 100 | "snyk": true 101 | } 102 | -------------------------------------------------------------------------------- /src/api/users/controller.ts: -------------------------------------------------------------------------------- 1 | import UserResolver from '../../api/users/resolver'; 2 | import CrudController from '../../common/crud-controller'; 3 | import User from '../../model/user'; 4 | 5 | export default class UserController extends CrudController { 6 | constructor(id?: string) { 7 | super(id, new UserResolver()); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/api/users/resolver.ts: -------------------------------------------------------------------------------- 1 | import Repository from '../../common/base-repository'; 2 | import Resolver from '../../common/base-resolver'; 3 | import User from '../../model/user'; 4 | 5 | export default class UserResolver extends Resolver { 6 | constructor() { 7 | super(new Repository()); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/api/users/routes.ts: -------------------------------------------------------------------------------- 1 | import * as Hapi from '@hapi/hapi'; 2 | import UserController from '../../api/users/controller'; 3 | import validate from '../../api/users/validate'; 4 | import Logger from '../../helper/logger'; 5 | import IRoute from '../../helper/route'; 6 | 7 | export default class UserRoutes implements IRoute { 8 | public async register(server: Hapi.Server): Promise { 9 | return new Promise(resolve => { 10 | Logger.info('UserRoutes - Start adding user routes'); 11 | 12 | // Passing ID by constructor it's not neccesary as default value it's 'id' 13 | const controller = new UserController('USER_ID'); 14 | 15 | server.route([ 16 | { 17 | method: 'POST', 18 | path: '/api/users', 19 | options: { 20 | handler: controller.create, 21 | validate: validate.create, 22 | description: 'Method that creates a new user.', 23 | tags: ['api', 'users'], 24 | auth: false, 25 | }, 26 | }, 27 | { 28 | method: 'PUT', 29 | path: `/api/users/{${controller.id}}`, 30 | options: { 31 | handler: controller.updateById, 32 | validate: validate.updateById, 33 | description: 'Method that updates a user by its id.', 34 | tags: ['api', 'users'], 35 | auth: false, 36 | }, 37 | }, 38 | { 39 | method: 'GET', 40 | path: `/api/users/{${controller.id}}`, 41 | options: { 42 | handler: controller.getById, 43 | validate: validate.getById, 44 | description: 'Method that get a user by its id.', 45 | tags: ['api', 'users'], 46 | auth: false, 47 | }, 48 | }, 49 | { 50 | method: 'GET', 51 | path: '/api/users', 52 | options: { 53 | handler: controller.getAll, 54 | description: 'Method that gets all users.', 55 | tags: ['api', 'users'], 56 | auth: false, 57 | }, 58 | }, 59 | { 60 | method: 'DELETE', 61 | path: `/api/users/{${controller.id}}`, 62 | options: { 63 | handler: controller.deleteById, 64 | validate: validate.deleteById, 65 | description: 'Method that deletes a user by its id.', 66 | tags: ['api', 'users'], 67 | auth: false, 68 | }, 69 | }, 70 | ]); 71 | 72 | Logger.info('UserRoutes - Finish adding user routes'); 73 | 74 | resolve(); 75 | }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/api/users/validate.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from '@hapi/joi'; 2 | 3 | export default { 4 | create: { 5 | payload: { 6 | age: Joi.number() 7 | .integer() 8 | .required(), 9 | name: Joi.string().required(), 10 | lastName: Joi.string().required(), 11 | }, 12 | }, 13 | updateById: { 14 | params: { 15 | USER_ID: Joi.string().required(), 16 | }, 17 | payload: { 18 | age: Joi.number() 19 | .integer() 20 | .optional(), 21 | name: Joi.string().optional(), 22 | lastName: Joi.string().optional(), 23 | }, 24 | }, 25 | getById: { 26 | params: { 27 | USER_ID: Joi.string().required(), 28 | }, 29 | }, 30 | deleteById: { 31 | params: { 32 | USER_ID: Joi.string().required(), 33 | }, 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /src/common/base-repository.ts: -------------------------------------------------------------------------------- 1 | import * as DataStore from 'nedb'; 2 | 3 | export default class Repository { 4 | public dataSource = new DataStore({ 5 | inMemoryOnly: true, 6 | }); 7 | 8 | public save(data: T): Promise { 9 | return new Promise((resolve, reject) => { 10 | this.dataSource.insert(data, (error, document) => { 11 | if (error) { 12 | reject(error); 13 | } 14 | 15 | resolve(document); 16 | }); 17 | }); 18 | } 19 | 20 | public getById(_id: string): Promise { 21 | return new Promise((resolve, reject) => { 22 | this.dataSource.findOne({ _id }, (error, document) => { 23 | if (error) { 24 | reject(error); 25 | } 26 | 27 | resolve(document); 28 | }); 29 | }); 30 | } 31 | 32 | public getAll(): Promise { 33 | return new Promise((resolve, reject) => { 34 | this.dataSource.find({}, {}, (error, documents) => { 35 | if (error) { 36 | reject(error); 37 | } 38 | 39 | resolve(documents); 40 | }); 41 | }); 42 | } 43 | 44 | public updateById(_id: string, data: T): Promise { 45 | return new Promise((resolve, reject) => { 46 | this.dataSource.update({ _id }, data, {}, error => { 47 | if (error) { 48 | reject(error); 49 | } 50 | 51 | this.getById(_id).then(value => resolve(value)); 52 | }); 53 | }); 54 | } 55 | 56 | public deleteById(_id: string): Promise { 57 | return new Promise((resolve, reject) => { 58 | this.dataSource.remove({ _id }, error => { 59 | if (error) { 60 | reject(error); 61 | } 62 | 63 | resolve(_id); 64 | }); 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/common/base-resolver.ts: -------------------------------------------------------------------------------- 1 | import Repository from '../common/base-repository'; 2 | 3 | export default class CrudResolver { 4 | constructor(protected repository: Repository) {} 5 | 6 | public async save(data: T): Promise { 7 | return await this.repository.save(data); 8 | } 9 | 10 | public async getOneById(id: string): Promise { 11 | return await this.repository.getById(id); 12 | } 13 | 14 | public async updateOneById(id: string, update: any): Promise { 15 | return await this.repository.updateById(id, update); 16 | } 17 | 18 | public async deleteOneById(id: string): Promise { 19 | return await this.repository.deleteById(id); 20 | } 21 | 22 | public async getAll(): Promise { 23 | return await this.repository.getAll(); 24 | } 25 | 26 | public async bulkUpdate( 27 | ids: string[], 28 | field: string, 29 | value: string 30 | ): Promise { 31 | return await Promise.all( 32 | ids.map(async id => await this.updateOneById(id, { [field]: value })) 33 | ); 34 | } 35 | 36 | public async bulkDelete(ids: string[]): Promise { 37 | return await Promise.all(ids.map(async id => await this.deleteOneById(id))); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/common/base-service.ts: -------------------------------------------------------------------------------- 1 | export default class Service {} 2 | -------------------------------------------------------------------------------- /src/common/crud-controller.ts: -------------------------------------------------------------------------------- 1 | import * as Boom from '@hapi/boom'; 2 | import * as Hapi from '@hapi/hapi'; 3 | import CrudResolver from '../common/base-resolver'; 4 | import Logger from '../helper/logger'; 5 | import newResponse from '../helper/response'; 6 | 7 | export default class CrudController { 8 | constructor( 9 | public id: string = 'id', 10 | private crudResolver: CrudResolver 11 | ) {} 12 | 13 | public create = async ( 14 | request: Hapi.Request, 15 | toolkit: Hapi.ResponseToolkit 16 | ): Promise => { 17 | try { 18 | Logger.info(`POST - ${request.url.href}`); 19 | 20 | const data: any = await this.crudResolver.save(request.payload as any); 21 | 22 | return toolkit.response( 23 | newResponse(request, { 24 | value: { _id: data['_id'] }, 25 | }) 26 | ); 27 | } catch (error) { 28 | return toolkit.response( 29 | newResponse(request, { 30 | boom: Boom.badImplementation(error), 31 | }) 32 | ); 33 | } 34 | }; 35 | 36 | public updateById = async ( 37 | request: Hapi.Request, 38 | toolkit: Hapi.ResponseToolkit 39 | ): Promise => { 40 | try { 41 | Logger.info(`PUT - ${request.url.href}`); 42 | 43 | const id = encodeURIComponent(request.params[this.id]); 44 | 45 | const updatedEntity: T = await this.crudResolver.updateOneById( 46 | id, 47 | request.payload 48 | ); 49 | 50 | if (!updatedEntity) { 51 | return toolkit.response( 52 | newResponse(request, { 53 | boom: Boom.notFound(), 54 | }) 55 | ); 56 | } 57 | 58 | return toolkit.response( 59 | newResponse(request, { 60 | value: updatedEntity, 61 | }) 62 | ); 63 | } catch (error) { 64 | return toolkit.response( 65 | newResponse(request, { 66 | boom: Boom.badImplementation(error), 67 | }) 68 | ); 69 | } 70 | }; 71 | 72 | public getById = async ( 73 | request: Hapi.Request, 74 | toolkit: Hapi.ResponseToolkit 75 | ): Promise => { 76 | try { 77 | Logger.info(`GET - ${request.url.href}`); 78 | 79 | const id = encodeURIComponent(request.params[this.id]); 80 | 81 | const entity: T = await this.crudResolver.getOneById(id); 82 | 83 | if (!entity) { 84 | return toolkit.response( 85 | newResponse(request, { 86 | boom: Boom.notFound(), 87 | }) 88 | ); 89 | } 90 | 91 | return toolkit.response( 92 | newResponse(request, { 93 | value: entity, 94 | }) 95 | ); 96 | } catch (error) { 97 | return toolkit.response( 98 | newResponse(request, { 99 | boom: Boom.badImplementation(error), 100 | }) 101 | ); 102 | } 103 | }; 104 | 105 | public getAll = async ( 106 | request: Hapi.Request, 107 | toolkit: Hapi.ResponseToolkit 108 | ): Promise => { 109 | try { 110 | Logger.info(`GET - ${request.url.href}`); 111 | 112 | const entities: T[] = await this.crudResolver.getAll(); 113 | 114 | return toolkit.response( 115 | newResponse(request, { 116 | value: entities, 117 | }) 118 | ); 119 | } catch (error) { 120 | return toolkit.response( 121 | newResponse(request, { 122 | boom: Boom.badImplementation(error), 123 | }) 124 | ); 125 | } 126 | }; 127 | 128 | public deleteById = async ( 129 | request: Hapi.Request, 130 | toolkit: Hapi.ResponseToolkit 131 | ): Promise => { 132 | try { 133 | Logger.info(`DELETE - ${request.url.href}`); 134 | 135 | const id = encodeURIComponent(request.params[this.id]); 136 | 137 | await this.crudResolver.deleteOneById(id); 138 | 139 | return toolkit.response( 140 | newResponse(request, { 141 | value: { _id: id }, 142 | }) 143 | ); 144 | } catch (error) { 145 | return toolkit.response( 146 | newResponse(request, { 147 | boom: Boom.badImplementation(error), 148 | }) 149 | ); 150 | } 151 | }; 152 | } 153 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | swagger: { 3 | options: { 4 | info: { 5 | title: 'API Documentation', 6 | version: 'v1.0.0', 7 | contact: { 8 | name: 'John doe', 9 | email: 'johndoe@johndoe.com', 10 | }, 11 | }, 12 | grouping: 'tags', 13 | sortEndpoints: 'ordered', 14 | }, 15 | }, 16 | status: { 17 | options: { 18 | path: '/status', 19 | title: 'API Monitor', 20 | routeConfig: { 21 | auth: false, 22 | }, 23 | }, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/helper/logger.ts: -------------------------------------------------------------------------------- 1 | import * as Dotenv from 'dotenv'; 2 | import * as Winston from 'winston'; 3 | 4 | Dotenv.config(); 5 | 6 | export class ApiLogger { 7 | public static newInstance(): Winston.Logger { 8 | const consoleTransport = new Winston.transports.Console({ 9 | format: Winston.format.combine( 10 | Winston.format.colorize(), 11 | Winston.format.timestamp(), 12 | Winston.format.align(), 13 | Winston.format.printf(info => { 14 | const { timestamp, level, message, ...args } = info; 15 | 16 | const ts = timestamp.slice(0, 19).replace('T', ' '); 17 | return `${ts} [${level}]: ${message} ${ 18 | Object.keys(args).length ? JSON.stringify(args, null, 2) : '' 19 | }`; 20 | }) 21 | ), 22 | level: process.env.LOG_LEVEL, 23 | }); 24 | 25 | return Winston.createLogger({ 26 | transports: [consoleTransport], 27 | }); 28 | } 29 | } 30 | 31 | export default ApiLogger.newInstance(); 32 | -------------------------------------------------------------------------------- /src/helper/response.ts: -------------------------------------------------------------------------------- 1 | import * as Boom from '@hapi/boom'; 2 | import * as Hapi from '@hapi/hapi'; 3 | 4 | interface IResponseMeta { 5 | operation?: string; 6 | method?: string; 7 | paging?: string | null; 8 | } 9 | 10 | interface IResponseError { 11 | code?: string | number; 12 | message?: string; 13 | error?: string; 14 | } 15 | 16 | interface IResponse { 17 | meta: IResponseMeta; 18 | data: T[]; 19 | errors: IResponseError[]; 20 | } 21 | 22 | interface IResponseOptions { 23 | value?: T | null | undefined; 24 | boom?: Boom.Boom | null | undefined; 25 | } 26 | 27 | export default function createResponse( 28 | request: Hapi.Request, 29 | { value = null, boom = null }: IResponseOptions 30 | ): IResponse { 31 | const errors: IResponseError[] = []; 32 | const data: any = []; 33 | 34 | if (boom) { 35 | errors.push({ 36 | code: boom.output.payload.statusCode, 37 | error: boom.output.payload.error, 38 | message: boom.output.payload.message, 39 | }); 40 | } 41 | 42 | if (value && data) { 43 | if (Array.isArray(value)) { 44 | data.push(...value); 45 | } else { 46 | data.push(value); 47 | } 48 | } 49 | 50 | return { 51 | meta: { 52 | method: request.method.toUpperCase(), 53 | operation: request.url.pathname, 54 | paging: null, 55 | }, 56 | data, 57 | errors, 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/helper/route.ts: -------------------------------------------------------------------------------- 1 | import * as Hapi from '@hapi/hapi'; 2 | 3 | interface IRoute { 4 | register(server: Hapi.Server): Promise; 5 | } 6 | 7 | export default IRoute; 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Logger from './helper/logger'; 2 | import Server from './server'; 3 | 4 | (async () => { 5 | await Server.start(); 6 | })(); 7 | 8 | // listen on SIGINT signal and gracefully stop the server 9 | process.on('SIGINT', () => { 10 | Logger.info('Stopping hapi server'); 11 | 12 | Server.stop().then(err => { 13 | Logger.info(`Server stopped`); 14 | process.exit(err ? 1 : 0); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/main.d.ts: -------------------------------------------------------------------------------- 1 | // declare module 'dotenv'; 2 | -------------------------------------------------------------------------------- /src/model/user.ts: -------------------------------------------------------------------------------- 1 | export default class User { 2 | public _id: string; 3 | 4 | public age: number; 5 | 6 | public name: string; 7 | 8 | public lastName: string; 9 | 10 | public creationDate: Date; 11 | 12 | constructor(name: string) { 13 | this._id = 'test'; 14 | this.name = name; 15 | this.age = 12; 16 | this.lastName = 'Smith'; 17 | this.creationDate = new Date(); 18 | } 19 | 20 | public toString() { 21 | return `UserID: ${this._id}, Age: ${this.age}, Name: ${this.name}, LastName: ${this.lastName}`; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/plugin/index.ts: -------------------------------------------------------------------------------- 1 | import Config from '../config'; 2 | import * as Hapi from '@hapi/hapi'; 3 | import Logger from '../helper/logger'; 4 | 5 | export default class Plugins { 6 | public static async status(server: Hapi.Server): Promise { 7 | try { 8 | Logger.info('Plugins - Registering status-monitor'); 9 | 10 | await Plugins.register(server, { 11 | options: Config.status.options, 12 | plugin: require('hapijs-status-monitor'), 13 | }); 14 | } catch (error) { 15 | Logger.info( 16 | `Plugins - Ups, something went wrong when registering status plugin: ${error}` 17 | ); 18 | } 19 | } 20 | 21 | public static async swagger(server: Hapi.Server): Promise { 22 | try { 23 | Logger.info('Plugins - Registering swagger-ui'); 24 | 25 | await Plugins.register(server, [ 26 | require('@hapi/vision'), 27 | require('@hapi/inert'), 28 | { 29 | options: Config.swagger.options, 30 | plugin: require('hapi-swagger'), 31 | }, 32 | ]); 33 | } catch (error) { 34 | Logger.info( 35 | `Plugins - Ups, something went wrong when registering swagger-ui plugin: ${error}` 36 | ); 37 | } 38 | } 39 | 40 | public static async registerAll(server: Hapi.Server): Promise { 41 | if (process.env.NODE_ENV === 'development') { 42 | await Plugins.status(server); 43 | await Plugins.swagger(server); 44 | } 45 | } 46 | 47 | private static async register( 48 | server: Hapi.Server, 49 | plugin: any 50 | ): Promise { 51 | Logger.debug('registering: ' + JSON.stringify(plugin)); 52 | 53 | return new Promise((resolve, reject) => { 54 | server.register(plugin); 55 | resolve(); 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import * as Hapi from '@hapi/hapi'; 2 | import UserRoutes from './api/users/routes'; 3 | import Logger from './helper/logger'; 4 | 5 | export default class Router { 6 | public static async loadRoutes(server: Hapi.Server): Promise { 7 | Logger.info('Router - Start adding routes'); 8 | 9 | await new UserRoutes().register(server); 10 | 11 | Logger.info('Router - Finish adding routes'); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import * as Hapi from '@hapi/hapi'; 2 | import Logger from './helper/logger'; 3 | import Plugin from './plugin'; 4 | import Router from './router'; 5 | import * as DotEnv from 'dotenv'; 6 | 7 | export default class Server { 8 | private static _instance: Hapi.Server; 9 | 10 | public static async start(): Promise { 11 | try { 12 | DotEnv.config({ 13 | path: `${process.cwd()}/.env`, 14 | }); 15 | 16 | Server._instance = new Hapi.Server({ 17 | port: process.env.PORT, 18 | }); 19 | 20 | Server._instance.validator(require('@hapi/joi')); 21 | 22 | await Plugin.registerAll(Server._instance); 23 | await Router.loadRoutes(Server._instance); 24 | 25 | await Server._instance.start(); 26 | 27 | Logger.info( 28 | `Server - Up and running at http://${process.env.HOST}:${process.env.PORT}` 29 | ); 30 | Logger.info( 31 | `Server - Visit http://${process.env.HOST}:${process.env.PORT}/api/users for REST API` 32 | ); 33 | Logger.info( 34 | `Server - Visit http://${process.env.HOST}:${process.env.PORT}/documentation for Swagger docs` 35 | ); 36 | 37 | return Server._instance; 38 | } catch (error) { 39 | Logger.info(`Server - There was something wrong: ${error}`); 40 | 41 | throw error; 42 | } 43 | } 44 | 45 | public static stop(): Promise { 46 | Logger.info(`Server - Stopping execution`); 47 | 48 | return Server._instance.stop(); 49 | } 50 | 51 | public static async recycle(): Promise { 52 | Logger.info(`Server - Recycling instance`); 53 | 54 | await Server.stop(); 55 | 56 | return await Server.start(); 57 | } 58 | 59 | public static instance(): Hapi.Server { 60 | return Server._instance; 61 | } 62 | 63 | public static async inject( 64 | options: string | Hapi.ServerInjectOptions 65 | ): Promise { 66 | return await Server._instance.inject(options); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as Hapi from '@hapi/hapi'; 2 | import * as test from 'tape'; 3 | import { Test } from 'tape'; 4 | import Server from '../src/server'; 5 | 6 | export interface IPayload { 7 | status: number; 8 | data: T; 9 | } 10 | 11 | export const startServer = async (): Promise => { 12 | if (Server.instance() === undefined) { 13 | return await Server.start(); 14 | } 15 | 16 | return await Server.recycle(); 17 | }; 18 | 19 | export const stopServer = async (): Promise => { 20 | return await Server.stop(); 21 | }; 22 | 23 | export const extractPayload = ( 24 | response: Hapi.ServerInjectResponse 25 | ): IPayload => { 26 | const payload = JSON.parse(response.payload) as IPayload; 27 | 28 | return payload; 29 | }; 30 | 31 | export const serverTest = async ( 32 | description: string, 33 | testFunc: (server: Hapi.Server, t: Test) => void 34 | ) => { 35 | test(description, async t => { 36 | const server = await startServer(); 37 | await testFunc(server, t); 38 | await stopServer(); 39 | t.end(); 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /test/users.spec.ts: -------------------------------------------------------------------------------- 1 | import { extractPayload, serverTest } from './helpers'; 2 | 3 | interface IUser { 4 | id?: string; 5 | age: number; 6 | name: string; 7 | lastName: string; 8 | } 9 | 10 | serverTest('[GET] /api/users should return 200 status', async (server, t) => { 11 | const response = await server.inject({ 12 | method: 'GET', 13 | url: '/api/users', 14 | }); 15 | 16 | const payload = extractPayload(response); 17 | 18 | t.equals(response.statusCode, 200, 'Status code is 200'); 19 | t.equals(payload.data.length, 0, 'Data is empty'); 20 | }); 21 | 22 | serverTest('[POST] /api/users should return 201', async (server, t) => { 23 | const response = await server.inject({ 24 | method: 'POST', 25 | url: '/api/users', 26 | payload: { 27 | age: 33, 28 | name: 'John', 29 | lastName: 'Doe', 30 | }, 31 | }); 32 | 33 | const payload = extractPayload(response); 34 | 35 | t.equal(response.statusCode, 200, 'Status is 200'); 36 | t.assert(typeof payload.data.id === 'string', 'ID is a string'); 37 | }); 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */, 5 | "module": "commonjs" /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | "lib": [ 7 | "es2015" 8 | ] /* Specify library files to be included in the compilation: */, 9 | "allowJs": false /* Allow javascript files to be compiled. */, 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | "declaration": true /* Generates corresponding '.d.ts' file. */, 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "dist" /* Redirect output structure to the directory. */, 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true /* Enable all strict type-checking options. */, 25 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | // "strictNullChecks": true, /* Enable strict null checks. */ 27 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 28 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 29 | 30 | /* Additional Checks */ 31 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 32 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 33 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 34 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 35 | 36 | /* Module Resolution Options */ 37 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 38 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 39 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 40 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 41 | // "typeRoots": [], /* List of folders to include type definitions from. */ 42 | // "types": [], /* Type declaration files to be included in compilation. */ 43 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 44 | 45 | /* Source Map Options */ 46 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 47 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 48 | "inlineSourceMap": false /* Emit a single file with source maps instead of having a separate file. */, 49 | "inlineSources": false /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */, 50 | /* Experimental Options */ 51 | "experimentalDecorators": true, 52 | "emitDecoratorMetadata": true 53 | }, 54 | "exclude": ["node_modules", "**/*.spec.ts", "dist"] 55 | } 56 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": {}, 5 | "rules": { 6 | "no-trailing-whitespace": false, 7 | "object-literal-key-quotes": false, 8 | "object-literal-sort-keys": false, 9 | "no-string-literal": false, 10 | "ordered-imports": false, 11 | "variable-name": false, 12 | "arrow-parens": false, 13 | "quotemark": false, 14 | "trailing-comma": false, 15 | "semicolon": [ 16 | true, 17 | "always", 18 | "ignore-interfaces", 19 | "ignore-bound-class-methods" 20 | ], 21 | "member-ordering": [true, "variables-before-functions"] 22 | }, 23 | "rulesDirectory": [] 24 | } 25 | --------------------------------------------------------------------------------