├── .mocharc.json ├── src ├── api │ ├── components │ │ ├── user │ │ │ ├── policy.json │ │ │ ├── repository.ts │ │ │ ├── model.ts │ │ │ ├── routes.ts │ │ │ ├── controller.ts │ │ │ └── user.spec.ts │ │ ├── user-role │ │ │ ├── policy.json │ │ │ ├── repository.ts │ │ │ ├── model.ts │ │ │ ├── routes.ts │ │ │ ├── controller.ts │ │ │ └── user-role.spec.ts │ │ ├── user-invitation │ │ │ ├── policy.json │ │ │ ├── templates │ │ │ │ └── invitation.html │ │ │ ├── repository.ts │ │ │ ├── model.ts │ │ │ ├── services │ │ │ │ └── mail.ts │ │ │ ├── routes.ts │ │ │ ├── controller.ts │ │ │ └── user-invitation.spec.ts │ │ ├── index.ts │ │ ├── auth │ │ │ ├── routes.ts │ │ │ ├── auth.spec.ts │ │ │ └── controller.ts │ │ └── helper.ts │ ├── server.ts │ ├── routes.ts │ └── middleware │ │ └── index.ts ├── config │ ├── policy.ts │ ├── globals.ts │ └── logger.ts ├── services │ ├── auth │ │ ├── strategies │ │ │ ├── base.ts │ │ │ └── jwt.ts │ │ └── index.ts │ ├── mail.ts │ ├── utility.ts │ └── redis.ts ├── app.ts └── test │ └── factory.ts ├── tslint.json ├── .prettierrc ├── Dockerfile ├── .editorconfig ├── tsconfig.json ├── db ├── seed.sql └── index.js ├── .env.example ├── .env.docker.example ├── gulpfile.js ├── .github └── workflows │ └── test.yaml ├── docker-compose.yml ├── LICENSE ├── .gitignore ├── package.json └── README.md /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec": ["./dist/api/components/**/*.spec.js"], 3 | "timeout": 5000 4 | } 5 | -------------------------------------------------------------------------------- /src/api/components/user/policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Admin": [{ "resources": "user", "permissions": "*" }], 3 | "User": [{ "resources": "user", "permissions": ["read"] }] 4 | } 5 | -------------------------------------------------------------------------------- /src/api/components/user-role/policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Admin": [{ "resources": "user-role", "permissions": "*" }], 3 | "User": [{ "resources": "user-role", "permissions": [] }] 4 | } 5 | -------------------------------------------------------------------------------- /src/api/components/user-invitation/policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Admin": [{ "resources": "user-invitation", "permissions": "*" }], 3 | "User": [{ "resources": "user-invitation", "permissions": [] }] 4 | } 5 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended", "tslint-config-prettier"], 4 | "jsRules": {}, 5 | "rules": { 6 | "no-console": false 7 | }, 8 | "rulesDirectory": [] 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "printWidth": 120, 5 | "semi": true, 6 | "singleQuote": true, 7 | "useTabs": true, 8 | "tabWidth": 2, 9 | "trailingComma": "none" 10 | } 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | # Create app directory 4 | RUN mkdir -p /usr/src/app 5 | WORKDIR /usr/src/app 6 | 7 | # Copy package.json 8 | COPY package.json /usr/src/app 9 | 10 | # Install node_modules 11 | RUN npm install 12 | 13 | # Copy files 14 | COPY . /usr/src/app 15 | -------------------------------------------------------------------------------- /src/api/components/user-invitation/templates/invitation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Hello,

4 |

You were invited to join: expressjs-api.

5 |

Complete your registration here.

6 |

Kindest regards.

