├── .env.example ├── .gitignore ├── .npmrc ├── .swcrc.test ├── README.md ├── index.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── config.ts ├── container.ts ├── domain │ ├── Entity.ts │ ├── User │ │ └── User.ts │ └── primitives │ │ ├── Time.ts │ │ ├── alpacaResponse.ts │ │ ├── exceptions.ts │ │ └── logger.interface.ts ├── infrastructure │ ├── adapters │ │ ├── db │ │ │ ├── container.ts │ │ │ ├── db.adapter.ts │ │ │ └── index.ts │ │ ├── logger.ts │ │ └── server │ │ │ ├── middlewares │ │ │ ├── error.middleware.ts │ │ │ ├── logger.middleware.ts │ │ │ ├── requestId.middleware.ts │ │ │ ├── sanitizer.middleware.ts │ │ │ └── swaggerParams.middleware.ts │ │ │ ├── model │ │ │ └── params.model.ts │ │ │ ├── response.model.ts │ │ │ └── server.adapter.ts │ ├── api │ │ ├── definition │ │ │ ├── definitions.ts │ │ │ ├── index.ts │ │ │ └── user │ │ │ │ ├── definitions.ts │ │ │ │ └── index.ts │ │ └── entrypoints │ │ │ ├── health.entrypoint.ts │ │ │ └── user.entrypoint.ts │ ├── controllers │ │ ├── apiResponses.ts │ │ ├── health.controller.ts │ │ └── user.controller.ts │ └── repositories │ │ └── user │ │ └── user.repository.ts ├── services │ └── user │ │ └── user.service.ts └── usecases │ ├── abstractions │ └── userRepository.interface.ts │ ├── types │ └── queryParams.types.ts │ └── user │ ├── outputModels │ └── user.model.ts │ └── user.usecases.ts ├── tests ├── builders │ └── user.builder.ts ├── stubs │ └── user.repository.stub.ts ├── test.container.ts ├── test.env ├── test.setup.ts └── usecases │ ├── __snapshots__ │ └── user.usecases.test.ts.snap │ └── user.usecases.test.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | ENV=LOCAL 2 | SERVER_PORT=8080 3 | SERVER_HOST=localhost 4 | SERVER_URL=https://apialpaca.net 5 | LOG_LEVEL=info 6 | 7 | DB_PORT=3306 8 | DB_DATABASE=alpaca 9 | DB_USERNAME=alpaca_user 10 | DB_HOST=alpaca_host.com 11 | DB_PASSWORD=12345 12 | 13 | ENCRYPTION_IV=XXX 14 | ENCRYPTION_KEY=XXX 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | build 4 | .vscode 5 | .idea 6 | coverage 7 | .eslintignore -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | install-links=false 2 | -------------------------------------------------------------------------------- /.swcrc.test: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "jsc": { 4 | "parser": { 5 | "syntax": "typescript", 6 | "dynamicImport": false, 7 | "decorators": false 8 | }, 9 | "target": "es2022", 10 | "loose": false, 11 | "externalHelpers": false 12 | }, 13 | "minify": false 14 | } 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alpaca's Backend 2 | 3 | ## Getting started 4 | 5 | This is a basic REST API built with Node.js and Express following a variation of the Clean Architecture principles. It's created to provide a simple and scalable backend project to learn from. 6 | 7 | It has a simple CRUD for a `User` entity. 8 | 9 | ## Instalattion 10 | 11 | 1. Clone the repository 12 | 2. Run `npm install` 13 | 3. Run `npm start` 14 | 15 | ## Running the tests 16 | 17 | Run `npm test` 18 | 19 | ## API Documentation 20 | 21 | The API documentation is available at `http://localhost:SERVER_PORT/docs`. 22 | 23 | ## Technologies to Highlight 24 | 25 | - **Node.js**: Runtime environment for JavaScript. 26 | - **Express**: Web application framework for Node.js. 27 | - **Jest**: Testing framework. 28 | - **Awilix**: Dependency injection framework. 29 | - **MySQL2**: Library to connect to MySQL. 30 | - **Swagger**: To document the API. 31 | 32 | ## Patterns 33 | 34 | - **Dependency Inversion Principle**: High-level modules should not depend on low-level modules. Both should depend on abstractions. 35 | - **Adapter Pattern**: Allows the interface of a class to be compatible with another interface. 36 | - **Repository Pattern**: Provides an interface for data access and storage, abstracting the underlying implementation. 37 | - **Builder Pattern**: Allows for the step-by-step construction of complex objects using a builder class. 38 | - **Singleton Pattern**: Ensures that a class has only one instance and provides a global point of access to it. 39 | 40 | ## Main Layers 41 | - **Domain**: Core business rules and logic. 42 | - **Infrastructure**: Handles data access and external services. Here you have adapters, repositories, etc. 43 | - **Services**: Encapsulates different services and operations. 44 | - **Usecases**: Contains the business logic and rules. 45 | - **Controllers**: Handles HTTP requests and responses. 46 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import config from './src/config'; 3 | import container, { Dependencies } from './src/container'; 4 | import apiDefinition from './src/infrastructure/api/definition'; 5 | 6 | const serverAdapter = container.resolve('serverAdapter') as Dependencies['serverAdapter']; 7 | 8 | async function start() { 9 | serverAdapter({ 10 | port: config.SERVER.PORT, 11 | host: config.SERVER.HOST, 12 | apiDefinition, 13 | controllersPath: path.join(__dirname, './src/infrastructure/api/entrypoints'), 14 | }); 15 | } 16 | 17 | start(); 18 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | module.exports = { 3 | roots: [ '/tests' ], 4 | transform: { 5 | '^.+\\.[tj]sx?$': 'ts-jest', 6 | }, 7 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 8 | moduleFileExtensions: [ 'ts', 'tsx', 'js', 'jsx', 'json', 'node' ], 9 | testEnvironment: 'node', 10 | collectCoverageFrom: [ 11 | 'src/**/*.{ts,js}', 12 | '!src/infrastructure/repositories/**/*.repository.{ts,js}', 13 | '!src/infrastructure/controllers/**/*.controller.{ts,js}', 14 | ], 15 | reporters: [ 'default', [ 'jest-junit', { outputDirectory: './coverage' } ] ], 16 | setupFiles: [ '/tests/test.setup.ts' ], 17 | snapshotFormat: { 18 | printBasicPrototype: true, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alpaca-api", 3 | "version": "1.0.0", 4 | "description": "Alpaca's backend", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node ./build/index.js", 8 | "test": "jest --maxWorkers=25%", 9 | "dev": "tsnd --respawn --transpile-only index.ts", 10 | "lint": "eslint . --ext .ts", 11 | "build": "tsc --alwaysStrict --extendedDiagnostics -p ." 12 | }, 13 | "author": "Jesús Lagares", 14 | "license": "ISC", 15 | "dependencies": { 16 | "awilix": "^8.0.1", 17 | "axios": "^1.3.5", 18 | "cors": "^2.8.5", 19 | "dotenv": "^16.0.3", 20 | "express": "^4.18.2", 21 | "helmet": "^7.0.0", 22 | "http-status-codes": "^2.2.0", 23 | "joi": "^17.9.2", 24 | "knex": "^2.4.2", 25 | "moment": "^2.30.1", 26 | "mysql2": "^3.4.4", 27 | "pino": "^8.11.0", 28 | "sanitize-html": "^2.11.0", 29 | "swagger-tools": "^0.10.1", 30 | "typescript": "^5.0.4", 31 | "uuid": "^10.0.0" 32 | }, 33 | "devDependencies": { 34 | "@swc/jest": "^0.2.27", 35 | "@types/express": "^4.17.7", 36 | "@types/jest": "^29.5.0", 37 | "@types/node": "^18.15.11", 38 | "@types/swagger-tools": "^0.10.6", 39 | "jest": "^29.5.0", 40 | "jest-junit": "^16.0.0", 41 | "nodemon": "^2.0.22", 42 | "ts-jest": "^29.1.0", 43 | "ts-node-dev": "^2.0.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | import { Environment } from './infrastructure/adapters/server/server.adapter'; 3 | 4 | config(); 5 | 6 | export default { 7 | SERVER: { 8 | PORT: Number(process.env.SERVER_PORT) || 8080, 9 | HOST: process.env.SERVER_HOST || '0.0.0.0', 10 | CORS: { 11 | CREDENTIALS: true, 12 | WHITELIST: [ 'http://localhost:3000', /^https:\/\/.*\.alpaca\.com$/ ], 13 | MAX_AGE: 3600, 14 | }, 15 | PUBLIC_URL: process.env.SERVER_URL, 16 | }, 17 | ENV: process.env.ENV || Environment.DEV, 18 | DEPLOYMENT_INFO: process.env.DEPLOYMENT_INFO || 'LOCAL', 19 | LOG_LEVEL: process.env.LOG_LEVEL || 'info', 20 | DB: { 21 | HOST: process.env.DB_HOST, 22 | PORT: process.env.DB_PORT || '3306', 23 | DATABASE: process.env.DB_DATABASE || 'alpaca', 24 | USERNAME: process.env.DB_USERNAME, 25 | PASSWORD: process.env.DB_PASSWORD, 26 | }, 27 | TABLES: { 28 | USER: 'user', 29 | }, 30 | ENCRYPTION: { 31 | ALGORITHM: 'aes-256-cbc', 32 | KEY: process.env.ENCRYPTION_KEY, 33 | IV: process.env.ENCRYPTION_IV, 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /src/container.ts: -------------------------------------------------------------------------------- 1 | import { AwilixContainer, Lifetime, asValue, createContainer } from 'awilix'; 2 | 3 | import axios from 'axios'; 4 | import cors from 'cors'; 5 | import express from 'express'; 6 | import helmet from 'helmet'; 7 | import knex from 'knex'; 8 | import mysql2 from 'mysql2'; 9 | import pino from 'pino'; 10 | import swaggerTools from 'swagger-tools'; 11 | 12 | import LoggerInterface from './domain/primitives/logger.interface'; 13 | 14 | import config from './config'; 15 | import { DbAdapterInterface } from './infrastructure/adapters/db/db.adapter'; 16 | import dbAdapter from './infrastructure/adapters/db/index'; 17 | import serverAdapter from './infrastructure/adapters/server/server.adapter'; 18 | import healthController from './infrastructure/controllers/health.controller'; 19 | import userController from './infrastructure/controllers/user.controller'; 20 | import userService from './services/user/user.service'; 21 | import UserRepositoryInterface from './usecases/abstractions/userRepository.interface'; 22 | import userUsecases from './usecases/user/user.usecases'; 23 | 24 | const container: AwilixContainer = createContainer(); 25 | 26 | container.loadModules( 27 | [ 28 | `${__dirname}/**/*.usecases.{ts,js}`, 29 | `${__dirname}/**/*.adapter.{ts,js}`, 30 | `${__dirname}/**/*.facade.{ts,js}`, 31 | `${__dirname}/**/*.factory.{ts,js}`, 32 | `${__dirname}/**/logger.{ts,js}`, 33 | `${__dirname}/**/*.controller.{ts,js}`, 34 | `${__dirname}/**/*.middleware.{ts,js}`, 35 | `${__dirname}/**/*.routes.{ts,js}`, 36 | `${__dirname}/**/*.repository.{ts,js}`, 37 | `${__dirname}/**/*.service.{ts,js}`, 38 | `${__dirname}/**/*.handler.{ts,js}`, 39 | `${__dirname}/**/*.proxy.{ts,js}`, 40 | ], 41 | { 42 | formatName: 'camelCase', 43 | resolverOptions: { 44 | lifetime: Lifetime.SINGLETON, 45 | }, 46 | } 47 | ); 48 | 49 | container.register({ 50 | express: asValue(express), 51 | swaggerTools: asValue(swaggerTools), 52 | pino: asValue(pino), 53 | axios: asValue(axios), 54 | mysql2: asValue(mysql2), 55 | knex: asValue(knex), 56 | cors: asValue(cors), 57 | helmet: asValue(helmet), 58 | dbAdapter: asValue(dbAdapter(config.DB)), 59 | }); 60 | 61 | export default container; 62 | 63 | export type Dependencies = { 64 | // Libraries 65 | express: typeof express; 66 | swaggerTools: typeof swaggerTools; 67 | pino: typeof pino; 68 | axios: typeof axios; 69 | mysql2: typeof mysql2; 70 | knex: typeof knex; 71 | cors: typeof cors; 72 | helmet: typeof helmet; 73 | 74 | // Definitions 75 | logger: LoggerInterface; 76 | 77 | // Adapters 78 | serverAdapter: ReturnType; 79 | dbAdapter: DbAdapterInterface; 80 | 81 | // Controllers 82 | userController: ReturnType; 83 | healthController: ReturnType; 84 | 85 | // Repositories 86 | userRepository: UserRepositoryInterface; 87 | 88 | // Usecases 89 | userUsecases: ReturnType; 90 | 91 | // Services 92 | userService: ReturnType; 93 | }; 94 | -------------------------------------------------------------------------------- /src/domain/Entity.ts: -------------------------------------------------------------------------------- 1 | import Time from './primitives/Time'; 2 | 3 | const lookUpPropertyDescriptor = (object: unknown, key: string): PropertyDescriptor | undefined => { 4 | const propertyDescriptor = Object.getOwnPropertyDescriptor(object, key); 5 | if (propertyDescriptor) { 6 | return propertyDescriptor; 7 | } 8 | const prototype = Object.getPrototypeOf(object); 9 | if (!prototype) { 10 | return undefined; 11 | } 12 | if (prototype === Object.prototype) { 13 | return undefined; 14 | } 15 | return lookUpPropertyDescriptor(prototype, key); 16 | }; 17 | 18 | export interface EntityParams { 19 | id?: IdType; 20 | createdAt?: number | Time; 21 | updatedAt?: number | Time; 22 | } 23 | 24 | export default abstract class Entity { 25 | public id?: IdType; 26 | public createdAt: Time; 27 | public updatedAt: Time; 28 | 29 | protected constructor(params: EntityParams) { 30 | this.id = params.id; 31 | this.createdAt = params.createdAt instanceof Time ? params.createdAt : Time.timestamp(params.createdAt); 32 | this.updatedAt = params.updatedAt instanceof Time ? params.updatedAt : Time.timestamp(params.updatedAt); 33 | } 34 | 35 | public update(fragment: Partial): void { 36 | this.updatedAt = Time.now(); 37 | Object.keys(fragment).forEach(key => { 38 | if (fragment[key] !== undefined) { 39 | const propertyDescriptor = lookUpPropertyDescriptor(this, key); 40 | if ( 41 | (propertyDescriptor?.enumerable && propertyDescriptor.writable) || 42 | propertyDescriptor?.set 43 | ) { 44 | this[key] = fragment[key]; 45 | } 46 | } 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/domain/User/User.ts: -------------------------------------------------------------------------------- 1 | import Entity, { EntityParams } from '../Entity'; 2 | 3 | type UserParams = EntityParams & { 4 | email: string; 5 | nickname: string; 6 | password: string; 7 | } 8 | 9 | export default class User extends Entity { 10 | public email: string; 11 | public nickname: string; 12 | public password: string; 13 | 14 | constructor(user: UserParams) { 15 | super(user); 16 | this.email = user.email; 17 | this.nickname = user.nickname; 18 | this.password = user.password; 19 | } 20 | 21 | public get userId(): number { 22 | return this.id; 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /src/domain/primitives/Time.ts: -------------------------------------------------------------------------------- 1 | import moment, { DurationInputArg1, DurationInputArg2 } from 'moment'; 2 | 3 | export default class Time { 4 | private static readonly MILLISECONDS_TO_SECONDS = 1000; 5 | 6 | private readonly milliseconds: number; 7 | 8 | private constructor(milliseconds: number) { 9 | this.milliseconds = milliseconds; 10 | } 11 | 12 | public valueOf(): number { 13 | return this.milliseconds; 14 | } 15 | 16 | public valueOfSeconds() { 17 | return Math.round(this.milliseconds / Time.MILLISECONDS_TO_SECONDS); 18 | } 19 | 20 | public toUnix(): number { 21 | return moment.utc(this.valueOf()).unix(); 22 | } 23 | 24 | public toDate(): Date { 25 | return moment.utc(this.valueOf()).toDate(); 26 | } 27 | 28 | public format(format?: string): string { 29 | return moment.utc(this.valueOf()).format(format); 30 | } 31 | 32 | public toString(): string { 33 | return this.format(); 34 | } 35 | 36 | public isAfter(time: Time): boolean { 37 | return moment.utc(this.valueOf()).isAfter(time.valueOf()); 38 | } 39 | 40 | public isBefore(time: Time): boolean { 41 | return moment.utc(this.valueOf()).isBefore(time.valueOf()); 42 | } 43 | 44 | public get isFuture(): boolean { 45 | return moment.utc().isBefore(this.valueOf()); 46 | } 47 | 48 | public get isPast(): boolean { 49 | return !this.isFuture; 50 | } 51 | 52 | public get isToday(): boolean { 53 | return moment.utc().isSame(moment.utc(this.valueOf()), 'day'); 54 | } 55 | 56 | public add(amount: DurationInputArg1, unit: DurationInputArg2): Time { 57 | return Time.from(moment.utc(this.valueOf()).add(amount, unit).valueOf()); 58 | } 59 | 60 | public subtract(amount: DurationInputArg1, unit: DurationInputArg2): Time { 61 | return Time.from(moment.utc(this.valueOf()).subtract(amount, unit).valueOf()); 62 | } 63 | 64 | public static from(milliseconds: number): Time { 65 | return new Time(milliseconds); 66 | } 67 | 68 | public static fromString(timestamp: string): Time { 69 | return Time.from(moment.utc(timestamp).valueOf()); 70 | } 71 | 72 | public static timestamp(milliseconds?: number): Time { 73 | return milliseconds ? Time.from(milliseconds) : Time.now(); 74 | } 75 | 76 | public static optional(milliseconds?: number): Time | null { 77 | return milliseconds ? Time.from(milliseconds) : null; 78 | } 79 | 80 | public static optionalFromString(timestamp?: string): Time | null { 81 | return timestamp ? Time.fromString(timestamp) : null; 82 | } 83 | 84 | public static fromSeconds(seconds: number): Time { 85 | return Time.from(seconds * Time.MILLISECONDS_TO_SECONDS); 86 | } 87 | 88 | public static now(): Time { 89 | return Time.from(moment.utc().valueOf()); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/domain/primitives/alpacaResponse.ts: -------------------------------------------------------------------------------- 1 | type AlpacaResponseParams = { 2 | code: string; 3 | statusCode: number; 4 | message: string; 5 | } 6 | 7 | export default class AlpacaResponse { 8 | public code: string; 9 | public statusCode: number; 10 | public message: string; 11 | 12 | constructor(response: AlpacaResponseParams) { 13 | this.code = response.code; 14 | this.statusCode = response.statusCode; 15 | this.message = response.message; 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /src/domain/primitives/exceptions.ts: -------------------------------------------------------------------------------- 1 | import statusCodes from 'http-status-codes'; 2 | import AlpacaResponse from './alpacaResponse'; 3 | 4 | export const UserNotFound = new AlpacaResponse({ code: 'US40401', statusCode: statusCodes.BAD_REQUEST, message: 'The user does not exist' }); 5 | 6 | export const UserAlreadyExists = new AlpacaResponse({ code: 'US40901', statusCode: statusCodes.CONFLICT, message: 'User already exists' }); 7 | 8 | export const InternalError = new AlpacaResponse({ code: 'ER50001', statusCode: statusCodes.INTERNAL_SERVER_ERROR, message: 'Internal server error' }); 9 | -------------------------------------------------------------------------------- /src/domain/primitives/logger.interface.ts: -------------------------------------------------------------------------------- 1 | interface LogFunction { 2 | (message: string): void; 3 | (object: Record, message?: string): void; 4 | } 5 | 6 | interface LoggerInterface { 7 | trace: LogFunction; 8 | debug: LogFunction; 9 | info: LogFunction; 10 | warn: LogFunction; 11 | error: LogFunction; 12 | fatal: LogFunction; 13 | child(object: Record): LoggerInterface; 14 | } 15 | 16 | export default LoggerInterface; 17 | -------------------------------------------------------------------------------- /src/infrastructure/adapters/db/container.ts: -------------------------------------------------------------------------------- 1 | import { AwilixContainer, asValue, createContainer, asFunction } from 'awilix'; 2 | import mysql2 from 'mysql2'; 3 | import knex from 'knex'; 4 | 5 | import dbAdapter from './db.adapter'; 6 | 7 | const container: AwilixContainer = createContainer(); 8 | 9 | container.register({ 10 | mysql2: asValue(mysql2), 11 | knex: asValue(knex), 12 | dbAdapter: asFunction(dbAdapter).singleton(), 13 | }); 14 | 15 | export default container; 16 | 17 | export type Dependencies = { 18 | // Libraries 19 | mysql2: typeof mysql2; 20 | knex: typeof knex; 21 | 22 | // adapter 23 | dbAdapter: ReturnType; 24 | }; 25 | -------------------------------------------------------------------------------- /src/infrastructure/adapters/db/db.adapter.ts: -------------------------------------------------------------------------------- 1 | import { FieldPacket, ResultSetHeader, RowDataPacket } from 'mysql2'; 2 | 3 | import { Pool } from 'mysql2/promise'; 4 | import knexLib from 'knex'; 5 | import { Dependencies } from '../../../container'; 6 | 7 | type AllowedTypes = string | number | boolean | Date; 8 | export type SQLQuery = { 9 | sql: string; 10 | params?: (AllowedTypes | (AllowedTypes)[])[]; 11 | }; 12 | 13 | type Row = { 14 | [key: string]: AllowedTypes; 15 | }; 16 | 17 | type QueryResult = [ 18 | RowDataPacket[], FieldPacket[] 19 | ]; 20 | 21 | type InsertResult = [ ResultSetHeader ]; 22 | 23 | type DbConfig = { 24 | HOST: string; 25 | USERNAME: string; 26 | PASSWORD: string; 27 | DATABASE: string; 28 | } 29 | 30 | export enum DBErrors { 31 | CONSTRAINT_VIOLATION = '23000', 32 | } 33 | 34 | export default ({ mysql2, knex }: Dependencies) => (dbConfig: DbConfig): DbAdapterInterface => { 35 | const pool = mysql2.createPool({ 36 | host: dbConfig.HOST, 37 | user: dbConfig.USERNAME, 38 | password: dbConfig.PASSWORD, 39 | database: dbConfig.DATABASE, 40 | timezone: '+00:00', 41 | dateStrings: true, 42 | }).promise(); 43 | 44 | const db = knex({ 45 | client: 'mysql2', 46 | connection: { 47 | host: dbConfig.HOST, 48 | user: dbConfig.USERNAME, 49 | password: dbConfig.PASSWORD, 50 | database: dbConfig.DATABASE, 51 | timezone: '+00:00', 52 | dateStrings: true, 53 | }, 54 | }); 55 | 56 | async function query({ sql, params = [] }: SQLQuery): Promise { 57 | try { 58 | const res = await pool.query(sql, params); 59 | return res as QueryResult; 60 | } catch (error) { 61 | console.error({ error }, 'Error with the db'); 62 | throw error; 63 | } 64 | } 65 | 66 | function buildInsertQuery(table: string, row: Row): SQLQuery { 67 | const fieldsArray = Object.keys(row).filter(key => row[key] !== undefined); 68 | const templateString = fieldsArray.map(() => '?').join(', '); 69 | return { 70 | sql: `INSERT INTO ${table} (${fieldsArray.join(', ')}) VALUES (${templateString})`, 71 | params: fieldsArray.map(key => row[key]), 72 | }; 73 | } 74 | 75 | function buildUpdateQuery(table: string, keys: Row, row: Row): SQLQuery { 76 | const fieldsArray = Object.keys(row).filter(key => row[key] !== undefined); 77 | const updateString = fieldsArray.map(field => `${field} = ?`).join(', '); 78 | const conditionString = Object.keys(keys) 79 | .map(field => `${field} = ?`) 80 | .join(' AND '); 81 | 82 | return { 83 | sql: `UPDATE ${table} SET ${updateString} WHERE (${conditionString})`, 84 | params: fieldsArray.map(key => row[key]).concat(Object.keys(keys).map(key => keys[key])), 85 | }; 86 | } 87 | 88 | return { 89 | pool, 90 | db, 91 | query, 92 | async findRecords(sqlQuery: SQLQuery): Promise { 93 | const [ result ] = await query(sqlQuery); 94 | return result as RowDataPacket[]; 95 | }, 96 | async closeConnection() { 97 | await pool.end(); 98 | }, 99 | 100 | async insert(table: string, rows: Row[]) { 101 | const ids = []; 102 | try { 103 | for (const row of rows) { 104 | const [ result ] = await query(buildInsertQuery(table, row)) as InsertResult; 105 | if (result && result.insertId) { 106 | ids.push(result.insertId); 107 | } else { 108 | ids.push(null); 109 | } 110 | } 111 | return ids; 112 | } catch (error) { 113 | console.error(error); 114 | throw error; 115 | } 116 | }, 117 | 118 | async updateByFields(table: string, keys: Row, object: Row) { 119 | return query(buildUpdateQuery(table, keys, object)) as Promise; 120 | }, 121 | }; 122 | }; 123 | 124 | export interface DbAdapterInterface { 125 | pool: Pool, 126 | db: ReturnType; 127 | closeConnection(): Promise; 128 | query(query: SQLQuery): Promise; 129 | findRecords(query: SQLQuery): Promise; 130 | insert(table: string, rows: Row[]): Promise; 131 | updateByFields(table: string, keys: Row, object: Row): Promise; 132 | } 133 | -------------------------------------------------------------------------------- /src/infrastructure/adapters/db/index.ts: -------------------------------------------------------------------------------- 1 | import container, { Dependencies } from './container'; 2 | 3 | export default container.resolve('dbAdapter') as Dependencies['dbAdapter']; 4 | -------------------------------------------------------------------------------- /src/infrastructure/adapters/logger.ts: -------------------------------------------------------------------------------- 1 | import { Dependencies } from '../../container'; 2 | import config from '../../config'; 3 | import LoggerInterface from '../../domain/primitives/logger.interface'; 4 | 5 | export default ({ pino }: Dependencies): LoggerInterface => pino({ 6 | level: config.LOG_LEVEL, 7 | serializers: { err: pino.stdSerializers.err, error: pino.stdSerializers.err }, 8 | }); 9 | -------------------------------------------------------------------------------- /src/infrastructure/adapters/server/middlewares/error.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import httpStatusCodes from 'http-status-codes'; 3 | import AlpacaResponse from '../../../../domain/primitives/alpacaResponse'; 4 | import APIResponse, { MimeTypes } from '../response.model'; 5 | import { ServerRequest } from '../server.adapter'; 6 | 7 | export default (error, req: ServerRequest & Request, res: Response, next: NextFunction) => { 8 | let alpacaResponse: AlpacaResponse; 9 | let errors: string[]; 10 | 11 | if (error instanceof Error) { 12 | alpacaResponse = new AlpacaResponse({ 13 | statusCode: httpStatusCodes.INTERNAL_SERVER_ERROR, 14 | message: httpStatusCodes.getStatusText(httpStatusCodes.INTERNAL_SERVER_ERROR), 15 | code: 'ALPACA50000', 16 | }); 17 | errors = [ error.message ]; 18 | } 19 | if (error instanceof AlpacaResponse) { 20 | alpacaResponse = error; 21 | } 22 | if (error.code === 'SCHEMA_VALIDATION_FAILED' || error.failedValidation) { 23 | alpacaResponse = new AlpacaResponse({ 24 | statusCode: httpStatusCodes.BAD_REQUEST, 25 | message: error.message, 26 | code: 'ALPACA40000', 27 | }); 28 | if (error.results && error.results.errors && error.results.errors.length > 0) { 29 | errors = error.results.errors.map(({ message, path }) => `${path?.join('.')}: ${message}`); 30 | } 31 | } 32 | 33 | const response = new APIResponse(alpacaResponse, req, { errors }); 34 | response.forceType(MimeTypes.JSON); 35 | response.send(res); 36 | 37 | next(); 38 | }; 39 | -------------------------------------------------------------------------------- /src/infrastructure/adapters/server/middlewares/logger.middleware.ts: -------------------------------------------------------------------------------- 1 | import LoggerInterface from '../../../../domain/primitives/logger.interface'; 2 | import { ServerRequest } from '../server.adapter'; 3 | 4 | export default (logger: LoggerInterface) => (req: ServerRequest, _res, next) => { 5 | req.logger = logger.child({ requestUuid: req.id }); 6 | next(); 7 | }; 8 | -------------------------------------------------------------------------------- /src/infrastructure/adapters/server/middlewares/requestId.middleware.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | function generateV4UUID() { 4 | return uuidv4(); 5 | } 6 | 7 | const ATTRIBUTE_NAME = 'id'; 8 | 9 | export default function requestID({ 10 | generator = generateV4UUID, 11 | headerName = 'X-Request-Id', 12 | setHeader = true, 13 | } = {}) { 14 | return function (request, response, next) { 15 | const oldValue = request.get(headerName); 16 | const id = oldValue === undefined ? generator() : oldValue; 17 | 18 | if (setHeader) { 19 | response.set(headerName, id); 20 | } 21 | 22 | request[ATTRIBUTE_NAME] = id; 23 | 24 | next(); 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/infrastructure/adapters/server/middlewares/sanitizer.middleware.ts: -------------------------------------------------------------------------------- 1 | import sanitizeHtml from 'sanitize-html'; 2 | import { ServerRequest } from '../server.adapter'; 3 | 4 | function htmlSanitize(html: string): string { 5 | return sanitizeHtml(html, { 6 | allowedTags: [ 'strong', 'em', 'a', 'p', 'ul', 'ol', 'li', 'br' ], 7 | allowedAttributes: { 8 | a: [ 'href' ], 9 | }, 10 | allowedSchemes: [ 'http', 'https' ], 11 | allowProtocolRelative: false, 12 | }); 13 | } 14 | 15 | function sanitizePayloadWithSchema(schema, payload) { 16 | if (schema.allOf && Array.isArray(schema.allOf)) { 17 | return schema.allOf.forEach(schema => sanitizePayloadWithSchema(schema, payload)); 18 | } 19 | 20 | const schemaProperties = schema.properties ?? schema.items ?? {}; 21 | Object.keys(schemaProperties).forEach(property => { 22 | if (schemaProperties[property]?.properties) { 23 | sanitizePayloadWithSchema(schemaProperties[property], payload[property] ?? {}); 24 | } 25 | if (schemaProperties[property]?.items) { 26 | (payload[property] ?? []).forEach(propertyValue => { 27 | sanitizePayloadWithSchema(schemaProperties[property], propertyValue); 28 | }); 29 | } 30 | }); 31 | 32 | const fieldsToSanitize = Object.keys(schemaProperties).filter(property => schemaProperties[property]?.['x-format'] === 'html'); 33 | for (const attribute of fieldsToSanitize) { 34 | if (typeof payload[attribute] === 'string') { 35 | payload[attribute] = htmlSanitize(payload[attribute]); 36 | } 37 | } 38 | } 39 | 40 | function sanitizeSwaggerParams(swaggerParams) { 41 | const fieldsToSanitize = Object.keys(swaggerParams).filter(property => swaggerParams[property].schema?.['x-format'] === 'html'); 42 | for (const attribute of fieldsToSanitize) { 43 | const param = swaggerParams[attribute]; 44 | if (typeof param.value === 'string') { 45 | param.value = htmlSanitize(param.value); 46 | } 47 | } 48 | } 49 | 50 | export default (req: ServerRequest, _res, next) => { 51 | const swaggerParams = req.swagger?.params; 52 | const baseSchema = swaggerParams?.data?.schema as unknown as { schema: Record }; 53 | const schema = baseSchema?.schema; 54 | 55 | if (swaggerParams) { 56 | try { 57 | sanitizeSwaggerParams(swaggerParams); 58 | } catch (error) { 59 | req.logger.fatal({ error }, 'Unhandled error sanitizing payload (swagger params)'); 60 | } 61 | } 62 | if (schema) { 63 | try { 64 | sanitizePayloadWithSchema(schema, req.body); 65 | } catch (error) { 66 | req.logger.fatal({ error }, 'Unhandled error sanitizing payload (payload with schema)'); 67 | } 68 | } 69 | 70 | next(); 71 | }; 72 | -------------------------------------------------------------------------------- /src/infrastructure/adapters/server/middlewares/swaggerParams.middleware.ts: -------------------------------------------------------------------------------- 1 | import { ServerRequest } from '../server.adapter'; 2 | 3 | export default (req: ServerRequest, _res, next) => { 4 | if (!req.swagger) { 5 | return next(); 6 | } 7 | const paramExpressMapper = { 8 | path: 'params', 9 | }; 10 | for (const parameter of Object.values(req.swagger.params)) { 11 | const expressParameter = paramExpressMapper[parameter.schema.in] || parameter.schema.in; 12 | if (!req[expressParameter]) { 13 | req[expressParameter] = {}; 14 | } 15 | if (parameter.schema.in === 'body') { 16 | req[expressParameter] = parameter.value; 17 | continue; 18 | } 19 | req[expressParameter][parameter.schema.name] = parameter.value; 20 | } 21 | next(); 22 | }; 23 | -------------------------------------------------------------------------------- /src/infrastructure/adapters/server/model/params.model.ts: -------------------------------------------------------------------------------- 1 | import { ServerRequest } from '../server.adapter'; 2 | 3 | export function getParams(req: ServerRequest): T { 4 | const queryParams: Record = req.swagger?.params; 5 | const modelParams = {}; 6 | for (const param in queryParams) { 7 | modelParams[param] = queryParams[param].value; 8 | } 9 | return modelParams as T; 10 | } 11 | -------------------------------------------------------------------------------- /src/infrastructure/adapters/server/response.model.ts: -------------------------------------------------------------------------------- 1 | import httpStatusCodes from 'http-status-codes'; 2 | import AlpacaResponse from '../../../domain/primitives/alpacaResponse'; 3 | import { ServerRequest } from './server.adapter'; 4 | 5 | type Payload = { 6 | result: { 7 | code: string; 8 | message: string; 9 | requestId: string; 10 | errors?: string[]; 11 | }, 12 | data?: unknown; 13 | extra?: unknown; 14 | } 15 | 16 | type ExtraParams = { data?: unknown; extra?: unknown; errors?: string[] }; 17 | export enum MimeTypes { 18 | JSON = 'application/json', 19 | } 20 | 21 | export default class APIResponse { 22 | private defaultType: MimeTypes = MimeTypes.JSON; 23 | 24 | public statusCode: number; 25 | public internalCode: string; 26 | public requestId: string; 27 | public message: string; 28 | public data: unknown; 29 | public extra: unknown; 30 | public errors: string[]; 31 | public request: ServerRequest; 32 | public forcedType: MimeTypes; 33 | 34 | public constructor(response: AlpacaResponse, request: ServerRequest, extra: ExtraParams = {}) { 35 | this.statusCode = response.statusCode || httpStatusCodes.INTERNAL_SERVER_ERROR; 36 | this.internalCode = response.code || `${this.statusCode}00`; 37 | this.requestId = request.id; 38 | this.message = response.message; 39 | this.data = extra.data; 40 | this.extra = extra.extra; 41 | this.errors = extra.errors || []; 42 | this.request = request; 43 | } 44 | 45 | private handlers: Record Payload | string> = { 46 | [ MimeTypes.JSON ]: () => { 47 | const response: Payload = { 48 | result: { 49 | code: this.internalCode, 50 | message: this.message, 51 | requestId: this.requestId, 52 | }, 53 | data: this.data, 54 | extra: this.extra, 55 | }; 56 | if (this.errors.length > 0) { 57 | response.result.errors = this.errors; 58 | } 59 | return response; 60 | }, 61 | }; 62 | 63 | public forceType(type: MimeTypes): void { 64 | this.forcedType = type; 65 | } 66 | 67 | public get mimeType(): MimeTypes { 68 | const mimeTypes = this.request?.swagger?.operation?.produces || []; 69 | const requiredContent = this.forcedType || this.request?.headers?.accept; 70 | return mimeTypes.find(mimeType => mimeType === requiredContent) || this.defaultType; 71 | } 72 | 73 | public build(): Payload | string { 74 | const responseType = this.mimeType; 75 | return this.handlers[responseType] ? this.handlers[responseType]() : this.handlers[this.defaultType](); 76 | } 77 | 78 | public send(res): void { 79 | if (this.mimeType === MimeTypes.JSON) { 80 | res.status(this.statusCode).json(this.build()); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/infrastructure/adapters/server/server.adapter.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | import { Response } from 'express-serve-static-core'; 3 | import http, { IncomingMessage } from 'http'; 4 | import { SwaggerRequestParameters } from 'swagger-tools'; 5 | import config from '../../../config'; 6 | import { Dependencies } from '../../../container'; 7 | import AlpacaResponse from '../../../domain/primitives/alpacaResponse'; 8 | import { InternalError } from '../../../domain/primitives/exceptions'; 9 | import LoggerInterface from '../../../domain/primitives/logger.interface'; 10 | import errorMiddleware from './middlewares/error.middleware'; 11 | import loggerMiddleware from './middlewares/logger.middleware'; 12 | import requestID from './middlewares/requestId.middleware'; 13 | import sanitizerMiddleware from './middlewares/sanitizer.middleware'; 14 | import swaggerParamsMiddleware from './middlewares/swaggerParams.middleware'; 15 | import { getParams } from './model/params.model'; 16 | import APIResponse, { MimeTypes } from './response.model'; 17 | 18 | export enum Environment { 19 | DEV = 'DEV', 20 | PRO = 'PRO' 21 | } 22 | 23 | type ServerConfig = { 24 | port: number; 25 | sslPort?: number; 26 | host: string, 27 | controllersPath: string; 28 | apiDefinition; 29 | middlewares?: { path: string, handler: RequestHandler }[]; 30 | } 31 | 32 | export type ServerRequest = Express.Request & IncomingMessage & { 33 | swagger: { 34 | params: SwaggerRequestParameters; 35 | operation: { 36 | produces: MimeTypes[]; 37 | operationId: string; 38 | 'x-swagger-router-controller': string; 39 | }; 40 | }; 41 | params: Record; 42 | body: Record; 43 | query: Record; 44 | headers: Record; 45 | logger: LoggerInterface; 46 | id: string; 47 | }; 48 | 49 | type Handler = (req: ServerRequest, res: Response) => Promise; 50 | 51 | function getChildLog(req: ServerRequest): LoggerInterface { 52 | return req.logger.child({ 53 | entrypoint: req.swagger?.operation?.['x-swagger-router-controller'], 54 | operation: req.swagger?.operation?.operationId, 55 | referer: req.headers.referer, 56 | }); 57 | } 58 | 59 | function buildResponseFromError(error: unknown, req: ServerRequest, childLog: LoggerInterface): APIResponse { 60 | if (error instanceof AlpacaResponse) { 61 | childLog.debug({ error }, 'Error executing controller'); 62 | return new APIResponse(error, req); 63 | } else { 64 | const params = JSON.stringify(getParams(req)); 65 | childLog.fatal({ error, params }, 'Unhandled error executing controller'); 66 | return new APIResponse(InternalError, req); 67 | } 68 | } 69 | 70 | export const requestHandler = (fn: Handler) => async (req: ServerRequest, res: Response) => { 71 | const childLog = getChildLog(req); 72 | childLog.debug('Executing'); 73 | let response: APIResponse; 74 | try { 75 | response = await fn(req, res); 76 | childLog.debug('Executed correctly'); 77 | } catch (error) { 78 | response = buildResponseFromError(error, req, childLog); 79 | } finally { 80 | res.status(response.statusCode).json(response.build()); 81 | } 82 | }; 83 | 84 | export default ({ 85 | express, 86 | swaggerTools, 87 | logger, 88 | cors, 89 | helmet, 90 | }: Dependencies) => { 91 | const app = express() as any; 92 | return (serverConfig: ServerConfig) => { 93 | const options = { 94 | controllers: serverConfig.controllersPath, 95 | }; 96 | 97 | const CORS_CONFIG = { 98 | origin: config.SERVER.CORS.WHITELIST, 99 | credentials: config.SERVER.CORS.CREDENTIALS, 100 | maxAge: config.SERVER.CORS.MAX_AGE, 101 | }; 102 | 103 | const server = http.createServer(app); 104 | 105 | swaggerTools.initializeMiddleware(serverConfig.apiDefinition, function (middleware) { 106 | app.use(cors(CORS_CONFIG)); 107 | app.use(middleware.swaggerMetadata()); 108 | app.use(requestID()); 109 | app.use(helmet({ 110 | contentSecurityPolicy: false, 111 | crossOriginResourcePolicy: { policy: 'cross-origin' }, 112 | })); 113 | app.use(loggerMiddleware(logger)); 114 | app.use(middleware.swaggerValidator()); 115 | app.use(sanitizerMiddleware); 116 | app.use(swaggerParamsMiddleware); 117 | app.use(middleware.swaggerRouter(options)); 118 | app.use(errorMiddleware); 119 | app.use(middleware.swaggerUi()); 120 | 121 | serverConfig.middlewares?.forEach(({ path, handler }) => app.use(path, handler)); 122 | server.listen(serverConfig.port, serverConfig.host); 123 | 124 | logger.debug(`Your server is listening on port ${serverConfig.port} (http://${serverConfig.host}:${serverConfig.port})`); 125 | logger.debug(`Swagger-ui is available on http://${serverConfig.host}:${serverConfig.port}/docs`); 126 | }); 127 | }; 128 | }; 129 | -------------------------------------------------------------------------------- /src/infrastructure/api/definition/definitions.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | apiResult: { 3 | type: 'object', 4 | properties: { 5 | result: { 6 | type: 'object', 7 | properties: { 8 | code: { 9 | description: 'The unique code of the response', 10 | type: 'string', 11 | example: 'ALPACA40001', 12 | }, 13 | requestId: { 14 | description: 'The unique identifier for the request', 15 | type: 'string', 16 | format: 'uuid', 17 | example: '8c8ff55c-11f5-4b3c-8596-3d9831a8934d', 18 | }, 19 | message: { 20 | description: 'The response message in a human format', 21 | type: 'string', 22 | example: 'Request message response', 23 | }, 24 | }, 25 | }, 26 | }, 27 | }, 28 | 29 | paginationInformation: { 30 | type: 'object', 31 | properties: { 32 | total: { 33 | description: 'Total entities retrieved in data', 34 | type: 'number', 35 | example: 3, 36 | }, 37 | limit: { 38 | description: 'Maximum number of returned entities', 39 | type: 'number', 40 | example: 20, 41 | }, 42 | offset: { 43 | description: 'Numbers of entities omitted', 44 | type: 'number', 45 | example: 0, 46 | }, 47 | }, 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /src/infrastructure/api/definition/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { version } from './../../../../package.json'; 3 | import definitions from './definitions'; 4 | import userEndpoints from './user'; 5 | import userDefinitions from './user/definitions'; 6 | 7 | export default { 8 | swagger: '2.0', 9 | info: { 10 | version, 11 | title: 'Alpaca API', 12 | }, 13 | basePath: '/', 14 | schemes: [ 'http', 'https' ], 15 | paths: { 16 | ...userEndpoints, 17 | '/public/health': { 18 | get: { 19 | operationId: 'healthCheck', 20 | 'x-swagger-router-controller': 'health.entrypoint', 21 | tags: [ 'health' ], 22 | description: 'Checks the health of the service', 23 | responses: { 24 | 200: { 25 | description: 'Service is healthy', 26 | schema: { 27 | $ref: '#/definitions/apiResult', 28 | }, 29 | }, 30 | 500: { 31 | description: 'Internal server error', 32 | schema: { 33 | $ref: '#/definitions/apiResult', 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | definitions: { 41 | ...definitions, 42 | ...userDefinitions, 43 | }, 44 | parameters: { 45 | limit: { 46 | name: 'limit', 47 | in: 'query', 48 | required: false, 49 | default: 20, 50 | type: 'integer', 51 | description: 'number of items per page', 52 | }, 53 | offset: { 54 | name: 'offset', 55 | in: 'query', 56 | required: false, 57 | default: 0, 58 | type: 'integer', 59 | description: 'numbers of items to skip', 60 | }, 61 | sort: { 62 | name: 'sort', 63 | in: 'query', 64 | required: false, 65 | default: 'createdAt:DESC', 66 | type: 'string', 67 | description: 68 | 'the field you want to sort and the order you want separate with :', 69 | }, 70 | }, 71 | }; 72 | -------------------------------------------------------------------------------- /src/infrastructure/api/definition/user/definitions.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | user: { 3 | type: 'object', 4 | properties: { 5 | id: { 6 | description: 'User ID', 7 | type: 'number', 8 | example: 53, 9 | }, 10 | email: { 11 | description: 'User email', 12 | type: 'string', 13 | example: 'john.doe@example.com', 14 | }, 15 | nickname: { 16 | description: 'User nickname', 17 | type: 'string', 18 | example: 'John Doe', 19 | }, 20 | createdAt: { 21 | description: 'The date when the user was created', 22 | type: 'string', 23 | example: '2020-01-13 09:45:01', 24 | }, 25 | updatedAt: { 26 | description: 'The date when the user was updated', 27 | type: 'string', 28 | example: '2020-01-13 09:45:01', 29 | }, 30 | }, 31 | }, 32 | 33 | userResponse: { 34 | allOf: [ 35 | { 36 | $ref: '#/definitions/apiResult', 37 | }, 38 | { 39 | type: 'object', 40 | properties: { 41 | data: { 42 | type: 'object', 43 | $ref: '#/definitions/user', 44 | }, 45 | }, 46 | }, 47 | ], 48 | }, 49 | 50 | userPaginatedResponse: { 51 | allOf: [ 52 | { 53 | $ref: '#/definitions/apiResult', 54 | }, 55 | { 56 | type: 'object', 57 | properties: { 58 | data: { 59 | type: 'object', 60 | properties: { 61 | data: { 62 | type: 'array', 63 | items: { 64 | $ref: '#/definitions/user', 65 | }, 66 | }, 67 | }, 68 | }, 69 | extra: { 70 | type: 'object', 71 | $ref: '#/definitions/paginationInformation', 72 | }, 73 | }, 74 | }, 75 | ], 76 | }, 77 | }; 78 | -------------------------------------------------------------------------------- /src/infrastructure/api/definition/user/index.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | '/users': { 3 | post: { 4 | operationId: 'create', 5 | 'x-swagger-router-controller': 'user.entrypoint', 6 | tags: [ 7 | 'users', 8 | ], 9 | description: 'Creates a new user', 10 | parameters: [ 11 | { 12 | name: 'data', 13 | in: 'body', 14 | required: true, 15 | schema: { 16 | type: 'object', 17 | additionalProperties: false, 18 | required: [ 'email', 'nickname' ], 19 | properties: { 20 | email: { 21 | description: 'User email', 22 | type: 'string', 23 | example: 'johndoe@example.com', 24 | }, 25 | nickname: { 26 | description: 'User nickname', 27 | type: 'string', 28 | example: 'johndoe', 29 | }, 30 | }, 31 | }, 32 | }, 33 | ], 34 | responses: { 35 | 201: { 36 | description: 'User created successfully', 37 | schema: { 38 | $ref: '#/definitions/userResponse', 39 | }, 40 | }, 41 | 500: { 42 | description: 'Internal server error', 43 | schema: { 44 | $ref: '#/definitions/apiResult', 45 | }, 46 | }, 47 | }, 48 | }, 49 | get: { 50 | operationId: 'getAll', 51 | 'x-swagger-router-controller': 'user.entrypoint', 52 | tags: [ 53 | 'users', 54 | ], 55 | description: 'Retrieves all users', 56 | parameters: [ 57 | { 58 | name: 'nickname', 59 | in: 'query', 60 | type: 'string', 61 | description: 'User nickname', 62 | }, 63 | { 64 | name: 'email', 65 | in: 'query', 66 | type: 'string', 67 | description: 'User email', 68 | }, 69 | { $ref: '#/parameters/limit' }, 70 | { $ref: '#/parameters/offset' }, 71 | ], 72 | responses: { 73 | 200: { 74 | description: 'Users retrieved successfully', 75 | schema: { 76 | $ref: '#/definitions/userPaginatedResponse', 77 | }, 78 | }, 79 | 500: { 80 | description: 'Internal server error', 81 | schema: { 82 | $ref: '#/definitions/apiResult', 83 | }, 84 | }, 85 | }, 86 | }, 87 | }, 88 | 89 | '/users/{userId}': { 90 | parameters: [ 91 | { 92 | name: 'userId', 93 | in: 'path', 94 | required: true, 95 | type: 'number', 96 | description: 'User id', 97 | }, 98 | ], 99 | get: { 100 | operationId: 'get', 101 | 'x-swagger-router-controller': 'user.entrypoint', 102 | tags: [ 103 | 'users', 104 | ], 105 | description: 'Retrieves a user by id', 106 | responses: { 107 | 200: { 108 | description: 'User retrieved successfully', 109 | schema: { 110 | $ref: '#/definitions/userResponse', 111 | }, 112 | }, 113 | 404: { 114 | description: 'User not found', 115 | schema: { 116 | $ref: '#/definitions/apiResult', 117 | }, 118 | }, 119 | 500: { 120 | description: 'Internal server error', 121 | schema: { 122 | $ref: '#/definitions/apiResult', 123 | }, 124 | }, 125 | }, 126 | }, 127 | patch: { 128 | operationId: 'update', 129 | 'x-swagger-router-controller': 'user.entrypoint', 130 | tags: [ 131 | 'users', 132 | ], 133 | description: 'Updates a user by id', 134 | parameters: [ 135 | { 136 | name: 'data', 137 | in: 'body', 138 | required: true, 139 | schema: { 140 | type: 'object', 141 | additionalProperties: false, 142 | properties: { 143 | email: { 144 | description: 'User email', 145 | type: 'string', 146 | example: 'johndoe@example.com', 147 | }, 148 | nickname: { 149 | description: 'User nickname', 150 | type: 'string', 151 | example: 'johndoe', 152 | }, 153 | }, 154 | }, 155 | }, 156 | ], 157 | responses: { 158 | 202: { 159 | description: 'User updated successfully', 160 | schema: { 161 | $ref: '#/definitions/userResponse', 162 | }, 163 | }, 164 | 404: { 165 | description: 'User not found', 166 | schema: { 167 | $ref: '#/definitions/apiResult', 168 | }, 169 | }, 170 | 500: { 171 | description: 'Internal server error', 172 | schema: { 173 | $ref: '#/definitions/apiResult', 174 | }, 175 | }, 176 | }, 177 | }, 178 | delete: { 179 | operationId: 'delete', 180 | 'x-swagger-router-controller': 'user.entrypoint', 181 | tags: [ 182 | 'users', 183 | ], 184 | description: 'Deletes a user by id', 185 | responses: { 186 | 204: { 187 | description: 'User deleted successfully', 188 | }, 189 | 500: { 190 | description: 'Internal server error', 191 | schema: { 192 | $ref: '#/definitions/apiResult', 193 | }, 194 | }, 195 | }, 196 | }, 197 | }, 198 | }; 199 | 200 | -------------------------------------------------------------------------------- /src/infrastructure/api/entrypoints/health.entrypoint.ts: -------------------------------------------------------------------------------- 1 | import container, { Dependencies } from '../../../container'; 2 | 3 | const healthController = container.resolve('healthController') as Dependencies['healthController']; 4 | 5 | export = healthController; 6 | -------------------------------------------------------------------------------- /src/infrastructure/api/entrypoints/user.entrypoint.ts: -------------------------------------------------------------------------------- 1 | import container, { Dependencies } from '../../../container'; 2 | 3 | const userController = container.resolve('userController') as Dependencies['userController']; 4 | 5 | export = userController; 6 | -------------------------------------------------------------------------------- /src/infrastructure/controllers/apiResponses.ts: -------------------------------------------------------------------------------- 1 | import statusCodes from 'http-status-codes'; 2 | import AlpacaResponse from '../../domain/primitives/alpacaResponse'; 3 | 4 | export const ServiceOk = new AlpacaResponse({ code: 'SV20000', statusCode: statusCodes.OK, message: 'Service is OK' }); 5 | export const UserRetrievedOk = new AlpacaResponse({ code: 'US20001', statusCode: statusCodes.OK, message: 'User retrieved correctly' }); 6 | export const UsersRetrievedOk = new AlpacaResponse({ code: 'US20002', statusCode: statusCodes.OK, message: 'Users retrieved correctly' }); 7 | 8 | export const UserCreatedOk = new AlpacaResponse({ code: 'US20100', statusCode: statusCodes.CREATED, message: 'User created successfully' }); 9 | 10 | export const UserUpdatedOk = new AlpacaResponse({ code: 'US20200', statusCode: statusCodes.ACCEPTED, message: 'User updated successfully' }); 11 | 12 | export const UserDeletedOk = new AlpacaResponse({ code: 'US20400', statusCode: statusCodes.NO_CONTENT, message: 'User deleted successfully' }); 13 | -------------------------------------------------------------------------------- /src/infrastructure/controllers/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { version } from '../../../package.json'; 2 | import config from '../../config'; 3 | import { Dependencies } from '../../container'; 4 | import APIResponse from '../adapters/server/response.model'; 5 | import { requestHandler } from '../adapters/server/server.adapter'; 6 | import { ServiceOk } from './apiResponses'; 7 | 8 | export default ({ logger, dbAdapter, axios }: Dependencies) => { 9 | return { 10 | healthCheck: requestHandler(async req => { 11 | const health = { 12 | db: false, 13 | connectivity: false, 14 | }; 15 | 16 | try { 17 | await dbAdapter.query({ sql: 'SELECT 1' }); 18 | health.db = true; 19 | } catch (error) { 20 | logger.error({ error }, 'Error connecting to the Alpaca database'); 21 | } 22 | 23 | try { 24 | await axios.get('http://www.google.com'); 25 | health.connectivity = true; 26 | } catch (error) { 27 | logger.error({ error }, 'Error with internet connectivity'); 28 | } 29 | 30 | return new APIResponse(ServiceOk, req, { data: { 31 | ...config.SERVER, 32 | version, 33 | health, 34 | deployment: config.DEPLOYMENT_INFO, 35 | } }); 36 | }), 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/infrastructure/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Dependencies } from '../../container'; 2 | import { UserFilter } from '../../usecases/abstractions/userRepository.interface'; 3 | import { EntityPagination } from '../../usecases/types/queryParams.types'; 4 | import { UserCreationPayload, UserUpdationPayload } from '../../usecases/user/user.usecases'; 5 | import { getParams } from '../adapters/server/model/params.model'; 6 | import APIResponse from '../adapters/server/response.model'; 7 | import { requestHandler } from '../adapters/server/server.adapter'; 8 | import { UserCreatedOk, UserDeletedOk, UserRetrievedOk, UsersRetrievedOk, UserUpdatedOk } from './apiResponses'; 9 | 10 | export default ({ userUsecases }: Dependencies) => { 11 | return { 12 | create: requestHandler(async req => { 13 | const { email, password, nickname } = getParams(req); 14 | const payload = { email, password, nickname } satisfies UserCreationPayload; 15 | const user = await userUsecases.create(payload); 16 | return new APIResponse(UserCreatedOk, req, { data: user }); 17 | }), 18 | 19 | get: requestHandler(async req => { 20 | const { userId } = getParams<{ userId: number }>(req); 21 | const user = await userUsecases.get(userId); 22 | return new APIResponse(UserRetrievedOk, req, { data: user }); 23 | }), 24 | 25 | getAll: requestHandler(async req => { 26 | const { nickname, email, ...paginationParams } = getParams(req); 27 | const filter = { nickname, email } satisfies UserFilter; 28 | const { users, total } = await userUsecases.getAll(filter, paginationParams); 29 | return new APIResponse(UsersRetrievedOk, req, { 30 | data: users, 31 | extra: { 32 | ...paginationParams, 33 | total, 34 | }, 35 | }); 36 | }), 37 | 38 | update: requestHandler(async req => { 39 | const { userId } = getParams<{ userId: number }>(req); 40 | const { email, nickname } = getParams(req); 41 | const payload = { email, nickname } satisfies UserUpdationPayload; 42 | const user = await userUsecases.update(userId, payload); 43 | return new APIResponse(UserUpdatedOk, req, { data: user }); 44 | }), 45 | 46 | delete: requestHandler(async req => { 47 | const { userId } = getParams<{ userId: number }>(req); 48 | await userUsecases.delete(userId); 49 | return new APIResponse(UserDeletedOk, req); 50 | }), 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/user/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | import config from '../../../config'; 3 | import { Dependencies } from '../../../container'; 4 | import User from '../../../domain/User/User'; 5 | import Time from '../../../domain/primitives/Time'; 6 | import UserRepositoryInterface, { UserFilter } from '../../../usecases/abstractions/userRepository.interface'; 7 | import { EntityPagination } from '../../../usecases/types/queryParams.types'; 8 | 9 | const TABLE_NAME = 'user'; 10 | const tablesConfig = config.TABLES; 11 | type UserRestore = { 12 | [TABLE_NAME]: DbUser 13 | } 14 | 15 | export type DbUser = { 16 | id: number; 17 | email: string; 18 | nickname: string; 19 | password: string; 20 | created_at: string; 21 | updated_at: string; 22 | }; 23 | 24 | export default ({ dbAdapter }: Dependencies): UserRepositoryInterface => { 25 | function baseQuery(filter: UserFilter = {}, paginationParams?: EntityPagination,) { 26 | const queryBuilder = dbAdapter 27 | .db(`${tablesConfig.USER} as ${TABLE_NAME}`) 28 | .options({ nestTables: true }); 29 | 30 | addFilter(queryBuilder, filter); 31 | addPagination(queryBuilder, paginationParams); 32 | 33 | return queryBuilder; 34 | } 35 | 36 | function addFilter(query: Knex.QueryBuilder, filter: UserFilter) { 37 | if (filter.userId) { 38 | query.where(`${TABLE_NAME}.id`, filter.userId); 39 | } 40 | 41 | if (filter.email) { 42 | query.where(`${TABLE_NAME}.email`, filter.email); 43 | } 44 | 45 | if (filter.nickname) { 46 | query.where(`${TABLE_NAME}.nickname`, filter.nickname); 47 | } 48 | } 49 | 50 | function addPagination(query: Knex.QueryBuilder, paginationParams?: EntityPagination) { 51 | if (paginationParams) { 52 | query.limit(paginationParams.limit).offset(paginationParams.offset); 53 | } 54 | } 55 | 56 | return { 57 | async create(user) { 58 | const [ id ] = await dbAdapter.db(config.TABLES.USER) 59 | .insert(adapt(user), [ 'id' ]); 60 | user.id = id; 61 | return user; 62 | }, 63 | 64 | async get(userId) { 65 | const user = await baseQuery({ userId }).first(); 66 | return user ? restore(user) : null; 67 | }, 68 | 69 | async getAll(filter = {}, paginationParams) { 70 | const query = baseQuery(filter, paginationParams); 71 | const users = await query; 72 | return users.map(restore); 73 | }, 74 | 75 | async update(user) { 76 | await dbAdapter.db(tablesConfig.USER).where('id', user.id).update(adapt(user)); 77 | }, 78 | 79 | async delete(userId) { 80 | await dbAdapter.db(tablesConfig.USER).where('id', userId).del(); 81 | }, 82 | 83 | async count(filter = {}) { 84 | const query = baseQuery(filter).options({ nestTables: false }); 85 | const [ { total } ] = await query.countDistinct<{ total: number }[]>({ 86 | total: `${TABLE_NAME}.id`, 87 | }); 88 | return total; 89 | }, 90 | }; 91 | }; 92 | 93 | function adapt(user: User): DbUser { 94 | return { 95 | id: user.id, 96 | email: user.email, 97 | nickname: user.nickname, 98 | password: user.password, 99 | created_at: user.createdAt.format(), 100 | updated_at: user.updatedAt.format(), 101 | }; 102 | } 103 | 104 | export function restore(dbUser: UserRestore): User { 105 | const userRow = dbUser[TABLE_NAME]; 106 | return new User({ 107 | id: userRow.id, 108 | email: userRow.email, 109 | nickname: userRow.nickname, 110 | password: userRow.password, 111 | createdAt: Time.fromString(userRow.created_at), 112 | updatedAt: Time.fromString(userRow.updated_at), 113 | }); 114 | } 115 | -------------------------------------------------------------------------------- /src/services/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Dependencies } from '../../container'; 2 | import { UserNotFound } from '../../domain/primitives/exceptions'; 3 | import User from '../../domain/User/User'; 4 | 5 | export default ({ 6 | logger, 7 | userRepository, 8 | }: Dependencies) => { 9 | return { 10 | async getOrFail(userId: number): Promise { 11 | const childLog = logger.child({ userId }); 12 | childLog.debug('Getting user'); 13 | const user = await userRepository.get(userId); 14 | if (!user) { 15 | childLog.warn('User not found'); 16 | throw UserNotFound; 17 | } 18 | childLog.debug('User found'); 19 | return user; 20 | }, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/usecases/abstractions/userRepository.interface.ts: -------------------------------------------------------------------------------- 1 | import User from '../../domain/User/User'; 2 | import { EntityPagination } from '../types/queryParams.types'; 3 | 4 | export type UserFilter = { 5 | userId?: number; 6 | email?: string; 7 | nickname?: string; 8 | } 9 | 10 | interface UserRepositoryInterface { 11 | create(user: User): Promise; 12 | get(userId: number): Promise; 13 | getAll(filter?: UserFilter, paginationParams?: EntityPagination): Promise; 14 | update(user: User): Promise; 15 | delete(userId: number): Promise; 16 | count(filter?: UserFilter): Promise; 17 | } 18 | 19 | export default UserRepositoryInterface; 20 | -------------------------------------------------------------------------------- /src/usecases/types/queryParams.types.ts: -------------------------------------------------------------------------------- 1 | export type EntityPagination = { 2 | limit: number, 3 | offset: number, 4 | } 5 | 6 | export type EntitySort = { 7 | sort: T, 8 | order: 'ASC' | 'DESC', 9 | } 10 | -------------------------------------------------------------------------------- /src/usecases/user/outputModels/user.model.ts: -------------------------------------------------------------------------------- 1 | import User from '../../../domain/User/User'; 2 | 3 | export type UserOutput = { 4 | id: number; 5 | email: string; 6 | nickname: string; 7 | createdAt: string; 8 | updatedAt: string; 9 | }; 10 | 11 | export type UserPaginatedOutput = { 12 | total: number; 13 | users: UserOutput[]; 14 | }; 15 | 16 | export default function userModel(user: User): UserOutput { 17 | return { 18 | id: user.id, 19 | email: user.email, 20 | nickname: user.nickname, 21 | createdAt: user.createdAt.format(), 22 | updatedAt: user.updatedAt.format(), 23 | }; 24 | } 25 | 26 | export function paginatedUserModel( 27 | users: User[], 28 | total: number, 29 | ): UserPaginatedOutput { 30 | return { 31 | total, 32 | users: users.map(userModel), 33 | }; 34 | } -------------------------------------------------------------------------------- /src/usecases/user/user.usecases.ts: -------------------------------------------------------------------------------- 1 | import { Dependencies } from '../../container'; 2 | import { UserAlreadyExists } from '../../domain/primitives/exceptions'; 3 | import User from '../../domain/User/User'; 4 | import { UserFilter } from '../abstractions/userRepository.interface'; 5 | import { EntityPagination } from '../types/queryParams.types'; 6 | import userModel, { paginatedUserModel, UserOutput, UserPaginatedOutput } from './outputModels/user.model'; 7 | 8 | export type UserCreationPayload = Pick; 9 | export type UserUpdationPayload = Partial>; 10 | 11 | export default ({ 12 | logger, 13 | userService, 14 | userRepository, 15 | }: Dependencies) => { 16 | return { 17 | async create(payload: UserCreationPayload): Promise { 18 | const childLog = logger.child({ email: payload.email }); 19 | childLog.debug('Creating user'); 20 | 21 | const existingUser = await userRepository.getAll({ email: payload.email }); 22 | if (existingUser.length > 0) { 23 | childLog.warn('User already exists'); 24 | throw UserAlreadyExists; 25 | } 26 | 27 | const user = await userRepository.create(new User({ 28 | ...payload, 29 | })); 30 | childLog.debug('User created successfully'); 31 | return userModel(user); 32 | }, 33 | 34 | async get(userId: number): Promise { 35 | const childLog = logger.child({ userId }); 36 | childLog.debug('Getting an user'); 37 | 38 | const user = await userService.getOrFail(userId); 39 | 40 | childLog.debug('User retrieved successfully'); 41 | return userModel(user); 42 | }, 43 | 44 | async getAll( 45 | filter: UserFilter = {}, 46 | paginatedParams?: EntityPagination 47 | ): Promise { 48 | const childLog = logger.child({ filter, paginatedParams }); 49 | childLog.debug('Getting users'); 50 | const [ users, total ] = await Promise.all([ 51 | userRepository.getAll(filter, paginatedParams), 52 | userRepository.count(filter), 53 | ]); 54 | 55 | childLog.debug('Users retrieved successfully'); 56 | return paginatedUserModel(users, total); 57 | }, 58 | 59 | async update(userId: number, payload: UserUpdationPayload): Promise { 60 | const childLog = logger.child({ userId }); 61 | childLog.debug('Updating user'); 62 | const user = await userService.getOrFail(userId); 63 | user.update(payload); 64 | await userRepository.update(user); 65 | childLog.debug('User updated successfully'); 66 | }, 67 | 68 | async delete(userId: number): Promise { 69 | const childLog = logger.child({ userId }); 70 | childLog.debug('Deleting user'); 71 | const user = await userRepository.get(userId); 72 | if (user) { 73 | await userRepository.delete(user.id); 74 | } 75 | childLog.debug('User deleted successfully'); 76 | }, 77 | }; 78 | }; 79 | -------------------------------------------------------------------------------- /tests/builders/user.builder.ts: -------------------------------------------------------------------------------- 1 | import User from '../../src/domain/User/User'; 2 | 3 | export class UserBuilder { 4 | private data: ConstructorParameters[0] = { 5 | id: 12412, 6 | nickname: 'John Doe', 7 | email: 'john.doe@example.com', 8 | password: 'password', 9 | createdAt: 5461362678, 10 | updatedAt: 5461362678, 11 | }; 12 | 13 | public with(key: string, value: unknown) { 14 | this.data[key] = value; 15 | return this; 16 | } 17 | 18 | public build(): User { 19 | return new User({ 20 | ...this.data, 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/stubs/user.repository.stub.ts: -------------------------------------------------------------------------------- 1 | import UserRepositoryInterface from '../../src/usecases/abstractions/userRepository.interface'; 2 | import Mocked = jest.Mocked; 3 | 4 | const userRepositoryStub: Mocked = { 5 | create: jest.fn(), 6 | getAll: jest.fn(), 7 | get: jest.fn(), 8 | update: jest.fn(), 9 | count: jest.fn(), 10 | delete: jest.fn(), 11 | }; 12 | 13 | export default userRepositoryStub; 14 | -------------------------------------------------------------------------------- /tests/test.container.ts: -------------------------------------------------------------------------------- 1 | import { asValue, AwilixContainer, createContainer } from 'awilix'; 2 | 3 | import baseContainer from './../src/container'; 4 | import userRepositoryStub from './stubs/user.repository.stub'; 5 | 6 | const container: AwilixContainer = createContainer(); 7 | 8 | container.register({ 9 | ...baseContainer.registrations, 10 | 11 | // Repositories 12 | userRepository: asValue(userRepositoryStub), 13 | }); 14 | 15 | export default container; 16 | -------------------------------------------------------------------------------- /tests/test.env: -------------------------------------------------------------------------------- 1 | LOG_LEVEL=error 2 | SERVER_URL=https://dev.alpaca.com/ -------------------------------------------------------------------------------- /tests/test.setup.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | dotenv.config({ path: `${__dirname}/test.env` }); 4 | -------------------------------------------------------------------------------- /tests/usecases/__snapshots__/user.usecases.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`User Usecases Create should model it properly 1`] = ` 4 | Object { 5 | "createdAt": "1970-03-05T05:02:42Z", 6 | "email": "test@test.com", 7 | "id": 12412, 8 | "nickname": "test", 9 | "updatedAt": "1970-03-05T05:02:42Z", 10 | } 11 | `; 12 | 13 | exports[`User Usecases Get All should model it properly 1`] = ` 14 | Object { 15 | "total": 1, 16 | "users": Array [ 17 | Object { 18 | "createdAt": "1970-03-05T05:02:42Z", 19 | "email": "john.doe@example.com", 20 | "id": 12412, 21 | "nickname": "John Doe", 22 | "updatedAt": "1970-03-05T05:02:42Z", 23 | }, 24 | ], 25 | } 26 | `; 27 | 28 | exports[`User Usecases Get should model it properly 1`] = ` 29 | Object { 30 | "createdAt": "1970-03-05T05:02:42Z", 31 | "email": "john.doe@example.com", 32 | "id": 12412, 33 | "nickname": "John Doe", 34 | "updatedAt": "1970-03-05T05:02:42Z", 35 | } 36 | `; 37 | -------------------------------------------------------------------------------- /tests/usecases/user.usecases.test.ts: -------------------------------------------------------------------------------- 1 | import { UserAlreadyExists, UserNotFound } from "../../src/domain/primitives/exceptions"; 2 | import { UserFilter } from "../../src/usecases/abstractions/userRepository.interface"; 3 | import { EntityPagination } from "../../src/usecases/types/queryParams.types"; 4 | import { UserCreationPayload, UserUpdationPayload } from "../../src/usecases/user/user.usecases"; 5 | import { UserBuilder } from "../builders/user.builder"; 6 | import userRepositoryStub from "../stubs/user.repository.stub"; 7 | import container from "../test.container"; 8 | 9 | const { get, getAll, create, update, delete: deleteUser } = container.resolve('userUsecases'); 10 | 11 | describe('User Usecases', () => { 12 | const user = new UserBuilder().build(); 13 | 14 | describe('Create', () => { 15 | const payload: UserCreationPayload = { 16 | email: 'test@test.com', 17 | nickname: 'test', 18 | password: 'test', 19 | }; 20 | const newUser = new UserBuilder() 21 | .with('email', 'test@test.com') 22 | .with('nickname', 'test') 23 | .with('password', 'test') 24 | .build(); 25 | 26 | beforeEach(async () => { 27 | userRepositoryStub.create.mockReset().mockResolvedValueOnce(newUser); 28 | userRepositoryStub.getAll.mockReset().mockResolvedValueOnce([]); 29 | }); 30 | 31 | it('should retrieve the existing user from the repository', async () => { 32 | await create(payload); 33 | expect(userRepositoryStub.create).toHaveBeenCalledTimes(1); 34 | expect(userRepositoryStub.getAll).toHaveBeenCalledWith({ email: payload.email }); 35 | }); 36 | 37 | it('should fail if the user already exists', async () => { 38 | userRepositoryStub.getAll.mockResolvedValueOnce([user]); 39 | try { 40 | await create(payload); 41 | } catch (error) { 42 | expect(error).toBe(UserAlreadyExists); 43 | } 44 | }); 45 | 46 | it('should create the user if it does not exist', async () => { 47 | await create(payload); 48 | expect(userRepositoryStub.create).toHaveBeenCalledTimes(1); 49 | expect(userRepositoryStub.create).toHaveBeenCalledWith(expect.objectContaining({ 50 | email: payload.email, 51 | nickname: payload.nickname, 52 | password: payload.password, 53 | })); 54 | }); 55 | 56 | it('should model it properly', async () => { 57 | const user = await create(payload); 58 | expect(user).toMatchSnapshot(); 59 | }); 60 | }); 61 | 62 | describe('Get', () => { 63 | const userId = user.id; 64 | 65 | beforeEach(() => { 66 | userRepositoryStub.get.mockReset().mockResolvedValueOnce(user); 67 | }); 68 | 69 | it('should retrieve the user from the repository', async () => { 70 | await get(userId); 71 | expect(userRepositoryStub.get).toHaveBeenCalledTimes(1); 72 | expect(userRepositoryStub.get).toHaveBeenCalledWith(userId); 73 | }); 74 | 75 | it('should fail if the user does not exist', async () => { 76 | userRepositoryStub.get.mockReset().mockResolvedValueOnce(null); 77 | try { 78 | await get(userId); 79 | } catch (error) { 80 | expect(error).toBe(UserNotFound); 81 | } 82 | }); 83 | 84 | it('should model it properly', async () => { 85 | const user = await get(userId); 86 | expect(user).toMatchSnapshot(); 87 | }); 88 | }); 89 | 90 | describe('Get All', () => { 91 | const filter: UserFilter = { 92 | nickname: 'test', 93 | }; 94 | const paginationParams: EntityPagination = { 95 | limit: 10, 96 | offset: 0, 97 | }; 98 | 99 | const users = [user]; 100 | 101 | beforeEach(() => { 102 | userRepositoryStub.getAll.mockReset().mockResolvedValueOnce(users); 103 | userRepositoryStub.count.mockReset().mockResolvedValueOnce(users.length); 104 | }); 105 | 106 | it('should retrieve all users from the repository', async () => { 107 | await getAll(filter, paginationParams); 108 | expect(userRepositoryStub.getAll).toHaveBeenCalledTimes(1); 109 | expect(userRepositoryStub.getAll).toHaveBeenCalledWith(filter, paginationParams); 110 | }); 111 | 112 | it('should count the total number of users', async () => { 113 | await getAll(filter, paginationParams); 114 | expect(userRepositoryStub.count).toHaveBeenCalledTimes(1); 115 | expect(userRepositoryStub.count).toHaveBeenCalledWith(filter); 116 | }); 117 | 118 | it('should model it properly', async () => { 119 | const users = await getAll(filter, paginationParams); 120 | expect(users).toMatchSnapshot(); 121 | }); 122 | }); 123 | 124 | describe('Update', () => { 125 | const userId = user.id; 126 | const payload: UserUpdationPayload = { 127 | email: 'test@test.com', 128 | nickname: 'test', 129 | }; 130 | 131 | beforeEach(() => { 132 | userRepositoryStub.get.mockReset().mockResolvedValueOnce(user); 133 | userRepositoryStub.update.mockReset().mockResolvedValueOnce(); 134 | }); 135 | 136 | it('should retrieve the user from the repository', async () => { 137 | await update(userId, payload); 138 | expect(userRepositoryStub.get).toHaveBeenCalledTimes(1); 139 | expect(userRepositoryStub.get).toHaveBeenCalledWith(userId); 140 | }); 141 | 142 | it('should fail if the user does not exist', async () => { 143 | userRepositoryStub.get.mockReset().mockResolvedValueOnce(null); 144 | try { 145 | await update(userId, payload); 146 | } catch (error) { 147 | expect(error).toBe(UserNotFound); 148 | } 149 | }); 150 | 151 | it('should update the user', async () => { 152 | await update(userId, payload); 153 | expect(userRepositoryStub.update).toHaveBeenCalledTimes(1); 154 | expect(userRepositoryStub.update).toHaveBeenCalledWith(expect.objectContaining({ 155 | id: userId, 156 | email: payload.email, 157 | nickname: payload.nickname, 158 | })); 159 | }); 160 | }); 161 | 162 | describe('Delete', () => { 163 | const userId = user.id; 164 | 165 | beforeEach(() => { 166 | userRepositoryStub.get.mockReset().mockResolvedValueOnce(user); 167 | userRepositoryStub.delete.mockReset(); 168 | }); 169 | 170 | it('should retrieve the user from the repository', async () => { 171 | await deleteUser(userId); 172 | expect(userRepositoryStub.get).toHaveBeenCalledTimes(1); 173 | expect(userRepositoryStub.get).toHaveBeenCalledWith(userId); 174 | }); 175 | 176 | it('should delete the user', async () => { 177 | await deleteUser(userId); 178 | expect(userRepositoryStub.delete).toHaveBeenCalledTimes(1); 179 | expect(userRepositoryStub.delete).toHaveBeenCalledWith(userId); 180 | }); 181 | 182 | it('should do nothing if the user does not exist', async () => { 183 | userRepositoryStub.get.mockReset().mockResolvedValueOnce(null); 184 | await deleteUser(userId); 185 | expect(userRepositoryStub.delete).not.toHaveBeenCalled(); 186 | }); 187 | }); 188 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "target": "ES2022", 5 | "alwaysStrict": true, 6 | "outDir": "./build", 7 | "allowJs": false, 8 | "sourceMap": true, 9 | "module": "CommonJS", 10 | "moduleResolution": "node", 11 | "esModuleInterop": true, 12 | "resolveJsonModule": true, 13 | "lib": [ "ES2022" ], 14 | "skipLibCheck": true, 15 | "noImplicitOverride": true, 16 | }, 17 | "include": [ 18 | "./**/*.ts", 19 | "./**/*.js", 20 | "./**/*.hbs", 21 | "jest.config.js", 22 | ], 23 | "exclude": [ 24 | "node_modules" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------