├── .vscode ├── settings.json └── launch.json ├── src ├── enums │ └── invoice.status.enum.ts ├── interfaces │ ├── body.request.ts │ └── auth.request.ts ├── models │ ├── base.model.ts │ ├── address.model.ts │ ├── invoice-item.model.ts │ ├── user.model.ts │ ├── partner.model.ts │ └── invoice.model.ts ├── errors │ ├── dev.error.ts │ ├── http.error.ts │ └── validation.error.ts ├── services │ ├── token │ │ ├── public.key │ │ ├── token.ts │ │ ├── private.key │ │ ├── secrets.provider.ts │ │ └── token.service.ts │ ├── base.service.ts │ ├── auth.service.ts │ ├── partners.service.ts │ └── invoices.service.ts ├── dtos │ ├── auth │ │ ├── user.response.dto.ts │ │ ├── register.request.dto.ts │ │ └── login.request.dto.ts │ ├── address │ │ └── address.dto.ts │ ├── invoice │ │ ├── invoice-item.dto.ts │ │ └── invoice.dto.ts │ └── partner │ │ └── partner.dto.ts ├── loggers │ ├── app.logger.ts │ ├── auth.logger.ts │ ├── response.logger.ts │ ├── request.logger.ts │ └── base.logger.ts ├── server.ts ├── helpers │ ├── string.helper.ts │ ├── status.helper.ts │ └── error-extractor.helper.ts ├── repositories │ ├── users.repository.ts │ ├── invoices.repository.ts │ ├── partners.repository.ts │ └── base.repository.ts ├── wrappers │ ├── bcrypt.wrapper.ts │ └── jwt.wrapper.ts ├── middlewares │ ├── request-logger.middleware.ts │ ├── error.middleware.ts │ ├── auth.middleware.ts │ └── response-logger.middleware.ts ├── decorators │ ├── id-validator.decorator.ts │ └── dto-validator.decorator.ts ├── configurations │ ├── swagger.config.ts │ ├── app.config.ts │ └── inversify.config.ts ├── connectors │ └── mongodb.connector.ts ├── controllers │ ├── base.controller.ts │ ├── invoices.controller.ts │ ├── auth.controller.ts │ └── partners.controller.ts └── app.ts ├── .editorconfig ├── test └── unit │ ├── test.container.ts │ ├── test.context.ts │ └── services │ ├── token │ └── token.service.spec.ts │ └── auth.service.spec.ts ├── tsconfig.json ├── package.json ├── .gitignore ├── README.md └── server-queries.http /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "inversify" 4 | ] 5 | } -------------------------------------------------------------------------------- /src/enums/invoice.status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum InvoiceStatus { 2 | Created = 1, 3 | Payed = 2, 4 | Deleted = 9, 5 | } 6 | -------------------------------------------------------------------------------- /src/interfaces/body.request.ts: -------------------------------------------------------------------------------- 1 | import { AuthRequest } from './auth.request'; 2 | 3 | export interface BodyRequest extends AuthRequest { 4 | body: T; 5 | } 6 | -------------------------------------------------------------------------------- /src/models/base.model.ts: -------------------------------------------------------------------------------- 1 | export abstract class BaseModel { 2 | _id: string; 3 | 4 | constructor(init?: Partial){ 5 | Object.assign(this, init); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/errors/dev.error.ts: -------------------------------------------------------------------------------- 1 | export class DevError extends Error { 2 | constructor(message?: string) { 3 | super(message); 4 | Object.setPrototypeOf(this, DevError.prototype); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/interfaces/auth.request.ts: -------------------------------------------------------------------------------- 1 | import { TokenData } from '../services/token/token'; 2 | import { Request } from 'express'; 3 | 4 | export interface AuthRequest extends Request { 5 | auth: TokenData; 6 | } 7 | 8 | -------------------------------------------------------------------------------- /src/errors/http.error.ts: -------------------------------------------------------------------------------- 1 | export class HttpError extends Error { 2 | public status: number; 3 | 4 | constructor(status: number) { 5 | super(`HTTP ${status} status`); 6 | this.status = status; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/services/token/public.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAJZegwF4rnCQB5yTW7u3iJZXpJq/o6Ue 3 | /Qp2d5FwcYvU8UdHzd9MiQVuQiEbJEWRI4Mc2iuIn/Gd/H8qIqNvFpcCAwEAAQ== 4 | -----END PUBLIC KEY----- -------------------------------------------------------------------------------- /src/dtos/auth/user.response.dto.ts: -------------------------------------------------------------------------------- 1 | export class UserResponseDto { 2 | public name: string; 3 | public email: string; 4 | 5 | constructor(init?: Partial) { 6 | Object.assign(this, init); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/loggers/app.logger.ts: -------------------------------------------------------------------------------- 1 | import { BaseLogger } from "./base.logger"; 2 | import { injectable } from "inversify"; 3 | 4 | @injectable() 5 | export class AppLogger extends BaseLogger { 6 | public type: string = 'App'; 7 | } 8 | -------------------------------------------------------------------------------- /src/loggers/auth.logger.ts: -------------------------------------------------------------------------------- 1 | import { BaseLogger } from "./base.logger"; 2 | import { injectable } from "inversify"; 3 | 4 | @injectable() 5 | export class AuthLogger extends BaseLogger { 6 | public type: string = 'Auth'; 7 | } 8 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { Container } from './configurations/inversify.config'; 3 | 4 | const container = new Container(); 5 | const app = container.getApp(); 6 | 7 | app.initialize(process); 8 | app.listen(); 9 | -------------------------------------------------------------------------------- /src/helpers/string.helper.ts: -------------------------------------------------------------------------------- 1 | export function isNullOrWhitespace(input: string | null | undefined) { 2 | if (typeof input === 'undefined' || input === null) { 3 | return true; 4 | } 5 | return input.replace(/\s/g, '').length < 1; 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; Top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.key] 12 | trim_trailing_whitespace = false 13 | insert_final_newline = false 14 | -------------------------------------------------------------------------------- /src/services/token/token.ts: -------------------------------------------------------------------------------- 1 | import { UserResponseDto } from '../../dtos/auth/user.response.dto'; 2 | 3 | export interface TokenInfo { 4 | token: string; 5 | expiresIn: number; 6 | } 7 | 8 | export interface TokenData { 9 | userId: string; 10 | name: string; 11 | email: string; 12 | } 13 | 14 | export interface LoginResult { 15 | tokenInfo: TokenInfo; 16 | user: UserResponseDto 17 | } 18 | -------------------------------------------------------------------------------- /src/repositories/users.repository.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../models/user.model'; 2 | import { BaseRepository } from "./base.repository"; 3 | import { Document, Model } from 'mongoose'; 4 | import { injectable } from "inversify"; 5 | 6 | @injectable() 7 | export class UsersRepository extends BaseRepository { 8 | constructor(mongooseModel: Model>) { 9 | super(mongooseModel); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/wrappers/bcrypt.wrapper.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify'; 2 | import { hash, compare } from 'bcrypt'; 3 | 4 | @injectable() 5 | export class BcryptWrapper { 6 | public hash(data: any, saltOrRounds: string | number): Promise { 7 | return hash(data, saltOrRounds); 8 | } 9 | 10 | public compare(data: any, encrypted: string): Promise { 11 | return compare(data, encrypted); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/dtos/address/address.dto.ts: -------------------------------------------------------------------------------- 1 | import { MinLength } from 'class-validator'; 2 | 3 | export class AddressRequestDto { 4 | @MinLength(2) 5 | public street: string; 6 | 7 | @MinLength(2) 8 | public city: string; 9 | } 10 | 11 | export class AddressResponseDto { 12 | public street: string; 13 | public city: string; 14 | 15 | constructor(init?: Partial) { 16 | Object.assign(this, init); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/repositories/invoices.repository.ts: -------------------------------------------------------------------------------- 1 | import { Invoice } from './../models/invoice.model'; 2 | import { Document, Model } from 'mongoose'; 3 | import { BaseRepository } from "./base.repository"; 4 | import { injectable } from "inversify"; 5 | 6 | @injectable() 7 | export class InvoicesRepository extends BaseRepository { 8 | constructor(mongooseModel: Model>) { 9 | super(mongooseModel); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/repositories/partners.repository.ts: -------------------------------------------------------------------------------- 1 | import { Partner } from './../models/partner.model'; 2 | import { Document, Model } from 'mongoose'; 3 | import { BaseRepository } from "./base.repository"; 4 | import { injectable } from "inversify"; 5 | 6 | @injectable() 7 | export class PartnersRepository extends BaseRepository { 8 | constructor(mongooseModel: Model>) { 9 | super(mongooseModel); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/dtos/auth/register.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, MinLength, IsEmail, Matches } from 'class-validator'; 2 | 3 | export class RegisterRequestDto { 4 | @IsString() 5 | @MinLength(3) 6 | public name: string; 7 | 8 | @IsString() 9 | @IsEmail() 10 | public email: string; 11 | 12 | @IsString() 13 | @MinLength(8) 14 | @Matches(new RegExp(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/)) 15 | public password: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/models/address.model.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose'; 2 | import { BaseModel } from './base.model'; 3 | 4 | export const addressSchema = new Schema({ 5 | street: { type: Schema.Types.String, required: true }, 6 | city: { type: Schema.Types.String, required: true }, 7 | }); 8 | 9 | export class Address extends BaseModel { 10 | street: string; 11 | city: string; 12 | 13 | constructor(init?: Partial
) { 14 | super(init); 15 | Object.assign(this, init); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/middlewares/request-logger.middleware.ts: -------------------------------------------------------------------------------- 1 | import { RequestLogger } from '../loggers/request.logger'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | import { injectable, inject } from 'inversify'; 4 | 5 | @injectable() 6 | export class RequestLoggerMiddleware { 7 | @inject(RequestLogger) private readonly requestLogger: RequestLogger; 8 | 9 | public handle(request: Request, response: Response, next: NextFunction): void { 10 | this.requestLogger.log(request); 11 | next(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/unit/test.container.ts: -------------------------------------------------------------------------------- 1 | import { Container } from '../../src/configurations/inversify.config'; 2 | import { interfaces } from 'inversify'; 3 | 4 | export class TestContainer extends Container { 5 | public rebind(serviceIdentifier: interfaces.ServiceIdentifier): interfaces.BindingToSyntax { 6 | return this.container.rebind(serviceIdentifier); 7 | } 8 | 9 | public get(serviceIdentifier: interfaces.ServiceIdentifier): T { 10 | return this.container.get(serviceIdentifier); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/services/token/private.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIBOwIBAAJBAJZegwF4rnCQB5yTW7u3iJZXpJq/o6Ue/Qp2d5FwcYvU8UdHzd9M 3 | iQVuQiEbJEWRI4Mc2iuIn/Gd/H8qIqNvFpcCAwEAAQJAVz9bcB0fyfwoDneKAG9L 4 | d0A/J/MN9p72X33BfsfpeiIY1ifJVzeLDuTOGV0fs19XbxNznAiex7ybqQD+diMx 5 | AQIhAPTu0LW9y8RL+VrWpK0e6uvi/gPRwhdvOaiV8t/mSh3BAiEAnSncSH0OMomo 6 | oJZ5lP+U803OYenUkjpxH4KICaRRelcCIQC5/8EuwnrDDo7FlMppTVlI2I/dhqTF 7 | 9wjqJTTTIqaWAQIgUWcyMtWbOe/1SKBH/zXWV6MwR6TOtqLQnwqEHcJfdWcCIQDP 8 | AsbFu7oXW9gQAeLR3D2akd+zG1eiIuunsXi//aJtzg== 9 | -----END RSA PRIVATE KEY----- -------------------------------------------------------------------------------- /src/loggers/response.logger.ts: -------------------------------------------------------------------------------- 1 | import { BaseLogger } from "./base.logger"; 2 | import { Response, Request } from 'express'; 3 | import { injectable } from "inversify"; 4 | import { STATUS_CODES } from 'statuses'; 5 | 6 | @injectable() 7 | export class ResponseLogger extends BaseLogger { 8 | public type: string = 'Response'; 9 | 10 | public log(request: Request, response: Response, body?: any): void { 11 | if (!request.originalUrl.includes('swagger')) { 12 | this.debug(`${response.statusCode} ${STATUS_CODES[response.statusCode]} ${body}`); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "target": "es2017", 5 | "baseUrl": "./src", 6 | "outDir": "./dist", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | 10 | "types": ["reflect-metadata", "node", "jest"], 11 | "lib": ["es6", "dom"], 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true 14 | }, 15 | "include": [ 16 | "src/**/*.ts", 17 | "test/**/*.ts" 18 | ], 19 | "exclude": [ 20 | "node_modules" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/errors/validation.error.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from './http.error'; 2 | 3 | export enum ValidationErrorPlace { 4 | Body = 'BODY', 5 | Url = 'URL', 6 | } 7 | 8 | export class ValidationError extends HttpError { 9 | public place: ValidationErrorPlace; 10 | public errors: string[]; 11 | 12 | constructor( 13 | place: ValidationErrorPlace, 14 | errors: string | string[] 15 | ) { 16 | super(400); 17 | Object.setPrototypeOf(this, ValidationError.prototype); 18 | 19 | this.place = place; 20 | this.errors = errors instanceof Array ? errors : [errors]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/models/invoice-item.model.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose'; 2 | import { BaseModel } from './base.model'; 3 | 4 | export const invoiceItemSchema = new Schema({ 5 | name: { type: Schema.Types.String, required: true }, 6 | quantity: { type: Schema.Types.Number, required: true }, 7 | unitPrice: { type: Schema.Types.Decimal128, required: true }, 8 | }); 9 | 10 | export class InvoiceItem extends BaseModel { 11 | name: string; 12 | quantity: number; 13 | unitPrice: number; 14 | 15 | constructor(init?: Partial) { 16 | super(init); 17 | Object.assign(this, init); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/helpers/status.helper.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from './../errors/http.error'; 2 | 3 | export abstract class StatusHelper { 4 | public static status200OK: number = 200; 5 | public static status201Created: number = 201; 6 | public static status202Accepted: number = 202; 7 | public static status204NoContent: number = 204; 8 | 9 | public static error400BadRequest: HttpError = new HttpError(400); 10 | public static error401Unauthorized: HttpError = new HttpError(401); 11 | public static error403Forbidden: HttpError = new HttpError(403); 12 | public static error404NotFound: HttpError = new HttpError(404); 13 | } 14 | -------------------------------------------------------------------------------- /src/middlewares/error.middleware.ts: -------------------------------------------------------------------------------- 1 | import { ErrorExtractor } from '../helpers/error-extractor.helper'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | import { injectable, inject } from 'inversify'; 4 | 5 | @injectable() 6 | export class ErrorMiddleware { 7 | @inject(ErrorExtractor) private readonly errorHelper: ErrorExtractor; 8 | 9 | public handle(error: any, request: Request, response: Response, next: NextFunction): void { 10 | const result = this.errorHelper.extract(error); 11 | 12 | response 13 | .status(result.status) 14 | .send({ 15 | ...result 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/dtos/invoice/invoice-item.dto.ts: -------------------------------------------------------------------------------- 1 | import { MinLength, IsInt, IsPositive, IsDecimal, NotContains } from 'class-validator'; 2 | 3 | export class InvoiceItemRequestDto { 4 | @MinLength(3) 5 | public name: string; 6 | 7 | @IsInt() 8 | @IsPositive() 9 | public quantity: number; 10 | 11 | @IsDecimal({ force_decimal: true }) 12 | @NotContains("-") 13 | public unitPrice: number; 14 | } 15 | 16 | export class InvoiceItemResponseDto { 17 | public name: string; 18 | public quantity: number; 19 | public unitPrice: number; 20 | 21 | constructor(init?: Partial) { 22 | Object.assign(this, init); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import { model, Document, Schema } from 'mongoose'; 2 | import { BaseModel } from './base.model'; 3 | 4 | const userSchema = new Schema({ 5 | name: { type: Schema.Types.String, required: true }, 6 | email: { type: Schema.Types.String, required: true, unique: true }, 7 | password: { type: Schema.Types.String, required: true }, 8 | }); 9 | 10 | export class User extends BaseModel { 11 | name: string; 12 | email: string; 13 | password: string; 14 | 15 | constructor(init?: Partial) { 16 | super(init); 17 | Object.assign(this, init); 18 | } 19 | } 20 | 21 | export const UserModel = model>('User', userSchema); 22 | -------------------------------------------------------------------------------- /src/loggers/request.logger.ts: -------------------------------------------------------------------------------- 1 | import { BaseLogger } from "./base.logger"; 2 | import { Request } from 'express'; 3 | import { injectable } from "inversify"; 4 | 5 | @injectable() 6 | export class RequestLogger extends BaseLogger { 7 | public type: string = 'Request'; 8 | 9 | public log(request: Request): void { 10 | if (!request.path.startsWith('/swagger/')) { 11 | let query = ''; 12 | for (var propName in request.query) { 13 | if (request.query.hasOwnProperty(propName)) { 14 | query += `'${propName}:${request.query[propName]}' `; 15 | } 16 | } 17 | 18 | this.debug(`${request.method} '${request.path}' ${query} ${JSON.stringify(request.body)}`) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/services/token/secrets.provider.ts: -------------------------------------------------------------------------------- 1 | import { AppConfig } from '../../configurations/app.config'; 2 | import { injectable, inject } from 'inversify'; 3 | import { readFileSync } from 'fs'; 4 | 5 | @injectable() 6 | export class SecretsProvider { 7 | private _privateKey: string; 8 | public get privateKey(): string { 9 | return this._privateKey; 10 | } 11 | 12 | private _publicKey: string; 13 | public get publicKey(): string { 14 | return this._publicKey; 15 | } 16 | 17 | constructor( 18 | @inject(AppConfig) private readonly appConfig: AppConfig 19 | ) { 20 | this._privateKey = readFileSync(`${appConfig.sourcePath}/services/token/private.key`, 'utf8'); 21 | this._publicKey = readFileSync(`${appConfig.sourcePath}/services/token/public.key`, 'utf8'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/unit/test.context.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { TestContainer } from './test.container'; 3 | import { interfaces } from 'inversify'; 4 | 5 | export class TestContext { 6 | private container = new TestContainer(); 7 | 8 | public mock( 9 | implementation: () => Partial, 10 | serviceIdentifier: interfaces.ServiceIdentifier 11 | ) : T { 12 | const mock = this.mockClass(implementation); 13 | this.container.rebind(serviceIdentifier).toConstantValue(mock); 14 | return mock; 15 | } 16 | 17 | public get(serviceIdentifier: interfaces.ServiceIdentifier): T { 18 | return this.container.get(serviceIdentifier); 19 | } 20 | 21 | private mockClass(implementation: () => Partial): T { 22 | return jest.fn(implementation)() as T; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/dtos/auth/login.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, MinLength } from 'class-validator'; 2 | 3 | /** 4 | * @swagger 5 | * definitions: 6 | * LoginRequestDto: 7 | * type: object 8 | * required: 9 | * - email 10 | * - password 11 | * properties: 12 | * email: 13 | * type: string 14 | * format: email 15 | * description: Email of user, which is also login 16 | * password: 17 | * type: string 18 | * description: Strong password, min 5 chars length 19 | * example: 20 | * email: test@test.com 21 | * password: strongPWD_123 22 | */ 23 | export class LoginRequestDto { 24 | @IsString() 25 | @MinLength(3) 26 | public email: string; 27 | 28 | @IsString() 29 | @MinLength(5) 30 | public password: string; 31 | } 32 | -------------------------------------------------------------------------------- /src/dtos/partner/partner.dto.ts: -------------------------------------------------------------------------------- 1 | import { AddressResponseDto, AddressRequestDto } from './../address/address.dto'; 2 | import { MinLength, ValidateNested } from 'class-validator'; 3 | import { Type } from 'class-transformer/decorators'; 4 | import { jsonIgnore } from 'json-ignore'; 5 | 6 | export class PartnerRequestDto { 7 | @MinLength(2) 8 | public name: string; 9 | 10 | @MinLength(10) 11 | public taxNumber: string; 12 | 13 | @ValidateNested() 14 | @Type(() => AddressRequestDto) 15 | public address: AddressRequestDto; 16 | } 17 | 18 | export class PartnerResponseDto { 19 | public id: string; 20 | public name: string; 21 | public taxNumber: string; 22 | 23 | @jsonIgnore() 24 | public deleted: boolean; 25 | 26 | public address: AddressResponseDto; 27 | 28 | constructor(init?: Partial) { 29 | Object.assign(this, init); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/middlewares/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import { TokenService } from './../services/token/token.service'; 2 | import { AuthRequest } from '../interfaces/auth.request'; 3 | import { Response, NextFunction } from 'express'; 4 | import { injectable, inject } from 'inversify'; 5 | import { StatusHelper } from './../helpers/status.helper'; 6 | 7 | @injectable() 8 | export class AuthMiddleware { 9 | @inject(TokenService) private readonly tokenService: TokenService; 10 | 11 | public handle(request: AuthRequest, response: Response, next: NextFunction): void { 12 | if (!request.cookies || !request.cookies.Authorization) { 13 | throw StatusHelper.error401Unauthorized; 14 | } 15 | 16 | const tokenData = this.tokenService.verify(request.cookies.Authorization); 17 | if (!tokenData) { 18 | throw StatusHelper.error401Unauthorized; 19 | } 20 | 21 | request.auth = tokenData; 22 | next(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/models/partner.model.ts: -------------------------------------------------------------------------------- 1 | import { User } from './user.model'; 2 | import { addressSchema, Address } from './address.model'; 3 | import { model, Document, Schema } from 'mongoose'; 4 | import { BaseModel } from './base.model'; 5 | 6 | const partnerSchema = new Schema({ 7 | name: { type: Schema.Types.String, required: true }, 8 | taxNumber: { type: Schema.Types.String, required: true }, 9 | deleted: { type: Schema.Types.Boolean, required: true, default: false }, 10 | 11 | address: addressSchema, 12 | 13 | user: { 14 | ref: 'User', 15 | type: Schema.Types.ObjectId, 16 | required: true, 17 | }, 18 | }); 19 | 20 | export class Partner extends BaseModel { 21 | name: string; 22 | taxNumber: string; 23 | deleted: boolean; 24 | 25 | address: Address; 26 | 27 | user: User | string; 28 | 29 | constructor(init?: Partial) { 30 | super(init); 31 | Object.assign(this, init); 32 | } 33 | } 34 | 35 | export const PartnerModel = model>('Partner', partnerSchema); 36 | -------------------------------------------------------------------------------- /src/services/base.service.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify'; 2 | import { BaseRepository } from './../repositories/base.repository'; 3 | import { BaseModel } from './../models/base.model'; 4 | 5 | @injectable() 6 | export abstract class BaseService { 7 | protected abstract modelToDto(model: TModel): TResponseDto; 8 | protected abstract dtoToModel(dto: TRequestDto): TModel; 9 | 10 | protected abstract readonly repo: BaseRepository; 11 | 12 | public async isUnique(propsToCheck: string[], dto: TRequestDto, user: string, id?: string): Promise { 13 | let filter = { 14 | user, 15 | }; 16 | 17 | for (const item of propsToCheck) { 18 | filter[item] = dto[item]; 19 | const obj = await this.repo.findOne(filter as any); 20 | if ( 21 | (obj && !id) 22 | || 23 | (obj && id && obj._id != id)) { 24 | return `${item} already taken`; 25 | } 26 | delete filter[item]; 27 | } 28 | 29 | return ''; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/decorators/id-validator.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Response, Request } from "express"; 2 | import { Validator } from "class-validator"; 3 | import { ValidationError, ValidationErrorPlace } from '../errors/validation.error'; 4 | 5 | export function IdValidator(paramName: string = 'id') { 6 | return function (target: Object, propertyKey: string, descriptor: TypedPropertyDescriptor<(request: Request, response: Response) => Promise>) { 7 | const originalMethod = descriptor.value; 8 | 9 | descriptor.value = async function (request: Request, response: Response) { 10 | const id = request.params[paramName]; 11 | if (!id) { 12 | throw new ValidationError(ValidationErrorPlace.Url, `${paramName} URL param expected`); 13 | } 14 | 15 | const isValid = new Validator().isMongoId(id); 16 | if (!isValid) { 17 | throw new ValidationError(ValidationErrorPlace.Url, `${paramName} URL param has invalid value`); 18 | } 19 | 20 | await originalMethod.apply(this, [request, response]); 21 | } 22 | 23 | return descriptor; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/dtos/invoice/invoice.dto.ts: -------------------------------------------------------------------------------- 1 | import { InvoiceItemRequestDto, InvoiceItemResponseDto } from './invoice-item.dto'; 2 | import { PartnerResponseDto } from './../partner/partner.dto'; 3 | import { MinLength, ValidateNested, IsDateString, IsMongoId, ArrayMinSize, MaxDate, MinDate } from 'class-validator'; 4 | import { Type } from 'class-transformer/decorators'; 5 | 6 | export class InvoiceRequestDto { 7 | @MinLength(5) 8 | public number: string; 9 | 10 | @IsDateString() 11 | public invoiceDate: Date; 12 | 13 | @IsDateString() 14 | public paymentDate: Date; 15 | 16 | @ValidateNested() 17 | @ArrayMinSize(1) 18 | @Type(() => InvoiceItemRequestDto) 19 | public invoiceItems: InvoiceItemRequestDto[]; 20 | 21 | @IsMongoId() 22 | public partnerId: string; 23 | } 24 | 25 | export class InvoiceResponseDto { 26 | public id: string; 27 | public number: string; 28 | public invoiceDate: Date | string; 29 | public paymentDate: Date | string; 30 | public status: number; 31 | 32 | public invoiceItems: InvoiceItemResponseDto[]; 33 | 34 | public partner: PartnerResponseDto; 35 | 36 | constructor(init?: Partial) { 37 | Object.assign(this, init); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/middlewares/response-logger.middleware.ts: -------------------------------------------------------------------------------- 1 | import { ResponseLogger } from './../loggers/response.logger'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | import { injectable, inject } from 'inversify'; 4 | 5 | @injectable() 6 | export class ResponseLoggerMiddleware { 7 | @inject(ResponseLogger) private readonly responseLogger: ResponseLogger; 8 | 9 | public handle(request: Request, response: Response, next: NextFunction): void { 10 | const originalWrite = response.write, 11 | originalEnd = response.end, 12 | logger = this.responseLogger, 13 | chunks = []; 14 | 15 | response.write = function (chunk: any) { 16 | if (chunk instanceof Buffer) { 17 | chunks.push(chunk); 18 | } 19 | return originalWrite.apply(response, arguments); 20 | }; 21 | 22 | response.end = function (chunk: any) { 23 | if (chunk instanceof Buffer) { 24 | if (chunk) { 25 | chunks.push(chunk); 26 | } 27 | 28 | const body = Buffer.concat(chunks).toString('utf8'); 29 | logger.log(request, response, body); 30 | } 31 | 32 | return originalEnd.apply(response, arguments); 33 | }; 34 | 35 | next(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/models/invoice.model.ts: -------------------------------------------------------------------------------- 1 | import { InvoiceItem, invoiceItemSchema } from './invoice-item.model'; 2 | import { Partner } from './partner.model'; 3 | import { User } from './user.model'; 4 | import { model, Document, Schema } from 'mongoose'; 5 | import { BaseModel } from './base.model'; 6 | import * as autopopulate from 'mongoose-autopopulate'; 7 | 8 | const invoiceSchema = new Schema({ 9 | number: { type: Schema.Types.String, required: true }, 10 | invoiceDate: { type: Schema.Types.Date, required: true }, 11 | paymentDate: { type: Schema.Types.Date, required: true }, 12 | status: { type: Schema.Types.Number, required: true }, 13 | 14 | invoiceItems: [ invoiceItemSchema ], 15 | 16 | user: { 17 | ref: 'User', 18 | type: Schema.Types.ObjectId, 19 | required: true, 20 | }, 21 | 22 | partner: { 23 | ref: 'Partner', 24 | type: Schema.Types.ObjectId, 25 | required: true, 26 | autopopulate: true, 27 | }, 28 | }); 29 | invoiceSchema.plugin(autopopulate); 30 | 31 | export class Invoice extends BaseModel { 32 | number: string; 33 | invoiceDate: Date | string; 34 | paymentDate: Date | string; 35 | status: number; 36 | 37 | invoiceItems: InvoiceItem[]; 38 | 39 | partner: Partner | string; 40 | user: User | string; 41 | 42 | constructor(init?: Partial) { 43 | super(init); 44 | Object.assign(this, init); 45 | } 46 | } 47 | 48 | export const InvoiceModel = model>('Invoice', invoiceSchema); 49 | -------------------------------------------------------------------------------- /src/helpers/error-extractor.helper.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from './../errors/http.error'; 2 | import { AppConfig } from '../configurations/app.config'; 3 | import { ValidationError } from '../errors/validation.error'; 4 | import { injectable, inject } from 'inversify'; 5 | import { STATUS_CODES } from 'statuses'; 6 | 7 | export interface ErrorResult { 8 | status: number; 9 | message: string; 10 | place?: string; 11 | errors?: string[]; 12 | stack?: string; 13 | } 14 | 15 | @injectable() 16 | export class ErrorExtractor { 17 | @inject(AppConfig) private appConfig: AppConfig; 18 | 19 | public extract(error: any): ErrorResult { 20 | const status500InternalServerError: number = 500; 21 | 22 | let status = status500InternalServerError; 23 | if (error instanceof HttpError) { 24 | status = error.status; 25 | } 26 | 27 | let message = STATUS_CODES[status]; 28 | 29 | let errors = null; 30 | let place = null; 31 | if (error instanceof ValidationError) { 32 | errors = error.errors; 33 | place = error.place; 34 | } 35 | 36 | const result: ErrorResult = { 37 | status, 38 | message, 39 | } 40 | 41 | if (this.appConfig.debug && status === status500InternalServerError) { 42 | result.stack = error.stack; 43 | errors = [error.message]; 44 | } 45 | 46 | if (errors !== null) { 47 | result.errors = errors; 48 | } 49 | if (place !== null) { 50 | result.place = place; 51 | } 52 | 53 | return result; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/configurations/swagger.config.ts: -------------------------------------------------------------------------------- 1 | import { AppConfig } from './app.config'; 2 | import { injectable, inject } from 'inversify'; 3 | import * as swaggerJSDoc from "swagger-jsdoc"; 4 | import * as swaggerUi from "swagger-ui-express"; 5 | import { Application, Router } from 'express'; 6 | 7 | @injectable() 8 | export class SwaggerConfig { 9 | @inject(AppConfig) private readonly appConfig: AppConfig; 10 | 11 | public initialize(app: Application) { 12 | const options = { 13 | swaggerDefinition: { 14 | info: { 15 | title: 'Very simple API in NodeJS using express', 16 | version: '1.0.2', 17 | description: 'Made by Łukasz K.', 18 | contact: { 19 | url: 'https://kurzyniec.pl' 20 | } 21 | }, 22 | schemes: ['http'], 23 | host: `${this.appConfig.applicationHost}:${this.appConfig.applicationPort}`, 24 | basePath: this.appConfig.apiPath, 25 | }, 26 | apis: [ 27 | `${this.appConfig.sourcePath}/controllers/*.controller.ts`, 28 | `${this.appConfig.sourcePath}/dtos/**/*.dto.ts`, 29 | ] 30 | } 31 | 32 | const swaggerSpec = swaggerJSDoc(options); 33 | 34 | const swaggerRouter = Router(); 35 | swaggerRouter.get('/v1/swagger.json', function (req, res) { 36 | res.setHeader('Content-Type', 'application/json') 37 | res.send(swaggerSpec) 38 | }) 39 | swaggerRouter.use('/', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); 40 | 41 | app.use('/swagger', swaggerRouter); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/connectors/mongodb.connector.ts: -------------------------------------------------------------------------------- 1 | import { AppLogger } from './../loggers/app.logger'; 2 | import { AppConfig } from './../configurations/app.config'; 3 | import * as mongoose from 'mongoose'; 4 | import { injectable, inject } from 'inversify'; 5 | import { isNullOrWhitespace } from './../helpers/string.helper'; 6 | 7 | @injectable() 8 | export class MongoDbConnector { 9 | @inject(AppConfig) private readonly appConfig: AppConfig; 10 | @inject(AppLogger) private readonly appLogger: AppLogger; 11 | 12 | public connect(): void { 13 | const connectionOptions = { 14 | useNewUrlParser: true, 15 | useUnifiedTopology: true, 16 | useFindAndModify: false, 17 | useCreateIndex: true, 18 | }; 19 | 20 | let connector; 21 | if (isNullOrWhitespace(this.appConfig.mongoUser) && isNullOrWhitespace(this.appConfig.mongoPassword)) { 22 | connector = mongoose.connect(`mongodb://${this.appConfig.mongoHost}:${this.appConfig.mongoPort}/${this.appConfig.mongoDatabase}`, connectionOptions); 23 | } else { 24 | connector = mongoose.connect(`mongodb://${this.appConfig.mongoUser}:${this.appConfig.mongoPassword}@${this.appConfig.mongoHost}:${this.appConfig.mongoPort}/${this.appConfig.mongoDatabase}`, connectionOptions); 25 | } 26 | 27 | connector.then( 28 | () => { 29 | this.appLogger.info(`Successfully connected to '${this.appConfig.mongoDatabase}'.`); 30 | }, 31 | err => { 32 | this.appLogger.error(err, `Error while connecting to '${this.appConfig.mongoDatabase}'.`); 33 | } 34 | ); 35 | 36 | mongoose.set('debug', this.appConfig.debug); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/loggers/base.logger.ts: -------------------------------------------------------------------------------- 1 | import { AppConfig } from './../configurations/app.config'; 2 | import { isNullOrWhitespace } from "./../helpers/string.helper"; 3 | import { injectable, inject } from "inversify"; 4 | 5 | enum Colors { 6 | Black = 30, 7 | Red = 31, 8 | Green = 32, 9 | Yellow = 33, 10 | Pink = 35, 11 | Blue = 36, 12 | 13 | BgBlack = 40, 14 | BgRed = 41, 15 | BgGreen = 42, 16 | BgYellow = 43, 17 | BgPink = 45, 18 | BgBlue = 46, 19 | } 20 | 21 | @injectable() 22 | export abstract class BaseLogger { 23 | @inject(AppConfig) protected readonly appConfig: AppConfig; 24 | 25 | public abstract type: string; 26 | 27 | public debug(message: any): void { 28 | if (!this.appConfig.debug) { 29 | return; 30 | } 31 | console.log(`${this.colored('DEBUG', Colors.Blue)} ${this.type}: ${message}`); 32 | } 33 | 34 | public info(message: any): void { 35 | console.log(`${this.colored('INFO', Colors.Green)} ${this.type}: ${message}`); 36 | } 37 | 38 | public warn(message: any): void { 39 | console.log(`${this.colored('WARN', Colors.Yellow)} ${this.type}: ${message}`); 40 | } 41 | 42 | public error(error: any, message?: any): void { 43 | if (isNullOrWhitespace(message)) { 44 | console.log(`${this.colored('ERROR', Colors.Red)} ${this.type}: ${error}`); 45 | return; 46 | } 47 | console.log(`${this.colored('ERROR', Colors.Red)} ${this.type}: ${message}. ${error}`); 48 | } 49 | 50 | public table(tabularData: any): void { 51 | console.log(`${this.type}: ${tabularData}`); 52 | } 53 | 54 | private colored(str: string, col: Colors): string { 55 | return `\u001b[${col}m${str}\u001b[0m`; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/services/token/token.service.ts: -------------------------------------------------------------------------------- 1 | import { AuthLogger } from './../../loggers/auth.logger'; 2 | import { SecretsProvider } from './secrets.provider'; 3 | import { User } from '../../models/user.model'; 4 | import { AppConfig } from '../../configurations/app.config'; 5 | import { TokenInfo, TokenData } from './token'; 6 | import { injectable, inject } from 'inversify'; 7 | import { JwtWrapper } from '../../wrappers/jwt.wrapper'; 8 | 9 | @injectable() 10 | export class TokenService { 11 | @inject(AppConfig) private readonly appConfig: AppConfig; 12 | @inject(SecretsProvider) private readonly secretsProvider: SecretsProvider; 13 | @inject(AuthLogger) private readonly authLogger: AuthLogger; 14 | @inject(JwtWrapper) private readonly jwt: JwtWrapper; 15 | 16 | public create(user: User): TokenInfo { 17 | const tokenData: TokenData = { 18 | userId: user._id, 19 | name: user.name, 20 | email: user.email, 21 | }; 22 | 23 | const options = { 24 | algorithm: 'RS256', 25 | expiresIn: this.appConfig.tokenExpirationInMin * 60, 26 | }; 27 | const token = this.jwt.sign(tokenData, this.secretsProvider.privateKey, options); 28 | 29 | return { 30 | expiresIn: options.expiresIn as number, 31 | token, 32 | }; 33 | } 34 | 35 | public verify(token: string): TokenData { 36 | try { 37 | const options = { 38 | algorithms: ['RS256'], 39 | }; 40 | const tokenData = this.jwt.verify(token, this.secretsProvider.publicKey, options) as TokenData; 41 | return tokenData; 42 | } catch (err) { 43 | if (err.name !== 'TokenExpiredError') { 44 | this.authLogger.warn(err.message); 45 | } 46 | return null; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "args": [ 12 | "${workspaceFolder}/src/server.ts" 13 | ], 14 | "runtimeArgs": [ 15 | "--nolazy", 16 | "-r", 17 | "ts-node/register", 18 | ], 19 | "sourceMaps": true, 20 | "cwd": "${workspaceRoot}", 21 | "protocol": "inspector", 22 | "console": "internalConsole", 23 | "internalConsoleOptions": "openOnSessionStart", 24 | }, 25 | { 26 | "type": "node", 27 | "request": "launch", 28 | "name": "Jest All", 29 | "program": "${workspaceFolder}/node_modules/.bin/jest", 30 | "args": [ 31 | "--runInBand" 32 | ], 33 | "console": "integratedTerminal", 34 | "internalConsoleOptions": "neverOpen", 35 | "disableOptimisticBPs": true, 36 | "windows": { 37 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 38 | } 39 | }, 40 | { 41 | "type": "node", 42 | "request": "launch", 43 | "name": "Jest Current File", 44 | "program": "${workspaceFolder}/node_modules/.bin/jest", 45 | "args": [ 46 | "${fileBasenameNoExtension}", 47 | ], 48 | "console": "integratedTerminal", 49 | "internalConsoleOptions": "neverOpen", 50 | "disableOptimisticBPs": true, 51 | "windows": { 52 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 53 | } 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /src/controllers/base.controller.ts: -------------------------------------------------------------------------------- 1 | import { AuthRequest } from '../interfaces/auth.request'; 2 | import { AuthMiddleware } from './../middlewares/auth.middleware'; 3 | import { isNullOrWhitespace } from './../helpers/string.helper'; 4 | import { DevError } from './../errors/dev.error'; 5 | import { Router, Request, Response, NextFunction, RequestHandler } from 'express'; 6 | import { injectable, inject } from 'inversify'; 7 | import { Validator } from "class-validator"; 8 | import PromiseRouter from "express-promise-router"; 9 | 10 | @injectable() 11 | export abstract class BaseController { 12 | @inject(AuthMiddleware) private readonly authMiddleware: AuthMiddleware; 13 | 14 | public readonly path: string; 15 | public readonly router: Router; 16 | 17 | public abstract initializeRoutes(): void; 18 | 19 | constructor(path: string = '', addAuth: boolean = true) { 20 | if (isNullOrWhitespace(path)) { 21 | throw new DevError(`Parameter 'path' can not be empty.`); 22 | } 23 | 24 | this.router = PromiseRouter(); 25 | this.path = path; 26 | 27 | if (addAuth) { 28 | this.router 29 | .all(this.path, this.authenticate()) 30 | .all(`${this.path}/*`, this.authenticate()); 31 | } 32 | } 33 | 34 | protected getBoolFromQueryParams(request: Request, queryParam: string): boolean { 35 | const paramValue = request.query[queryParam] || "false"; 36 | const value = new Validator().isBooleanString(paramValue) && (paramValue.toLowerCase() === "true" || paramValue === "1"); 37 | return value; 38 | } 39 | 40 | private authenticate(): RequestHandler { 41 | return (request: AuthRequest, response: Response, next: NextFunction) => { 42 | this.authMiddleware.handle(request, response, next); 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/decorators/dto-validator.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Response } from "express"; 2 | import { validate, ValidationError as Error } from 'class-validator'; 3 | import { plainToClass } from 'class-transformer'; 4 | import { ClassType } from 'class-transformer/ClassTransformer'; 5 | import { ValidationError, ValidationErrorPlace } from './../errors/validation.error'; 6 | import { BodyRequest } from './../interfaces/body.request'; 7 | 8 | export function DtoValidator(type: ClassType, skipMissingProperties = false) { 9 | const getError = function (err: Error): string { 10 | if (err.children && err.children.length) { 11 | return `${err.property}: ` + err.children.map((item) => { return getError(item); }).join('; '); 12 | } 13 | return Object.values(err.constraints).join('; '); 14 | } 15 | 16 | return function (target: Object, propertyKey: string, descriptor: TypedPropertyDescriptor<(request: BodyRequest, response: Response) => Promise>) { 17 | const originalMethod = descriptor.value; 18 | 19 | descriptor.value = async function (request: BodyRequest, response: Response) { 20 | if (Object.keys(request.body).length === 0) { 21 | throw new ValidationError(ValidationErrorPlace.Body, 'Body of the request is required'); 22 | } 23 | 24 | const dto = plainToClass(type, request.body) as T; 25 | request.body = dto; 26 | 27 | const errors = await validate(dto, { validationError: { target: false }, skipMissingProperties }); 28 | if (errors.length > 0) { 29 | const resultErrors = errors.map((item) => { return getError(item); }); 30 | throw new ValidationError(ValidationErrorPlace.Body, resultErrors); 31 | } 32 | 33 | await originalMethod.apply(this, [request, response]); 34 | } 35 | 36 | return descriptor; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/repositories/base.repository.ts: -------------------------------------------------------------------------------- 1 | import { Model, Document } from 'mongoose'; 2 | import { BaseModel } from './../models/base.model'; 3 | 4 | export abstract class BaseRepository{ 5 | constructor( 6 | private mongooseModel: Model> 7 | ) { 8 | 9 | } 10 | 11 | public async findById(id: string): Promise { 12 | const item = await this.mongooseModel.findById(id).exec(); 13 | if (item == null) { 14 | return null; 15 | } 16 | return item.toObject(); 17 | } 18 | 19 | public async findOne(conditions: Partial): Promise { 20 | const item = await this.mongooseModel.findOne(conditions as any).exec(); 21 | if (item == null) { 22 | return null; 23 | } 24 | return item.toObject(); 25 | } 26 | 27 | public exists(conditions: Partial): Promise { 28 | return this.mongooseModel.exists(conditions as any); 29 | } 30 | 31 | public async getAll(conditions?: Partial, sort?: Partial): Promise { 32 | const query = this.mongooseModel.find(conditions as any); 33 | if (sort) { 34 | query.sort(sort); 35 | } 36 | const items = await query.exec(); 37 | if (items == null || !items.length) { 38 | return []; 39 | } 40 | return items.map(item => item.toObject()); 41 | } 42 | 43 | public async create(data: TModel): Promise { 44 | const entity = new this.mongooseModel(data); 45 | const saved = await entity.save(); 46 | return await this.findById(saved.id); 47 | } 48 | 49 | public async update(id: string, data: TModel): Promise { 50 | const saved = await this.mongooseModel.findByIdAndUpdate(id, data as any).exec(); 51 | if (!saved) { 52 | return null; 53 | } 54 | return this.findById(saved.id); 55 | } 56 | 57 | public async delete(id: string): Promise { 58 | const deleted = await this.mongooseModel.findByIdAndDelete(id).exec(); 59 | return !!deleted; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsexpress", 3 | "version": "0.0.2", 4 | "description": "Simple API in nodeJS with TypeScript and express", 5 | "main": "server.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "dev": "ts-node-dev --no-notify --clear --debounce 1000 ./src/server.ts", 9 | "test": "jest", 10 | "test:watch": "jest --watch" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/lkurzyniec/tsexpress.git" 15 | }, 16 | "author": "Łukasz Kurzyniec", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/lkurzyniec/tsexpress/issues" 20 | }, 21 | "homepage": "https://github.com/lkurzyniec/tsexpress", 22 | "dependencies": { 23 | "bcrypt": "^5.0.0", 24 | "class-transformer": "^0.3.1", 25 | "class-validator": "^0.11.0", 26 | "cookie-parser": "^1.4.4", 27 | "cors": "^2.8.5", 28 | "dotenv": "^8.2.0", 29 | "envalid": "^6.0.0", 30 | "express": "^4.17.1", 31 | "express-promise-router": "^3.0.3", 32 | "helmet": "^3.21.2", 33 | "inversify": "^5.0.1", 34 | "json-ignore": "^0.4.0", 35 | "jsonwebtoken": "^8.5.1", 36 | "mongoose": "^5.12.3", 37 | "mongoose-autopopulate": "^0.9.1", 38 | "reflect-metadata": "^0.1.13", 39 | "statuses": "^1.5.0", 40 | "swagger-jsdoc": "^3.4.0", 41 | "swagger-ui-express": "^4.1.2", 42 | "typescript": "^3.7.2" 43 | }, 44 | "devDependencies": { 45 | "@types/bcrypt": "^3.0.0", 46 | "@types/cookie-parser": "^1.4.2", 47 | "@types/cors": "^2.8.6", 48 | "@types/express": "^4.17.2", 49 | "@types/helmet": "0.0.45", 50 | "@types/jest": "^24.0.23", 51 | "@types/jsonwebtoken": "^8.3.5", 52 | "@types/mongoose": "^5.5.32", 53 | "@types/node": "^12.12.9", 54 | "@types/statuses": "^1.5.0", 55 | "jest": "^26.6.3", 56 | "ts-jest": "^26.5.4", 57 | "ts-node-dev": "^1.1.6" 58 | }, 59 | "jest": { 60 | "preset": "ts-jest", 61 | "testEnvironment": "node", 62 | "setupFilesAfterEnv": [ 63 | "reflect-metadata" 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.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 | # Git 13 | *.orig 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | 81 | # next.js build output 82 | .next 83 | 84 | # nuxt.js build output 85 | .nuxt 86 | 87 | # gatsby files 88 | .cache/ 89 | public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # compiled output 107 | /dist 108 | /tmp 109 | /out-tsc 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tsexpress 2 | 3 | ## Repository contains 4 | 5 | * NodeJS 6 | * Express 7 | * TypeScript 8 | * ts-node-dev 9 | * InversifyJS 10 | * MongoDB with mongoose 11 | * dotenv and envalid 12 | * cors and helmet 13 | * cookie-parser and json-ignore 14 | * Swagger and SwaggerUI 15 | * express-validator, class-transformer, class-validator 16 | * express-promise-router 17 | * bcrypt and jsonwebtoken 18 | * jest (unit tests) 19 | 20 | ## What I like in this solution 21 | 22 | * DI container (by InversifyJS) and injection of dependencies 23 | * modularity - build at the top of SOLID rules, lowly coupled 24 | * unit tests (with jest) 25 | 26 | ## Start 27 | 28 | ### Start MongoDB in Docker 29 | 30 | Create volume to persist data. 31 | 32 | ```docker 33 | docker volume create --name=mongodata 34 | ``` 35 | 36 | Run container. 37 | 38 | ```docker 39 | docker run --name mongodb -v mongodata:/data/db -d -p 27017:27017 mongo:latest 40 | ``` 41 | 42 | ### Configuration (environment variables) 43 | 44 | Create `.env` file in the root directory as follows: 45 | 46 | ```ini 47 | MONGO_USER= 48 | MONGO_PASSWORD= 49 | MONGO_HOST=localhost 50 | MONGO_PORT=27017 51 | MONGO_DATABASE=libraryDB 52 | APPLICATION_PORT=5000 53 | DEBUG=true 54 | TOKEN_EXPIRATION_IN_MIN=15 55 | ``` 56 | 57 | ### Instal dependencies 58 | 59 | Install dependencies executing `npm install`. 60 | 61 | ### Start the application 62 | 63 | Type `npm run dev` in terminal, the application will be available under `http://localhost:{APPLICATION_PORT}/` where `APPLICATION_PORT` is environment variable. 64 | By default is . 65 | 66 | To interact with API you can either send requests with [Postman](https://www.getpostman.com/) or send exemplary 67 | request from [http queries](server-queries.http) file with [REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) 68 | which is VSCode extension. 69 | If you choose the second option, then remember to double check your `PORT` value from `.env` configuration file with `@apiUrl` variable. 70 | 71 | Swagger documentation is under , but there is not much. 72 | -------------------------------------------------------------------------------- /src/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { BcryptWrapper } from './../wrappers/bcrypt.wrapper'; 2 | import { User } from './../models/user.model'; 3 | import { UserResponseDto } from './../dtos/auth/user.response.dto'; 4 | import { LoginResult } from './token/token'; 5 | import { TokenService } from './token/token.service'; 6 | import { LoginRequestDto } from '../dtos/auth/login.request.dto'; 7 | import { UsersRepository } from './../repositories/users.repository'; 8 | import { RegisterRequestDto } from '../dtos/auth/register.request.dto'; 9 | import { injectable, inject } from 'inversify'; 10 | 11 | export enum RegisterResult { 12 | Success = 'Success', 13 | EmailTaken = 'Email already taken', 14 | } 15 | 16 | @injectable() 17 | export class AuthService { 18 | private readonly salt = 10; 19 | 20 | @inject(UsersRepository) private readonly repo: UsersRepository; 21 | @inject(TokenService) private readonly tokenService: TokenService; 22 | @inject(BcryptWrapper) private readonly bcrypt: BcryptWrapper; 23 | 24 | public async register(dto: RegisterRequestDto): Promise { 25 | const isEmailTaken = await this.repo.exists({ email: dto.email }); 26 | if (isEmailTaken) { 27 | return RegisterResult.EmailTaken; 28 | } 29 | 30 | const data = this.dtoToModel(dto); 31 | data.password = await this.bcrypt.hash(dto.password, this.salt); 32 | await this.repo.create(data); 33 | return RegisterResult.Success; 34 | } 35 | 36 | public async login(dto: LoginRequestDto): Promise { 37 | const user = await this.repo.findOne({ email: dto.email }); 38 | if (user) { 39 | const isPasswordMatch = await this.bcrypt.compare(dto.password, user.password); 40 | if (isPasswordMatch) { 41 | const token = this.tokenService.create(user); 42 | const userDto = this.modelToDto(user); 43 | return { 44 | tokenInfo: token, 45 | user: userDto, 46 | } 47 | } 48 | } 49 | return null; 50 | } 51 | 52 | private modelToDto(model: User): UserResponseDto { 53 | return new UserResponseDto({ 54 | name: model.name, 55 | email: model.email, 56 | }); 57 | } 58 | 59 | private dtoToModel(dto: RegisterRequestDto): User { 60 | return new User({ 61 | name: dto.name, 62 | email: dto.email, 63 | }); 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /src/wrappers/jwt.wrapper.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify'; 2 | import * as jwt from 'jsonwebtoken'; 3 | 4 | @injectable() 5 | export class JwtWrapper { 6 | public sign( 7 | payload: string | Buffer | object, 8 | secretOrPrivateKey: Secret, 9 | options?: SignOptions, 10 | ): string { 11 | return jwt.sign(payload, secretOrPrivateKey, options); 12 | } 13 | 14 | public verify( 15 | token: string, 16 | secretOrPublicKey: Secret, 17 | options?: VerifyOptions 18 | ): object | string { 19 | return jwt.verify(token, secretOrPublicKey, options); 20 | } 21 | } 22 | 23 | export type Secret = 24 | | string 25 | | Buffer 26 | | { key: string | Buffer; passphrase: string }; 27 | 28 | export interface SignOptions { 29 | /** 30 | * Signature algorithm. Could be one of these values : 31 | * - HS256: HMAC using SHA-256 hash algorithm (default) 32 | * - HS384: HMAC using SHA-384 hash algorithm 33 | * - HS512: HMAC using SHA-512 hash algorithm 34 | * - RS256: RSASSA using SHA-256 hash algorithm 35 | * - RS384: RSASSA using SHA-384 hash algorithm 36 | * - RS512: RSASSA using SHA-512 hash algorithm 37 | * - ES256: ECDSA using P-256 curve and SHA-256 hash algorithm 38 | * - ES384: ECDSA using P-384 curve and SHA-384 hash algorithm 39 | * - ES512: ECDSA using P-521 curve and SHA-512 hash algorithm 40 | * - none: No digital signature or MAC value included 41 | */ 42 | algorithm?: string; 43 | keyid?: string; 44 | /** expressed in seconds or a string describing a time span [zeit/ms](https://github.com/zeit/ms.js). Eg: 60, "2 days", "10h", "7d" */ 45 | expiresIn?: string | number; 46 | /** expressed in seconds or a string describing a time span [zeit/ms](https://github.com/zeit/ms.js). Eg: 60, "2 days", "10h", "7d" */ 47 | notBefore?: string | number; 48 | audience?: string | string[]; 49 | subject?: string; 50 | issuer?: string; 51 | jwtid?: string; 52 | mutatePayload?: boolean; 53 | noTimestamp?: boolean; 54 | header?: object; 55 | encoding?: string; 56 | }; 57 | 58 | export interface VerifyOptions { 59 | algorithms?: string[]; 60 | audience?: string | RegExp | Array; 61 | clockTimestamp?: number; 62 | clockTolerance?: number; 63 | complete?: boolean; 64 | issuer?: string | string[]; 65 | ignoreExpiration?: boolean; 66 | ignoreNotBefore?: boolean; 67 | jwtid?: string; 68 | nonce?: string; 69 | subject?: string; 70 | /** 71 | * @deprecated 72 | * Max age of token 73 | */ 74 | maxAge?: string; 75 | } 76 | -------------------------------------------------------------------------------- /server-queries.http: -------------------------------------------------------------------------------- 1 | @apiUrl = http://localhost:5000/api 2 | 3 | # ================= AUTH ======================= # 4 | 5 | ### LOGIN 6 | POST {{apiUrl}}/auth/login 7 | content-type: application/json 8 | 9 | { 10 | "email": "sample.email@address.pl", 11 | "password": "wiezA123" 12 | } 13 | 14 | ### REGISTER 15 | POST {{apiUrl}}/auth/register 16 | content-type: application/json 17 | 18 | { 19 | "name": "Łukasz K.", 20 | "email": "sample.email@address.pl", 21 | "password": "wiezA123" 22 | } 23 | 24 | ### LOGOUT 25 | POST {{apiUrl}}/auth/logout 26 | 27 | # ================= PARTNERS ======================= # 28 | 29 | ### GET all 30 | @deletedPartners = 0 31 | // @name partners 32 | GET {{apiUrl}}/partners?withDeleted={{deletedPartners}} 33 | 34 | ### GET one 35 | // @name partner 36 | GET {{apiUrl}}/partners/{{partners.response.body.$.[0].id}} 37 | 38 | ### CREATE 39 | // @name partner 40 | POST {{apiUrl}}/partners 41 | content-type: application/json 42 | 43 | { 44 | "name": "ABC Company", 45 | "taxNumber": "78912311222", 46 | "address": { 47 | "street": "Some street", 48 | "city": "Some city" 49 | } 50 | } 51 | 52 | ### UPDATE 53 | // @name partner 54 | PUT {{apiUrl}}/partners/{{partners.response.body.$.[0].id}} 55 | content-type: application/json 56 | 57 | { 58 | "name": "ABC Company {{$randomInt 1 20}}", 59 | "taxNumber": "78912311221" 60 | } 61 | 62 | ### DELETE 63 | DELETE {{apiUrl}}/partners/{{partner.response.body.$.id}} 64 | 65 | # ================= INVOICES ======================= # 66 | 67 | ### GET all 68 | @deletedInvoices = 1 69 | // @name invoices 70 | GET {{apiUrl}}/invoices?withDeleted={{deletedInvoices}} 71 | 72 | ### GET one 73 | // @name invoice 74 | GET {{apiUrl}}/invoices/{{invoices.response.body.$.[0].id}} 75 | 76 | ### CREATE 77 | // @name invoice 78 | POST {{apiUrl}}/invoices 79 | content-type: application/json 80 | 81 | { 82 | "number": "01/12/2019", 83 | "invoiceDate": "{{$datetime iso8601}}", 84 | "paymentDate": "{{$datetime iso8601 10 d}}", 85 | "partnerId": "{{partner.response.body.$.id}}", 86 | "invoiceItems": [ 87 | { 88 | "name": "item 1", 89 | "quantity": {{$randomInt 1 6}}, 90 | "unitPrice": "{{$randomInt 5 55}}.{{$randomInt 0 100}}" 91 | }, 92 | { 93 | "name": "item 2", 94 | "quantity": {{$randomInt 2 10}}, 95 | "unitPrice": "{{$randomInt 1 35}}.{{$randomInt 0 100}}" 96 | } 97 | ] 98 | } 99 | 100 | ### DELETE 101 | DELETE {{apiUrl}}/invoices/{{invoice.response.body.$.id}} 102 | -------------------------------------------------------------------------------- /src/configurations/app.config.ts: -------------------------------------------------------------------------------- 1 | import { DevError } from './../errors/dev.error'; 2 | import { cleanEnv, str, port, host, bool, num } from 'envalid'; 3 | import { injectable } from 'inversify'; 4 | import { isNullOrWhitespace } from './../helpers/string.helper'; 5 | 6 | @injectable() 7 | export class AppConfig { 8 | public readonly sourcePath: string = './src'; 9 | public readonly apiPath: string = '/api'; 10 | 11 | private _mongoUser: string; 12 | public get mongoUser(): string { 13 | return this._mongoUser; 14 | } 15 | 16 | private _mongoPassword: string; 17 | public get mongoPassword(): string { 18 | return this._mongoPassword; 19 | } 20 | 21 | private _mongoHost: string; 22 | public get mongoHost(): string { 23 | return this._mongoHost; 24 | } 25 | 26 | private _mongoPort: number; 27 | public get mongoPort(): number { 28 | return this._mongoPort; 29 | } 30 | 31 | private _mongoDatabase: string; 32 | public get mongoDatabase(): string { 33 | return this._mongoDatabase; 34 | } 35 | 36 | private _applicationPort: number; 37 | public get applicationPort(): number { 38 | return this._applicationPort; 39 | } 40 | 41 | private _applicationHost: string; 42 | public get applicationHost(): string { 43 | return this._applicationHost; 44 | } 45 | 46 | private _debug: boolean; 47 | public get debug(): boolean { 48 | return this._debug; 49 | } 50 | 51 | private _tokenExpirationInMin: number; 52 | public get tokenExpirationInMin(): number { 53 | return this._tokenExpirationInMin; 54 | } 55 | 56 | public setApplicationHost(host: string) { 57 | if (!isNullOrWhitespace(this._applicationHost)) { 58 | throw new DevError(`Variable 'applicationHost' already set-up: '${this._applicationHost}'`); 59 | } 60 | this._applicationHost = host === '::' ? 'localhost' : host; 61 | } 62 | 63 | public initialize(processEnv: NodeJS.ProcessEnv) { 64 | const env = cleanEnv(processEnv, { 65 | MONGO_USER: str({ example: 'lkurzyniec', devDefault: '' }), 66 | MONGO_PASSWORD: str({ example: 'someSTRONGpwd123', devDefault: '' }), 67 | MONGO_HOST: host({ devDefault: 'localhost', example: 'mongodb0.example.com' }), 68 | MONGO_PORT: port({ default: 27017 }), 69 | MONGO_DATABASE: str({ default: 'libraryDB' }), 70 | APPLICATION_PORT: port({ devDefault: 5000, desc: 'Port number on which the Application will run' }), 71 | DEBUG: bool({ default: false, devDefault: true }), 72 | TOKEN_EXPIRATION_IN_MIN: num({ default: 15, devDefault: 60 }), 73 | }); 74 | 75 | this._mongoUser = env.MONGO_USER; 76 | this._mongoPassword = env.MONGO_PASSWORD; 77 | this._mongoHost = env.MONGO_HOST; 78 | this._mongoPort = env.MONGO_PORT; 79 | this._mongoDatabase = env.MONGO_DATABASE; 80 | this._applicationPort = env.APPLICATION_PORT; 81 | this._debug = env.DEBUG; 82 | this._tokenExpirationInMin = env.TOKEN_EXPIRATION_IN_MIN; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/services/partners.service.ts: -------------------------------------------------------------------------------- 1 | import { User } from './../models/user.model'; 2 | import { Partner } from './../models/partner.model'; 3 | import { PartnersRepository } from './../repositories/partners.repository'; 4 | import { inject, injectable } from 'inversify'; 5 | import { PartnerResponseDto, PartnerRequestDto } from './../dtos/partner/partner.dto'; 6 | import { AddressResponseDto } from './../dtos/address/address.dto'; 7 | import { BaseService } from './base.service'; 8 | import { Address } from './../models/address.model'; 9 | 10 | @injectable() 11 | export class PartnersService extends BaseService { 12 | @inject(PartnersRepository) protected readonly repo: PartnersRepository; 13 | 14 | public async getAll(user: string, withDeleted: boolean): Promise { 15 | let query = { 16 | user, 17 | } as any; 18 | if (!withDeleted) { 19 | query = { 20 | user, 21 | deleted: false, 22 | }; 23 | } 24 | 25 | const data = await this.repo.getAll(query, { name: 'asc' }); 26 | const result = data.map((item) => this.modelToDto(item)); 27 | return result; 28 | } 29 | 30 | public async findById(id: string, user: string): Promise { 31 | const data = await this.repo.findById(id); 32 | if (data && data.user == user) { 33 | return this.modelToDto(data); 34 | } 35 | return null; 36 | } 37 | 38 | public async create(dto: PartnerRequestDto, userId: string): Promise { 39 | let model = this.dtoToModel(dto); 40 | model.user = new User({ 41 | _id: userId 42 | }); 43 | 44 | model = await this.repo.create(model); 45 | const result = this.modelToDto(model); 46 | return result; 47 | } 48 | 49 | public async update(id: string, dto: PartnerRequestDto, user: string): Promise { 50 | const exist = await this.repo.exists({ _id: id, user }); 51 | if (!exist) { 52 | return null; 53 | } 54 | 55 | let model = this.dtoToModel(dto); 56 | model = await this.repo.update(id, model); 57 | return this.modelToDto(model); 58 | } 59 | 60 | public async delete(id: string, user: string): Promise { 61 | const exist = await this.repo.exists({ _id: id, user }); 62 | if (!exist) { 63 | return null; 64 | } 65 | 66 | let model = new Partner({ 67 | _id: id, 68 | deleted: true, 69 | }); 70 | model = await this.repo.update(id, model); 71 | return !!model; 72 | } 73 | 74 | protected modelToDto(model: Partner): PartnerResponseDto { 75 | return new PartnerResponseDto({ 76 | id: model._id, 77 | name: model.name, 78 | taxNumber: model.taxNumber, 79 | deleted: model.deleted, 80 | 81 | address: model.address ? new AddressResponseDto({ 82 | street: model.address.street, 83 | city: model.address.city, 84 | }) : null, 85 | }); 86 | } 87 | 88 | protected dtoToModel(dto: PartnerRequestDto): Partner { 89 | return new Partner({ 90 | name: dto.name, 91 | taxNumber: dto.taxNumber, 92 | 93 | address: dto.address ? new Address({ 94 | street: dto.address.street, 95 | city: dto.address.city, 96 | }) : null, 97 | }); 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /src/controllers/invoices.controller.ts: -------------------------------------------------------------------------------- 1 | import { PartnersService } from './../services/partners.service'; 2 | import { InvoiceRequestDto } from './../dtos/invoice/invoice.dto'; 3 | import { InvoicesService } from './../services/invoices.service'; 4 | import { ValidationError, ValidationErrorPlace } from '../errors/validation.error'; 5 | import { AuthRequest } from '../interfaces/auth.request'; 6 | import { injectable, inject } from 'inversify'; 7 | import { BaseController } from './base.controller'; 8 | import { Response } from "express"; 9 | import { StatusHelper } from '../helpers/status.helper'; 10 | import { isNullOrWhitespace } from '../helpers/string.helper'; 11 | import { BodyRequest } from './../interfaces/body.request'; 12 | import { DtoValidator } from './../decorators/dto-validator.decorator'; 13 | import { IdValidator } from './../decorators/id-validator.decorator'; 14 | 15 | @injectable() 16 | export class InvoicesController extends BaseController { 17 | @inject(InvoicesService) private readonly service: InvoicesService; 18 | @inject(PartnersService) private readonly partnersService: PartnersService; 19 | 20 | constructor() { 21 | super('/invoices'); 22 | } 23 | 24 | public initializeRoutes(): void { 25 | this.router 26 | .get(this.path, this.getAll.bind(this)) 27 | .get(`${this.path}/:id`, this.getById.bind(this)) 28 | .post(this.path, this.create.bind(this)) 29 | .delete(`${this.path}/:id`, this.delete.bind(this)); 30 | } 31 | 32 | private async getAll(request: AuthRequest, response: Response) { 33 | const withDeleted = this.getBoolFromQueryParams(request, 'withDeleted'); 34 | const data = await this.service.getAll(request.auth.userId, withDeleted); 35 | response.send(data); 36 | } 37 | 38 | @IdValidator() 39 | private async getById(request: AuthRequest, response: Response) { 40 | const id = request.params.id; 41 | 42 | const data = await this.service.findById(id, request.auth.userId); 43 | if (data) { 44 | response.send(data); 45 | } else { 46 | throw StatusHelper.error404NotFound; 47 | } 48 | } 49 | 50 | @DtoValidator(InvoiceRequestDto) 51 | private async create(request: BodyRequest, response: Response) { 52 | const dto = request.body; 53 | 54 | const uniqueError = await this.service.isUnique(['number'], dto, request.auth.userId); 55 | if (!isNullOrWhitespace(uniqueError)) { 56 | throw new ValidationError(ValidationErrorPlace.Body, uniqueError); 57 | } 58 | 59 | const partner = await this.partnersService.findById(dto.partnerId, request.auth.userId); 60 | if (!partner) { 61 | throw new ValidationError(ValidationErrorPlace.Body, 'partner does not exists'); 62 | } 63 | if (partner.deleted) { 64 | throw new ValidationError(ValidationErrorPlace.Body, 'partner deleted'); 65 | } 66 | 67 | const data = await this.service.create(dto, request.auth.userId); 68 | response 69 | .location(`${this.path}/${data.id}`) 70 | .status(StatusHelper.status201Created) 71 | .send(data); 72 | } 73 | 74 | @IdValidator() 75 | private async delete(request: AuthRequest, response: Response) { 76 | const id = request.params.id; 77 | 78 | const deleted = await this.service.delete(id, request.auth.userId); 79 | if (deleted) { 80 | response.sendStatus(StatusHelper.status204NoContent); 81 | } else { 82 | throw StatusHelper.error404NotFound; 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/controllers/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { AppConfig } from './../configurations/app.config'; 2 | import { ValidationError, ValidationErrorPlace } from './../errors/validation.error'; 3 | import { AuthService, RegisterResult } from './../services/auth.service'; 4 | import { RegisterRequestDto } from '../dtos/auth/register.request.dto'; 5 | import { LoginRequestDto } from '../dtos/auth/login.request.dto'; 6 | import { injectable, inject } from 'inversify'; 7 | import { BaseController } from './base.controller'; 8 | import { Request, Response } from "express"; 9 | import { StatusHelper } from '../helpers/status.helper'; 10 | import { BodyRequest } from './../interfaces/body.request'; 11 | import { DtoValidator } from './../decorators/dto-validator.decorator'; 12 | 13 | @injectable() 14 | export class AuthController extends BaseController { 15 | @inject(AuthService) private readonly auth: AuthService; 16 | @inject(AppConfig) private readonly appConfig: AppConfig; 17 | 18 | constructor() { 19 | super('/auth', false); 20 | } 21 | 22 | public initializeRoutes(): void { 23 | this.router 24 | .post(`${this.path}/register`, this.register.bind(this)) 25 | .post(`${this.path}/login`, this.login.bind(this)) 26 | .post(`${this.path}/logout`, this.logout.bind(this)); 27 | } 28 | 29 | @DtoValidator(RegisterRequestDto) 30 | private async register(request: BodyRequest, response: Response) { 31 | response.setHeader('Set-Cookie', 'Authorization=; Max-Age=0'); 32 | 33 | const dto = request.body; 34 | 35 | const result = await this.auth.register(dto); 36 | if (result === RegisterResult.Success) { 37 | response.sendStatus(StatusHelper.status204NoContent); 38 | return; 39 | } 40 | 41 | throw new ValidationError(ValidationErrorPlace.Body, result); 42 | } 43 | 44 | /** 45 | * @swagger 46 | * /auth/login/: 47 | * post: 48 | * tags: 49 | * - auth 50 | * description: Login user 51 | * produces: 52 | * - application/json 53 | * parameters: 54 | * - in: body 55 | * name: body 56 | * description: Login data (email and password) 57 | * required: true 58 | * schema: 59 | * $ref: '#/definitions/LoginRequestDto' 60 | * responses: 61 | * 200: 62 | * description: Information of logged user 63 | * 401: 64 | * description: Wrong login data 65 | */ 66 | @DtoValidator(LoginRequestDto) 67 | private async login(request: BodyRequest, response: Response) { 68 | const dto = request.body; 69 | 70 | const loginResult = await this.auth.login(dto); 71 | if (loginResult) { 72 | response.setHeader('Set-Cookie', `Authorization=${loginResult.tokenInfo.token}; HttpOnly; Max-Age=${loginResult.tokenInfo.expiresIn}; Path=${this.appConfig.apiPath}`); 73 | response.send(loginResult.user); 74 | return; 75 | } 76 | 77 | throw StatusHelper.error401Unauthorized; 78 | } 79 | 80 | /** 81 | * @swagger 82 | * /auth/logout/: 83 | * post: 84 | * tags: 85 | * - auth 86 | * summary: Logout currently logged user 87 | * responses: 88 | * 204: 89 | * description: Successfully logged out 90 | */ 91 | private async logout(request: Request, response: Response) { 92 | response.setHeader('Set-Cookie', 'Authorization=; Max-Age=0'); 93 | response.sendStatus(StatusHelper.status204NoContent); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/controllers/partners.controller.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError, ValidationErrorPlace } from './../errors/validation.error'; 2 | import { AuthRequest } from '../interfaces/auth.request'; 3 | import { PartnersService } from './../services/partners.service'; 4 | import { PartnerRequestDto } from './../dtos/partner/partner.dto'; 5 | import { injectable, inject } from 'inversify'; 6 | import { BaseController } from './base.controller'; 7 | import { Response } from "express"; 8 | import { StatusHelper } from './../helpers/status.helper'; 9 | import { isNullOrWhitespace } from './../helpers/string.helper'; 10 | import { BodyRequest } from './../interfaces/body.request'; 11 | import { DtoValidator } from './../decorators/dto-validator.decorator'; 12 | import { IdValidator } from './../decorators/id-validator.decorator'; 13 | 14 | @injectable() 15 | export class PartnersController extends BaseController { 16 | @inject(PartnersService) private readonly service: PartnersService; 17 | 18 | constructor() { 19 | super('/partners'); 20 | } 21 | 22 | public initializeRoutes(): void { 23 | this.router 24 | .get(this.path, this.getAll.bind(this)) 25 | .get(`${this.path}/:id`, this.getById.bind(this)) 26 | .post(this.path, this.create.bind(this)) 27 | .put(`${this.path}/:id`, this.update.bind(this)) 28 | .delete(`${this.path}/:id`, this.delete.bind(this)); 29 | } 30 | 31 | private async getAll(request: AuthRequest, response: Response) { 32 | const withDeleted = this.getBoolFromQueryParams(request, 'withDeleted'); 33 | const data = await this.service.getAll(request.auth.userId, withDeleted); 34 | response.send(data); 35 | } 36 | 37 | @IdValidator() 38 | private async getById(request: AuthRequest, response: Response) { 39 | const id = request.params.id; 40 | const data = await this.service.findById(id, request.auth.userId); 41 | if (data) { 42 | response.send(data); 43 | } else { 44 | throw StatusHelper.error404NotFound; 45 | } 46 | } 47 | 48 | @DtoValidator(PartnerRequestDto) 49 | private async create(request: BodyRequest, response: Response) { 50 | const dto = request.body; 51 | 52 | const uniqueError = await this.service.isUnique(['name', 'taxNumber'], dto, request.auth.userId); 53 | if (!isNullOrWhitespace(uniqueError)) { 54 | throw new ValidationError(ValidationErrorPlace.Body, uniqueError); 55 | } 56 | 57 | const data = await this.service.create(dto, request.auth.userId); 58 | response 59 | .location(`${this.path}/${data.id}`) 60 | .status(StatusHelper.status201Created) 61 | .send(data); 62 | } 63 | 64 | @IdValidator() 65 | @DtoValidator(PartnerRequestDto) 66 | private async update(request: BodyRequest, response: Response) { 67 | const id = request.params.id; 68 | const dto = request.body; 69 | 70 | const uniqueError = await this.service.isUnique(['name', 'taxNumber'], dto, request.auth.userId, id); 71 | if (!isNullOrWhitespace(uniqueError)) { 72 | throw new ValidationError(ValidationErrorPlace.Body, uniqueError); 73 | } 74 | 75 | const data = await this.service.update(id, dto, request.auth.userId); 76 | if (data) { 77 | response.send(data); 78 | } else { 79 | throw StatusHelper.error404NotFound; 80 | } 81 | } 82 | 83 | @IdValidator() 84 | private async delete(request: AuthRequest, response: Response) { 85 | const id = request.params.id; 86 | const deleted = await this.service.delete(id, request.auth.userId); 87 | if (deleted) { 88 | response.sendStatus(StatusHelper.status204NoContent); 89 | } else { 90 | throw StatusHelper.error404NotFound; 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { RequestLoggerMiddleware } from './middlewares/request-logger.middleware'; 2 | import { ResponseLoggerMiddleware } from './middlewares/response-logger.middleware'; 3 | import { AppLogger } from './loggers/app.logger'; 4 | import { MongoDbConnector } from './connectors/mongodb.connector'; 5 | import { DevError } from './errors/dev.error'; 6 | import { AppConfig } from './configurations/app.config'; 7 | import { BaseController } from './controllers/base.controller'; 8 | import { ErrorMiddleware } from './middlewares/error.middleware'; 9 | import { SwaggerConfig } from './configurations/swagger.config'; 10 | import * as cookieParser from 'cookie-parser'; 11 | import * as express from 'express'; 12 | import * as helmet from 'helmet'; 13 | import * as cors from 'cors'; 14 | import { jsonIgnoreReplacer } from 'json-ignore'; 15 | import { injectable, inject, multiInject } from 'inversify'; 16 | import { AddressInfo } from 'net'; 17 | 18 | @injectable() 19 | export class App { 20 | private app: express.Application = express(); 21 | private isInitialized: boolean = false; 22 | 23 | @inject(AppConfig) private readonly appConfig: AppConfig; 24 | @multiInject(BaseController) private controllers: BaseController[]; 25 | @inject(MongoDbConnector) private readonly dbConnector: MongoDbConnector; 26 | @inject(SwaggerConfig) private readonly swaggerConfig: SwaggerConfig; 27 | @inject(AppLogger) private readonly appLogger: AppLogger; 28 | @inject(ErrorMiddleware) private readonly errorMiddleware: ErrorMiddleware; 29 | @inject(RequestLoggerMiddleware) private readonly requestLoggerMiddleware: RequestLoggerMiddleware; 30 | @inject(ResponseLoggerMiddleware) private readonly responseLoggerMiddleware: ResponseLoggerMiddleware; 31 | 32 | public initialize(process: NodeJS.Process): void { 33 | this.appConfig.initialize(process.env); 34 | 35 | this.dbConnector.connect(); 36 | 37 | this.setExpressSettings(); 38 | this.initializePreMiddlewares(); 39 | this.initializeControllers(); 40 | this.initializePostMiddlewares(); 41 | 42 | this.isInitialized = true; 43 | } 44 | 45 | public listen() { 46 | if (!this.isInitialized) { 47 | throw new DevError('Call initialize() before.'); 48 | } 49 | 50 | const server = this.app.listen(this.appConfig.applicationPort, () => { 51 | const addressInfo = server.address() as AddressInfo; 52 | this.appConfig.setApplicationHost(addressInfo.address); 53 | 54 | this.swaggerConfig.initialize(this.app); 55 | 56 | this.appLogger.info(`Listening at 'http://${this.appConfig.applicationHost}:${this.appConfig.applicationPort}'.`); 57 | }); 58 | } 59 | 60 | private setExpressSettings(): void { 61 | this.app.set('json replacer', jsonIgnoreReplacer); 62 | } 63 | 64 | private initializePreMiddlewares(): void { 65 | this.app.use(helmet()); 66 | this.app.use(cors()); 67 | this.app.use(cookieParser()); 68 | this.app.use(express.json()); 69 | 70 | this.app.use(this.requestLoggerMiddleware.handle.bind(this.requestLoggerMiddleware)); 71 | this.app.use(this.responseLoggerMiddleware.handle.bind(this.responseLoggerMiddleware)); 72 | } 73 | 74 | private initializeControllers(): void { 75 | this.app.get('/', (req, res) => { 76 | res.redirect('/swagger'); 77 | }); 78 | 79 | this.controllers.forEach((controller: BaseController) => { 80 | controller.initializeRoutes(); 81 | this.app.use(this.appConfig.apiPath, controller.router); 82 | this.appLogger.debug(`Registered '${this.appConfig.apiPath}${controller.path}'.`); 83 | }); 84 | } 85 | 86 | private initializePostMiddlewares(): void { 87 | this.app.use(this.errorMiddleware.handle.bind(this.errorMiddleware)); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /test/unit/services/token/token.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { SecretsProvider } from './../../../../src/services/token/secrets.provider'; 2 | import { JwtWrapper } from './../../../../src/wrappers/jwt.wrapper'; 3 | import { TokenService } from './../../../../src/services/token/token.service'; 4 | import { AppConfig } from './../../../../src/configurations/app.config'; 5 | import { TestContext } from './../../test.context'; 6 | 7 | describe('TokenService', () => { 8 | const testContext: TestContext = new TestContext(); 9 | 10 | describe('create', () => { 11 | test('create action Should return expected data (token and expiration)', () => { 12 | //given 13 | const token = 'TEST TOKEN', 14 | privateKey = 'TEST KEY'; 15 | const expirationInMin = 1, 16 | expirationTime = expirationInMin * 60; 17 | 18 | const user = { 19 | _id: 'SOME TEST ID', 20 | name: 'John Smith', 21 | email: 'test@test.pl', 22 | }; 23 | 24 | testContext.mock(() => ({ 25 | tokenExpirationInMin: expirationInMin, 26 | }), AppConfig); 27 | 28 | testContext.mock(() => ({ 29 | privateKey: privateKey, 30 | }), SecretsProvider); 31 | 32 | const jwtWrapperMock = testContext.mock(() => ({ 33 | sign: jest.fn(() => token), 34 | }), JwtWrapper); 35 | 36 | //when 37 | var result = testContext.get(TokenService).create(user as any); 38 | 39 | //then 40 | expect(result.token) 41 | .toEqual(token); 42 | expect(result.expiresIn) 43 | .toEqual(expirationTime); 44 | 45 | expect(jwtWrapperMock.sign) 46 | .toBeCalledWith( 47 | expect.objectContaining({ 48 | userId: user._id, 49 | email: user.email, 50 | name: user.name, 51 | }), 52 | privateKey, 53 | expect.anything(), 54 | ); 55 | }); 56 | }); 57 | 58 | describe('verify', () => { 59 | test('When token successfully verified Then should return token data', () => { 60 | //given 61 | const token = 'TEST TOKEN', 62 | publicKey = 'TEST KEY public'; 63 | const userId = 'SOME USER ID'; 64 | 65 | testContext.mock(() => ({ 66 | debug: false, 67 | }), AppConfig); 68 | 69 | testContext.mock(() => ({ 70 | publicKey: publicKey, 71 | }), SecretsProvider); 72 | 73 | const jwtWrapperMock = testContext.mock(() => ({ 74 | verify: jest.fn(() => ({ userId })), 75 | }), JwtWrapper); 76 | 77 | //when 78 | var result = testContext.get(TokenService).verify(token); 79 | 80 | //then 81 | expect(result.userId) 82 | .toEqual(userId); 83 | 84 | expect(jwtWrapperMock.verify) 85 | .toBeCalledWith(token, publicKey, expect.anything()); 86 | }); 87 | 88 | test('When token is wrong Then should return null', () => { 89 | //given 90 | testContext.mock(() => ({ 91 | debug: false, 92 | }), AppConfig); 93 | 94 | // testContext.mock(() => ({ 95 | // publicKey: '', 96 | // }), SecretsProvider); 97 | 98 | const jwtWrapperMock = testContext.mock(() => ({ 99 | verify: jest.fn(() => { throw new Error('some unit test error msg') }), 100 | }), JwtWrapper); 101 | 102 | //when 103 | var result = testContext.get(TokenService).verify('any token'); 104 | 105 | //then 106 | expect(result) 107 | .toBeNull(); 108 | 109 | expect(jwtWrapperMock.verify) 110 | .toBeCalled(); 111 | }); 112 | }); 113 | }) 114 | -------------------------------------------------------------------------------- /src/services/invoices.service.ts: -------------------------------------------------------------------------------- 1 | import { InvoiceStatus } from './../enums/invoice.status.enum'; 2 | import { InvoiceItem } from './../models/invoice-item.model'; 3 | import { InvoiceItemResponseDto } from './../dtos/invoice/invoice-item.dto'; 4 | import { AddressResponseDto } from './../dtos/address/address.dto'; 5 | import { Partner } from './../models/partner.model'; 6 | import { PartnerResponseDto } from './../dtos/partner/partner.dto'; 7 | import { InvoiceResponseDto, InvoiceRequestDto } from './../dtos/invoice/invoice.dto'; 8 | import { Invoice } from './../models/invoice.model'; 9 | import { InvoicesRepository } from './../repositories/invoices.repository'; 10 | import { User } from '../models/user.model'; 11 | import { inject, injectable } from 'inversify'; 12 | import { BaseService } from './base.service'; 13 | 14 | @injectable() 15 | export class InvoicesService extends BaseService { 16 | @inject(InvoicesRepository) protected readonly repo: InvoicesRepository; 17 | 18 | public async getAll(user: string, withDeleted: boolean): Promise { 19 | let query = { 20 | user, 21 | } as any; 22 | if (!withDeleted) { 23 | query = { 24 | user, 25 | status: { $ne: InvoiceStatus.Deleted as number } 26 | } 27 | } 28 | 29 | const data = await this.repo.getAll(query, { invoiceDate: 'desc' }); 30 | const result = data.map((item) => this.modelToDto(item)); 31 | return result; 32 | } 33 | 34 | public async findById(id: string, user: string): Promise { 35 | const data = await this.repo.findById(id); 36 | if (data && data.user == user) { 37 | return this.modelToDto(data); 38 | } 39 | return null; 40 | } 41 | 42 | public async create(dto: InvoiceRequestDto, userId: string): Promise { 43 | let model = this.dtoToModel(dto); 44 | model.status = InvoiceStatus.Created; 45 | model.user = new User({ 46 | _id: userId 47 | }); 48 | 49 | model = await this.repo.create(model); 50 | const result = this.modelToDto(model); 51 | return result; 52 | } 53 | 54 | public async delete(id: string, user: string): Promise { 55 | const exist = await this.repo.exists({ _id: id, user }); 56 | if (!exist) { 57 | return null; 58 | } 59 | 60 | let model = new Invoice({ 61 | _id: id, 62 | status: InvoiceStatus.Deleted, 63 | }); 64 | model = await this.repo.update(id, model); 65 | return !!model; 66 | } 67 | 68 | protected modelToDto(model: Invoice): InvoiceResponseDto { 69 | const dto = new InvoiceResponseDto({ 70 | id: model._id, 71 | number: model.number, 72 | invoiceDate: model.invoiceDate, 73 | paymentDate: model.paymentDate, 74 | status: model.status, 75 | 76 | invoiceItems: model.invoiceItems.map(item => (new InvoiceItemResponseDto({ 77 | name: item.name, 78 | quantity: item.quantity, 79 | unitPrice: item.unitPrice, 80 | }))), 81 | }); 82 | 83 | const partner = model.partner as Partner; 84 | dto.partner = new PartnerResponseDto({ 85 | id: partner._id, 86 | name: partner.name, 87 | taxNumber: partner.taxNumber, 88 | address: partner.address ? new AddressResponseDto({ 89 | street: partner.address.street, 90 | city: partner.address.city, 91 | }) : null, 92 | }); 93 | 94 | return dto; 95 | } 96 | 97 | protected dtoToModel(dto: InvoiceRequestDto): Invoice { 98 | return new Invoice({ 99 | number: dto.number, 100 | invoiceDate: dto.invoiceDate, 101 | paymentDate: dto.paymentDate, 102 | 103 | invoiceItems: dto.invoiceItems.map(item => (new InvoiceItem({ 104 | name: item.name, 105 | quantity: item.quantity, 106 | unitPrice: item.unitPrice, 107 | }))), 108 | 109 | partner: new Partner({ 110 | _id: dto.partnerId 111 | }) 112 | }); 113 | }; 114 | } 115 | -------------------------------------------------------------------------------- /src/configurations/inversify.config.ts: -------------------------------------------------------------------------------- 1 | import { InvoicesRepository } from './../repositories/invoices.repository'; 2 | import { InvoiceModel } from './../models/invoice.model'; 3 | import { PartnersRepository } from './../repositories/partners.repository'; 4 | import { PartnersService } from './../services/partners.service'; 5 | import { JwtWrapper } from './../wrappers/jwt.wrapper'; 6 | import { BcryptWrapper } from './../wrappers/bcrypt.wrapper'; 7 | import { AuthLogger } from './../loggers/auth.logger'; 8 | import { AuthMiddleware } from './../middlewares/auth.middleware'; 9 | import { SecretsProvider } from './../services/token/secrets.provider'; 10 | import { TokenService } from './../services/token/token.service'; 11 | import { AuthService } from './../services/auth.service'; 12 | import { UserModel } from './../models/user.model'; 13 | import { UsersRepository } from './../repositories/users.repository'; 14 | import { AuthController } from './../controllers/auth.controller'; 15 | import { ErrorExtractor } from './../helpers/error-extractor.helper'; 16 | import { ResponseLoggerMiddleware } from './../middlewares/response-logger.middleware'; 17 | import { ResponseLogger } from './../loggers/response.logger'; 18 | import { ErrorMiddleware } from './../middlewares/error.middleware'; 19 | import { RequestLoggerMiddleware } from './../middlewares/request-logger.middleware'; 20 | import { RequestLogger } from './../loggers/request.logger'; 21 | import { Container as InversifyContainer, interfaces, ContainerModule } from 'inversify'; 22 | import { AppLogger } from './../loggers/app.logger'; 23 | import { PartnerModel } from './../models/partner.model'; 24 | import { PartnersController } from './../controllers/partners.controller'; 25 | import { InvoicesController } from './../controllers/invoices.controller'; 26 | import { MongoDbConnector } from './../connectors/mongodb.connector'; 27 | import { App } from './../app'; 28 | import { AppConfig } from './app.config'; 29 | import { BaseController } from './../controllers/base.controller'; 30 | import { InvoicesService } from './../services/invoices.service'; 31 | import { SwaggerConfig } from './swagger.config'; 32 | 33 | // more info: https://github.com/inversify/InversifyJS/tree/master/wiki 34 | 35 | export class Container { 36 | private _container: InversifyContainer = new InversifyContainer(); 37 | 38 | protected get container(): InversifyContainer { 39 | return this._container; 40 | } 41 | 42 | constructor() { 43 | this.register(); 44 | } 45 | 46 | public getApp(): App { 47 | return this.container.get(App); 48 | } 49 | 50 | // https://github.com/inversify/InversifyJS/blob/master/wiki/recipes.md#injecting-dependencies-into-a-function 51 | private bindDependencies(func: Function, dependencies: any[]): Function { 52 | let injections = dependencies.map((dependency) => { 53 | return this.container.get(dependency); 54 | }); 55 | return func.bind(func, ...injections); 56 | } 57 | 58 | private register(): void { 59 | this._container.load(this.getRepositoriesModule()); 60 | this._container.load(this.getLoggersModule()); 61 | this._container.load(this.getMiddlewaresModule()); 62 | this._container.load(this.getGeneralModule()); 63 | this._container.load(this.getControllersModule()); 64 | this._container.load(this.getHelpersModule()); 65 | this._container.load(this.getServicesModule()); 66 | this._container.load(this.getWrappersModule()); 67 | 68 | this._container.bind(App).toSelf(); 69 | } 70 | 71 | private getControllersModule(): ContainerModule { 72 | return new ContainerModule((bind: interfaces.Bind) => { 73 | bind(BaseController).to(InvoicesController); 74 | bind(BaseController).to(PartnersController); 75 | bind(BaseController).to(AuthController); 76 | }); 77 | } 78 | 79 | private getServicesModule(): ContainerModule { 80 | return new ContainerModule((bind: interfaces.Bind) => { 81 | bind(AuthService).toSelf(); 82 | bind(TokenService).toSelf(); 83 | bind(PartnersService).toSelf(); 84 | bind(InvoicesService).toSelf(); 85 | }); 86 | } 87 | 88 | private getRepositoriesModule(): ContainerModule { 89 | return new ContainerModule((bind: interfaces.Bind) => { 90 | bind(PartnersRepository).toConstantValue(new PartnersRepository(PartnerModel)); 91 | bind(UsersRepository).toConstantValue(new UsersRepository(UserModel)); 92 | bind(InvoicesRepository).toConstantValue(new InvoicesRepository(InvoiceModel)); 93 | }); 94 | } 95 | 96 | private getLoggersModule(): ContainerModule { 97 | return new ContainerModule((bind: interfaces.Bind) => { 98 | bind(AppLogger).toSelf(); 99 | bind(RequestLogger).toSelf(); 100 | bind(ResponseLogger).toSelf(); 101 | bind(AuthLogger).toSelf(); 102 | }); 103 | } 104 | 105 | private getMiddlewaresModule(): ContainerModule { 106 | return new ContainerModule((bind: interfaces.Bind) => { 107 | bind(RequestLoggerMiddleware).toSelf(); 108 | bind(ErrorMiddleware).toSelf(); 109 | bind(ResponseLoggerMiddleware).toSelf(); 110 | bind(AuthMiddleware).toSelf(); 111 | }); 112 | } 113 | 114 | private getGeneralModule(): ContainerModule { 115 | return new ContainerModule((bind: interfaces.Bind) => { 116 | bind(AppConfig).toSelf().inSingletonScope(); 117 | bind(SwaggerConfig).toSelf(); 118 | bind(MongoDbConnector).toSelf(); 119 | }); 120 | } 121 | 122 | private getHelpersModule(): ContainerModule { 123 | return new ContainerModule((bind: interfaces.Bind) => { 124 | bind(ErrorExtractor).toSelf(); 125 | bind(SecretsProvider).toSelf().inSingletonScope(); 126 | }); 127 | } 128 | 129 | private getWrappersModule(): ContainerModule { 130 | return new ContainerModule((bind: interfaces.Bind) => { 131 | bind(BcryptWrapper).toSelf(); 132 | bind(JwtWrapper).toSelf(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /test/unit/services/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TokenService } from './../../../src/services/token/token.service'; 2 | import { BcryptWrapper } from './../../../src/wrappers/bcrypt.wrapper'; 3 | import { TestContext } from './../test.context'; 4 | import { LoginRequestDto } from './../../../src/dtos/auth/login.request.dto'; 5 | import { RegisterRequestDto } from './../../../src/dtos/auth/register.request.dto'; 6 | import { UsersRepository } from './../../../src/repositories/users.repository'; 7 | import { AuthService, RegisterResult } from './../../../src/services/auth.service'; 8 | import { TokenInfo } from './../../../src/services/token/token'; 9 | 10 | describe('AuthService', () => { 11 | const testContext: TestContext = new TestContext(); 12 | 13 | describe('register', () => { 14 | test('When email already exists Then return EmailTaken and do not proceed', async () => { 15 | //given 16 | const email = 'test@test.com'; 17 | 18 | const usersRepositoryMock = testContext.mock(() => ({ 19 | exists: jest.fn(() => Promise.resolve(true)), 20 | create: jest.fn(), 21 | }), UsersRepository); 22 | 23 | //when 24 | var result = await testContext.get(AuthService).register({ 25 | email 26 | } as RegisterRequestDto); 27 | 28 | //then 29 | expect(result) 30 | .toEqual(RegisterResult.EmailTaken); 31 | 32 | expect(usersRepositoryMock.exists) 33 | .toBeCalledWith( 34 | expect.objectContaining({ 35 | email 36 | }) 37 | ); 38 | 39 | expect(usersRepositoryMock.create) 40 | .toBeCalledTimes(0); 41 | }); 42 | 43 | test('When email not exists Then should create user and return success', async () => { 44 | //given 45 | const password = 'TEST_PASSWORD', 46 | hashedPassword = 'HASHED_PWD', 47 | name = 'UNIT_TEST', 48 | email = 'test@test.com'; 49 | 50 | const usersRepositoryMock = testContext.mock(() => ({ 51 | exists: jest.fn(() => Promise.resolve(false)), 52 | create: jest.fn(), 53 | }), UsersRepository); 54 | 55 | const bcryptMock = testContext.mock(() => ({ 56 | hash: jest.fn(() => Promise.resolve(hashedPassword)), 57 | }), BcryptWrapper); 58 | 59 | //when 60 | var result = await testContext.get(AuthService).register({ 61 | name, 62 | email, 63 | password, 64 | }); 65 | 66 | //then 67 | expect(result) 68 | .toEqual(RegisterResult.Success); 69 | 70 | expect(usersRepositoryMock.exists) 71 | .toBeCalledTimes(1); 72 | 73 | expect(bcryptMock.hash) 74 | .toBeCalledWith(password, expect.any(Number)); 75 | 76 | expect(usersRepositoryMock.create) 77 | .toBeCalledWith(expect.objectContaining({ 78 | password: hashedPassword, 79 | name, 80 | email, 81 | })); 82 | }); 83 | }); 84 | 85 | describe('login', () => { 86 | test('When user not found in DB Then return null and do not proceed', async () => { 87 | //given 88 | const email = 'test@test.com'; 89 | 90 | const usersRepositoryMock = testContext.mock(() => ({ 91 | findOne: jest.fn(() => Promise.resolve(null)), 92 | }), UsersRepository); 93 | 94 | const bcryptMock = testContext.mock(() => ({ 95 | compare: jest.fn(), 96 | }), BcryptWrapper); 97 | 98 | //when 99 | var result = await testContext.get(AuthService).login({ 100 | email 101 | } as LoginRequestDto); 102 | 103 | //then 104 | expect(result) 105 | .toBeNull(); 106 | 107 | expect(usersRepositoryMock.findOne) 108 | .toBeCalledWith(expect.objectContaining({ 109 | email, 110 | })); 111 | 112 | expect(bcryptMock.compare) 113 | .not.toBeCalled(); 114 | }); 115 | 116 | test('When passwords do not match Then return null and do not proceed', async () => { 117 | //given 118 | const password = 'TEST PWD', 119 | userPassword = 'TEST USER PWD'; 120 | 121 | const usersRepositoryMock = testContext.mock(() => ({ 122 | findOne: jest.fn(() => Promise.resolve({ 123 | password: userPassword 124 | } as any)), 125 | }), UsersRepository); 126 | 127 | const bcryptMock = testContext.mock(() => ({ 128 | compare: jest.fn(() => Promise.resolve(false)), 129 | }), BcryptWrapper); 130 | 131 | const tokenServiceMock = testContext.mock(() => ({ 132 | create: jest.fn(), 133 | }), TokenService); 134 | 135 | //when 136 | var result = await testContext.get(AuthService).login({ 137 | password 138 | } as LoginRequestDto); 139 | 140 | //then 141 | expect(result) 142 | .toBeNull(); 143 | 144 | expect(usersRepositoryMock.findOne) 145 | .toBeCalled(); 146 | 147 | expect(bcryptMock.compare) 148 | .toBeCalledWith(password, userPassword); 149 | 150 | expect(tokenServiceMock.create) 151 | .not.toBeCalled(); 152 | }); 153 | 154 | test('When passwords match Then should create token and return user with it', async () => { 155 | //given 156 | const user = { 157 | name: 'TEST', 158 | email: 'test@test.com', 159 | }; 160 | 161 | const tokenInfo = { 162 | token: 'TEST token', 163 | }; 164 | 165 | const usersRepositoryMock = testContext.mock(() => ({ 166 | findOne: jest.fn(() => Promise.resolve(user as any)), 167 | }), UsersRepository); 168 | 169 | const bcryptMock = testContext.mock(() => ({ 170 | compare: jest.fn(() => Promise.resolve(true)), 171 | }), BcryptWrapper); 172 | 173 | const tokenServiceMock = testContext.mock(() => ({ 174 | create: jest.fn(() => tokenInfo as TokenInfo), 175 | }), TokenService); 176 | 177 | //when 178 | var result = await testContext.get(AuthService).login({} as LoginRequestDto); 179 | 180 | //then 181 | expect(result.tokenInfo) 182 | .toEqual(tokenInfo); 183 | expect(result.user) 184 | .toEqual(user); 185 | 186 | expect(usersRepositoryMock.findOne) 187 | .toBeCalled(); 188 | 189 | expect(bcryptMock.compare) 190 | .toBeCalled(); 191 | 192 | expect(tokenServiceMock.create) 193 | .toBeCalledWith(user); 194 | }); 195 | }); 196 | }) 197 | --------------------------------------------------------------------------------