7 | 8 | 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most editorconfig file 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [{*.yml,*.yaml,package.json}] 13 | indent_style = space 14 | indent_size = 2 -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "es6", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "outDir": "dist", 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true 11 | }, 12 | "lib": ["es2015"] 13 | } 14 | -------------------------------------------------------------------------------- /src/api/components/user-role/repository.ts: -------------------------------------------------------------------------------- 1 | import { getManager } from 'typeorm'; 2 | 3 | import { AbsRepository } from '../helper'; 4 | 5 | import { UserRole } from './model'; 6 | 7 | export class UserRoleRepository extends AbsRepository { 8 | constructor() { 9 | super('user-role', getManager().getRepository(UserRole)); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/api/components/user-invitation/repository.ts: -------------------------------------------------------------------------------- 1 | import { getManager } from 'typeorm'; 2 | 3 | import { AbsRepository } from '../helper'; 4 | 5 | import { UserInvitation } from './model'; 6 | 7 | export class UserInvitationRepository extends AbsRepository { 8 | constructor() { 9 | super('user-invitation', getManager().getRepository(UserInvitation)); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/api/server.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import { initRestRoutes } from './routes'; 4 | 5 | export class Server { 6 | private readonly _app: express.Application = express(); 7 | 8 | public constructor() { 9 | initRestRoutes(this._app); 10 | } 11 | 12 | /** 13 | * Get Express app 14 | * 15 | * @returns {express.Application} Returns Express app 16 | */ 17 | public get app(): express.Application { 18 | return this._app; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /db/seed.sql: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | ##### Database seeding ##### 4 | Run the following command after all tables were created: 5 | 'npm run seed' 6 | */ 7 | 8 | /* Insert user roles */ 9 | INSERT INTO user_role (name) 10 | VALUES ('Admin'), 11 | ('User'); 12 | 13 | SET @admin_role_id = (SELECT id FROM user_role WHERE name = 'Admin'); 14 | 15 | /* Insert admin account */ 16 | INSERT INTO user (email, firstname, lastname, password, userRoleId) 17 | VALUES ('admin@email.com', 'Admin', 'Admin', ?, @admin_role_id); 18 | -------------------------------------------------------------------------------- /src/config/policy.ts: -------------------------------------------------------------------------------- 1 | import acl from 'acl'; 2 | import { readFileSync } from 'fs'; 3 | import { logger } from './logger'; 4 | 5 | const policy = new acl(new acl.memoryBackend()); 6 | 7 | // Read permissions from combined policies 8 | try { 9 | const policies = JSON.parse(readFileSync('./dist/output/policies.combined.json', 'utf-8')); 10 | policy.allow([ 11 | { 12 | allows: policies.Admin, 13 | roles: ['Admin'] 14 | }, 15 | { 16 | allows: policies.User, 17 | roles: ['User'] 18 | } 19 | ]); 20 | } catch (error) { 21 | logger.error(error.message); 22 | } 23 | 24 | export { policy }; 25 | -------------------------------------------------------------------------------- /src/api/routes.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | 3 | import { registerApiRoutes } from './components'; 4 | import { registerErrorHandler, registerMiddleware } from './middleware'; 5 | 6 | /** 7 | * Init Express REST routes 8 | * 9 | * @param {Router} router 10 | * @returns {void} 11 | */ 12 | export function initRestRoutes(router: Router): void { 13 | const prefix: string = '/api/v1'; 14 | 15 | router.get(prefix, (req: Request, res: Response) => res.send('PING')); 16 | 17 | registerMiddleware(router); 18 | registerApiRoutes(router, prefix); 19 | registerErrorHandler(router); 20 | } 21 | -------------------------------------------------------------------------------- /src/config/globals.ts: -------------------------------------------------------------------------------- 1 | // Environment variables imported from .env file 2 | export const env = { 3 | REDIS_URL: process.env.REDIS_URL, 4 | NODE_ENV: process.env.NODE_ENV || 'development', 5 | NODE_PORT: process.env.NODE_PORT || process.env.PORT || 3000, 6 | DOMAIN: process.env.DOMAIN, 7 | SMTP: { 8 | auth: { 9 | pass: process.env.SMTP_PASSWORD || '', 10 | user: process.env.SMTP_USERNAME || '' 11 | }, 12 | host: process.env.SMTP_HOST || '', 13 | port: process.env.SMTP_PORT || '', 14 | tls: { 15 | rejectUnauthorized: false 16 | } 17 | } 18 | }; 19 | 20 | export const mails = { 21 | support: 'support@my-company.com' 22 | }; 23 | -------------------------------------------------------------------------------- /src/api/components/user-role/model.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | import { User } from '../user/model'; 4 | 5 | @Entity() 6 | export class UserRole { 7 | constructor(id: number, name: string) { 8 | this.id = id; 9 | this.name = name; 10 | } 11 | 12 | @PrimaryGeneratedColumn() 13 | public id: number; 14 | 15 | @Column({ 16 | nullable: false, 17 | unique: true 18 | }) 19 | public name: string; 20 | 21 | @OneToMany((type) => User, (user) => user.userRole) 22 | public users: User[]; 23 | 24 | public static mockTestUserRole(): UserRole { 25 | return new UserRole(1, 'Admin'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # General config 2 | NODE_ENV=development 3 | NODE_PORT=3000 4 | 5 | DOMAIN=http://localhost:3000 6 | 7 | # SMTP config for mail service 8 | SMTP_HOST=smtp.ethereal.email 9 | SMTP_PORT=587 10 | SMTP_USERNAME=username 11 | SMTP_PASSWORD=password 12 | 13 | # Redis 14 | REDIS_URL= 15 | 16 | # Database 17 | TYPEORM_CONNECTION=mysql 18 | TYPEORM_HOST=localhost 19 | TYPEORM_USERNAME=root 20 | TYPEORM_PASSWORD=password 21 | TYPEORM_DATABASE=expressjs_api 22 | TYPEORM_PORT=3306 23 | TYPEORM_SYNCHRONIZE=true 24 | TYPEORM_LOGGING=false 25 | TYPEORM_ENTITIES=dist/api/components/**/model.js 26 | TYPEORM_MIGRATIONS=dist/api/components/**/migration.js 27 | TYPEORM_SUBSCRIBERS=dist/api/components/**/subcriber.js 28 | -------------------------------------------------------------------------------- /.env.docker.example: -------------------------------------------------------------------------------- 1 | # General config 2 | NODE_ENV=production 3 | NODE_PORT=3000 4 | 5 | DOMAIN=http://localhost:3000 6 | 7 | # SMTP config for mail service 8 | SMTP_HOST=mailhog 9 | SMTP_PORT=1025 10 | SMTP_USERNAME=username 11 | SMTP_PASSWORD=password 12 | 13 | # Redis 14 | REDIS_URL=redis://redis 15 | 16 | # Database 17 | TYPEORM_CONNECTION=mysql 18 | TYPEORM_HOST=mysql 19 | TYPEORM_USERNAME=root 20 | TYPEORM_PASSWORD=password 21 | TYPEORM_DATABASE=expressjs_api 22 | TYPEORM_PORT=3306 23 | TYPEORM_SYNCHRONIZE=true 24 | TYPEORM_LOGGING=false 25 | TYPEORM_ENTITIES=dist/api/components/**/model.js 26 | TYPEORM_MIGRATIONS=dist/api/components/**/migration.js 27 | TYPEORM_SUBSCRIBERS=dist/api/components/**/subcriber.js 28 | -------------------------------------------------------------------------------- /src/api/components/user/repository.ts: -------------------------------------------------------------------------------- 1 | import { bind } from 'decko'; 2 | import { getManager } from 'typeorm'; 3 | 4 | import { AbsRepository } from '../helper'; 5 | 6 | import { User } from './model'; 7 | 8 | export class UserRepository extends AbsRepository { 9 | constructor() { 10 | super('user', getManager().getRepository(User), ['userRole']); 11 | } 12 | 13 | /** 14 | * Read user by email from db 15 | * 16 | * @param email Email to search for 17 | * @returns User 18 | */ 19 | @bind 20 | readByEmail(email: string): Promise { 21 | try { 22 | return this.read({ 23 | where: { 24 | email 25 | } 26 | }); 27 | } catch (err) { 28 | throw new Error(err); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/api/components/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { AuthRoutes } from './auth/routes'; 4 | import { UserRoutes } from './user/routes'; 5 | import { UserInvitationRoutes } from './user-invitation/routes'; 6 | import { UserRoleRoutes } from './user-role/routes'; 7 | 8 | /** 9 | * Init component routes 10 | * 11 | * @param {Router} router 12 | * @param {string} prefix 13 | * @returns {void} 14 | */ 15 | export function registerApiRoutes(router: Router, prefix: string = ''): void { 16 | router.use(`${prefix}/auth`, new AuthRoutes().router); 17 | router.use(`${prefix}/users`, new UserRoutes().router); 18 | router.use(`${prefix}/user-invitations`, new UserInvitationRoutes().router); 19 | router.use(`${prefix}/user-roles`, new UserRoleRoutes().router); 20 | } 21 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const merge = require('gulp-merge-json'); 3 | 4 | const bases = { 5 | src: 'src/', 6 | dist: 'dist/', 7 | components: 'api/components/', 8 | output: 'output/' 9 | }; 10 | 11 | const paths = { 12 | policy: 'api/components/**/policy.json', 13 | html: 'api/components/**/templates/*.html' 14 | }; 15 | 16 | // Task for copying html templates 17 | gulp.task('copy', () => { 18 | console.log('Copying HTML templates...'); 19 | return gulp.src(paths.html, { cwd: bases.src }).pipe(gulp.dest(bases.components, { cwd: bases.dist })); 20 | }); 21 | 22 | // Task for merging policies 23 | gulp.task('merge', () => { 24 | console.log('Copying HTML templates...'); 25 | return gulp 26 | .src(paths.policy, { cwd: bases.src }) 27 | .pipe(merge({ fileName: 'policies.combined.json', concatArrays: true })) 28 | .pipe(gulp.dest(bases.output, { cwd: bases.dist })); 29 | }); 30 | -------------------------------------------------------------------------------- /src/api/components/user-invitation/model.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | import { UtilityService } from '../../../services/utility'; 4 | 5 | @Entity() 6 | export class UserInvitation { 7 | constructor(id: number, email: string, hash: string, active: boolean) { 8 | this.id = id; 9 | this.email = email; 10 | this.uuid = hash; 11 | this.active = active; 12 | } 13 | 14 | @PrimaryGeneratedColumn() 15 | public id: number; 16 | 17 | @Column({ 18 | nullable: false, 19 | unique: true 20 | }) 21 | public email: string; 22 | 23 | @Column({ 24 | nullable: false, 25 | unique: true 26 | }) 27 | public uuid: string; 28 | 29 | @Column({ 30 | default: true 31 | }) 32 | public active: boolean; 33 | 34 | public static mockTestUserInvitation(): UserInvitation { 35 | return new UserInvitation(1, 'test@email.com', UtilityService.generateUuid(), true); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [14.x, 15.x, 16.x] 11 | redis-version: [4, 5, 6] 12 | 13 | steps: 14 | - name: Git checkout 15 | uses: actions/checkout@v2 16 | 17 | - name: Cache node modules 18 | uses: actions/cache@v2 19 | with: 20 | path: ~/.npm 21 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 22 | restore-keys: | 23 | ${{ runner.os }}-node- 24 | 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v1 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | 30 | - name: Start Redis 31 | uses: supercharge/redis-github-action@1.2.0 32 | with: 33 | redis-version: ${{ matrix.redis-version }} 34 | 35 | - run: npm ci 36 | - run: npm test 37 | -------------------------------------------------------------------------------- /db/index.js: -------------------------------------------------------------------------------- 1 | const mysql = require('mysql2'); 2 | const fs = require('fs'); 3 | const bcrypt = require('bcryptjs'); 4 | 5 | require('dotenv').config(); 6 | 7 | // Read SQL seed query 8 | const seedQuery = fs.readFileSync('db/seed.sql', { 9 | encoding: 'utf-8' 10 | }); 11 | 12 | // Connect to database 13 | const connection = mysql.createConnection({ 14 | host: process.env.TYPEORM_HOST, 15 | user: process.env.TYPEORM_USERNAME, 16 | password: process.env.TYPEORM_PASSWORD, 17 | database: process.env.TYPEORM_DATABASE, 18 | multipleStatements: true 19 | }); 20 | 21 | connection.connect(); 22 | 23 | // Generate random password for initial admin user 24 | const psw = Math.random().toString(36).substring(2); 25 | const hash = bcrypt.hashSync(psw, 10); 26 | 27 | console.log('Running SQL seed...'); 28 | 29 | // Run seed query 30 | connection.query(seedQuery, [hash], (err) => { 31 | if (err) { 32 | throw err; 33 | } 34 | 35 | console.log('SQL seed completed! Password for initial admin account: ' + psw); 36 | connection.end(); 37 | }); 38 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | server: 5 | build: . 6 | container_name: expressjs-api 7 | depends_on: 8 | - mysql 9 | - redis 10 | - mailhog 11 | ports: 12 | - 3000:3000 13 | links: 14 | - mysql 15 | - redis 16 | - mailhog 17 | env_file: 18 | - .env.docker 19 | command: npm run start-docker 20 | mysql: 21 | image: mysql 22 | restart: always 23 | env_file: 24 | - .env.docker 25 | environment: 26 | MYSQL_ROOT_PASSWORD: $TYPEORM_PASSWORD 27 | MYSQL_DATABASE: $TYPEORM_DATABASE 28 | cap_add: 29 | - SYS_NICE 30 | ports: 31 | - 11543:3306 32 | volumes: 33 | - db-config:/etc/mysql 34 | - db-data:/var/lib/mysql 35 | redis: 36 | image: redis 37 | restart: always 38 | expose: 39 | - 6379 40 | mailhog: 41 | image: mailhog/mailhog 42 | restart: always 43 | ports: 44 | - 1025:1025 45 | - 8025:8025 46 | 47 | volumes: 48 | db-config: 49 | db-data: 50 | -------------------------------------------------------------------------------- /src/services/auth/strategies/base.ts: -------------------------------------------------------------------------------- 1 | import { BasicStrategy as Strategy_Basic } from 'passport-http'; 2 | import { Strategy as Strategy_Jwt } from 'passport-jwt'; 3 | import { getManager, Repository } from 'typeorm'; 4 | 5 | import { policy } from '../../../config/policy'; 6 | 7 | import { User } from '../../../api/components/user/model'; 8 | 9 | /** 10 | * Abstract BaseStrategy 11 | * 12 | * Other strategies inherits from this one 13 | */ 14 | export abstract class BaseStrategy { 15 | protected readonly userRepo: Repository = getManager().getRepository('User'); 16 | protected _strategy: Strategy_Jwt | Strategy_Basic; 17 | 18 | /** 19 | * Get strategy 20 | * 21 | * @returns Returns Passport strategy 22 | */ 23 | public get strategy(): Strategy_Jwt | Strategy_Basic { 24 | return this._strategy; 25 | } 26 | 27 | /** 28 | * Sets acl permission for user 29 | * 30 | * @param user 31 | * @returns 32 | */ 33 | protected async setPermissions(user: User): Promise { 34 | // add role from db 35 | await policy.addUserRoles(user.id, user.userRole.name); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/api/components/user-invitation/services/mail.ts: -------------------------------------------------------------------------------- 1 | import { SendMailOptions, SentMessageInfo } from 'nodemailer'; 2 | 3 | import { env, mails } from '../../../../config/globals'; 4 | 5 | import { MailService } from '../../../../services/mail'; 6 | 7 | export class UserInvitationMailService extends MailService { 8 | /** 9 | * Send user invitation email 10 | * 11 | * @param email Email to which the invitation is sent 12 | * @param uuid UUID for registration link 13 | * @returns Info of sent mail 14 | */ 15 | async sendUserInvitation(email: string, uuid: string): Promise { 16 | const templateParams = { 17 | confirmUrl: `${env.DOMAIN}/register/${uuid}?email=${encodeURIComponent(email)}` 18 | }; 19 | 20 | const mailTemplate = await this.renderMailTemplate( 21 | './dist/api/components/user-invitation/templates/invitation.html', 22 | templateParams 23 | ); 24 | 25 | const mail: SendMailOptions = { 26 | from: mails.support, 27 | html: mailTemplate, 28 | subject: 'You were invited to join expressjs-api', 29 | to: email 30 | }; 31 | 32 | return this.sendMail(mail); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Lars Wächter 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 | -------------------------------------------------------------------------------- /src/services/mail.ts: -------------------------------------------------------------------------------- 1 | import { Data, renderFile } from 'ejs'; 2 | import { createTransport, SendMailOptions, SentMessageInfo, Transporter, TransportOptions } from 'nodemailer'; 3 | import { resolve } from 'path'; 4 | 5 | import { env } from '../config/globals'; 6 | import { logger } from '../config/logger'; 7 | 8 | /** 9 | * MailService 10 | * 11 | * Service for sending emails 12 | */ 13 | export class MailService { 14 | private transporter: Transporter = createTransport(env.SMTP as TransportOptions); 15 | 16 | /** 17 | * Send email 18 | * 19 | * @param options Mail options 20 | * @param forceSend Force email to be sent 21 | * @returns info of sent mail 22 | */ 23 | public sendMail(options: SendMailOptions, forceSend: boolean = false): Promise | void { 24 | if (env.NODE_ENV === 'production' || forceSend) { 25 | return this.transporter.sendMail(options); 26 | } 27 | logger.info('Emails are only sent in production mode!'); 28 | } 29 | 30 | /** 31 | * Render EJS template for Email 32 | * 33 | * @param templatePath Path of template to render 34 | * @param templateData Data for template to render 35 | */ 36 | public renderMailTemplate(templatePath: string, templateData: Data): Promise { 37 | return renderFile(resolve(templatePath), templateData); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | // Set env variables from .env file 4 | import { config } from 'dotenv'; 5 | config(); 6 | 7 | import express from 'express'; 8 | 9 | import { createServer, Server as HttpServer } from 'http'; 10 | import { createConnection, Connection } from 'typeorm'; 11 | 12 | import { env } from './config/globals'; 13 | import { logger } from './config/logger'; 14 | 15 | import { Server } from './api/server'; 16 | import { RedisService } from './services/redis'; 17 | 18 | // Startup 19 | (async function main() { 20 | try { 21 | // Connect db 22 | logger.info('Initializing ORM connection...'); 23 | const connection: Connection = await createConnection(); 24 | 25 | // Connect redis 26 | RedisService.connect(); 27 | 28 | // Init express server 29 | const app: express.Application = new Server().app; 30 | const server: HttpServer = createServer(app); 31 | 32 | // Start express server 33 | server.listen(env.NODE_PORT); 34 | 35 | server.on('listening', () => { 36 | logger.info(`node server is listening on port ${env.NODE_PORT} in ${env.NODE_ENV} mode`); 37 | }); 38 | 39 | server.on('close', () => { 40 | connection.close(); 41 | RedisService.disconnect(); 42 | logger.info('node server closed'); 43 | }); 44 | } catch (err) { 45 | logger.error(err.stack); 46 | } 47 | })(); 48 | -------------------------------------------------------------------------------- /src/api/middleware/index.ts: -------------------------------------------------------------------------------- 1 | import compression from 'compression'; 2 | import cors from 'cors'; 3 | import helmet from 'helmet'; 4 | 5 | import { json, NextFunction, Request, Response, Router } from 'express'; 6 | 7 | import { AuthService } from '../../services/auth'; 8 | import { UtilityService } from '../../services/utility'; 9 | 10 | import { env } from '../../config/globals'; 11 | 12 | /** 13 | * Init Express middleware 14 | * 15 | * @param {Router} router 16 | * @returns {void} 17 | */ 18 | export function registerMiddleware(router: Router): void { 19 | router.use(helmet()); 20 | 21 | if (env.NODE_ENV === 'development') { 22 | router.use(cors({ origin: '*' })); 23 | } else { 24 | router.use(cors({ origin: ['http://localhost:4200'] })); 25 | } 26 | 27 | router.use(json()); 28 | router.use(compression()); 29 | 30 | // Setup passport strategies 31 | new AuthService().initStrategies(); 32 | } 33 | 34 | /** 35 | * Init Express error handler 36 | * 37 | * @param {Router} router 38 | * @returns {void} 39 | */ 40 | export function registerErrorHandler(router: Router): Response | void { 41 | router.use((err: Error, req: Request, res: Response, next: NextFunction) => { 42 | UtilityService.handleError(err); 43 | 44 | return res.status(500).json({ 45 | error: err.message || err, 46 | status: 500 47 | }); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /src/config/logger.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import { existsSync, mkdirSync } from 'fs'; 3 | import { createLogger, format, transports } from 'winston'; 4 | 5 | import { env } from './globals'; 6 | 7 | const logDir = 'logs'; 8 | 9 | // Create the log directory if it does not exist 10 | if (!existsSync(logDir)) { 11 | mkdirSync(logDir); 12 | } 13 | 14 | const errorLog = join(logDir, 'error.log'); 15 | const combinedLog = join(logDir, 'combined.log'); 16 | const exceptionsLog = join(logDir, 'exceptions.log'); 17 | 18 | export const logger = createLogger({ 19 | level: 'info', 20 | format: format.combine( 21 | format.timestamp({ 22 | format: 'YYYY-MM-DD HH:mm:ss' 23 | }), 24 | format.printf((info) => `${info.timestamp} ${info.level}: ${info.message}`) 25 | ), 26 | transports: [ 27 | new transports.File({ 28 | filename: errorLog, 29 | level: 'error' 30 | }), 31 | new transports.File({ 32 | filename: combinedLog 33 | }) 34 | ], 35 | exceptionHandlers: [ 36 | new transports.File({ 37 | filename: exceptionsLog 38 | }) 39 | ] 40 | }); 41 | 42 | if (env.NODE_ENV !== 'production') { 43 | logger.add( 44 | new transports.Console({ 45 | format: format.combine( 46 | format.colorize(), 47 | format.printf((info) => `${info.timestamp} ${info.level}: ${info.message}`) 48 | ), 49 | level: 'debug' 50 | }) 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/api/components/auth/routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { body, param } from 'express-validator'; 3 | 4 | import { AuthService, PassportStrategy } from '../../../services/auth'; 5 | 6 | import { IComponentRoutes } from '../helper'; 7 | 8 | import { AuthController } from './controller'; 9 | 10 | export class AuthRoutes implements IComponentRoutes { 11 | readonly name: string = 'auth'; 12 | readonly controller: AuthController = new AuthController(); 13 | readonly router: Router = Router(); 14 | authSerivce: AuthService; 15 | 16 | constructor(defaultStrategy?: PassportStrategy) { 17 | this.authSerivce = new AuthService(defaultStrategy); 18 | this.initRoutes(); 19 | } 20 | 21 | initRoutes(): void { 22 | this.router.post( 23 | '/signin', 24 | body('email').isEmail(), 25 | body('password').isString(), 26 | this.authSerivce.validateRequest, 27 | this.controller.signinUser 28 | ); 29 | 30 | this.router.post( 31 | '/register/:uuid', 32 | param('uuid').isUUID(), 33 | body('email').isEmail(), 34 | body('firstname').isString(), 35 | body('lastname').isString(), 36 | body('password').isString(), 37 | this.authSerivce.validateRequest, 38 | this.controller.registerUser 39 | ); 40 | 41 | this.router.post( 42 | '/invite', 43 | body('email').isEmail(), 44 | this.authSerivce.validateRequest, 45 | this.controller.createUserInvitation 46 | ); 47 | 48 | this.router.post('/unregister', this.authSerivce.isAuthorized(), this.controller.unregisterUser); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/api/components/user/model.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, Timestamp, ManyToOne } from 'typeorm'; 2 | 3 | import { UserRole } from '../user-role/model'; 4 | 5 | @Entity() 6 | export class User { 7 | constructor(id: number, email: string, firstname: string, lastname: string, password: string, active: boolean) { 8 | this.id = id; 9 | this.email = email; 10 | this.firstname = firstname; 11 | this.lastname = lastname; 12 | this.password = password; 13 | this.active = active; 14 | } 15 | 16 | @PrimaryGeneratedColumn() 17 | public id: number; 18 | 19 | @Column({ 20 | nullable: false, 21 | unique: true 22 | }) 23 | public email: string; 24 | 25 | @Column() 26 | public firstname: string; 27 | 28 | @Column() 29 | public lastname: string; 30 | 31 | @Column({ 32 | select: false 33 | }) 34 | public password: string; 35 | 36 | @Column({ 37 | default: true 38 | }) 39 | public active: boolean; 40 | 41 | @CreateDateColumn() 42 | public created: Timestamp; 43 | 44 | @ManyToOne(() => UserRole, (userRole) => userRole.users) 45 | public userRole: UserRole; 46 | 47 | static deserialize(obj: User): User { 48 | const user: User = new User(obj.id, obj.email, obj.firstname, obj.lastname, obj.password, obj.active); 49 | user.userRole = obj.userRole; 50 | return user; 51 | } 52 | 53 | public static mockTestUser(): User { 54 | const user = new User(1, 'test@email.com', 'testFirstname', 'testLastname', 'testPassword', true); 55 | user.userRole = new UserRole(1, 'Admin'); 56 | return user; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/api/components/user-role/routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { body, param } from 'express-validator'; 3 | 4 | import { AuthService, PassportStrategy } from '../../../services/auth'; 5 | 6 | import { IComponentRoutes } from '../helper'; 7 | 8 | import { UserRoleController } from './controller'; 9 | 10 | export class UserRoleRoutes implements IComponentRoutes { 11 | readonly name: string = 'user-role'; 12 | readonly controller: UserRoleController = new UserRoleController(); 13 | readonly router: Router = Router(); 14 | authSerivce: AuthService; 15 | 16 | public constructor(defaultStrategy?: PassportStrategy) { 17 | this.authSerivce = new AuthService(defaultStrategy); 18 | this.initRoutes(); 19 | } 20 | 21 | initRoutes(): void { 22 | this.router.get( 23 | '/', 24 | this.authSerivce.isAuthorized(), 25 | this.authSerivce.hasPermission(this.name, 'read'), 26 | this.controller.readUserRoles 27 | ); 28 | 29 | this.router.get( 30 | '/:roleID', 31 | this.authSerivce.isAuthorized(), 32 | this.authSerivce.hasPermission(this.name, 'read'), 33 | param('roleID').isNumeric(), 34 | this.authSerivce.validateRequest, 35 | this.controller.readUserRole 36 | ); 37 | 38 | this.router.post( 39 | '/', 40 | this.authSerivce.isAuthorized(), 41 | this.authSerivce.hasPermission(this.name, 'create'), 42 | body('name').isString(), 43 | this.authSerivce.validateRequest, 44 | this.controller.createUserRole 45 | ); 46 | 47 | this.router.delete( 48 | '/:roleID', 49 | this.authSerivce.isAuthorized(), 50 | this.authSerivce.hasPermission(this.name, 'delete'), 51 | param('roleID').isNumeric(), 52 | this.authSerivce.validateRequest, 53 | this.controller.deleteUserRole 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/api/components/user-invitation/routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { body, param } from 'express-validator'; 3 | 4 | import { IComponentRoutes } from '../helper'; 5 | 6 | import { AuthService, PassportStrategy } from '../../../services/auth'; 7 | 8 | import { UserInvitationController } from './controller'; 9 | 10 | export class UserInvitationRoutes implements IComponentRoutes { 11 | readonly name: string = 'user-invitation'; 12 | readonly controller: UserInvitationController = new UserInvitationController(); 13 | readonly router: Router = Router(); 14 | authSerivce: AuthService; 15 | 16 | public constructor(defaultStrategy?: PassportStrategy) { 17 | this.authSerivce = new AuthService(defaultStrategy); 18 | this.initRoutes(); 19 | } 20 | 21 | initRoutes() { 22 | this.router.get( 23 | '/', 24 | this.authSerivce.isAuthorized(), 25 | this.authSerivce.hasPermission(this.name, 'read'), 26 | this.controller.readUserInvitations 27 | ); 28 | 29 | this.router.get( 30 | '/:invitationID', 31 | this.authSerivce.isAuthorized(), 32 | this.authSerivce.hasPermission(this.name, 'read'), 33 | param('invitationID').isString(), 34 | this.authSerivce.validateRequest, 35 | this.controller.readUserInvitation 36 | ); 37 | 38 | this.router.post( 39 | '/', 40 | this.authSerivce.isAuthorized(), 41 | this.authSerivce.hasPermission(this.name, 'create'), 42 | body('email').isEmail(), 43 | body('uuid').isUUID(), 44 | body('active').isBoolean(), 45 | this.authSerivce.validateRequest, 46 | this.controller.createUserInvitation 47 | ); 48 | 49 | this.router.delete( 50 | '/:invitationID', 51 | this.authSerivce.isAuthorized(), 52 | this.authSerivce.hasPermission(this.name, 'delete'), 53 | param('invitationID').isString(), 54 | this.authSerivce.validateRequest, 55 | this.controller.deleteUserInvitation 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/services/utility.ts: -------------------------------------------------------------------------------- 1 | import { compare, genSalt, hash } from 'bcryptjs'; 2 | import { v1 as uuidv1 } from 'uuid'; 3 | 4 | import * as crypto from 'crypto'; 5 | 6 | import { logger } from '../config/logger'; 7 | 8 | /** 9 | * UtilityService 10 | * 11 | * Service for utility functions 12 | */ 13 | export class UtilityService { 14 | /** 15 | * Error handler 16 | * 17 | * @param err 18 | * @returns 19 | */ 20 | public static handleError(err: any): void { 21 | logger.error(err.stack || err); 22 | } 23 | 24 | /** 25 | * Hash plain password 26 | * 27 | * @param plainPassword Password to hash 28 | * @returns hashed password 29 | */ 30 | public static hashPassword(plainPassword: string): Promise { 31 | return new Promise((resolve, reject) => { 32 | genSalt((err, salt) => { 33 | if (err) { 34 | reject(err); 35 | } 36 | 37 | hash(plainPassword, salt, (error, hashedVal) => { 38 | if (error) { 39 | reject(error); 40 | } 41 | 42 | resolve(hashedVal); 43 | }); 44 | }); 45 | }); 46 | } 47 | 48 | /** 49 | * Compares plain password with hashed password 50 | * 51 | * @param plainPassword Plain password to compare 52 | * @param hashedPassword Hashed password to compare 53 | * @returns whether passwords match 54 | */ 55 | public static verifyPassword(plainPassword: string, hashedPassword: string): Promise { 56 | return new Promise((resolve, reject) => { 57 | compare(plainPassword, hashedPassword, (err, res) => { 58 | if (err) { 59 | reject(err); 60 | } 61 | resolve(res); 62 | }); 63 | }); 64 | } 65 | 66 | /** 67 | * Hash string with sha256 algorithm 68 | * 69 | * @param text String to hash 70 | * @returns Returns hashed string 71 | */ 72 | public static hashString(text: string): string { 73 | return crypto.createHash('sha256').update(text).digest('hex'); 74 | } 75 | 76 | /** 77 | * Generate UUID 78 | * 79 | * @returns UUID 80 | */ 81 | public static generateUuid(): string { 82 | return uuidv1(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/test/factory.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | // Set env to test 4 | process.env.NODE_ENV = 'test'; 5 | 6 | // Set env variables from .env file 7 | import { config } from 'dotenv'; 8 | config(); 9 | 10 | import { createConnection, ConnectionOptions, Connection } from 'typeorm'; 11 | import { createServer, Server as HttpServer } from 'http'; 12 | 13 | import express from 'express'; 14 | import supertest from 'supertest'; 15 | 16 | import { env } from '../config/globals'; 17 | 18 | import { Server } from '../api/server'; 19 | import { RedisService } from '../services/redis'; 20 | 21 | /** 22 | * TestFactory 23 | * - Loaded in each unit test 24 | * - Starts server and DB connection 25 | */ 26 | 27 | export class TestFactory { 28 | private _app: express.Application; 29 | private _connection: Connection; 30 | private _server: HttpServer; 31 | 32 | // DB connection options 33 | private options: ConnectionOptions = { 34 | type: 'sqljs', 35 | database: new Uint8Array(), 36 | location: 'database', 37 | logging: false, 38 | synchronize: true, 39 | entities: ['dist/api/components/**/model.js'] 40 | }; 41 | 42 | public get app(): supertest.SuperTest { 43 | return supertest(this._app); 44 | } 45 | 46 | public get connection(): Connection { 47 | return this._connection; 48 | } 49 | 50 | public get server(): HttpServer { 51 | return this._server; 52 | } 53 | 54 | public async init(): Promise { 55 | // logger.info('Running startup for test case'); 56 | await this.startup(); 57 | } 58 | 59 | /** 60 | * Close server and DB connection 61 | */ 62 | public async close(): Promise { 63 | this._server.close(); 64 | this._connection.close(); 65 | RedisService.disconnect(); 66 | } 67 | 68 | /** 69 | * Connect to DB and start server 70 | */ 71 | private async startup(): Promise { 72 | this._connection = await createConnection(this.options); 73 | RedisService.connect(); 74 | this._app = new Server().app; 75 | this._server = createServer(this._app).listen(env.NODE_PORT); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | .env.docker 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | -------------------------------------------------------------------------------- /src/services/redis.ts: -------------------------------------------------------------------------------- 1 | import { createClient, RedisClient } from 'redis'; 2 | 3 | import { env } from '../config/globals'; 4 | import { logger } from '../config/logger'; 5 | 6 | export class RedisService { 7 | private static client: RedisClient; 8 | 9 | /** 10 | * Connect to Redis 11 | */ 12 | static connect() { 13 | this.client = createClient(env.REDIS_URL); 14 | } 15 | 16 | /** 17 | * Disconnect from Redis 18 | */ 19 | static disconnect() { 20 | this.client.end(true); 21 | } 22 | 23 | /** 24 | * Get object as instance of given type 25 | * 26 | * @param key Cache key 27 | * @returns Object 28 | */ 29 | static getObject(key: string): Promise { 30 | return new Promise((resolve, reject) => { 31 | return RedisService.client.get(key, (err, data) => { 32 | if (err) { 33 | reject(err); 34 | } 35 | 36 | if (data === null) resolve(null); 37 | 38 | return resolve(JSON.parse(data) as T); 39 | }); 40 | }); 41 | } 42 | 43 | /** 44 | * Store object 45 | * 46 | * @param key Cache Key 47 | * @param obj Object to store 48 | */ 49 | static setObject(key: string, obj: T) { 50 | RedisService.client.set(key, JSON.stringify(obj), (err) => { 51 | if (err) logger.error(err); 52 | }); 53 | } 54 | 55 | /** 56 | * Get object as instance of given type and store if not existing in cache 57 | * 58 | * @param key Cache Key 59 | * @param fn Function to fetch data if not existing 60 | * @returns Object 61 | */ 62 | static getAndSetObject(key: string, fn: () => Promise): Promise { 63 | return new Promise((resolve, reject) => { 64 | return RedisService.client.get(key, async (err, data) => { 65 | if (err) { 66 | reject(err); 67 | } 68 | 69 | // Fetch from db and store in cache 70 | if (data === null) { 71 | const fetched = await fn(); 72 | this.setObject(key, fetched); 73 | return resolve(fetched as T); 74 | } 75 | 76 | return resolve(JSON.parse(data) as T); 77 | }); 78 | }); 79 | } 80 | 81 | /** 82 | * Delete entry by key 83 | * 84 | * @param key Cache key 85 | */ 86 | static deleteByKey(key: string) { 87 | RedisService.client.del(key); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expressjs-api", 3 | "version": "1.0.0", 4 | "author": "Lars Wächter", 5 | "description": "An example Node.js REST-API built with Typescript & Express.js", 6 | "homepage": "https://larswaechter.dev/blog/nodejs-rest-api-structure/", 7 | "repository": "https://github.com/larswaechter/expressjs-api", 8 | "main": "./dist/app.js", 9 | "private": true, 10 | "scripts": { 11 | "start": "node ./dist/app.js", 12 | "start-docker": "npm run build && npm start", 13 | "build": "rm -rf dist && tsc -p tsconfig.json && gulp copy && gulp merge", 14 | "watch": "nodemon --exec \"npm run build && npm run start\" --watch src --ext ts", 15 | "watch-dirty": "nodemon --exec \"tsc -p tsconfig.json && npm run start\" --watch src --ext ts", 16 | "test": "npm run build && mocha", 17 | "seed": "node db/index.js", 18 | "lint": "tslint -p tsconfig.json", 19 | "prettier": "prettier --config ./.prettierrc --write src/**/*.ts" 20 | }, 21 | "license": "MIT", 22 | "devDependencies": { 23 | "@types/acl": "^0.4.38", 24 | "@types/bcryptjs": "^2.4.2", 25 | "@types/chai": "^4.2.21", 26 | "@types/compression": "^1.7.1", 27 | "@types/cors": "^2.8.12", 28 | "@types/ejs": "^3.0.7", 29 | "@types/express": "^4.17.13", 30 | "@types/mocha": "^8.2.3", 31 | "@types/nodemailer": "^6.4.4", 32 | "@types/passport": "^1.0.7", 33 | "@types/passport-http": "^0.3.9", 34 | "@types/passport-jwt": "^3.0.6", 35 | "@types/supertest": "^2.0.11", 36 | "@types/uuid": "^8.3.1", 37 | "@types/validator": "^13.6.3", 38 | "chai": "^4.3.4", 39 | "gulp": "^4.0.2", 40 | "gulp-merge-json": "^2.1.1", 41 | "mocha": "^9.0.2", 42 | "nodemon": "^2.0.12", 43 | "prettier": "^2.3.2", 44 | "sql.js": "^1.5.0", 45 | "supertest": "^6.1.3", 46 | "tslint": "^6.1.3", 47 | "tslint-config-prettier": "^1.18.0", 48 | "typescript": "^4.3.5" 49 | }, 50 | "dependencies": { 51 | "acl": "^0.4.11", 52 | "bcryptjs": "^2.4.3", 53 | "compression": "^1.7.4", 54 | "cors": "^2.8.5", 55 | "decko": "^1.2.0", 56 | "ejs": "^3.1.6", 57 | "express": "^4.17.1", 58 | "express-validator": "^6.12.0", 59 | "helmet": "^4.6.0", 60 | "jsonwebtoken": "^8.5.1", 61 | "mysql2": "^2.2.5", 62 | "nodemailer": "^6.6.2", 63 | "passport": "^0.4.1", 64 | "passport-http": "^0.3.0", 65 | "passport-jwt": "^4.0.0", 66 | "redis": "^2.8.0", 67 | "typeorm": "^0.2.34", 68 | "uuid": "^8.3.2", 69 | "validator": "^13.6.0", 70 | "winston": "^3.3.3" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/api/components/user/routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { body, param, query } from 'express-validator'; 3 | 4 | import { AuthService, PassportStrategy } from '../../../services/auth'; 5 | 6 | import { IComponentRoutes } from '../helper'; 7 | 8 | import { UserController } from './controller'; 9 | 10 | export class UserRoutes implements IComponentRoutes { 11 | readonly name: string = 'user'; 12 | readonly controller: UserController = new UserController(); 13 | readonly router: Router = Router(); 14 | authSerivce: AuthService; 15 | 16 | constructor(defaultStrategy?: PassportStrategy) { 17 | this.authSerivce = new AuthService(defaultStrategy); 18 | this.initRoutes(); 19 | } 20 | 21 | initRoutes(): void { 22 | this.router.get( 23 | '/', 24 | this.authSerivce.isAuthorized(), 25 | this.authSerivce.hasPermission(this.name, 'read'), 26 | this.controller.readUsers 27 | ); 28 | 29 | this.router.get( 30 | '/search', 31 | this.authSerivce.isAuthorized(), 32 | this.authSerivce.hasPermission(this.name, 'read'), 33 | query('email').isString(), 34 | this.authSerivce.validateRequest, 35 | this.controller.readUserByEmail 36 | ); 37 | 38 | this.router.get( 39 | '/:userID', 40 | this.authSerivce.isAuthorized(), 41 | this.authSerivce.hasPermission(this.name, 'read'), 42 | param('userID').isNumeric(), 43 | this.authSerivce.validateRequest, 44 | this.controller.readUser 45 | ); 46 | 47 | this.router.post( 48 | '/', 49 | this.authSerivce.isAuthorized(), 50 | this.authSerivce.hasPermission(this.name, 'create'), 51 | body('email').isEmail(), 52 | body('firstname').isString(), 53 | body('lastname').isString(), 54 | body('password').isString(), 55 | body('active').isBoolean(), 56 | this.authSerivce.validateRequest, 57 | this.controller.createUser 58 | ); 59 | 60 | this.router.put( 61 | '/:userID', 62 | this.authSerivce.isAuthorized(), 63 | this.authSerivce.hasPermission(this.name, 'update'), 64 | param('userID').isNumeric(), 65 | body('email').isEmail(), 66 | body('firstname').isString(), 67 | body('lastname').isString(), 68 | body('password').isString(), 69 | body('active').isBoolean(), 70 | this.authSerivce.validateRequest, 71 | this.controller.updateUser 72 | ); 73 | 74 | this.router.delete( 75 | '/:userID', 76 | this.authSerivce.isAuthorized(), 77 | this.authSerivce.hasPermission(this.name, 'delete'), 78 | param('userID').isNumeric(), 79 | this.authSerivce.validateRequest, 80 | this.controller.deleteUser 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/api/components/user-role/controller.ts: -------------------------------------------------------------------------------- 1 | import { bind } from 'decko'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | 4 | import { UserRole } from './model'; 5 | import { UserRoleRepository } from './repository'; 6 | 7 | export class UserRoleController { 8 | private readonly repo: UserRoleRepository = new UserRoleRepository(); 9 | 10 | /** 11 | * Read user roles 12 | * 13 | * @param req Express request 14 | * @param res Express response 15 | * @param next Express next 16 | * @returns HTTP response 17 | */ 18 | @bind 19 | async readUserRoles(req: Request, res: Response, next: NextFunction): Promise { 20 | try { 21 | const userRoles: UserRole[] = await this.repo.readAll({}, true); 22 | return res.json(userRoles); 23 | } catch (err) { 24 | return next(err); 25 | } 26 | } 27 | 28 | /** 29 | * Read user 30 | * 31 | * @param req Express request 32 | * @param res Express response 33 | * @param next Express next 34 | * @returns HTTP response 35 | */ 36 | @bind 37 | async readUserRole(req: Request, res: Response, next: NextFunction): Promise { 38 | try { 39 | const { roleID } = req.params; 40 | 41 | const userRole: UserRole | undefined = await this.repo.read({ 42 | where: { 43 | id: +roleID 44 | } 45 | }); 46 | 47 | return res.json(userRole); 48 | } catch (err) { 49 | return next(err); 50 | } 51 | } 52 | 53 | /** 54 | * Create user role 55 | * 56 | * @param req Express request 57 | * @param res Express response 58 | * @param next Express next 59 | * @returns HTTP response 60 | */ 61 | @bind 62 | async createUserRole(req: Request, res: Response, next: NextFunction): Promise { 63 | try { 64 | const { name } = req.body; 65 | 66 | const role = new UserRole(undefined, name); 67 | const newRole: UserRole = await this.repo.save(role); 68 | 69 | return res.json(newRole); 70 | } catch (err) { 71 | return next(err); 72 | } 73 | } 74 | 75 | /** 76 | * Delete user role 77 | * 78 | * @param req Express request 79 | * @param res Express response 80 | * @param next Express next 81 | * @returns HTTP response 82 | */ 83 | @bind 84 | async deleteUserRole(req: Request, res: Response, next: NextFunction): Promise { 85 | try { 86 | const { roleID } = req.params; 87 | 88 | const userRole: UserRole | undefined = await this.repo.read({ 89 | where: { 90 | id: +roleID 91 | } 92 | }); 93 | 94 | if (!userRole) { 95 | return res.status(404).json({ error: 'User role not found' }); 96 | } 97 | 98 | await this.repo.delete(userRole); 99 | 100 | return res.status(204).send(); 101 | } catch (err) { 102 | return next(err); 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/services/auth/strategies/jwt.ts: -------------------------------------------------------------------------------- 1 | import { bind } from 'decko'; 2 | import { Handler, NextFunction, Request, Response } from 'express'; 3 | import { authenticate } from 'passport'; 4 | import { Strategy, StrategyOptions } from 'passport-jwt'; 5 | 6 | import { User } from '../../../api/components/user/model'; 7 | 8 | import { BaseStrategy } from './base'; 9 | 10 | /** 11 | * Passport JWT Authentication 12 | * 13 | * - The client signs in via /signin endpoint 14 | * - If the signin is successfull a JWT is returned 15 | * - This JWT is used inside the request header for later requests 16 | */ 17 | export class JwtStrategy extends BaseStrategy { 18 | private strategyOptions: StrategyOptions; 19 | 20 | public constructor(strategyOptions: StrategyOptions) { 21 | super(); 22 | this.strategyOptions = strategyOptions; 23 | this._strategy = new Strategy(this.strategyOptions, this.verify); 24 | } 25 | 26 | /** 27 | * Middleware for checking if a user is authorized to access the endpoint 28 | * 29 | * @param req Express request 30 | * @param res Express response 31 | * @param next Express next 32 | * @returns Returns if user is authorized 33 | */ 34 | public isAuthorized(req: Request, res: Response, next: NextFunction): Handler | void { 35 | try { 36 | authenticate('jwt', { session: false }, (err, user: User, info) => { 37 | // internal error 38 | if (err) { 39 | return next(err); 40 | } 41 | if (info) { 42 | switch (info.message) { 43 | case 'No auth token': 44 | return res.status(401).json({ 45 | error: 'No jwt provided!' 46 | }); 47 | 48 | case 'jwt expired': 49 | return res.status(401).json({ 50 | error: 'Jwt expired!' 51 | }); 52 | } 53 | } 54 | 55 | if (!user) { 56 | return res.status(401).json({ 57 | error: 'User is not authorized!' 58 | }); 59 | } 60 | 61 | // success - store user in req scope 62 | req.user = user; 63 | 64 | return next(); 65 | })(req, res, next); 66 | } catch (err) { 67 | return next(err); 68 | } 69 | } 70 | 71 | /** 72 | * Verify incoming payloads from request -> validation in isAuthorized() 73 | * 74 | * @param payload JWT payload 75 | * @param next Express next 76 | * @returns 77 | */ 78 | @bind 79 | private async verify(payload: any, next: any): Promise { 80 | try { 81 | // pass error == null on error otherwise we get a 500 error instead of 401 82 | 83 | const user = await this.userRepo.findOne({ 84 | relations: ['userRole'], 85 | where: { 86 | active: true, 87 | id: payload.userID 88 | } 89 | }); 90 | 91 | if (!user) { 92 | return next(null, null); 93 | } 94 | 95 | await this.setPermissions(user); 96 | 97 | return next(null, user); 98 | } catch (err) { 99 | return next(err); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/api/components/helper.ts: -------------------------------------------------------------------------------- 1 | import { bind } from 'decko'; 2 | import { Router } from 'express'; 3 | import { Repository, FindManyOptions, FindOneOptions } from 'typeorm'; 4 | 5 | import { AuthService } from '../../services/auth'; 6 | import { RedisService } from '../../services/redis'; 7 | 8 | export interface IComponentRoutes { 9 | readonly name: string; 10 | readonly controller: T; 11 | readonly router: Router; 12 | authSerivce: AuthService; 13 | 14 | initRoutes(): void; 15 | initChildRoutes?(): void; 16 | } 17 | 18 | export abstract class AbsRepository { 19 | protected readonly name: string; 20 | protected readonly repo: Repository; 21 | protected readonly defaultRelations: string[]; 22 | 23 | constructor(name: string, repo: Repository, defaultRelations: string[] = []) { 24 | this.name = name; 25 | this.repo = repo; 26 | this.defaultRelations = defaultRelations; 27 | } 28 | 29 | /** 30 | * Delete cache entries 31 | */ 32 | @bind 33 | deleteFromCache() { 34 | RedisService.deleteByKey(this.name); 35 | } 36 | 37 | /** 38 | * Read all entities from db 39 | * 40 | * @param options Find options 41 | * @param cached Use cache 42 | * @returns Entity array 43 | */ 44 | @bind 45 | readAll(options: FindManyOptions = {}, cached?: boolean): Promise { 46 | try { 47 | if (Object.keys(options).length) { 48 | return this.repo.find({ 49 | relations: this.defaultRelations, 50 | ...options 51 | }); 52 | } 53 | 54 | if (cached) { 55 | return RedisService.getAndSetObject(this.name, () => this.readAll({}, false)); 56 | } 57 | 58 | return this.repo.find({ 59 | relations: this.defaultRelations 60 | }); 61 | } catch (err) { 62 | throw new Error(err); 63 | } 64 | } 65 | 66 | /** 67 | * Read a certain entity from db 68 | * 69 | * @param options Find options 70 | * @returns Entity 71 | */ 72 | @bind 73 | read(options: FindOneOptions): Promise { 74 | try { 75 | return this.repo.findOne({ 76 | relations: this.defaultRelations, 77 | ...options 78 | }); 79 | } catch (err) { 80 | throw new Error(err); 81 | } 82 | } 83 | 84 | /** 85 | * Save new or updated entity to db 86 | * 87 | * @param entity Entity to save 88 | * @returns Saved entity 89 | */ 90 | @bind 91 | async save(entity: T): Promise { 92 | try { 93 | const saved: T = await this.repo.save(entity); 94 | this.deleteFromCache(); 95 | 96 | return saved; 97 | } catch (err) { 98 | throw new Error(err); 99 | } 100 | } 101 | 102 | /** 103 | * Delete entity from db 104 | * 105 | * @param entity Entity to delete 106 | * @returns Deleted entity 107 | */ 108 | @bind 109 | async delete(entity: T): Promise { 110 | try { 111 | const deleted = await this.repo.remove(entity); 112 | this.deleteFromCache(); 113 | 114 | return deleted; 115 | } catch (err) { 116 | throw new Error(err); 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/api/components/user-invitation/controller.ts: -------------------------------------------------------------------------------- 1 | import { bind } from 'decko'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | 4 | import { UserInvitation } from './model'; 5 | 6 | import { UserInvitationRepository } from './repository'; 7 | import { UserInvitationMailService } from './services/mail'; 8 | 9 | export class UserInvitationController { 10 | private readonly repo: UserInvitationRepository = new UserInvitationRepository(); 11 | private readonly service: UserInvitationMailService = new UserInvitationMailService(); 12 | 13 | /** 14 | * Read user invitations 15 | * 16 | * @param req Express request 17 | * @param res Express response 18 | * @param next Express next 19 | * @returns HTTP response 20 | */ 21 | @bind 22 | async readUserInvitations(req: Request, res: Response, next: NextFunction): Promise { 23 | try { 24 | const userInvitations: UserInvitation[] = await this.repo.readAll(); 25 | 26 | return res.json(userInvitations); 27 | } catch (err) { 28 | return next(err); 29 | } 30 | } 31 | 32 | /** 33 | * Read user invitation 34 | * 35 | * @param req Express request 36 | * @param res Express response 37 | * @param next Express next 38 | * @returns HTTP response 39 | */ 40 | @bind 41 | async readUserInvitation(req: Request, res: Response, next: NextFunction): Promise { 42 | try { 43 | const { invitationID } = req.params; 44 | 45 | const invitation: UserInvitation | undefined = await this.repo.read({ 46 | where: { 47 | id: +invitationID 48 | } 49 | }); 50 | 51 | return res.json(invitation); 52 | } catch (err) { 53 | return next(err); 54 | } 55 | } 56 | 57 | /** 58 | * Create user invitation 59 | * 60 | * @param req Express request 61 | * @param res Express response 62 | * @param next Express next 63 | * @returns HTTP response 64 | */ 65 | @bind 66 | async createUserInvitation(req: Request, res: Response, next: NextFunction): Promise { 67 | try { 68 | const { email, uuid, active } = req.body; 69 | 70 | const invitation = new UserInvitation(undefined, email, uuid, active); 71 | const newInvitation: UserInvitation = await this.repo.save(invitation); 72 | 73 | this.service.sendUserInvitation(newInvitation.email, newInvitation.uuid); 74 | 75 | return res.json(newInvitation); 76 | } catch (err) { 77 | return next(err); 78 | } 79 | } 80 | 81 | /** 82 | * Delete user invitation 83 | * 84 | * @param req Express request 85 | * @param res Express response 86 | * @param next Express next 87 | * @returns HTTP response 88 | */ 89 | @bind 90 | async deleteUserInvitation(req: Request, res: Response, next: NextFunction): Promise { 91 | try { 92 | const { invitationID } = req.params; 93 | 94 | const invitation: UserInvitation | undefined = await this.repo.read({ 95 | where: { 96 | id: +invitationID 97 | } 98 | }); 99 | 100 | if (!invitation) { 101 | return res.status(404).json({ error: 'User invitation not found' }); 102 | } 103 | 104 | await this.repo.delete(invitation); 105 | 106 | return res.status(204).send(); 107 | } catch (err) { 108 | return next(err); 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/api/components/user-role/user-role.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert, expect } from 'chai'; 2 | 3 | import { TestFactory } from '../../../test/factory'; 4 | 5 | import { UserRole } from './model'; 6 | 7 | describe('Testing user-role component', () => { 8 | const factory: TestFactory = new TestFactory(); 9 | const testRole: UserRole = UserRole.mockTestUserRole(); 10 | 11 | before((done) => { 12 | factory.init().then(done); 13 | }); 14 | 15 | after((done) => { 16 | factory.close().then(done); 17 | }); 18 | 19 | describe('POST /user-roles', () => { 20 | it('responds with status 400', (done) => { 21 | factory.app 22 | .post('/api/v1/user-roles') 23 | .send() 24 | .set('Accept', 'application/json') 25 | .expect('Content-Type', /json/) 26 | .expect(400, done); 27 | }); 28 | 29 | it('responds with new user-role', (done) => { 30 | factory.app 31 | .post('/api/v1/user-roles') 32 | .send({ 33 | name: 'Admin' 34 | }) 35 | .set('Accept', 'application/json') 36 | .expect('Content-Type', /json/) 37 | .expect(200) 38 | .end((err, res) => { 39 | try { 40 | if (err) throw err; 41 | 42 | const role: UserRole = res.body; 43 | 44 | assert.isObject(role, 'userRole should be an object'); 45 | 46 | expect(role.id).eq(testRole.id, 'id does not match'); 47 | expect(role.name).eq(testRole.name, 'name does not match'); 48 | 49 | return done(); 50 | } catch (err) { 51 | return done(err); 52 | } 53 | }); 54 | }); 55 | }); 56 | 57 | describe('GET /user-roles', () => { 58 | it('responds with user-role array', (done) => { 59 | factory.app 60 | .get('/api/v1/user-roles') 61 | .set('Accept', 'application/json') 62 | .expect('Content-Type', /json/) 63 | .expect(200) 64 | .end((err, res) => { 65 | try { 66 | if (err) throw err; 67 | 68 | const roles: UserRole[] = res.body; 69 | 70 | assert.isArray(roles, 'userRoles shoud be an array'); 71 | 72 | expect(roles[0].id).eq(testRole.id, 'id does not match'); 73 | expect(roles[0].name).eq(testRole.name, 'name does not match'); 74 | 75 | return done(); 76 | } catch (err) { 77 | return done(err); 78 | } 79 | }); 80 | }); 81 | 82 | describe('GET /user-roles/1', () => { 83 | it('responds single user-role', (done) => { 84 | factory.app 85 | .get('/api/v1/user-roles/1') 86 | .set('Accept', 'application/json') 87 | .expect('Content-Type', /json/) 88 | .expect(200) 89 | .end((err, res) => { 90 | try { 91 | if (err) throw err; 92 | 93 | const role: UserRole = res.body; 94 | 95 | assert.isObject(role, 'userRole should be an object'); 96 | 97 | expect(role.id).eq(testRole.id, 'id does not match'); 98 | expect(role.name).eq(testRole.name, 'name does not match'); 99 | 100 | return done(); 101 | } catch (err) { 102 | return done(err); 103 | } 104 | }); 105 | }); 106 | }); 107 | 108 | describe('DELETE /user-roles/1', () => { 109 | it('responds with status 204', (done) => { 110 | factory.app.delete('/api/v1/user-roles/1').set('Accept', 'application/json').expect(204, done); 111 | }); 112 | 113 | it('responds with status 404', (done) => { 114 | factory.app.delete('/api/v1/user-roles/1').set('Accept', 'application/json').expect(404, done); 115 | }); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /src/api/components/user-invitation/user-invitation.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert, expect } from 'chai'; 2 | 3 | import { TestFactory } from '../../../test/factory'; 4 | 5 | import { UserInvitation } from './model'; 6 | 7 | describe('Testing user-invitation component', () => { 8 | const factory: TestFactory = new TestFactory(); 9 | const testInvitation: UserInvitation = UserInvitation.mockTestUserInvitation(); 10 | 11 | before((done) => { 12 | factory.init().then(done); 13 | }); 14 | 15 | after((done) => { 16 | factory.close().then(done); 17 | }); 18 | 19 | describe('POST /user-invitations', () => { 20 | it('responds with status 400', (done) => { 21 | factory.app 22 | .post('/api/v1/user-invitations') 23 | .send() 24 | .set('Accept', 'application/json') 25 | .expect('Content-Type', /json/) 26 | .expect(400, done); 27 | }); 28 | 29 | it('responds with new user-invitations', (done) => { 30 | factory.app 31 | .post('/api/v1/user-invitations') 32 | .send({ 33 | email: testInvitation.email, 34 | uuid: testInvitation.uuid, 35 | active: testInvitation.active 36 | }) 37 | .set('Accept', 'application/json') 38 | .expect('Content-Type', /json/) 39 | .expect(200) 40 | .end((err, res) => { 41 | try { 42 | if (err) throw err; 43 | 44 | const invitation: UserInvitation = res.body; 45 | 46 | assert.isObject(invitation, 'userInvitation should be an object'); 47 | 48 | expect(invitation.id).eq(testInvitation.id, 'id does not match'); 49 | expect(invitation.email).eq(testInvitation.email, 'email does not match'); 50 | expect(invitation.uuid).eq(testInvitation.uuid, 'uuid does not match'); 51 | expect(invitation.active).eq(testInvitation.active, 'active does not match'); 52 | 53 | return done(); 54 | } catch (err) { 55 | return done(err); 56 | } 57 | }); 58 | }); 59 | }); 60 | 61 | describe('GET /user-invitations', () => { 62 | it('responds with user-invitations array', (done) => { 63 | factory.app 64 | .get('/api/v1/user-invitations') 65 | .set('Accept', 'application/json') 66 | .expect('Content-Type', /json/) 67 | .expect(200) 68 | .end((err, res) => { 69 | try { 70 | if (err) throw err; 71 | 72 | const invitations: UserInvitation[] = res.body; 73 | 74 | assert.isArray(invitations, 'invitations shoud be an array'); 75 | 76 | expect(invitations[0].id).eq(testInvitation.id, 'id does not match'); 77 | expect(invitations[0].email).eq(testInvitation.email, 'email does not match'); 78 | expect(invitations[0].uuid).eq(testInvitation.uuid, 'uuid does not match'); 79 | expect(invitations[0].active).eq(testInvitation.active, 'active does not match'); 80 | 81 | return done(); 82 | } catch (err) { 83 | return done(err); 84 | } 85 | }); 86 | }); 87 | }); 88 | 89 | describe('GET /user-invitations/1', () => { 90 | it('responds with user-invitation', (done) => { 91 | factory.app 92 | .get('/api/v1/user-invitations/1') 93 | .set('Accept', 'application/json') 94 | .expect('Content-Type', /json/) 95 | .expect(200) 96 | .end((err, res) => { 97 | try { 98 | if (err) throw err; 99 | 100 | const invitation: UserInvitation = res.body; 101 | 102 | assert.isObject(invitation, 'invitations shoud be an object'); 103 | 104 | expect(invitation.id).eq(testInvitation.id, 'id does not match'); 105 | expect(invitation.email).eq(testInvitation.email, 'email does not match'); 106 | expect(invitation.uuid).eq(testInvitation.uuid, 'uuid does not match'); 107 | expect(invitation.active).eq(testInvitation.active, 'active does not match'); 108 | 109 | return done(); 110 | } catch (err) { 111 | return done(err); 112 | } 113 | }); 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # expressjs-api 2 | 3 | This repository is a dummy Node.js REST-API built with [TypeScript](https://www.typescriptlang.org/) and [Express](https://expressjs.com/). You might want to use it as codebase for your own Node project. 4 | 5 | You can find a detailed explanation about the application's architecture on my [blog](https://larswaechter.dev/blog/nodejs-rest-api-structure/). 6 | 7 | ## :pushpin: Packages 8 | 9 | A shortened list of the Node modules used in this app: 10 | 11 | - [acl](https://www.npmjs.com/package/acl) 12 | - [express](https://www.npmjs.com/package/express) 13 | - [mysql2](https://www.npmjs.com/package/mysql2) 14 | - [nodemailer](https://www.npmjs.com/package/nodemailer) 15 | - [passport](https://www.npmjs.com/package/passport) 16 | - [redis](https://www.npmjs.com/package/redis) 17 | - [typeorm](https://www.npmjs.com/package/typeorm) 18 | 19 | ## :crystal_ball: Features 20 | 21 | - ACL (access control list) 22 | - Component-based architecture 23 | - Caching (Redis) 24 | - DB seeding 25 | - Mailing 26 | - MySQL 27 | - Testing 28 | 29 | ## :open_file_folder: Folder structure 30 | 31 | Read more [here](https://larswaechter.dev/blog/nodejs-rest-api-structure/). 32 | 33 | - `src/api` everything needed for the REST API 34 | - `src/api/components` component routers, controllers, models, tests and more 35 | - `src/api/middleware` API middleware 36 | - `src/config` global configuration files 37 | - `src/services` services for sending mails, caching, authentication and more 38 | - `src/test` test factory 39 | 40 | ## :computer: Setup 41 | 42 | ### Native 43 | 44 | Requirements: 45 | 46 | - [MySQL](https://www.mysql.com/de/) 47 | - [Node.js](https://nodejs.org/en/) 48 | - [Redis](https://redis.io/) 49 | 50 | Installation: 51 | 52 | 1. Run `npm install` 53 | 2. Rename `.env.example` to `.env` and enter environment variables 54 | 3. Run `npm run build` to compile the TS code 55 | 4. Run `npm start` to start the application 56 | 57 | You can reach the server at [http://localhost:3000/api/v1/](http://localhost:3000/api/v1). 58 | 59 | ### Docker 60 | 61 | Requirements: 62 | 63 | - [Docker](https://www.docker.com/) 64 | - [Docker Compose](https://docs.docker.com/compose/) 65 | 66 | Installation: 67 | 68 | 1. Rename `.env.docker.example` to `.env.docker` and enter environment variables 69 | 2. Run `docker-compose up` to start the Docker containers 70 | 71 | You can reach the server at [http://localhost:3000/api/v1/](http://localhost:3000/api/v1). 72 | 73 | ### Building 74 | 75 | During the build process the following tasks are executed: 76 | 77 | - Compiling TS into JS 78 | - Copying mail HTML templates to `dist` directory 79 | - Merging component `policy.json` files into a single one in `dist/output/policies.combined.json` 80 | 81 | The last two tasks are executed using Gulp as you can see in `gulpfile.js`. 82 | 83 | ### Database seeding 84 | 85 | In `db/seed.sql` you'll find a SQL script that can be used for seeding the database with dummy data. Make sure that the database and its tables were created before executing the script. The tables are created on application start. 86 | 87 | You can load the script via a npm command: `npm run seed`. If you want to seed the database from a Docker container you must connect to it before: `docker exec -it expressjs-api bash`. 88 | 89 | Read more about database seeding on my [blog](https://larswaechter.dev/blog/nodejs-database-seeding/). 90 | 91 | ## :hammer: Tools 92 | 93 | ### ACL 94 | 95 | This application uses [acl](https://www.npmjs.com/package/acl) for permission management. Each component in `src/api/components` has its own `policy.json` which includes permissions for each role. 96 | 97 | During the build process all these `policy.json` files get merged into a single one using a Gulp task as described more above. 98 | 99 | ### MailHog 100 | 101 | [MailHog](https://github.com/mailhog/MailHog) is an email testing tool for developers. You can use it as SMTP server to simulate the process of sending mails. MailHog is included as Docker image within the `docker-compose.yml` file. 102 | 103 | Start the containers as described above and you can open the MailHog web interface at [http://localhost:8025](http://localhost:8025/) where you'll find an overview of all sent emails. 104 | 105 | ## :octocat: Community 106 | 107 | - [Website](https://larswaechter.dev/) 108 | - [Buy me a coffee](https://www.buymeacoffee.com/larswaechter) 109 | -------------------------------------------------------------------------------- /src/api/components/user/controller.ts: -------------------------------------------------------------------------------- 1 | import { bind } from 'decko'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | 4 | import { UtilityService } from '../../../services/utility'; 5 | 6 | import { User } from './model'; 7 | import { UserRepository } from './repository'; 8 | 9 | export class UserController { 10 | private readonly repo: UserRepository = new UserRepository(); 11 | 12 | /** 13 | * Read users 14 | * 15 | * @param req Express request 16 | * @param res Express response 17 | * @param next Express next 18 | * @returns HTTP response 19 | */ 20 | @bind 21 | async readUsers(req: Request, res: Response, next: NextFunction): Promise { 22 | try { 23 | const users: User[] = await this.repo.readAll({}, true); 24 | 25 | return res.json(users); 26 | } catch (err) { 27 | return next(err); 28 | } 29 | } 30 | 31 | /** 32 | * Read user 33 | * 34 | * @param req Express request 35 | * @param res Express response 36 | * @param next Express next 37 | * @returns HTTP response 38 | */ 39 | @bind 40 | async readUser(req: Request, res: Response, next: NextFunction): Promise { 41 | try { 42 | const { userID } = req.params; 43 | 44 | const user: User | undefined = await this.repo.read({ 45 | where: { 46 | id: +userID 47 | } 48 | }); 49 | 50 | return res.json(user); 51 | } catch (err) { 52 | return next(err); 53 | } 54 | } 55 | 56 | /** 57 | * Read user by email 58 | * 59 | * @param req Express request 60 | * @param res Express response 61 | * @param next Express next 62 | * @returns HTTP response 63 | */ 64 | @bind 65 | async readUserByEmail(req: Request, res: Response, next: NextFunction): Promise { 66 | try { 67 | const { email } = req.query; 68 | 69 | const user: User = await this.repo.readByEmail(email as string); 70 | 71 | return res.json(user); 72 | } catch (err) { 73 | return next(err); 74 | } 75 | } 76 | 77 | /** 78 | * Create user 79 | * 80 | * @param req Express request 81 | * @param res Express response 82 | * @param next Express next 83 | * @returns HTTP response 84 | */ 85 | @bind 86 | async createUser(req: Request, res: Response, next: NextFunction): Promise { 87 | try { 88 | const { email, firstname, lastname, password, active } = req.body; 89 | 90 | const existingUser: User | undefined = await this.repo.read({ 91 | where: { 92 | email 93 | } 94 | }); 95 | 96 | if (existingUser) { 97 | return res.status(400).json({ error: 'Email is already taken' }); 98 | } 99 | 100 | const user: User = new User( 101 | undefined, 102 | email, 103 | firstname, 104 | lastname, 105 | await UtilityService.hashPassword(password), 106 | active 107 | ); 108 | const newUser: User = await this.repo.save(user); 109 | 110 | return res.json(newUser); 111 | } catch (err) { 112 | return next(err); 113 | } 114 | } 115 | 116 | /** 117 | * Update user 118 | * 119 | * @param req Express request 120 | * @param res Express response 121 | * @param next Express next 122 | * @returns HTTP response 123 | */ 124 | @bind 125 | async updateUser(req: Request, res: Response, next: NextFunction): Promise { 126 | try { 127 | const { userID } = req.params; 128 | const { email, firstname, lastname, password, active } = req.body; 129 | 130 | if (!userID) { 131 | return res.status(400).json({ error: 'Invalid request' }); 132 | } 133 | 134 | const existingUser: User | undefined = await this.repo.read({ 135 | where: { 136 | id: +userID 137 | } 138 | }); 139 | 140 | if (!existingUser) { 141 | return res.status(404).json({ error: 'User not found' }); 142 | } 143 | 144 | existingUser.email = email; 145 | existingUser.firstname = firstname; 146 | existingUser.lastname = lastname; 147 | existingUser.password = await UtilityService.hashPassword(password); 148 | existingUser.active = active; 149 | 150 | const updatedUser: User = await this.repo.save(existingUser); 151 | 152 | return res.json(updatedUser); 153 | } catch (err) { 154 | return next(err); 155 | } 156 | } 157 | 158 | /** 159 | * Delete user 160 | * 161 | * @param req Express request 162 | * @param res Express response 163 | * @param next Express next 164 | * @returns HTTP response 165 | */ 166 | @bind 167 | async deleteUser(req: Request, res: Response, next: NextFunction): Promise { 168 | try { 169 | const { userID } = req.params; 170 | 171 | const user: User | undefined = await this.repo.read({ 172 | where: { 173 | id: +userID 174 | } 175 | }); 176 | 177 | if (!user) { 178 | return res.status(404).json({ error: 'User not found' }); 179 | } 180 | 181 | await this.repo.delete(user); 182 | 183 | return res.status(204).send(); 184 | } catch (err) { 185 | return next(err); 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/services/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { bind } from 'decko'; 2 | import { Handler, NextFunction, Request, Response } from 'express'; 3 | import { sign, SignOptions } from 'jsonwebtoken'; 4 | import { use } from 'passport'; 5 | import { ExtractJwt, StrategyOptions } from 'passport-jwt'; 6 | import { validationResult } from 'express-validator'; 7 | 8 | import { env } from '../../config/globals'; 9 | import { policy } from '../../config/policy'; 10 | 11 | import { User } from '../../api/components/user/model'; 12 | 13 | import { JwtStrategy } from './strategies/jwt'; 14 | 15 | export type PassportStrategy = 'jwt'; 16 | 17 | /** 18 | * AuthService 19 | * 20 | * Available passport strategies for authentication: 21 | * - JWT (default) 22 | * 23 | * Pass a strategy when initializing module routes to setup this strategy for the complete module: Example: new UserRoutes('jwt') 24 | * 25 | * To setup a strategy for individual endpoints in a module pass the strategy on isAuthorized call 26 | * Example: isAuthorized('basic') 27 | */ 28 | export class AuthService { 29 | private defaultStrategy: PassportStrategy; 30 | private jwtStrategy: JwtStrategy; 31 | 32 | private readonly strategyOptions: StrategyOptions = { 33 | audience: 'expressjs-api-client', 34 | issuer: 'expressjs-api', 35 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 36 | secretOrKey: 'my-super-secret-key' 37 | }; 38 | 39 | // JWT options 40 | private readonly signOptions: SignOptions = { 41 | audience: this.strategyOptions.audience, 42 | expiresIn: '8h', 43 | issuer: this.strategyOptions.issuer 44 | }; 45 | 46 | public constructor(defaultStrategy: PassportStrategy = 'jwt') { 47 | // Setup default strategy -> use jwt if none is provided 48 | this.defaultStrategy = defaultStrategy; 49 | this.jwtStrategy = new JwtStrategy(this.strategyOptions); 50 | } 51 | 52 | /** 53 | * Create JWT 54 | * 55 | * @param userID Used for JWT payload 56 | * @returns Returns JWT 57 | */ 58 | public createToken(userID: number): string { 59 | return sign({ userID }, this.strategyOptions.secretOrKey as string, this.signOptions); 60 | } 61 | 62 | /** 63 | * Middleware for verifying user permissions from acl 64 | * 65 | * @param resource Requested resource 66 | * @param action Performed action on requested resource 67 | * @returns Returns if action on resource is allowed 68 | */ 69 | public hasPermission(resource: string, action: string): Handler { 70 | return async (req: Request, res: Response, next: NextFunction) => { 71 | try { 72 | const { id } = req.user as User; 73 | const access: boolean = await policy.isAllowed(id, resource, action); 74 | 75 | if (!access) { 76 | return res.status(403).json({ 77 | error: 'Missing user rights!' 78 | }); 79 | } 80 | 81 | return next(); 82 | } catch (err) { 83 | return next(err); 84 | } 85 | }; 86 | } 87 | 88 | /** 89 | * Init passport strategies 90 | * 91 | * @returns 92 | */ 93 | public initStrategies(): void { 94 | use('jwt', this.jwtStrategy.strategy); 95 | } 96 | 97 | /** 98 | * Setup target passport authorization 99 | * 100 | * @param strategy Passport strategy 101 | * @returns Returns if user is authorized 102 | */ 103 | @bind 104 | public isAuthorized(strategy?: PassportStrategy): Handler { 105 | return (req: Request, res: Response, next: NextFunction) => { 106 | try { 107 | if (env.NODE_ENV !== 'test') { 108 | // if no strategy is provided use default strategy 109 | const tempStrategy: PassportStrategy = strategy || this.defaultStrategy; 110 | return this.doAuthentication(req, res, next, tempStrategy); 111 | } 112 | 113 | // Mock user 114 | const testUser: User = User.mockTestUser(); 115 | req.user = testUser; 116 | policy.addUserRoles(testUser.id, testUser.userRole.name); 117 | 118 | return next(); 119 | } catch (err) { 120 | return next(err); 121 | } 122 | }; 123 | } 124 | 125 | @bind 126 | public validateRequest(req: Request, res: Response, next: NextFunction): Response | void { 127 | const errors = validationResult(req); 128 | 129 | if (!errors.isEmpty()) { 130 | return res.status(400).json({ error: errors.array() }); 131 | } 132 | 133 | return next(); 134 | } 135 | 136 | /** 137 | * Executes the target passport authorization 138 | * 139 | * @param req Express request 140 | * @param res Express response 141 | * @param next Express next 142 | * @param strategy Passport strategy name 143 | * @returns Returns if user is authorized 144 | */ 145 | @bind 146 | private doAuthentication( 147 | req: Request, 148 | res: Response, 149 | next: NextFunction, 150 | strategy: PassportStrategy 151 | ): Handler | void { 152 | try { 153 | switch (strategy) { 154 | case 'jwt': 155 | return this.jwtStrategy.isAuthorized(req, res, next); 156 | default: 157 | throw new Error(`Unknown passport strategy: ${strategy}`); 158 | } 159 | } catch (err) { 160 | return next(err); 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/api/components/auth/auth.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, assert } from 'chai'; 2 | 3 | import { TestFactory } from '../../../test/factory'; 4 | 5 | import { User } from '../user/model'; 6 | 7 | describe('Testing auth component', () => { 8 | const factory: TestFactory = new TestFactory(); 9 | const testUser: User = new User(1, 'peter@griffin.com', 'Peter', 'Griffin', '1234', true); 10 | 11 | let invitationUUID: string; 12 | 13 | before((done) => { 14 | factory.init().then(done); 15 | }); 16 | 17 | after((done) => { 18 | factory.close().then(done); 19 | }); 20 | 21 | describe('POST /auth/invite', () => { 22 | it('responds with status 400', (done) => { 23 | factory.app 24 | .post('/api/v1/auth/invite') 25 | .send() 26 | .set('Accept', 'application/json') 27 | .expect('Content-Type', /json/) 28 | .expect(400, done); 29 | }); 30 | 31 | it('responds with new invitation uuid', (done) => { 32 | factory.app 33 | .post('/api/v1/auth/invite') 34 | .send({ 35 | email: testUser.email 36 | }) 37 | .set('Accept', 'application/json') 38 | .expect('Content-Type', /json/) 39 | .expect(200) 40 | .end((err, res) => { 41 | try { 42 | if (err) throw err; 43 | 44 | const uuid: string = res.body; 45 | 46 | assert.isString(uuid, 'uuid should be a string'); 47 | 48 | invitationUUID = uuid; 49 | 50 | return done(); 51 | } catch (err) { 52 | return done(err); 53 | } 54 | }); 55 | }); 56 | }); 57 | 58 | describe('POST /user-roles', () => { 59 | it('responds with new user-role', (done) => { 60 | factory.app 61 | .post('/api/v1/user-roles') 62 | .send({ 63 | name: 'Admin' 64 | }) 65 | .set('Accept', 'application/json') 66 | .expect('Content-Type', /json/) 67 | .expect(200, done); 68 | }); 69 | }); 70 | 71 | describe('POST /auth/register', () => { 72 | it('responds with status 403', (done) => { 73 | factory.app 74 | .post('/api/v1/auth/register/d20c47b2-e2ac-11eb-ba80-0242ac130004') 75 | .send({ 76 | email: testUser.email, 77 | firstname: testUser.firstname, 78 | lastname: testUser.lastname, 79 | password: testUser.password, 80 | active: testUser.active 81 | }) 82 | .set('Accept', 'application/json') 83 | .expect('Content-Type', /json/) 84 | .expect(403, done); 85 | }); 86 | 87 | it('responds with registered user', (done) => { 88 | factory.app 89 | .post(`/api/v1/auth/register/${invitationUUID}`) 90 | .send({ 91 | email: testUser.email, 92 | firstname: testUser.firstname, 93 | lastname: testUser.lastname, 94 | password: testUser.password, 95 | active: testUser.active 96 | }) 97 | .set('Accept', 'application/json') 98 | .expect(200) 99 | .end((err, res) => { 100 | try { 101 | if (err) throw err; 102 | 103 | const user: User = res.body; 104 | 105 | assert.isObject(user, 'user should be an object'); 106 | 107 | expect(user.id).eq(testUser.id, 'id does not match'); 108 | expect(user.email).eq(testUser.email, 'email does not match'); 109 | expect(user.firstname).eq(testUser.firstname, 'firstname does not match'); 110 | expect(user.lastname).eq(testUser.lastname, 'lastname does not match'); 111 | expect(user.active).eq(testUser.active, 'active does not match'); 112 | 113 | return done(); 114 | } catch (err) { 115 | return done(err); 116 | } 117 | }); 118 | }); 119 | }); 120 | 121 | describe('POST /auth/signin', () => { 122 | it('responds with status 400', (done) => { 123 | factory.app 124 | .post('/api/v1/auth/signin') 125 | .send() 126 | .set('Accept', 'application/json') 127 | .expect('Content-Type', /json/) 128 | .expect(400, done); 129 | }); 130 | 131 | it('responds with status 401', (done) => { 132 | factory.app 133 | .post('/api/v1/auth/signin') 134 | .send({ 135 | email: testUser.email, 136 | password: 'wrongpassword' 137 | }) 138 | .set('Accept', 'application/json') 139 | .expect('Content-Type', /json/) 140 | .expect(401, done); 141 | }); 142 | 143 | it('responds with signed in user and token', (done) => { 144 | factory.app 145 | .post('/api/v1/auth/signin') 146 | .send({ 147 | email: testUser.email, 148 | password: testUser.password 149 | }) 150 | .set('Accept', 'application/json') 151 | .expect('Content-Type', /json/) 152 | .expect(200) 153 | .end((err, res) => { 154 | try { 155 | if (err) throw err; 156 | 157 | const { token, user } = res.body; 158 | 159 | assert.isString(token, 'token should be a string'); 160 | assert.isObject(user, 'user should be an object'); 161 | 162 | expect(user.id).eq(testUser.id, 'id does not match'); 163 | expect(user.email).eq(testUser.email, 'email does not match'); 164 | expect(user.firstname).eq(testUser.firstname, 'firstname does not match'); 165 | expect(user.lastname).eq(testUser.lastname, 'lastname does not match'); 166 | expect(user.active).eq(testUser.active, 'active does not match'); 167 | 168 | return done(); 169 | } catch (err) { 170 | return done(err); 171 | } 172 | }); 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /src/api/components/auth/controller.ts: -------------------------------------------------------------------------------- 1 | import { bind } from 'decko'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | 4 | import { AuthService } from '../../../services/auth'; 5 | import { UtilityService } from '../../../services/utility'; 6 | 7 | import { User } from '../user/model'; 8 | import { UserRole } from '../user-role/model'; 9 | import { UserRepository } from '../user/repository'; 10 | 11 | import { UserInvitation } from '../user-invitation/model'; 12 | import { UserInvitationRepository } from '../user-invitation/repository'; 13 | import { UserInvitationMailService } from '../user-invitation/services/mail'; 14 | 15 | export class AuthController { 16 | private readonly authService: AuthService = new AuthService(); 17 | private readonly userInvMailService: UserInvitationMailService = new UserInvitationMailService(); 18 | 19 | private readonly userRepo: UserRepository = new UserRepository(); 20 | private readonly userInvRepo: UserInvitationRepository = new UserInvitationRepository(); 21 | 22 | /** 23 | * @param req Express request 24 | * @param res Express response 25 | * @param next Express next 26 | * @returns HTTP response 27 | */ 28 | @bind 29 | async signinUser(req: Request, res: Response, next: NextFunction): Promise { 30 | try { 31 | const { email, password } = req.body; 32 | 33 | const user: User | undefined = await this.userRepo.read({ 34 | select: ['id', 'email', 'firstname', 'lastname', 'password', 'active'], 35 | where: { 36 | email, 37 | active: true 38 | } 39 | }); 40 | 41 | if (!user || !(await UtilityService.verifyPassword(password, user.password))) { 42 | return res.status(401).json({ status: 401, error: 'Wrong email or password' }); 43 | } 44 | 45 | // Create jwt -> required for further requests 46 | const token: string = this.authService.createToken(user.id); 47 | 48 | // Don't send user password in response 49 | delete user.password; 50 | 51 | return res.json({ token, user }); 52 | } catch (err) { 53 | return next(err); 54 | } 55 | } 56 | 57 | /** 58 | * Register new user 59 | * 60 | * @param req Express request 61 | * @param res Express response 62 | * @param next Express next 63 | * @returns HTTP response 64 | */ 65 | @bind 66 | async registerUser(req: Request, res: Response, next: NextFunction): Promise { 67 | try { 68 | const { uuid } = req.params; 69 | const { email, firstname, lastname, password } = req.body; 70 | 71 | const invitation: UserInvitation | undefined = await this.getUserInvitation(uuid, email); 72 | 73 | if (!invitation) { 74 | return res.status(403).json({ error: 'Invalid UUID' }); 75 | } 76 | 77 | const user: User | undefined = await this.userRepo.read({ 78 | where: { 79 | email 80 | } 81 | }); 82 | 83 | if (user) { 84 | return res.status(400).json({ error: 'Email is already taken' }); 85 | } 86 | 87 | const newUser = new User( 88 | undefined, 89 | email, 90 | firstname, 91 | lastname, 92 | await UtilityService.hashPassword(password), 93 | true 94 | ); 95 | newUser.userRole = new UserRole(1, 'Admin'); 96 | 97 | const savedUser = await this.userRepo.save(newUser); 98 | 99 | await this.userInvRepo.delete(invitation); 100 | 101 | return res.status(200).json(savedUser); 102 | } catch (err) { 103 | return next(err); 104 | } 105 | } 106 | 107 | /** 108 | * Create user invitation that is required for registration 109 | * 110 | * @param req Express request 111 | * @param res Express response 112 | * @param next Express next 113 | * @returns HTTP response 114 | */ 115 | @bind 116 | async createUserInvitation(req: Request, res: Response, next: NextFunction): Promise { 117 | try { 118 | const { email } = req.body; 119 | 120 | const existingUser: User | undefined = await this.userRepo.read({ 121 | where: { 122 | email 123 | } 124 | }); 125 | 126 | if (existingUser) { 127 | return res.status(400).json({ error: 'Email is already taken' }); 128 | } 129 | 130 | const existingInvitation: UserInvitation | undefined = await this.userInvRepo.read({ 131 | where: { 132 | email 133 | } 134 | }); 135 | 136 | if (existingInvitation) { 137 | return res.status(400).json({ error: 'User is already invited' }); 138 | } 139 | 140 | // UUID for registration link 141 | const uuid = UtilityService.generateUuid(); 142 | 143 | const invitation: UserInvitation = new UserInvitation(undefined, email, uuid, true); 144 | 145 | await this.userInvRepo.save(invitation); 146 | await this.userInvMailService.sendUserInvitation(email, uuid); 147 | 148 | return res.status(200).json(uuid); 149 | } catch (err) { 150 | return next(err); 151 | } 152 | } 153 | 154 | /** 155 | * Unregister user 156 | * 157 | * @param req Express request 158 | * @param res Express response 159 | * @param next Express next 160 | * @returns HTTP response 161 | */ 162 | @bind 163 | async unregisterUser(req: Request, res: Response, next: NextFunction): Promise { 164 | try { 165 | const { email } = req.user as User; 166 | 167 | const user: User | undefined = await this.userRepo.read({ 168 | where: { 169 | email 170 | } 171 | }); 172 | 173 | if (!user) { 174 | return res.status(404).json({ error: 'User not found' }); 175 | } 176 | 177 | await this.userRepo.delete(user); 178 | 179 | return res.status(204).send(); 180 | } catch (err) { 181 | return next(err); 182 | } 183 | } 184 | 185 | /** 186 | * Get user invitation 187 | * 188 | * @param uuid 189 | * @param email 190 | * @returns User invitation 191 | */ 192 | @bind 193 | private async getUserInvitation(uuid: string, email: string): Promise { 194 | try { 195 | return this.userInvRepo.read({ where: { uuid, email } }); 196 | } catch (err) { 197 | throw err; 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/api/components/user/user.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert, expect } from 'chai'; 2 | 3 | import { TestFactory } from '../../../test/factory'; 4 | 5 | import { User } from './model'; 6 | 7 | describe('Testing user component', () => { 8 | const factory: TestFactory = new TestFactory(); 9 | const testUser: User = User.mockTestUser(); 10 | const testUserUpdated: User = { ...testUser, firstname: 'testFirstnameModified', lastname: 'testLastnameModified' }; 11 | 12 | before((done) => { 13 | factory.init().then(done); 14 | }); 15 | 16 | after((done) => { 17 | factory.close().then(done); 18 | }); 19 | 20 | describe('POST /users', () => { 21 | it('responds with status 400', (done) => { 22 | factory.app 23 | .post('/api/v1/users') 24 | .send() 25 | .set('Accept', 'application/json') 26 | .expect('Content-Type', /json/) 27 | .expect(400, done); 28 | }); 29 | 30 | it('responds with new user', (done) => { 31 | factory.app 32 | .post('/api/v1/users') 33 | .send({ 34 | email: testUser.email, 35 | firstname: testUser.firstname, 36 | lastname: testUser.lastname, 37 | password: testUser.password, 38 | active: testUser.active 39 | }) 40 | .set('Accept', 'application/json') 41 | .expect('Content-Type', /json/) 42 | .expect(200) 43 | .end((err, res) => { 44 | try { 45 | if (err) throw err; 46 | 47 | const user: User = res.body; 48 | 49 | assert.isObject(user, 'user should be an object'); 50 | 51 | expect(user.id).eq(testUser.id, 'id does not match'); 52 | expect(user.email).eq(testUser.email, 'email does not match'); 53 | expect(user.firstname).eq(testUser.firstname, 'firstname does not match'); 54 | expect(user.lastname).eq(testUser.lastname, 'lastname does not match'); 55 | expect(user.active).eq(testUser.active, 'active does not match'); 56 | 57 | return done(); 58 | } catch (err) { 59 | return done(err); 60 | } 61 | }); 62 | }); 63 | }); 64 | 65 | describe('PUT /users/1', () => { 66 | it('responds with updated user', (done) => { 67 | factory.app 68 | .put('/api/v1/users/1') 69 | .send({ 70 | email: testUserUpdated.email, 71 | firstname: testUserUpdated.firstname, 72 | lastname: testUserUpdated.lastname, 73 | password: testUserUpdated.password, 74 | active: testUserUpdated.active 75 | }) 76 | .set('Accept', 'application/json') 77 | .expect('Content-Type', /json/) 78 | .end((err, res) => { 79 | try { 80 | if (err) throw err; 81 | 82 | const user: User = res.body; 83 | 84 | assert.isObject(user, 'user should be an object'); 85 | 86 | expect(user.id).eq(testUserUpdated.id, 'id does not match'); 87 | expect(user.email).eq(testUserUpdated.email, 'email does not match'); 88 | expect(user.firstname).eq(testUserUpdated.firstname, 'firstname does not match'); 89 | expect(user.lastname).eq(testUserUpdated.lastname, 'lastname does not match'); 90 | expect(user.active).eq(testUserUpdated.active, 'active does not match'); 91 | 92 | return done(); 93 | } catch (err) { 94 | return done(err); 95 | } 96 | }); 97 | }); 98 | }); 99 | 100 | describe('GET /users', () => { 101 | it('responds with user array', (done) => { 102 | factory.app 103 | .get('/api/v1/users') 104 | .set('Accept', 'application/json') 105 | .expect('Content-Type', /json/) 106 | .expect(200) 107 | .end((err, res) => { 108 | try { 109 | if (err) throw err; 110 | 111 | const users: User[] = res.body; 112 | 113 | assert.isArray(users, 'users should be an array'); 114 | 115 | expect(users[0].id).eq(testUserUpdated.id, 'id does not match'); 116 | expect(users[0].email).eq(testUserUpdated.email, 'email does not match'); 117 | expect(users[0].firstname).eq(testUserUpdated.firstname, 'firstname does not match'); 118 | expect(users[0].lastname).eq(testUserUpdated.lastname, 'lastname does not match'); 119 | expect(users[0].active).eq(testUserUpdated.active, 'active does not match'); 120 | 121 | return done(); 122 | } catch (err) { 123 | return done(err); 124 | } 125 | }); 126 | }); 127 | }); 128 | 129 | describe('GET /users/1', () => { 130 | it('responds with single user', (done) => { 131 | factory.app 132 | .get('/api/v1/users/1') 133 | .set('Accept', 'application/json') 134 | .expect('Content-Type', /json/) 135 | .expect(200) 136 | .end((err, res) => { 137 | try { 138 | if (err) throw err; 139 | 140 | const user: User = res.body; 141 | 142 | assert.isObject(user, 'user should be an object'); 143 | 144 | expect(user.id).eq(testUserUpdated.id, 'id does not match'); 145 | expect(user.email).eq(testUserUpdated.email, 'email does not match'); 146 | expect(user.firstname).eq(testUserUpdated.firstname, 'firstname does not match'); 147 | expect(user.lastname).eq(testUserUpdated.lastname, 'lastname does not match'); 148 | expect(user.active).eq(testUserUpdated.active, 'active does not match'); 149 | 150 | return done(); 151 | } catch (err) { 152 | return done(err); 153 | } 154 | }); 155 | }); 156 | }); 157 | 158 | describe('GET /users/search', () => { 159 | it('responds with single user', (done) => { 160 | factory.app 161 | .get('/api/v1/users/search') 162 | .query({ email: testUserUpdated.email }) 163 | .set('Accept', 'application/json') 164 | .expect('Content-Type', /json/) 165 | .expect(200) 166 | .end((err, res) => { 167 | try { 168 | if (err) throw err; 169 | 170 | const user: User = res.body; 171 | 172 | assert.isObject(user, 'user should be an object'); 173 | 174 | expect(user.id).eq(testUserUpdated.id, 'id does not match'); 175 | expect(user.email).eq(testUserUpdated.email, 'email does not match'); 176 | expect(user.firstname).eq(testUserUpdated.firstname, 'firstname does not match'); 177 | expect(user.lastname).eq(testUserUpdated.lastname, 'lastname does not match'); 178 | expect(user.active).eq(testUserUpdated.active, 'active does not match'); 179 | 180 | return done(); 181 | } catch (err) { 182 | return done(err); 183 | } 184 | }); 185 | }); 186 | }); 187 | 188 | describe('DELETE /users/1', () => { 189 | it('responds with status 204', (done) => { 190 | factory.app.delete('/api/v1/users/1').set('Accept', 'application/json').expect(204, done); 191 | }); 192 | 193 | it('responds with status 404', (done) => { 194 | factory.app.delete('/api/v1/users/1').set('Accept', 'application/json').expect(404, done); 195 | }); 196 | }); 197 | }); 198 | --------------------------------------------------------------------------------