├── .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 |
--------------------------------------------------------------------------------