├── .eslintignore ├── .prettierrc ├── src ├── utils │ ├── index.ts │ ├── try_json.ts │ ├── handler.ts │ ├── unique_array.ts │ ├── deep_merge.ts │ └── random.ts ├── services │ ├── index.ts │ ├── socket.ts │ ├── redis.ts │ ├── logger.ts │ ├── swagger.ts │ ├── methods.ts │ ├── token.ts │ └── http_errors.ts ├── database │ ├── index.ts │ ├── mysql.ts │ └── mongo.ts ├── middlewares │ ├── cors.ts │ ├── i18n │ │ ├── types.ts │ │ ├── locales │ │ │ ├── en.json │ │ │ └── fa.json │ │ └── index.ts │ ├── rate_limit.ts │ ├── check_auth.ts │ ├── transformer.ts │ ├── validator.ts │ └── api_log.ts ├── configs │ ├── index.ts │ └── types.ts ├── app.ts ├── server.ts ├── validators │ └── sample.ts ├── routes │ ├── index.ts │ └── sample.ts ├── controllers │ └── sample.ts └── models │ ├── mongo_sample.ts │ ├── mysql_sample.ts │ └── mongo_base.ts ├── .docker ├── node.dockerfile ├── .gitlab-ci.yml └── docker-compose.yml ├── .eslintrc ├── jest.config.js ├── __tests__ ├── body_samples │ └── body_sample.json └── sample.test.js ├── types └── express │ └── index.d.ts ├── .env.example ├── ssl-cert ├── server.crt └── server.key ├── .gitignore ├── package.json ├── README.md └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | __tests__ 4 | .docker 5 | logs 6 | ssl-cert 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 150 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './deep_merge' 2 | export * from './handler' 3 | export * from './random' 4 | export * from './try_json' 5 | export * from './unique_array' 6 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http_errors' 2 | export * from './logger' 3 | export * from './methods' 4 | export * from './redis' 5 | // export * from './socket' 6 | export * from './swagger' 7 | export * from './token' 8 | -------------------------------------------------------------------------------- /.docker/node.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12-alpine3.12 AS builder 2 | 3 | WORKDIR /usr/src 4 | 5 | RUN npm i -g nodemon typescript ts-node 6 | 7 | COPY package.json ./package.json 8 | RUN npm install 9 | 10 | COPY . . 11 | 12 | CMD ["nodemon"] 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globals: { 3 | "ts-jest": { 4 | tsConfig: "tsconfig.json" 5 | } 6 | }, 7 | moduleFileExtensions: [ 8 | "ts", 9 | "js" 10 | ], 11 | transform: { 12 | "^.+\\.(ts|tsx)$": "ts-jest" 13 | }, 14 | testMatch: [ 15 | "**/__tests__/**/*.test.(ts|js)" 16 | ], 17 | testEnvironment: "node" 18 | } -------------------------------------------------------------------------------- /src/database/index.ts: -------------------------------------------------------------------------------- 1 | import mongoConnect from './mongo' 2 | import mysqlConnect from './mysql' 3 | import { config } from '../configs' 4 | 5 | /** 6 | * Connect to MongoDB or MySQL database 7 | */ 8 | async function dbConnect(): Promise { 9 | if (config.env.DB_TYPE === 'mongodb') await mongoConnect() 10 | else await mysqlConnect() 11 | } 12 | 13 | export default dbConnect 14 | -------------------------------------------------------------------------------- /__tests__/body_samples/body_sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "userId": "5c84d3a915bddd763cf410a0", 3 | "firstName": "Amin", 4 | "lastName": "Abbasi", 5 | "email": "amin.abbasi.rs@gmail.com", 6 | "address": { 7 | "country": "Turkey", 8 | "city": "Antalya", 9 | "location": { 10 | "lat": 32.6665, 11 | "lon": 51.46773 12 | }, 13 | "address": "No. 27, ..." 14 | } 15 | } -------------------------------------------------------------------------------- /types/express/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { Language, Translate } from '../../src/services/i18n' 3 | import { UserAuth } from '../../src/configs/types' 4 | 5 | declare global { 6 | namespace Express { 7 | interface Request { 8 | language: Language 9 | user: UserAuth 10 | } 11 | 12 | interface Response { 13 | t: Translate 14 | result: any 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/services/socket.ts: -------------------------------------------------------------------------------- 1 | import socket from 'socket.io' 2 | import app from '../app' 3 | import { logger } from '.' 4 | // import { } from './methods' 5 | 6 | const io: socket.Server = app.get('io') 7 | 8 | io.sockets.on('connection', (socket: socket.Socket) => { 9 | logger.info(' >>>>> Socket.io Is Connected!') 10 | 11 | // Wait for connection 12 | socket.on('someEvent', (data: { [key: string]: string }) => { 13 | // ... do something 14 | io.emit('test', data) 15 | }) 16 | }) 17 | 18 | export default io 19 | -------------------------------------------------------------------------------- /src/services/redis.ts: -------------------------------------------------------------------------------- 1 | import { RedisClientOptions, createClient } from 'redis' 2 | import { config } from '../configs' 3 | import { logger } from './logger' 4 | 5 | const { REDIS_HOST, REDIS_PORT, REDIS_PASS } = config.env 6 | const url = `redis://${REDIS_PASS ? `:${REDIS_PASS}@` : ''}${REDIS_HOST}:${REDIS_PORT}` 7 | const options: RedisClientOptions = { url } 8 | if (REDIS_PASS) options.password = REDIS_PASS 9 | 10 | const client = createClient(options) 11 | 12 | client.on('error', (err: any) => { 13 | logger.error(`>>>> Redis Error: ${err}`) 14 | }) 15 | logger.info(`<<<< Connected to Redis >>>>`) 16 | 17 | export const Redis = client 18 | -------------------------------------------------------------------------------- /src/middlewares/cors.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | 3 | // Function to set needed header cors 4 | function initCors(_req: Request, res: Response, next: NextFunction): void { 5 | res.append('Access-Control-Allow-Origin', '') 6 | res.append('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE') 7 | res.append( 8 | 'Access-Control-Allow-Headers', 9 | 'Origin, Accept, Access-Control-Allow-Headers, Origin,Accept, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers, Authorization, refresh_token' 10 | ) 11 | res.append('Access-Control-Allow-Credentials', 'true') 12 | next() 13 | } 14 | 15 | export default initCors 16 | -------------------------------------------------------------------------------- /.docker/.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - build 3 | - check 4 | 5 | ##Production(master-docker-compose) 6 | building: 7 | stage: build 8 | script: 9 | - docker-compose -f ./.docker/docker-compose.yml up -d --build --force-recreate ms-sample-project 10 | tags: 11 | - run_trader 12 | only: 13 | refs: 14 | - develop 15 | checking: 16 | stage: check 17 | script: 18 | - docker-compose -f ./.docker/docker-compose.yml logs ms-sample-project 19 | - docker-compose -f ./.docker/docker-compose.yml ps --services --filter "status=running" | grep "ms-sample-project" 20 | tags: 21 | - check_trader 22 | only: 23 | refs: 24 | - develop 25 | -------------------------------------------------------------------------------- /src/middlewares/i18n/types.ts: -------------------------------------------------------------------------------- 1 | // ------------- ALL ERROR MESSAGES IN I18N ------------- 2 | 3 | export enum MESSAGES { 4 | DB_VALIDATION_FAILED = 'DB_VALIDATION_FAILED', 5 | SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', 6 | ILLEGAL_SERVICE_TOKEN = 'ILLEGAL_SERVICE_TOKEN', 7 | USER_NOT_FOUND = 'USER_NOT_FOUND', 8 | USER_FORBIDDEN = 'USER_FORBIDDEN', 9 | UNAUTHORIZED = 'UNAUTHORIZED', 10 | VALIDATION_ERROR = 'VALIDATION_ERROR', 11 | LOGIN_ISSUE = 'LOGIN_ISSUE', 12 | ADMIN_CREDS_ISSUE = 'ADMIN_CREDS_ISSUE', 13 | INVALID_ACCESS_TOKEN = 'INVALID_ACCESS_TOKEN', 14 | INVALID_REFRESH_TOKEN = 'INVALID_REFRESH_TOKEN', 15 | MODEL_NOT_FOUND = 'MODEL_NOT_FOUND', 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/try_json.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if a string can be parsed to JSON 3 | * @param object The object to be parsed to JSON 4 | * @returns Returns the parsed JSON object if successful, otherwise returns false 5 | */ 6 | export function tryJSON(object: unknown): false | Record { 7 | if (typeof object !== 'string') return false // Check if it's a string 8 | 9 | try { 10 | const parsedJSON = JSON.parse(object) as Record 11 | if (typeof parsedJSON !== 'object' || parsedJSON === null) { 12 | return false // Ensure parsed result is an object 13 | } 14 | return parsedJSON // Return parsed JSON object 15 | } catch (e) { 16 | return false // Return false on parsing error 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/middlewares/rate_limit.ts: -------------------------------------------------------------------------------- 1 | import rateLimit from 'express-rate-limit' 2 | 3 | const DEFAULT_MINUTES: number = 10 4 | const DEFAULT_MAX_REQUEST: number = 300 5 | 6 | /** 7 | * Rate Limiter Middleware 8 | * @param minutes Time threshold to restart the limit, default is `10 minutes` 9 | * @param maxRequests Maximum requests per second per IP address limit number, default is `300` 10 | * @returns returns the rate limit middleware to use in express app or routes 11 | */ 12 | export function limiter(maxRequests: number = DEFAULT_MAX_REQUEST, minutes: number = DEFAULT_MINUTES) { 13 | return rateLimit({ 14 | windowMs: minutes * 60 * 1000, 15 | max: maxRequests, 16 | message: `Too many requests, please try again later in ${minutes} minutes.` 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /src/middlewares/i18n/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "DB_VALIDATION_FAILED" : "Database validation failed. Please check your inputs.", 3 | "SERVICE_UNAVAILABLE" : "Connection to service failed. Please contact customer service.", 4 | "ILLEGAL_SERVICE_TOKEN" : "Invalid token. Permission denied.", 5 | "USER_NOT_FOUND" : "User was not found.", 6 | "USER_FORBIDDEN" : "User can not use the service.", 7 | "UNAUTHORIZED" : "User is unauthorized.", 8 | "VALIDATION_ERROR" : "Validation error in request.", 9 | "LOGIN_ISSUE" : "Invalid username or password.", 10 | "ADMIN_CREDS_ISSUE" : "Admin credentials issue.", 11 | "INVALID_ACCESS_TOKEN" : "Invalid access token.", 12 | "INVALID_REFRESH_TOKEN" : "Invalid refresh token.", 13 | "MODEL_NOT_FOUND" : "Model was not found." 14 | } 15 | -------------------------------------------------------------------------------- /src/middlewares/i18n/locales/fa.json: -------------------------------------------------------------------------------- 1 | { 2 | "DB_VALIDATION_FAILED" : "خطا در ذخیره سازی داده ها در دیتابیس. لطفا ورودی های خود را دوباره بررسی کنید", 3 | "SERVICE_UNAVAILABLE" : "ارتباط با سرویس مورد نظر مقدور نمی باشد. لطفا با پشتیبانی تماس بگیرید", 4 | "ILLEGAL_SERVICE_TOKEN" : "توکن اشتباه است. دسترسی به میکروسرویس مورد نظر مسدود می باشد", 5 | "USER_NOT_FOUND" : "کاربر مورد نظر پیدا نشد", 6 | "USER_FORBIDDEN" : "دسترسی به عمل مورد نظر مسدود می باشد", 7 | "UNAUTHORIZED" : "شما دسترسی به این عملیات را ندارید", 8 | "VALIDATION_ERROR" : "خطا در داده های ورودی", 9 | "LOGIN_ISSUE" : "خطا در ورود نام کاربری یا رمز عبور", 10 | "ADMIN_CREDS_ISSUE" : "مشکل در فراخوانی اطلاعات محرمانه ادمین", 11 | "INVALID_ACCESS_TOKEN" : "توکن دسترسی کاربر نامعتبر است", 12 | "INVALID_REFRESH_TOKEN" : "توکن بازیابی کاربر نامعتبر است", 13 | "MODEL_NOT_FOUND" : "مدل مورد نظر پیدا نشد" 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/handler.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | 3 | interface HandlerFunction { 4 | /** 5 | * An asynchronous function that handles an Express request. 6 | * @param req - The Express request object. 7 | * @param res - The Express response object. 8 | * @param next - The Next function to pass control to the next middleware. 9 | */ 10 | (req: Request, res: Response, next: NextFunction): Promise 11 | } 12 | 13 | /** 14 | * Wraps an asynchronous route handler to catch errors and pass them to the next middleware. 15 | * 16 | * @param handler - An asynchronous function to handle the Express request. 17 | * @returns A function to handle the request and catch any errors. 18 | */ 19 | export function handlerFn(handler: HandlerFunction) { 20 | return async (req: Request, res: Response, next: NextFunction): Promise => { 21 | try { 22 | await handler(req, res, next) 23 | } catch (err) { 24 | next(err) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ------------ Node Environment : development / staging / stable / production 2 | NODE_ENV=production 3 | 4 | # ------------ App Environment : local / staging / live 5 | APP_ENV=live 6 | 7 | # ------------ Server Configs 8 | SERVER_PROTOCOL=http 9 | SERVER_HOST=localhost 10 | SERVER_PORT=4000 11 | 12 | # ------------ Database Environment (MongoDB / MySQL) 13 | DB_TYPE=mongodb 14 | 15 | # MongoDB Configs: 16 | DB_HOST=localhost 17 | # DB_USER=your_db_user_name 18 | # DB_PASS=your_db_user_pass 19 | DB_PORT=27017 20 | DB_NAME=your_db_name 21 | 22 | # MySQL Configs: 23 | # ROOT_PASS=vd1fewTy52u67kjh 24 | # DB_NAME=your_db_name 25 | # DB_USER=your_db_user_name 26 | # DB_PASS=your_db_user_pass 27 | # DB_CONNECTION=your_db_connection 28 | 29 | 30 | # ------------ Logger Configs 31 | LOGGER_HOST=localhost 32 | LOGGER_PORT=4000 33 | 34 | # ------------ Redis 35 | REDIS_HOST=localhost 36 | REDIS_PORT=6379 37 | # REDIS_PASS=your_redis_password 38 | 39 | # ------------ JWT Secret Key 40 | JWT_SECRET=your_jwt_secret_key 41 | -------------------------------------------------------------------------------- /src/database/mysql.ts: -------------------------------------------------------------------------------- 1 | import { Connection, ConnectionOptions, createConnection } from 'typeorm' 2 | import { config } from '../configs' 3 | import { logger } from '../services' 4 | 5 | // Database Connection Options 6 | const { DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_CONNECTION } = config.env 7 | let options: ConnectionOptions = { 8 | name: DB_CONNECTION || 'default', 9 | // name: DB_NAME, 10 | type: 'mysql', 11 | host: DB_HOST, 12 | port: DB_PORT, 13 | database: DB_NAME, 14 | logging: true, 15 | // synchronize: true 16 | entities: ['./src/models/*.ts', './dist/models/*.js'] 17 | } 18 | if (DB_USER && DB_PASS) options = { ...options, username: DB_USER, password: DB_PASS } 19 | 20 | // create typeorm connection 21 | async function connectMySQL(): Promise { 22 | try { 23 | const dbConnection: Connection = await createConnection(options) 24 | logger.debug('DB Connection: ', dbConnection) 25 | return dbConnection 26 | } catch (error) { 27 | logger.error('MySQL Connection Error: ', error) 28 | throw Error(`MySQL Connection Error: ${error}`) 29 | } 30 | } 31 | 32 | export default connectMySQL 33 | -------------------------------------------------------------------------------- /src/database/mongo.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import { config } from '../configs' 3 | import { logger } from '../services' 4 | 5 | // Database URL 6 | const { DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS, NODE_ENV } = config.env 7 | const dbURL = `mongodb://${DB_HOST}:${DB_PORT}/${DB_NAME}` 8 | 9 | // Import the mongoose module 10 | const options: mongoose.ConnectOptions = { 11 | autoIndex: false 12 | } 13 | 14 | // Secure MongoDB with username and password 15 | if (DB_USER && DB_PASS) { 16 | options.user = DB_USER 17 | options.pass = DB_PASS 18 | } 19 | 20 | async function connectDB(): Promise { 21 | try { 22 | // Mongoose Debug Mode [set it as `false` in production] 23 | mongoose.set('debug', NODE_ENV === 'development') 24 | mongoose.set('strictQuery', false) 25 | 26 | await mongoose.connect(dbURL, options) 27 | logger.info('<<<< Connected to MongoDB >>>>') 28 | 29 | mongoose.Promise = global.Promise // Get Mongoose to use the global promise library 30 | const db: mongoose.Connection = mongoose.connection // Get the default connection 31 | return db 32 | } catch (error) { 33 | logger.error('MongoDB Connection Error: ', error) 34 | process.exit(1) 35 | } 36 | } 37 | 38 | export default connectDB 39 | -------------------------------------------------------------------------------- /ssl-cert/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIID5TCCAs2gAwIBAgIUSGRrlM4ixHPuAr93g4rMVQLdhqcwDQYJKoZIhvcNAQEL 3 | BQAwgYExCzAJBgNVBAYTAklSMQ8wDQYDVQQIDAZUZWhyYW4xDzANBgNVBAcMBlRl 4 | aHJhbjERMA8GA1UECgwITW9iaXNvZnQxCzAJBgNVBAsMAklUMQ0wCwYDVQQDDARB 5 | bWluMSEwHwYJKoZIhvcNAQkBFhJhbWluNDE5M0BnbWFpbC5jb20wHhcNMjAwNzEy 6 | MDQxMjQwWhcNMjEwNzEyMDQxMjQwWjCBgTELMAkGA1UEBhMCSVIxDzANBgNVBAgM 7 | BlRlaHJhbjEPMA0GA1UEBwwGVGVocmFuMREwDwYDVQQKDAhNb2Jpc29mdDELMAkG 8 | A1UECwwCSVQxDTALBgNVBAMMBEFtaW4xITAfBgkqhkiG9w0BCQEWEmFtaW40MTkz 9 | QGdtYWlsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK9o6d1/ 10 | YosNJg9GCjWAKsoG0FkW9bctJOqmyEm2mdH8NC+odNJsuQJ35vkA4XuJkmK4tNIb 11 | C5diWVoXamAce21B3PDX1tHz2+/bXTXYseptaQ674Yc4ThQgOJkDn/tC3EcRkaJV 12 | 2FmgDI43DSwPovj3vUn6xaWjF/NyMrzw9yzZIBqBZkPprbN7BrLlQr6XghwGhCDT 13 | ytSe5/LoPAiXVP9U23dgM86VNZXQQOM+DXEAoPlO6eBYlhZdcz1dVKlNpA3P0ETB 14 | EHCA+b/x0WmqBrhx3/s8ZtS1KabPFezo4uCTGCSez2MpYD0o4SFVjIqnYnV6viSV 15 | wru66gQW8LXe75cCAwEAAaNTMFEwHQYDVR0OBBYEFJqLOIpvKr3CWTnZYu/fTOeO 16 | 4Z0MMB8GA1UdIwQYMBaAFJqLOIpvKr3CWTnZYu/fTOeO4Z0MMA8GA1UdEwEB/wQF 17 | MAMBAf8wDQYJKoZIhvcNAQELBQADggEBAKdsw7Ec0Mkss6GB5R5+bo9fFUmz+1g1 18 | WDkIDucayhBzrJV0Y1zLg2LwkTYJ6OOwS2rpmzo4WFfjCklQMBzQ3hUyVwg8hg29 19 | 72cF82UVmd9sCAtzknkW6Kr6CI63hj7/rn1pUaWQUNaqTpFiQP5sj9jKfXnLpz7D 20 | Y1tNqUEEAdf6y8soFiNgi4DxjVgeg1B9UhDFPvQ6moGdwj0qTgoXiksgKaDdCv53 21 | M5cF7Dvadw4ZIwhu0SQtN/y0yEwMXTU/Nht8IX0vv6hHsMgnbHhvz+GFVs+ohKlD 22 | OWngg0NjRVaG34i+rXhhs4fLuuaNiiF0WDfBo94/ecOw4lKvHRLDwWY= 23 | -----END CERTIFICATE----- 24 | -------------------------------------------------------------------------------- /src/middlewares/check_auth.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { config } from '../configs' 3 | 4 | import { UserAuth } from '../configs/types' 5 | import { MESSAGES } from './i18n/types' 6 | 7 | import { Errors, Token, logger } from '../services' 8 | 9 | // Function to set needed header auth 10 | export async function checkToken(req: Request, _res: Response, next: NextFunction): Promise { 11 | try { 12 | const authToken: string | undefined = req.headers.authorization?.split(' ')[1] 13 | if (!authToken || authToken === '') throw Errors.Unauthorized(MESSAGES.INVALID_ACCESS_TOKEN) 14 | const user = await Token.isValid(authToken) 15 | if (!user) throw Errors.Unauthorized(MESSAGES.INVALID_ACCESS_TOKEN) 16 | req.user = user as UserAuth 17 | next() 18 | } catch (error) { 19 | logger.error('Check Token Error: ', error) 20 | next(error) 21 | } 22 | } 23 | 24 | // Function to set needed header auth 25 | export function checkRole(roles?: string[]): (req: Request, _res: Response, next: NextFunction) => void { 26 | return function (req: Request, _res: Response, next: NextFunction): void { 27 | try { 28 | const validRoles: string[] = roles ? roles : [config.roleTypes.normal] 29 | const user: UserAuth = req.user 30 | if (!user || !validRoles.includes(user.role)) throw Errors.Unauthorized(MESSAGES.UNAUTHORIZED) 31 | next() 32 | } catch (error) { 33 | logger.error('Check Role Error: ', error) 34 | next(error) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/configs/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import { ConfigModel, EnvironmentModel } from './types' 3 | dotenv.config() 4 | 5 | const env = JSON.parse(JSON.stringify(process.env)) as EnvironmentModel 6 | 7 | // All Configs that needed to be centralized 8 | export const config: ConfigModel = { 9 | // JWT Configuration 10 | jwt: { 11 | key: env.JWT_SECRET || 'your_random_jwt_secret_key', 12 | expiration: 20 * 60 * 1000, // milliseconds (e.g.: 60, "2 days", "10h", "7d") 13 | algorithm: 'HS384', // (default: HS256) 14 | cache_prefix: 'token:', 15 | allow_renew: true, 16 | renew_threshold: 2 * 60 * 1000 17 | }, 18 | 19 | // dotenv App Environment Variables 20 | env, 21 | 22 | // Base URL 23 | // baseURL: 'https://www.your_domain.com', 24 | baseURL: `${env.SERVER_PROTOCOL}://${env.SERVER_HOST}:${env.SERVER_PORT}`, 25 | 26 | // Max Page Size Limit in listing 27 | maxPageSizeLimit: 20, 28 | 29 | // Regex 30 | regex: { 31 | objectId: /^[0-9a-fA-F]{24}$/ 32 | }, 33 | 34 | // Role Types 35 | roleTypes: { 36 | normal: 'normal', 37 | admin: 'admin', 38 | agent: 'agent', 39 | other: 'other' 40 | }, 41 | 42 | // Sort Types 43 | sortTypes: { 44 | date: 'createdAt', 45 | name: 'name' 46 | }, 47 | 48 | // MS Configs --- Should be declared in interface before usage 49 | MS: { 50 | some_microservice: { 51 | // url: 'https://localhost:3000/api', 52 | url: 'https://example.com/api', 53 | paths: { 54 | doSomething: '/v1/samples' 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/middlewares/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import { MESSAGES } from './types' 3 | 4 | import en from './locales/en.json' 5 | import fa from './locales/fa.json' 6 | 7 | // export type LANGUAGES = 'en' | 'fa' | 'tr' 8 | 9 | export const SUPPORTED_LANGUAGES = ['en', 'fa', 'tr'] 10 | export type Language = typeof SUPPORTED_LANGUAGES[number] 11 | 12 | // export type Translations = { 13 | // [key in Language]: { 14 | // [key in MESSAGES]: string 15 | // } 16 | // } 17 | export type Translations = { 18 | [key: string]: { 19 | [key: string]: string 20 | } 21 | } 22 | 23 | const translations: Translations = { en, fa } 24 | 25 | // Function to translate based on set language [default: 'en'] 26 | export type Translate = (message: MESSAGES, lang: Language) => string 27 | 28 | export const t: Translate = (message: MESSAGES, lang: Language): string => { 29 | const translateTo = translations[lang] || translations['en'] 30 | return translateTo[message] || message 31 | } 32 | 33 | export * from './types' 34 | 35 | // middleware to set language 36 | export default function i18n(req: Request, res: Response, next: NextFunction) { 37 | const headerLang = req.headers['content-language'] || req.headers['accept-language'] 38 | 39 | // default language: 'en' 40 | let language: Language = SUPPORTED_LANGUAGES[0] 41 | 42 | if (typeof headerLang === 'string' && SUPPORTED_LANGUAGES.includes(headerLang)) { 43 | language = headerLang 44 | } 45 | 46 | req.language = language 47 | res.t = t 48 | 49 | return next() 50 | } 51 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | // ------ Import npm modules 2 | // import cors from 'cors' 3 | import express from 'express' 4 | import helmet from 'helmet' 5 | 6 | const app: express.Application = express() 7 | 8 | // ------ Initialize & Use Middle-Wares 9 | // app.set('trust proxy', 1) 10 | app.use(express.urlencoded({ extended: true })) 11 | app.use(express.json()) 12 | app.use(helmet()) 13 | // app.use(cors()) 14 | 15 | // ------ Add i18n (internationalization) 16 | import i18n from './middlewares/i18n' 17 | app.use(i18n) 18 | 19 | // TODO: Add other caching systems (like 'RabbitMQ') in the future 20 | 21 | // ------ Socket.io Integration 22 | // import http from 'http' 23 | // import socket from 'socket.io' 24 | // const server: http.Server = new http.Server(app) 25 | // const io: socket.Server = socket(server) 26 | // app.set('io', io) 27 | 28 | // ------ Allows cross-origin domains to access this API 29 | // import initCors from './middlewares/cors' 30 | // app.use(initCors) 31 | 32 | // ------ Add JWT to system globally 33 | // import jwt from 'express-jwt' 34 | // app.use(jwt({ secret: config.jwt.key })) 35 | 36 | // ------ Set Rate Limiter 37 | // import limiter from './middlewares/rate_limit' 38 | // app.use(limiter()) 39 | 40 | // ------ Add logger to system 41 | import logger from './middlewares/api_log' 42 | app.use(logger) 43 | 44 | // ------ Require all routes 45 | import router from './routes' 46 | app.use('/api', router) 47 | 48 | // ------ Add Response Transformer (& error handler) to system 49 | import transformer from './middlewares/transformer' 50 | app.use(transformer) 51 | 52 | export default app 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | *.pid.lock 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (https://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # TypeScript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | 61 | # parcel-bundler cache (https://parceljs.org/) 62 | .cache 63 | 64 | # next.js build output 65 | .next 66 | 67 | # nuxt.js build output 68 | .nuxt 69 | 70 | # vuepress build output 71 | .vuepress/dist 72 | 73 | # Serverless directories 74 | .serverless/ 75 | 76 | # FuseBox cache 77 | .fusebox/ 78 | 79 | # DynamoDB Local files 80 | .dynamodb/ 81 | 82 | # idea file 83 | .idea 84 | 85 | # vscode file 86 | .vscode 87 | 88 | # test snapshots folder 89 | __snapshots__ 90 | 91 | # complied typescript folder 92 | dist 93 | 94 | # ssl Certificate folder 95 | # sslCert/* 96 | 97 | .codegpt -------------------------------------------------------------------------------- /src/services/logger.ts: -------------------------------------------------------------------------------- 1 | import gach, { Colors } from 'gach' 2 | 3 | export enum LOG_LEVEL { 4 | DEBUG = 'debug', 5 | ERROR = 'error', 6 | WARN = 'warn', 7 | INFO = 'info' 8 | } 9 | 10 | const LOG_PRIORITY: Record = { 11 | [LOG_LEVEL.INFO]: 1, 12 | [LOG_LEVEL.DEBUG]: 2, 13 | [LOG_LEVEL.WARN]: 3, 14 | [LOG_LEVEL.ERROR]: 4 15 | } 16 | 17 | const LOG_COLORS: Record = { 18 | [LOG_LEVEL.DEBUG]: 'blue', 19 | [LOG_LEVEL.ERROR]: 'red', 20 | [LOG_LEVEL.WARN]: 'yellow', 21 | [LOG_LEVEL.INFO]: 'cyan' 22 | } 23 | 24 | export class Logger { 25 | private logPriority: number 26 | 27 | constructor(level: LOG_LEVEL = LOG_LEVEL.INFO) { 28 | this.logPriority = LOG_PRIORITY[level] 29 | } 30 | 31 | private log(level: LOG_LEVEL, ...args: any[]): void { 32 | if (LOG_PRIORITY[level] >= this.logPriority) { 33 | const coloredLevel = gach(`[${level.toUpperCase()}]`).color(LOG_COLORS[level]).text 34 | const coloredTime = gach(`[${Logger.getTimestamp()}]`).color('blue').text 35 | const logMessage = `${coloredTime} ${coloredLevel} ${args.join(' ')}` 36 | console[level](logMessage) 37 | } 38 | } 39 | 40 | static getTimestamp() { 41 | return new Date().toISOString().replace('T', ' - ').replace('Z', '') 42 | } 43 | 44 | setLevel(level: LOG_LEVEL) { 45 | this.logPriority = LOG_PRIORITY[level] 46 | } 47 | 48 | error(...args: any[]): void { 49 | this.log(LOG_LEVEL.ERROR, args) 50 | } 51 | 52 | warn(...args: any[]): void { 53 | this.log(LOG_LEVEL.WARN, args) 54 | } 55 | 56 | info(...args: any[]): void { 57 | this.log(LOG_LEVEL.INFO, args) 58 | } 59 | 60 | debug(...args: any[]): void { 61 | this.log(LOG_LEVEL.DEBUG, args) 62 | } 63 | } 64 | 65 | export const logger = new Logger() 66 | -------------------------------------------------------------------------------- /ssl-cert/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCvaOndf2KLDSYP 3 | Rgo1gCrKBtBZFvW3LSTqpshJtpnR/DQvqHTSbLkCd+b5AOF7iZJiuLTSGwuXYlla 4 | F2pgHHttQdzw19bR89vv21012LHqbWkOu+GHOE4UIDiZA5/7QtxHEZGiVdhZoAyO 5 | Nw0sD6L4971J+sWloxfzcjK88Pcs2SAagWZD6a2zeway5UK+l4IcBoQg08rUnufy 6 | 6DwIl1T/VNt3YDPOlTWV0EDjPg1xAKD5TungWJYWXXM9XVSpTaQNz9BEwRBwgPm/ 7 | 8dFpqga4cd/7PGbUtSmmzxXs6OLgkxgkns9jKWA9KOEhVYyKp2J1er4klcK7uuoE 8 | FvC13u+XAgMBAAECggEAakHPiZHlC/7HE3GGNaqSQRenlpBGANSkDFFD+wNWYtG4 9 | ezY8rkuWN1yS2jwiW0eXGZ5ySuR/PREit2R/2ADo8+J2CJ93Tm++x6nG6Rt/i1cD 10 | 0R0XHWaKRb7PCkIb44U5EwQdiI0gJgrCEFRg8lP2kYLhc7Mpj5EgrSR8f+Aom01b 11 | QE5rTW7ZjyBUvc+Z35IfN0ix99WiVVjOcY/y6rUIX2SfATmFIp+qqCZEkfs7C1aY 12 | wjZtYxC2KJb1kDISZKCmQvGqo51yfYGzdL1br78QSktCtNELtwvTCmecGNTCWVWt 13 | 61uJrSRCkOqX0TycDluBJP6iGoJlyM3Bnbt2p5KngQKBgQDaOc70xNqJmLLrtG2x 14 | CkM6P90XJNzojez5UiTmT32ne8zLKbjIgApSiet8J0JBalp2GSyV8EpYRG7lwqeX 15 | 1s2tqBMKzhV/kd15eixb8xDnRb9G7qnLEZA7Kwojbe3npn7GgLyLrDL2/mY0ya4z 16 | XaqoCedwqHWINHQx5Lb08a9FEQKBgQDNxc87OlTeAtVJaI2mf9BUSIvlpIBZWGUY 17 | ROWpsHxRYHjHS8SGVmDpppjGvp2k7q0oKXhaA+3JEgwSgGmhVbehxY9+Xlitjwny 18 | SialVCwELPR+YNDRnlBugf6HJA2O1ExY2wvwp8iT+jGoXw29cQh+RESCbnO+9QfE 19 | gbfpLOzKJwKBgHGRCA0FrR1SZDqZ6UPrLVVRCwQSYQx9X8fYYe+hiqjwmoFYMxvU 20 | 1oph0DUKUUatzGoNa9CT/ny1EGltxb4VQXCQOWi0GygKhxHC7PblJfTVrVeCuMY/ 21 | W6oPGfWaLecSFIPFV1nwwY0ck6ABHTEKG9fbX6CXaqL5eUbF2hja9fWhAoGBAIKt 22 | oNGoVcU4X2NjNrIKca9U8yM/uRMEhA9JkeCV6B11+r32bDQ0Hw/DcTqmS083FFhx 23 | HbFs1VHgWDJXwr1mxlvCL0K9f+uY72Qjmy9bqShttEeeDH9S3xmPDKmeR83xHRtA 24 | 4PBQXZ08QtT+qKcqZY5qpGeA5Zjb27b7+mPm1+n5AoGATRvWkt09hmYdPtum4JRJ 25 | H7d59ZDA2+z+gQd8+2hC6whBfY8zrgPDaCf3WHNqucitKgNgA102NvWFnqN2YrZd 26 | 9VnjjMipx0It8I5vefDWaeSCRTSCCMG4NydtyjOxr3YBTk8TD6v4/BfZiGnSuaXp 27 | iXO9/Jt/eEjYZFLs96xRYeY= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /src/utils/unique_array.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns an array with unique elements from the input array. 3 | * 4 | * This function has two overloads: 5 | * 1. For an array of strings, it returns an array with unique strings. 6 | * 2. For an array of objects, it returns an array with unique objects based on a specified property. 7 | * 8 | * @param array - The input array, either of strings or objects. 9 | * @returns An array with unique elements. 10 | */ 11 | export function setUniqueArray(array: string[]): string[] 12 | 13 | /** 14 | * Returns an array with unique elements from the input array of objects based on a specific property. 15 | * 16 | * @param array - The input array of objects. 17 | * @param prop - The property of the objects used to determine uniqueness. 18 | * @returns An array with unique objects based on the specified property. 19 | */ 20 | export function setUniqueArray>(array: T[], prop: string): T[] 21 | 22 | /** 23 | * Implementation of the `setUniqueArray` function that handles both strings and objects. 24 | * 25 | * For an array of strings, it removes duplicate strings. 26 | * For an array of objects, it removes duplicates based on a specified property. 27 | * 28 | * @param array - The input array, either of strings or objects. 29 | * @param [prop] - Optional property name if the input array is an array of objects. 30 | * @returns An array with unique elements. 31 | */ 32 | export function setUniqueArray(array: any[], prop?: string): any[] { 33 | if (typeof prop === 'string') { 34 | const uniqueMap = new Map() 35 | 36 | for (const item of array) { 37 | if (!uniqueMap.has(item[prop])) { 38 | uniqueMap.set(item[prop], item) 39 | } 40 | } 41 | 42 | return Array.from(uniqueMap.values()) 43 | } else { 44 | return [...new Set(array)] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/configs/types.ts: -------------------------------------------------------------------------------- 1 | import { Algorithm } from 'jsonwebtoken' 2 | 3 | export interface JwtModel { 4 | readonly key: string 5 | readonly expiration: number | string 6 | readonly algorithm: Algorithm 7 | readonly cache_prefix: string 8 | readonly allow_renew: boolean 9 | readonly renew_threshold: number 10 | } 11 | 12 | export interface EnvironmentModel { 13 | readonly NODE_ENV: string 14 | readonly APP_ENV: string 15 | readonly DB_HOST: string 16 | readonly DB_USER?: string 17 | readonly DB_PASS?: string 18 | readonly DB_PORT: number 19 | readonly DB_NAME: string 20 | readonly DB_TYPE: string 21 | readonly DB_CONNECTION: string 22 | readonly SERVER_PROTOCOL: string 23 | readonly SERVER_HOST: string 24 | readonly SERVER_PORT: number 25 | readonly LOGGER_HOST: string 26 | readonly LOGGER_PORT: number 27 | readonly REDIS_HOST?: string 28 | readonly REDIS_PORT?: number 29 | readonly REDIS_PASS?: string 30 | readonly ADMIN_USER?: string 31 | readonly ADMIN_PASS?: string 32 | readonly JWT_SECRET?: string 33 | } 34 | 35 | interface MS_Configs { 36 | [key: string]: { 37 | url: string 38 | paths: { 39 | [key: string]: string 40 | } 41 | } 42 | } 43 | 44 | export interface StringType { 45 | [key: string]: string 46 | } 47 | 48 | export interface RegexType { 49 | [key: string]: RegExp 50 | } 51 | 52 | export interface UserAuth { 53 | id: string 54 | role: string 55 | roleId: string 56 | isActive: boolean 57 | exp?: number 58 | iat?: number 59 | email?: string 60 | mobile?: string 61 | } 62 | 63 | export interface ConfigModel { 64 | readonly jwt: JwtModel 65 | readonly env: EnvironmentModel 66 | readonly baseURL: string 67 | readonly roleTypes: StringType 68 | readonly sortTypes: StringType 69 | readonly MS: MS_Configs 70 | readonly regex: RegexType 71 | readonly maxPageSizeLimit: number 72 | } 73 | -------------------------------------------------------------------------------- /src/services/swagger.ts: -------------------------------------------------------------------------------- 1 | // Open http://:/api/docs in your browser to view the documentation. 2 | import swaggerJSDoc from 'swagger-jsdoc' 3 | import { config } from '../configs' 4 | // eslint-disable-next-line @typescript-eslint/no-var-requires 5 | const myPackage = require('../../package.json') 6 | const { name, version, description, license, author } = myPackage 7 | 8 | const { SERVER_PROTOCOL, SERVER_HOST, SERVER_PORT } = config.env 9 | const url = `${SERVER_PROTOCOL}://${SERVER_HOST}:${SERVER_PORT}/api` 10 | 11 | const swaggerDefinition = { 12 | openapi: '3.0.0', 13 | info: { 14 | title: name, 15 | version, 16 | description, 17 | license: { name: license, url: 'http://aminabbasi.com/licenses' }, 18 | contact: { name: author, email: 'amin.abbasi.rs@gmail.com' } 19 | }, 20 | servers: [{ url: `${url}/v1` }], 21 | // basePath: '/v1', 22 | // schemes: ['http', 'https'], 23 | consumes: ['application/json'], 24 | produces: ['application/json'] 25 | // host: url, // Host (optional) 26 | // securityDefinitions: { 27 | // JWT: { 28 | // type: 'apiKey', 29 | // in: 'header', 30 | // name: 'Authorization', 31 | // description: "JWT Token for user's authorization", 32 | // } 33 | // } 34 | } 35 | 36 | const options: swaggerJSDoc.Options = { 37 | swaggerDefinition: swaggerDefinition, 38 | // Path files to be processes. for: {openapi: '3.0.0'} 39 | apis: ['./src/routes/*.ts', './src/models/*.ts', './dist/routes/*.js', './dist/models/*.js'] 40 | // files: ['../routes/*.js', '../models/*.js'], // Path files to be processes. for: {swagger: '2.0'} 41 | // basedir: __dirname, //app absolute path 42 | // onValidateError: (errors, req, res, next) => { // global handler for validation errors 43 | // res.status(400).send(errors) 44 | // }, 45 | } 46 | 47 | export const specs = swaggerJSDoc(options) 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-boilerplate", 3 | "version": "2.0.3", 4 | "description": "[Project Name] description", 5 | "main": "./src/server.ts", 6 | "scripts": { 7 | "start": "ts-node src/server.ts --trace-warnings", 8 | "test": "jest --updateSnapshot --detectOpenHandles --coverage --forceExit --verbose" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/amin-abbasi/typescript-boilerplate.git" 13 | }, 14 | "keywords": [ 15 | "typescript", 16 | "boilerplate" 17 | ], 18 | "author": "AAH", 19 | "license": "ISC", 20 | "bugs": { 21 | "url": "https://github.com/amin-abbasi/typescript-boilerplate/issues" 22 | }, 23 | "homepage": "https://github.com/amin-abbasi/typescript-boilerplate.git#readme", 24 | "dependencies": { 25 | "dotenv": "16.4.7", 26 | "express": "4.21.2", 27 | "express-rate-limit": "7.5.0", 28 | "gach": "1.7.5", 29 | "helmet": "8.0.0", 30 | "joi": "17.13.3", 31 | "jsonwebtoken": "9.0.2", 32 | "mongoose": "8.9.3", 33 | "mongoose-unique-validator": "5.0.1", 34 | "mysql": "2.18.1", 35 | "redis": "4.7.0", 36 | "reflect-metadata": "0.2.2", 37 | "socket.io": "4.8.1", 38 | "swagger-jsdoc": "6.2.8", 39 | "swagger-ui-express": "5.0.1", 40 | "typeorm": "0.3.20", 41 | "undici": "7.2.0" 42 | }, 43 | "devDependencies": { 44 | "@types/express": "5.0.0", 45 | "@types/helmet": "0.0.48", 46 | "@types/jest": "29.5.14", 47 | "@types/jsonwebtoken": "9.0.7", 48 | "@types/mongoose": "5.11.96", 49 | "@types/mongoose-unique-validator": "1.0.9", 50 | "@types/node": "22.10.3", 51 | "@types/redis": "4.0.10", 52 | "@types/socket.io": "3.0.1", 53 | "@types/swagger-jsdoc": "6.0.4", 54 | "@types/swagger-ui-express": "4.1.7", 55 | "jest": "29.7.0", 56 | "nodemon": "3.1.9", 57 | "prettier": "3.4.2", 58 | "supertest": "7.0.0", 59 | "ts-node": "10.9.2", 60 | "typescript": "5.7.2" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/services/methods.ts: -------------------------------------------------------------------------------- 1 | import { fetch, RequestInit, HeadersInit } from 'undici' 2 | import { Errors } from '../services' 3 | import { MESSAGES } from '../middlewares/i18n' 4 | import { logger } from './logger' 5 | 6 | export enum METHODS { 7 | POST = 'POST', 8 | GET = 'GET', 9 | PUT = 'PUT', 10 | DELETE = 'DELETE' 11 | } 12 | 13 | interface Response { 14 | success: boolean 15 | result?: { 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | [key: string]: any 18 | } 19 | error?: { 20 | statusCode: number 21 | message: string 22 | errors: any 23 | } 24 | } 25 | 26 | interface RestData { 27 | method: METHODS 28 | service: 'service1' | 'service2' 29 | baseUrl: string 30 | pathUrl?: string 31 | headers?: HeadersInit 32 | body?: { [key: string]: any } 33 | query?: { [key: string]: string } 34 | params?: { [key: string]: string } 35 | } 36 | 37 | /** 38 | * Simple Rest API function to do something from a 3rd party 39 | * @param {RestData} data API data 40 | * @return {Promise} returns response 41 | */ 42 | export async function restAPI(data: RestData): Promise { 43 | try { 44 | const { method, baseUrl, pathUrl, headers, body, query } = data 45 | let URL: string = `${baseUrl}${pathUrl || ''}` 46 | const init: RequestInit = { 47 | method, 48 | headers: { 'content-type': 'application/json' } 49 | } 50 | 51 | if (method !== METHODS.GET && body) init.body = JSON.stringify(body) 52 | if (headers) init.headers = { ...init.headers, ...headers } as HeadersInit 53 | if (query) URL += '?' + new URLSearchParams(query).toString() 54 | 55 | const response = await fetch(URL, init) 56 | if (!response.ok) throw await response.json() 57 | return { 58 | success: true, 59 | result: (await response.json()) as any 60 | } 61 | } catch (error) { 62 | logger.error(' ---- Rest API Error: ', error) 63 | throw Errors[503](MESSAGES.SERVICE_UNAVAILABLE, { data: error }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/middlewares/transformer.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { STATUS_CODES } from 'http' 3 | import { MESSAGES } from './i18n/types' 4 | import { logger } from '../services' 5 | 6 | interface MongoUniqueError { 7 | _message: string 8 | errors: any 9 | } 10 | 11 | // type Omit = Pick> 12 | 13 | interface Error extends MongoUniqueError { 14 | statusCode: number | string 15 | status?: number | string 16 | code?: number | string 17 | message: string 18 | data?: { [key: string]: string | boolean | unknown } 19 | } 20 | 21 | function transformer(err: Error, req: Request, res: Response, next: NextFunction): void { 22 | // mongoose-unique-validator error 23 | if (err._message?.includes('validation failed')) { 24 | err.statusCode = 400 25 | err.message = MESSAGES.DB_VALIDATION_FAILED 26 | err.data = JSON.parse(JSON.stringify(err.errors)) 27 | logger.debug(' ------- ResDec - Mongoose-Unique-Validator ERROR:', err) 28 | } 29 | 30 | const response = res.result 31 | ? { 32 | status: '', 33 | statusCode: res.statusCode, 34 | success: typeof res.result !== 'string', 35 | result: res.result 36 | } 37 | : { 38 | statusCode: err.statusCode || err.status || err.code || 500, 39 | message: err.message || STATUS_CODES[500], 40 | errors: err.data || err.errors || null 41 | } 42 | 43 | if (typeof response.statusCode !== 'number' || response.statusCode > 600 || response.statusCode < 100) { 44 | response.status = response.statusCode.toString() 45 | response.statusCode = 500 46 | logger.debug(' ------- ResDec - STRING STATUS CODE:', err) 47 | } else delete response.status 48 | 49 | if (response.statusCode >= 500) logger.debug(' ------- ResDec - SERVER ERROR:', err) 50 | if (response.message) response.message = res.t(response.message as MESSAGES, req.language) 51 | 52 | res.status(response.statusCode).json(response) 53 | next() 54 | } 55 | 56 | export default transformer 57 | -------------------------------------------------------------------------------- /src/utils/deep_merge.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks if the given item is an object. 3 | * @param item The item to check 4 | * @returns True if the item is an object, false otherwise 5 | */ 6 | export function isObject(item: any): boolean { 7 | return item !== null && typeof item === 'object' && !Array.isArray(item) 8 | } 9 | 10 | /** 11 | * Recursively merges two or more objects deeply. 12 | * Arrays are concatenated, and objects are merged together. 13 | * @param target The target object to merge into 14 | * @param sources Objects to merge into the target 15 | * @returns A new object resulting from the deep merge 16 | */ 17 | export function mergeDeep(target: any, ...sources: any[]): any { 18 | if (!sources.length) return target 19 | 20 | const merged = isObject(target) ? { ...target } : target 21 | 22 | for (const source of sources) { 23 | if (isObject(source)) { 24 | for (const key in source) { 25 | if (isObject(source[key]) && isObject(merged[key])) { 26 | merged[key] = mergeDeep(merged[key], source[key]) 27 | } else if (Array.isArray(source[key]) && Array.isArray(merged[key])) { 28 | merged[key] = merged[key].concat(source[key]) // Merge arrays 29 | } else { 30 | merged[key] = source[key] // Replace or set values 31 | } 32 | } 33 | } 34 | } 35 | 36 | return merged 37 | } 38 | 39 | // Example usage 40 | // const targetObject = { 41 | // a: 1, 42 | // b: { 43 | // c: 2, 44 | // d: [3, 4], 45 | // e: { f: 5 } 46 | // } 47 | // } 48 | 49 | // const sourceObject1 = { 50 | // b: { 51 | // c: 10, 52 | // d: [20], 53 | // e: { g: 30 } 54 | // }, 55 | // h: 40 56 | // } 57 | 58 | // const sourceObject2 = { 59 | // b: { 60 | // d: [50, 60], 61 | // e: { h: 70 } 62 | // }, 63 | // i: 80 64 | // } 65 | 66 | // const mergedObject = mergeDeep(targetObject, sourceObject1, sourceObject2) 67 | // console.log(mergedObject) 68 | 69 | // It'll give this: 70 | // { 71 | // a: 1, 72 | // b: { c: 10, d: [ 3, 4, 20, 50, 60 ], e: { f: 5, g: 30, h: 70 } }, 73 | // h: 40, 74 | // i: 80 75 | // } 76 | -------------------------------------------------------------------------------- /.docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | # --------> MY-APP-Server <-------- 5 | my-app: 6 | build: 7 | context: ../ 8 | dockerfile: ./.docker/node.dockerfile 9 | image: my-app 10 | container_name: my-app 11 | restart: unless-stopped 12 | working_dir: /usr/src 13 | env_file: ../.env 14 | ports: 15 | - ${SERVER_PORT}:4000 16 | volumes: 17 | - my-app-volume:/usr 18 | networks: 19 | - my-network 20 | stdin_open: true 21 | tty: true 22 | 23 | 24 | # --------> MY-APP-MONGODB <-------- 25 | my-mongo: 26 | image: mongo:latest 27 | container_name: my-mongo 28 | # env_file: 29 | # - .env 30 | # environment: 31 | # DB_PASS: ${DB_PASS} 32 | # DB_USER: ${DB_USER} 33 | restart: always 34 | networks: 35 | - my-network 36 | ports: 37 | - ${DB_PORT}:27017 38 | volumes: 39 | - my-mongodb-volume:/data/db 40 | 41 | 42 | # --------> MY-APP-MYSQL <-------- 43 | # my-mysql: 44 | # image: mysql:latest 45 | # container_name: my-mysql 46 | # command: --default-authentication-plugin=mysql_native_password 47 | # restart: always 48 | # volumes: 49 | # - my-mysql-volume:/var/lib/mysql 50 | # networks: 51 | # - my-network 52 | # env_file: 53 | # - .env 54 | # environment: 55 | # MYSQL_ROOT_PASSWORD: ${ROOT_PASS} 56 | # MYSQL_DATABASE: ${DB_NAME} 57 | # MYSQL_USER: ${DB_USER} 58 | # MYSQL_PASSWORD: ${DB_PASS} 59 | 60 | 61 | # --------> MY-APP-REDIS <-------- 62 | # my-redis: 63 | # container_name: my-redis 64 | # image: "bitnami/redis:latest" 65 | # user: "root" 66 | # environment: 67 | # ALLOW_EMPTY_PASSWORD: "yes" 68 | # networks: 69 | # - my-network 70 | # ports: 71 | # - ${REDIS_PORT}:6379 72 | # restart: unless-stopped 73 | # volumes: 74 | # - ${REDIS_PASS}/my-redis:/bitnami/redis 75 | 76 | networks: 77 | my-network: 78 | external: 79 | name: my-network 80 | 81 | volumes: 82 | my-app-volume: 83 | my-mongodb-volume: 84 | # my-mysql-volume: 85 | -------------------------------------------------------------------------------- /src/middlewares/validator.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { Errors } from '../services' 3 | import Joi from 'joi' 4 | import { MESSAGES } from './i18n/types' 5 | 6 | enum REQUEST_TYPE { 7 | body = 'body', 8 | query = 'query', 9 | params = 'params', 10 | headers = 'headers' 11 | } 12 | 13 | type ValidationErrors = { 14 | [key: string]: string[] 15 | } 16 | 17 | type SchemaToValidate = { 18 | [key in REQUEST_TYPE]?: Joi.Schema 19 | } 20 | 21 | function createMessage(error: Joi.ValidationError, reqKey: string): ValidationErrors { 22 | const errors: ValidationErrors = {} 23 | for (let i = 0; i < error.details.length; i++) { 24 | const message: string = error.details[i].message 25 | const key: string = message.split('"')[1] 26 | errors[key] = [message + ` (${reqKey})`] 27 | } 28 | return errors 29 | } 30 | 31 | const getKeyValue = (key: U) => (obj: T) => obj[key] 32 | const setKeyValue = (key: U) => (obj: T, value: any) => (obj[key] = value) 33 | 34 | export function validate(schemaToValidate: SchemaToValidate): (req: Request, _res: Response, next: NextFunction) => void { 35 | return async function (req: Request, _res: Response, next: NextFunction): Promise { 36 | try { 37 | let errors: ValidationErrors = {} 38 | 39 | const keys: REQUEST_TYPE[] = Object.keys(schemaToValidate) as REQUEST_TYPE[] 40 | for (let i = 0; i < keys.length; i++) { 41 | const key: REQUEST_TYPE = keys[i] 42 | const schema: Joi.Schema = schemaToValidate[key] as Joi.Schema 43 | const dataToValidate = getKeyValue(key)(req) 44 | const { error, value } = schema.validate(dataToValidate, { abortEarly: false }) 45 | if (error) errors = { ...errors, ...createMessage(error, key) } 46 | else setKeyValue(key)(req, value) 47 | } 48 | 49 | if (Object.keys(errors).length !== 0) throw Errors[400](MESSAGES.VALIDATION_ERROR, { errors }) 50 | next() 51 | } catch (error) { 52 | next(error) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | // Your Express Server Configuration Here 2 | import 'reflect-metadata' 3 | import fs from 'fs' 4 | import path from 'path' 5 | import gach from 'gach' 6 | import http from 'http' 7 | import https from 'https' 8 | 9 | import app from './app' 10 | import dbConnect from './database' 11 | import { config } from './configs' 12 | import { logger } from './services' 13 | 14 | const { NODE_ENV, SERVER_PROTOCOL, SERVER_HOST, SERVER_PORT } = config.env 15 | 16 | // TODO: Avoids DEPTH_ZERO_SELF_SIGNED_CERT error for self-signed certs 17 | // process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' 18 | 19 | // ---------------- Create Server Instance ---------------- 20 | let server: http.Server | https.Server 21 | if (!SERVER_PROTOCOL || SERVER_PROTOCOL === 'http') server = http.createServer(app) 22 | else { 23 | const keyPath: string = path.join(__dirname, '../sslCert/server.key') 24 | const crtPath: string = path.join(__dirname, '../sslCert/server.crt') 25 | const checkPath: boolean = fs.existsSync(keyPath) && fs.existsSync(crtPath) 26 | if (!checkPath) { 27 | logger.error('No SSL Certificate found to run HTTPS Server!!') 28 | process.exit(1) 29 | } 30 | const key: string = fs.readFileSync(keyPath, 'utf8') 31 | const cert: string = fs.readFileSync(crtPath, 'utf8') 32 | const credentials: https.ServerOptions = { key, cert } 33 | server = https.createServer(credentials, app) 34 | } 35 | 36 | // ---------------- Add Socket.io ---------------- 37 | // import socket from 'socket.io' 38 | // const io: socket.Server = new socket.Server(server) 39 | // app.set('io', io) 40 | 41 | // ---------------- Start Server ---------------- 42 | async function startServer(server: http.Server | https.Server): Promise { 43 | server.listen(SERVER_PORT || 4000, () => { 44 | const url = `${SERVER_PROTOCOL || 'http'}://${SERVER_HOST || 'localhost'}:${SERVER_PORT || 4000}` 45 | logger.info(`Server is now running on ${gach(url).color('lightBlue').bold().text} in ${NODE_ENV || 'development'} mode`) 46 | }) 47 | } 48 | 49 | ;(async () => { 50 | try { 51 | await dbConnect() 52 | await startServer(server) 53 | } catch (error) { 54 | throw Error(`>>>>> Server Connection Error: ${error}`) 55 | } 56 | })() 57 | -------------------------------------------------------------------------------- /src/validators/sample.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi' 2 | import { config } from '../configs' 3 | import { validate } from '../middlewares/validator' 4 | 5 | const objectId = Joi.string().regex(config.regex.objectId) 6 | 7 | const exportResult = { 8 | // Create new Sample 9 | create: validate({ 10 | body: Joi.object({ 11 | name: Joi.string().required().description('User Name'), 12 | age: Joi.number().min(1).description('User Age') 13 | }), 14 | query: Joi.object({}) 15 | }), 16 | 17 | // List All Samples 18 | list: validate({ 19 | query: Joi.object({ 20 | size: Joi.number().default(10).description('Sample Pagination Size'), 21 | page: Joi.number().default(1).description('Sample Pagination Page'), 22 | // name: Joi.string().max(50).description('Sample Name'), 23 | // userId: Joi.string().max(50).description('User ID'), 24 | // dateRange: Joi.object({ 25 | // from: Joi.date().description('Date Range From'), 26 | // to: Joi.date().description('Date Range To'), 27 | // }).or('from', 'to').description('Date Range'), 28 | sortType: Joi.string() 29 | .valid(...Object.keys(config.sortTypes)) 30 | .description('Listing Sort By') 31 | }) 32 | }), 33 | 34 | // Show Sample Details 35 | details: validate({ 36 | params: Joi.object({ 37 | sampleId: objectId.required().description('Sample ID') 38 | }), 39 | query: Joi.object({}) 40 | }), 41 | 42 | // Update Sample 43 | update: validate({ 44 | // body: Joi.object({ 45 | // name: Joi.string().description('User Name'), 46 | // userId: objectId.required().description('User ID') 47 | // }), 48 | params: Joi.object({ 49 | sampleId: objectId.required().description('Sample ID') 50 | }), 51 | query: Joi.object({}) 52 | }), 53 | 54 | // Delete Sample (Soft Delete) 55 | delete: validate({ 56 | params: Joi.object({ 57 | sampleId: objectId.required().description('Sample ID') 58 | }), 59 | query: Joi.object({}) 60 | }), 61 | 62 | // Secure Action 63 | secureAction: validate({ 64 | params: Joi.object({ 65 | sampleId: objectId.required().description('Sample ID') 66 | }), 67 | query: Joi.object({}) 68 | }) 69 | } 70 | 71 | export default exportResult 72 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express' 2 | const router: Router = Router() 3 | 4 | // Sample APIs 5 | import sampleRouter from './sample' 6 | router.use('/v1/samples', sampleRouter) 7 | 8 | // API Documentation Swagger 9 | import swaggerUi from 'swagger-ui-express' 10 | import { specs } from '../services' 11 | router.use('/docs', swaggerUi.serve) 12 | router.get('/docs', swaggerUi.setup(specs, { explorer: true })) 13 | 14 | // Health-check Endpoint 15 | router.get('/health', (_req: Request, res: Response) => { 16 | res.send('200') 17 | }) 18 | 19 | export default router 20 | 21 | // ------ Set Default Components for OpenAPI documentation 22 | /** 23 | * @openapi 24 | * tags: 25 | * name: Samples 26 | * description: Sample management 27 | * components: 28 | * responses: 29 | * Success: 30 | * description: Successful action 31 | * content: 32 | * application/json: 33 | * schema: 34 | * $ref: '#/components/schemas/Success' 35 | * BadRequest: 36 | * description: Bad request schema 37 | * content: 38 | * application/json: 39 | * schema: 40 | * $ref: '#/components/schemas/Error' 41 | * NotFound: 42 | * description: The specified resource was not found 43 | * content: 44 | * application/json: 45 | * schema: 46 | * $ref: '#/components/schemas/Error' 47 | * Unauthorized: 48 | * description: Unauthorized access 49 | * content: 50 | * application/json: 51 | * schema: 52 | * $ref: '#/components/schemas/Error' 53 | * schemas: 54 | * Error: 55 | * type: object 56 | * properties: 57 | * statusCode: 58 | * type: integer 59 | * message: 60 | * type: string 61 | * body: 62 | * type: object 63 | * required: 64 | * - statusCode 65 | * - message 66 | * example: 67 | * statusCode: 400 68 | * message: 'Some Error ...' 69 | * body: null 70 | * Success: 71 | * type: object 72 | * properties: 73 | * success: 74 | * type: boolean 75 | * description: Response Status 76 | * result: 77 | * $ref: '#/components/schemas/Sample' 78 | */ 79 | -------------------------------------------------------------------------------- /src/controllers/sample.ts: -------------------------------------------------------------------------------- 1 | import { Errors } from '../services' 2 | import { MESSAGES } from '../middlewares/i18n' 3 | 4 | // import * as Sample from '../models/sample-mysql' 5 | import { Model, Sample, SampleQueryData } from '../models/mongo_sample' 6 | import { handlerFn } from '../utils' 7 | 8 | const exportResult = { 9 | // Create Sample 10 | create: handlerFn(async (req, res, next) => { 11 | const data = req.body as Sample 12 | const result = await Model.create(data) 13 | 14 | // ---- Use Socket.io 15 | // const io: SocketIO.Server = req.app.get('io') 16 | // io.emit('someEvent', { someData: '...' }) 17 | 18 | res.result = (result as any)._doc 19 | next(res) 20 | }), 21 | 22 | // List all Sample 23 | list: handlerFn(async (req, res, next) => { 24 | const query: SampleQueryData = (req.query as unknown) as SampleQueryData 25 | const result = await Model.list(query) 26 | res.result = result 27 | next(res) 28 | }), 29 | 30 | // Show Sample Details 31 | details: handlerFn(async (req, res, next) => { 32 | const sampleId: string = req.params.sampleId 33 | const result = await Model.details(sampleId) 34 | 35 | // Get your custom method 36 | const message = await Model.greetings(sampleId) 37 | 38 | res.result = { result, message } 39 | next(res) 40 | }), 41 | 42 | // Update Sample 43 | update: handlerFn(async (req, res, next) => { 44 | const sampleId: string = req.params.sampleId 45 | const result = await Model.updateById(sampleId, req.body) 46 | res.result = (result as any)._doc 47 | next(res) 48 | }), 49 | 50 | // Archive Sample (Soft Delete) 51 | archive: handlerFn(async (req, res, next) => { 52 | const sampleId: string = req.params.sampleId 53 | const result = await Model.softDelete(sampleId) 54 | res.result = (result as any)._doc 55 | next(res) 56 | }), 57 | 58 | // Delete Sample From DB 59 | delete: handlerFn(async (req, res, next) => { 60 | const sampleId: string = req.params.sampleId 61 | const result = await Model.remove(sampleId) 62 | res.result = result 63 | next(res) 64 | }), 65 | 66 | // Secure Action For Sample 67 | secureAction: handlerFn(async (req, res, next) => { 68 | // Check Sample in Auth Header 69 | if (req.user.role !== 'admin') throw Errors.Unauthorized(MESSAGES.UNAUTHORIZED) 70 | 71 | const sampleId: string = req.params.sampleId 72 | const result = await Model.details(sampleId) 73 | res.result = (result as any)._doc 74 | next(res) 75 | }) 76 | } 77 | 78 | export default exportResult 79 | -------------------------------------------------------------------------------- /src/models/mongo_sample.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import { Errors, logger } from '../services' 3 | import { BaseDocument, BaseModel, SchemaDefinition, BaseQueryData, Sort } from './mongo_base' 4 | import { MESSAGES } from '../middlewares/i18n' 5 | import { config } from '../configs' 6 | 7 | // My Custom Queries 8 | export type SampleQueryData = BaseQueryData & { 9 | name: string | { $regex: string; $options: string } 10 | } 11 | 12 | // ----------------------------------------------------------------------------------- 13 | // ------------------------------ Your Sample Interface ------------------------------ 14 | // ----------------------------------------------------------------------------------- 15 | export interface Sample extends BaseDocument { 16 | name: string 17 | age: number 18 | } 19 | 20 | // ----------------------------------------------------------------------------------- 21 | // --------------------- Write Your Custom Methods in Model Class -------------------- 22 | // ----------------------------------------------------------------------------------- 23 | class MySampleModel extends BaseModel { 24 | async greetings(sampleId: string): Promise { 25 | const sample: Sample | null = await this.model.findById(sampleId) 26 | logger.debug('sample: ', sample) 27 | if (!sample) throw Errors.NotFound(MESSAGES.MODEL_NOT_FOUND) 28 | return 'Hi ' + sample.name + '!!' 29 | } 30 | 31 | async findByAge(age: number): Promise { 32 | const sample: Sample | null = await this.model.findOne({ age }) 33 | if (!sample) throw Errors.NotFound(MESSAGES.MODEL_NOT_FOUND) 34 | return sample 35 | } 36 | 37 | async list(queryData: SampleQueryData): Promise<{ total: number; list: Sample[] }> { 38 | const { page, size, sortType, ...query } = queryData 39 | const limit: number = size > config.maxPageSizeLimit ? config.maxPageSizeLimit : size 40 | const skip: number = (page - 1) * limit 41 | const sortBy: Sort = sortType && sortType !== config.sortTypes.date ? { [config.sortTypes[sortType]]: 1 } : { createdAt: -1 } 42 | 43 | if (query.name) query.name = { $regex: query.name as string, $options: 'i' } 44 | 45 | query.deletedAt = 0 46 | 47 | const total: number = await this.model.countDocuments(query) 48 | const list: Sample[] = await this.model.find(query).limit(limit).skip(skip).sort(sortBy) 49 | return { total, list } 50 | } 51 | } 52 | 53 | // ----------------------------------------------------------------------------------- 54 | // ---------------------- Your MongoDB Schema Model Definition ----------------------- 55 | // ----------------------------------------------------------------------------------- 56 | const definition: SchemaDefinition = { 57 | name: { type: mongoose.Schema.Types.String, required: true, unique: true }, 58 | age: { type: mongoose.Schema.Types.Number, default: 18 } 59 | } 60 | 61 | export const Model = new MySampleModel(definition, 'my_samples') 62 | -------------------------------------------------------------------------------- /src/utils/random.ts: -------------------------------------------------------------------------------- 1 | const numbers = '0123456789', 2 | alphabets = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 3 | specials = '!$%^&*()_+|~-=`{}[]:;<>?,./' 4 | 5 | type Charset = 'numeric' | 'alphabetic' | 'alphanumeric' | 'all' 6 | 7 | interface Options { 8 | /** 9 | * The length of the generated string. 10 | */ 11 | length: number 12 | /** 13 | * The character set to use for generating the string. 14 | * - 'numeric': Only numeric characters (0-9). 15 | * - 'alphabetic': Only alphabetic characters (A-Z, a-z). 16 | * - 'alphanumeric': Both numeric and alphabetic characters. 17 | * - 'all': Numeric, alphabetic, and special characters. 18 | */ 19 | charset: Charset 20 | /** 21 | * Characters to exclude from the generated string. 22 | */ 23 | exclude: string[] 24 | } 25 | 26 | const DEFAULT_LENGTH: number = 8 27 | const DEFAULT_CHARSET: Charset = 'all' 28 | 29 | /** 30 | * Merges provided options with default values. 31 | * 32 | * @param options - Custom options to override the defaults. 33 | * @returns Complete options object with defaults applied. 34 | */ 35 | function useDefault(options?: Options): Options { 36 | const defaultOptions: Options = { 37 | length: options?.length || DEFAULT_LENGTH, 38 | charset: options?.charset || DEFAULT_CHARSET, 39 | exclude: Array.isArray(options?.exclude) ? options.exclude : [] 40 | } 41 | return defaultOptions 42 | } 43 | 44 | /** 45 | * Builds the character string to be used for generating random strings based on the given options. 46 | * 47 | * @param options - Options specifying the desired character set and exclusions. 48 | * @returns A string containing characters to use for random string generation. 49 | */ 50 | function buildChars(options: Options): string { 51 | let chars = '' 52 | switch (options.charset) { 53 | case 'numeric': 54 | chars = numbers 55 | break 56 | case 'alphabetic': 57 | chars = alphabets 58 | break 59 | case 'alphanumeric': 60 | chars = numbers + alphabets 61 | break 62 | default: 63 | chars = numbers + alphabets + specials 64 | break 65 | } 66 | if (options.exclude) { 67 | for (let i = 0; i < options.exclude.length; i++) { 68 | chars = chars.replace(options.exclude[i], '') 69 | } 70 | } 71 | return chars 72 | } 73 | 74 | /** 75 | * Generates a random string based on the provided options. 76 | * 77 | * @param options - Optional configuration for generating the random string. 78 | * @returns A random string of specified length and character set. 79 | */ 80 | export function random(options?: Options): string { 81 | options = useDefault(options) 82 | const length = options.length 83 | let random = '' 84 | const allChars = buildChars(options) 85 | const charsLength = allChars.length 86 | for (let i = 1; i <= length; i++) { 87 | const index = Math.floor(Math.random() * charsLength) 88 | random += allChars.substring(index, index + 1) 89 | } 90 | return random 91 | } 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Micro-service Boilerplate 2 | This is a micro-service boilerplate written with Typescript and implemented using [Node.js] (Express), [MongoDB] (Mongoose), [Redis], Jest, Socket.io and OpenAPI (Swagger). You can use it to initiate your own server-side application. 3 | 4 | 5 | ## [Name of the application] API 6 | Your can write your complete description about this app here... 7 | 8 | 9 | ### Prerequisites / Setting up for first time 10 | What you need to install before running this app 11 | ex: Make sure you have git, nvm, npm, [Node.js] installed 12 | 13 | 14 | ### Get the project and install npms 15 | - Clone the project `git clone https://github.com/amin-abbasi/typescript-boilerplate.git` 16 | - Go to the project folder and run: `npm i` 17 | 18 | 19 | ### Database Setup 20 | - Install [MongoDB] and [Redis] in your system and set necessary configurations. 21 | - Do not forget to check your environment settings in `.env` 22 | 23 | 24 | ### Run Application 25 | First you need to install [typescript] globally and compile typescript codes into javascript by: 26 | 27 | ``` 28 | npm i -g typescript 29 | 30 | npm run build 31 | ``` 32 | 33 | This will create a `dist` folder and put all compiled .js files in there. You can change and set your own configurations by modifying `tsconfig.json` file. 34 | 35 | Finally, you can start the project by: 36 | 37 | ``` 38 | node dist/server.js 39 | ``` 40 | or 41 | ``` 42 | npm start 43 | ``` 44 | 45 | You can also install [nodemon] globally in your system and simply use code below: 46 | ``` 47 | npm i -g nodemon 48 | 49 | nodemon 50 | ``` 51 | 52 | 53 | #### Note1: 54 | For security reasons, you should put "sslCert" folder into `.gitignore` file in production mode. 55 | 56 | 57 | #### Note2: 58 | If you want to directly run `server.ts` file, you can do this change in `package.json`: 59 | 60 | ``` 61 | ... 62 | "scripts": { 63 | ... 64 | "start": "nodemon --watch '*.ts' --exec 'ts-node' src/server.ts", 65 | ... 66 | } 67 | ``` 68 | 69 | and then run: `nodemon` 70 | 71 | 72 | 73 | ### Test Application 74 | For test we used Jest for functional testing and unit testing. you can write your own tests in `__test__` folder by creating new `your_entity.test.js` and then just run: 75 | 76 | ``` 77 | npm run test 78 | ``` 79 | 80 | #### Note: After development and test, you should put the following script in `.gitignore` file to prevent pushing tests files in production mode repositories: 81 | 82 | ``` 83 | # test folder 84 | __tests__ 85 | ``` 86 | 87 | 88 | ### Docker and Deployment 89 | You can simply set your own configs in `docker-compose.yml` file and run: 90 | ``` 91 | sudo docker-compose up -d 92 | ``` 93 | 94 | 95 | #### References 96 | [Node.js]: https://nodejs.org/en/download/ 97 | [MongoDB]: https://docs.mongodb.com/manual/installation 98 | [Redis]: https://redis.io/download 99 | [nodemon]: https://www.npmjs.com/package/nodemon 100 | [typescript]: https://www.npmjs.com/package/typescript 101 | -------------------------------------------------------------------------------- /__tests__/sample.test.js: -------------------------------------------------------------------------------- 1 | const supertest = require('supertest') 2 | const config = require('../src/configs/config') 3 | const server = require('../src/server') 4 | const body_sample = require('./body_samples/body_sample.json') 5 | 6 | jest.setTimeout(30000) 7 | // jest.mock('../__mocks__/samples.js') 8 | 9 | const { SERVER_PROTOCOL, SERVER_HOST, SERVER_PORT, DB_HOST, DB_PORT } = config.default.env 10 | const url = `${SERVER_PROTOCOL}://${SERVER_HOST}:${SERVER_PORT}/api` 11 | 12 | // ---------------------------------- MongoDB ---------------------------------------- 13 | // const mongoose = require('mongoose') 14 | // const mongoDB = { 15 | // mongoose, 16 | // connect: () => { 17 | // mongoose.Promise = Promise; 18 | // mongoose.connect(`mongodb://${DB_HOST}:${DB_PORT}/testDB`, { useNewUrlParser: true }); 19 | // }, 20 | // disconnect: (done) => { mongoose.disconnect(done) }, 21 | // } 22 | 23 | 24 | let sampleId 25 | const request = supertest(url) 26 | 27 | describe('Sample Worker', () => { 28 | 29 | // beforeAll(() => { mongoDB.connect() }) 30 | // afterAll((done) => { mongoDB.disconnect(done) }) 31 | 32 | // Create Samples 33 | test('should create a sample', async (done) => { 34 | const res = await request.post('/v1/samples').send(body_sample) 35 | const response = JSON.parse(res.text) 36 | sampleId = response.result._id 37 | expect(response.statusCode).toBe(200) 38 | expect(response.success).toBe(true) 39 | expect(response.result).toBeTruthy() 40 | expect(response.result).toMatchSnapshot() 41 | done() 42 | }) 43 | 44 | // List of Samples 45 | test('should get list of samples', async (done) => { 46 | const res = await request.get('/v1/samples') 47 | const response = JSON.parse(res.text) 48 | expect(response.statusCode).toBe(200) 49 | expect(response.success).toBe(true) 50 | expect(response.result).toBeTruthy() 51 | expect(response.result).toMatchSnapshot() 52 | done() 53 | }) 54 | 55 | // Sample Details 56 | test('should get sample details', async (done) => { 57 | const res = await request.get('/v1/samples/' + sampleId) 58 | const response = JSON.parse(res.text) 59 | expect(response.statusCode).toBe(200) 60 | expect(response.success).toBe(true) 61 | expect(response.result).toBeTruthy() 62 | expect(response.result).toMatchSnapshot() 63 | done() 64 | }) 65 | 66 | // Update Sample 67 | const updateData = { name: 'Changed Name' } // Some data to update 68 | test('should get sample details', async (done) => { 69 | const res = await request.put('/v1/samples/' + sampleId).send(updateData) 70 | const response = JSON.parse(res.text) 71 | expect(response.statusCode).toBe(200) 72 | expect(response.success).toBe(true) 73 | expect(response.result).toBeTruthy() 74 | expect(response.result).toMatchSnapshot() 75 | done() 76 | }) 77 | 78 | // Delete a Sample 79 | test('should delete a sample', async (done) => { 80 | const res = await request.del('/v1/samples/' + sampleId) 81 | const response = JSON.parse(res.text) 82 | expect(response.statusCode).toBe(200) 83 | expect(response.success).toBe(true) 84 | expect(response.result).toBeTruthy() 85 | expect(response.result).toMatchSnapshot() 86 | done() 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /src/services/token.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import { Errors, Redis, logger } from '.' 3 | 4 | import { config } from '../configs' 5 | import { UserAuth } from '../configs/types' 6 | import { MESSAGES } from '../middlewares/i18n' 7 | 8 | const { algorithm, allow_renew: allowRenew, cache_prefix: cachePrefix, key, expiration, renew_threshold: renewThreshold } = config.jwt 9 | 10 | interface Data { 11 | id: string 12 | role: string 13 | email?: string 14 | mobile?: string 15 | } 16 | 17 | enum CACHE_KEY_TYPES { 18 | VALID = 'valid', 19 | BLOCKED = 'blocked' 20 | } 21 | 22 | const MILLISECONDS_PER_SECOND = 1000 23 | 24 | export class Token { 25 | /** 26 | * Generate an access token 27 | * @param {string} userId UserAuth Id 28 | * @param {string} role UserAuth Role 29 | * @param {string} email UserAuth Email 30 | * @param {string} mobile UserAuth Mobile 31 | * @param {boolean} rememberMe if `true` it will generate non-expire token 32 | * @return {string} returns authorization token for header 33 | */ 34 | static createToken(userId: string, role: string, rememberMe: boolean, email?: string, mobile?: string): string { 35 | const jwtObject = { 36 | id: userId, 37 | email, 38 | mobile, 39 | role, 40 | iat: Math.floor(Date.now() / 1000) 41 | } 42 | const accessToken = rememberMe ? this.create(jwtObject) : this.create(jwtObject, expiration) 43 | return `Bearer ${accessToken}` 44 | } 45 | 46 | private static create(data: string | Data | Buffer, expiresIn = expiration): string { 47 | const secretKey: jwt.Secret = key 48 | const options: jwt.SignOptions = { algorithm } 49 | if (expiresIn) options.expiresIn = expiresIn 50 | const token: string = jwt.sign(data, secretKey, options) 51 | Redis.set(`${cachePrefix}${token}`, CACHE_KEY_TYPES.VALID) 52 | return token 53 | } 54 | 55 | private static decode(token: string): UserAuth | null { 56 | return jwt.decode(token) as UserAuth | null 57 | } 58 | 59 | static block(token: string | undefined): void { 60 | if (!token) throw Errors.InternalServerError(MESSAGES.INVALID_ACCESS_TOKEN) 61 | const decoded = this.decode(token) 62 | if (!decoded) throw Errors.Unauthorized(MESSAGES.INVALID_ACCESS_TOKEN) 63 | const key = `${cachePrefix}${token}` 64 | if (decoded.exp) { 65 | const expiration: number = decoded.exp - Date.now() 66 | Redis.multi().set(key, CACHE_KEY_TYPES.BLOCKED).expire(key, expiration).exec() 67 | } else { 68 | Redis.del(key) 69 | } 70 | } 71 | 72 | static renew(token: string | undefined, expire?: number): string { 73 | if (!token) throw Errors.InternalServerError(MESSAGES.INVALID_ACCESS_TOKEN) 74 | if (!allowRenew) throw Errors.MethodNotAllowed(MESSAGES.ILLEGAL_SERVICE_TOKEN) 75 | 76 | const now: number = Math.floor(Date.now() / MILLISECONDS_PER_SECOND) 77 | const decoded: UserAuth = jwt.decode(token) as UserAuth 78 | if (!decoded.exp) return token 79 | if (decoded.exp - now > renewThreshold) return token 80 | 81 | this.block(token) 82 | if (decoded.iat) delete decoded.iat 83 | if (decoded.exp) delete decoded.exp 84 | return this.create(decoded, expire || expiration) 85 | } 86 | 87 | static async isValid(token: string): Promise { 88 | try { 89 | const key = `${cachePrefix}${token}` 90 | const value: string | null = await Redis.get(key) 91 | const decoded: UserAuth = jwt.decode(token) as UserAuth 92 | 93 | const now = Math.floor(Date.now() / MILLISECONDS_PER_SECOND) 94 | if (!decoded.exp) return decoded // token is non-expired type 95 | if (decoded.exp < now) return false // token is expired 96 | if (!value || value !== CACHE_KEY_TYPES.VALID) return false // token is revoked 97 | 98 | return decoded 99 | } catch (err) { 100 | logger.error(' >>> JWT Token isValid error: ', err) 101 | throw Errors.Unauthorized(MESSAGES.INVALID_ACCESS_TOKEN) 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/middlewares/api_log.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import fs from 'fs' 3 | import path from 'path' 4 | import gach, { Colors } from 'gach' 5 | 6 | import { Logger } from '../services' 7 | 8 | enum MODE { 9 | SHORT = 'short', 10 | FULL = 'full' 11 | } 12 | 13 | enum LOG_TYPES { 14 | INFO = 'info', 15 | ERROR = 'error', 16 | WARN = 'warn' 17 | } 18 | 19 | interface LoggerOptions { 20 | colored?: boolean 21 | mode?: MODE 22 | saveToFile?: boolean 23 | pathToSave?: string 24 | } 25 | 26 | class LoggerMiddleware extends Logger { 27 | readonly #colored: boolean 28 | readonly #mode: MODE 29 | readonly #saveToFile: boolean 30 | readonly #pathToSave: string 31 | 32 | readonly #STATUS_DATA: { 33 | [key: string]: { type: LOG_TYPES; color: Colors } 34 | } = { 35 | '2xx': { type: LOG_TYPES.INFO, color: 'lightGreen' }, 36 | '3xx': { type: LOG_TYPES.WARN, color: 'lightRed' }, 37 | '4xx': { type: LOG_TYPES.ERROR, color: 'lightYellow' }, 38 | '5xx': { type: LOG_TYPES.ERROR, color: 'red' }, 39 | other: { type: LOG_TYPES.ERROR, color: 'lightMagenta' } 40 | } 41 | 42 | constructor({ colored, mode, saveToFile, pathToSave }: LoggerOptions = {}) { 43 | super() 44 | this.#colored = colored ?? true 45 | this.#mode = mode ?? MODE.SHORT 46 | this.#saveToFile = saveToFile ?? true 47 | this.#pathToSave = pathToSave ?? path.join(__dirname, '../../logs') 48 | 49 | // Create the log directory if it doesn't exist 50 | const exists: boolean = fs.existsSync(this.#pathToSave) 51 | if (!exists) fs.mkdirSync(this.#pathToSave, { recursive: true }) 52 | } 53 | 54 | private processTimeInMS(time: [number, number]): string { 55 | return `${(time[0] * 1000 + time[1] / 1e6).toFixed(2)}ms` 56 | } 57 | 58 | private color(text: string, color: Colors): string { 59 | return gach(text).color(color).text 60 | } 61 | 62 | private statusColor(status: number): { type: LOG_TYPES; text: string } { 63 | const text: string = status.toString() 64 | let result: { type: LOG_TYPES; text: string; color: Colors } = { 65 | ...this.#STATUS_DATA['other'], 66 | text 67 | } 68 | if (status >= 200 && status < 300) result = { ...this.#STATUS_DATA['2xx'], text } 69 | if (status >= 300 && status < 400) result = { ...this.#STATUS_DATA['3xx'], text } 70 | if (status >= 400 && status < 500) result = { ...this.#STATUS_DATA['4xx'], text } 71 | if (status >= 500) result = { ...this.#STATUS_DATA['5xx'], text } 72 | if (!this.#colored) return result 73 | result.text = this.color(result.text, result.color) 74 | return result 75 | } 76 | 77 | private requestLog(req: Request): string { 78 | const { headers, body, params, query } = req 79 | return ` ${JSON.stringify({ headers, params, query, body })} ` 80 | } 81 | 82 | private saveLog(log: string, type: LOG_TYPES): void { 83 | fs.appendFileSync(path.join(this.#pathToSave, `${type}.log`), `\n${log}`, { 84 | encoding: 'utf-8' 85 | }) 86 | } 87 | 88 | get(): (req: Request, res: Response, next: NextFunction) => void { 89 | return (req: Request, res: Response, next: NextFunction): void => { 90 | try { 91 | const { method, url } = req 92 | const start = process.hrtime() 93 | 94 | const timestamp = new Date().toISOString().replace('T', ' - ').replace('Z', '') 95 | const timeStampText = this.#colored ? this.color(`[${timestamp}]`, 'lightBlue') : `[${timestamp}]` 96 | 97 | res.once('finish', () => { 98 | const end = process.hrtime(start) 99 | const endText = this.#colored ? this.color(`${this.processTimeInMS(end)}`, 'green') : `${this.processTimeInMS(end)}` 100 | const status = this.statusColor(res.statusCode) 101 | const request: string = this.#mode === MODE.FULL ? this.requestLog(req) : ' ' 102 | const reqMethod = this.#colored ? this.color(method, 'yellow') : method 103 | const log = `${timeStampText} ${reqMethod}: ${url}${request}${status.text} ${endText}` 104 | console.log(log) 105 | if (this.#saveToFile) this.saveLog(log, status.type) 106 | }) 107 | 108 | next() 109 | } catch (error) { 110 | console.log(this.color('>>>>> Log Error: ', 'lightRed'), error) 111 | next(error) 112 | } 113 | } 114 | } 115 | } 116 | 117 | // Example of usage 118 | const middleware = new LoggerMiddleware() 119 | export default middleware.get() 120 | -------------------------------------------------------------------------------- /src/models/mysql_sample.ts: -------------------------------------------------------------------------------- 1 | import { BeforeInsert, BeforeUpdate, Column, Entity, FindManyOptions, PrimaryGeneratedColumn, Repository, UpdateResult, getRepository } from 'typeorm' 2 | import { Errors } from '../services' 3 | 4 | import { config } from '../configs' 5 | import { MESSAGES } from '../middlewares/i18n' 6 | 7 | @Entity() 8 | export class Sample { 9 | /** 10 | * Model Constructor 11 | * @param payload Object data to assign 12 | */ 13 | constructor(payload: Sample) { 14 | Object.assign(this, payload) 15 | } 16 | 17 | @PrimaryGeneratedColumn() 18 | id: string 19 | 20 | @Column({ length: 24, unique: true }) 21 | someId: string 22 | 23 | @Column({ default: 1 }) 24 | age: number 25 | 26 | @Column({ default: true }) 27 | isActive: boolean 28 | 29 | @Column({ length: 100 }) 30 | name: string 31 | 32 | @Column({ default: Date.now() }) 33 | createdAt: number 34 | 35 | @Column({ default: 0 }) 36 | updatedAt: number 37 | 38 | @Column({ default: 0 }) 39 | deletedAt: number 40 | 41 | @BeforeInsert() 42 | @BeforeUpdate() 43 | async setDates(): Promise { 44 | const now: number = Date.now() 45 | if (!this.createdAt) this.createdAt = now 46 | else this.updatedAt = now 47 | return true 48 | } 49 | } 50 | 51 | export interface QueryData { 52 | page: number 53 | size: number 54 | deletedAt: number // Always filter deleted documents 55 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 | [key: string]: any // needs to specified later based on entity or model 57 | } 58 | 59 | export class SampleRepository { 60 | private repository: Repository = getRepository(Sample, config.env.DB_CONNECTION) 61 | 62 | async add(data: Sample): Promise { 63 | const sample: Sample = new Sample(data) 64 | return await this.repository.save(sample) 65 | } 66 | 67 | async list(queryData: QueryData): Promise<{ total: number; list: Sample[] }> { 68 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 69 | const { page, size, ...query } = queryData 70 | query.deletedAt = 0 71 | query.isActive = true 72 | 73 | // query.order = { [config.sortTypes[query.sortBy]]: 'DESC' } 74 | // delete query.sortBy 75 | const [list, total] = await this.repository.findAndCount(query as FindManyOptions) 76 | return { total, list } 77 | } 78 | 79 | async details(someId: string): Promise { 80 | const sample: Sample | null = await this.repository.findOneBy({ someId }) 81 | if (!sample || sample.deletedAt !== 0 || !sample.isActive) throw Errors.NotFound(MESSAGES.MODEL_NOT_FOUND) 82 | return sample 83 | } 84 | 85 | async updateByQuery(query: QueryData, data: Sample): Promise { 86 | const sample: Sample | null = await this.repository.findOne(query as FindManyOptions) 87 | if (!sample || sample.deletedAt !== 0 || !sample.isActive) throw Errors.NotFound(MESSAGES.MODEL_NOT_FOUND) 88 | const result = await this.repository.update(query, data) 89 | return result 90 | } 91 | 92 | async updateById(someId: string, data: Sample): Promise { 93 | const sample: Sample = await this.details(someId) 94 | return await this.repository.update(sample.id, data) 95 | } 96 | 97 | async softDelete(someId: string): Promise { 98 | const sample: Sample = await this.details(someId) 99 | return await this.repository.update(sample.id, { deletedAt: Date.now() }) 100 | } 101 | 102 | async remove(someId: string): Promise { 103 | const sample: Sample | null = await this.repository.findOneBy({ someId }) 104 | if (!sample) return { isSampleRemoved: false } 105 | return await this.repository.remove(sample) 106 | } 107 | 108 | async restore(someId: string): Promise { 109 | const sample: Sample = await this.details(someId) 110 | return await this.repository.update(sample.id, { deletedAt: 0 }) 111 | } 112 | } 113 | 114 | // --------------- Swagger Models Definition --------------- 115 | 116 | /** 117 | * @openapi 118 | * components: 119 | * schemas: 120 | * Sample: 121 | * type: object 122 | * required: 123 | * - name 124 | * - email 125 | * properties: 126 | * name: 127 | * type: string 128 | * email: 129 | * type: string 130 | * format: email 131 | * description: Email for the user, needs to be unique. 132 | * example: 133 | * name: 'Amin' 134 | * email: 'amin.abbasi.rs@gmail.com' 135 | */ 136 | -------------------------------------------------------------------------------- /src/models/mongo_base.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema, Document } from 'mongoose' 2 | import uniqueV from 'mongoose-unique-validator' 3 | import { Errors } from '../services' 4 | import { config } from '../configs' 5 | import { mergeDeep } from '../utils' 6 | import { MESSAGES } from '../middlewares/i18n' 7 | 8 | // Typescript Base Document Model 9 | export interface BaseDocument extends Document { 10 | createdAt?: number 11 | updatedAt?: number 12 | deletedAt?: number 13 | } 14 | 15 | export type SchemaDefinition = 16 | | { 17 | [path: string]: mongoose.SchemaDefinitionProperty 18 | } 19 | | { 20 | [x: string]: mongoose.SchemaDefinitionProperty | undefined 21 | } 22 | | undefined 23 | 24 | export type SchemaOptions = mongoose.SchemaOptions | undefined 25 | 26 | const baseDefinition: SchemaDefinition = { 27 | createdAt: { type: Schema.Types.Number }, 28 | updatedAt: { type: Schema.Types.Number }, 29 | deletedAt: { type: Schema.Types.Number, default: 0 } 30 | } 31 | 32 | const baseOptions = { 33 | strict: false // To allow database in order to save Mixed type data in DB 34 | } 35 | 36 | export type BaseQueryData = { 37 | page: number 38 | size: number 39 | sortType?: string 40 | deletedAt: number // Always filter deleted documents 41 | } 42 | 43 | export type Sort = { 44 | [key: string]: mongoose.SortOrder 45 | } 46 | 47 | export class BaseModel { 48 | #schema: mongoose.Schema 49 | readonly model: mongoose.Model 50 | 51 | constructor(schemaDefinition: SchemaDefinition, modelName: string, schemaOptions?: SchemaOptions) { 52 | const options = schemaOptions ? { ...schemaOptions, ...baseOptions } : baseOptions 53 | this.#schema = new Schema({ ...schemaDefinition, ...baseDefinition }, options) 54 | this.#schema.plugin(uniqueV) 55 | this.model = mongoose.model(modelName, this.#schema) 56 | } 57 | 58 | async create(data: T): Promise { 59 | const modelData = { ...data, createdAt: Date.now() } 60 | return await this.model.create(modelData) 61 | } 62 | 63 | async list(queryData: Partial): Promise<{ total: number; list: T[] }> { 64 | const { page, size, sortType, ...query } = queryData 65 | const limit: number = !size || size > config.maxPageSizeLimit ? config.maxPageSizeLimit : size 66 | const skip: number = (page ?? 1 - 1) * limit 67 | const sortBy: Sort = sortType && sortType !== config.sortTypes.date ? { [config.sortTypes[sortType]]: 1 } : { createdAt: -1 } 68 | 69 | // if(query.dateRange) { 70 | // query.createdAt = {} 71 | // if(query.dateRange.from) query.createdAt['$gte'] = query.dateRange.from 72 | // if(query.dateRange.to) query.createdAt['$lte'] = query.dateRange.to 73 | // delete query.dateRange 74 | // } 75 | // if (query.name) query.name = { $regex: query.name, $options: 'i' } 76 | 77 | query.deletedAt = 0 78 | 79 | const total: number = await this.model.countDocuments(query) 80 | const list: T[] = await this.model.find(query).limit(limit).skip(skip).sort(sortBy) 81 | return { total, list } 82 | } 83 | 84 | async details(modelId: string): Promise { 85 | const model: T | null = await this.model.findById(modelId) 86 | if (!model || model.deletedAt !== 0) throw Errors.NotFound(MESSAGES.MODEL_NOT_FOUND) 87 | return model 88 | } 89 | 90 | async updateByQuery(query: Partial, data: Partial): Promise { 91 | const updatedData = { ...data, updatedAt: Date.now() } 92 | return (await this.model.findOneAndUpdate(query, updatedData, { new: true })) as T 93 | } 94 | 95 | async updateById(modelId: string, data: Partial): Promise { 96 | const model: T = await this.details(modelId) 97 | model.updatedAt = Date.now() 98 | const updatedModelName: T = mergeDeep(model, data) as T 99 | return (await this.model.findByIdAndUpdate(modelId, updatedModelName, { new: true })) as T 100 | } 101 | 102 | async softDelete(modelId: string): Promise { 103 | const model: T = await this.details(modelId) 104 | return (await this.model.findByIdAndUpdate(model.id, { deletedAt: Date.now() }, { new: true })) as T 105 | } 106 | 107 | async remove(modelId: string): Promise<{ ok?: number; n?: number } & { deletedCount?: number }> { 108 | const model: T = await this.details(modelId) 109 | return await this.model.deleteOne({ _id: model.id }) 110 | } 111 | 112 | async restore(modelId: string): Promise { 113 | const model: T = await this.details(modelId) 114 | return (await this.model.findByIdAndUpdate(model.id, { deletedAt: 0 }, { new: true })) as T 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/services/http_errors.ts: -------------------------------------------------------------------------------- 1 | enum ERROR_TYPES { 2 | BAD_REQUEST = 'BadRequest', 3 | UNAUTHORIZED = 'Unauthorized', 4 | PAYMENT_REQUIRED = 'PaymentRequired', 5 | FORBIDDEN = 'Forbidden', 6 | NOT_FOUND = 'NotFound', 7 | METHOD_NOT_ALLOWED = 'MethodNotAllowed', 8 | NOT_ACCEPTABLE = 'NotAcceptable', 9 | PROXY_AUTHENTICATION_REQUIRED = 'ProxyAuthenticationRequired', 10 | REQUEST_TIMEOUT = 'RequestTimeout', 11 | CONFLICT = 'Conflict', 12 | GONE = 'Gone', 13 | LENGTH_REQUIRED = 'LengthRequired', 14 | PRECONDITION_FAILED = 'PreconditionFailed', 15 | PAYLOAD_TOO_LARGE = 'PayloadTooLarge', 16 | URI_TOO_LONG = 'URITooLong', 17 | UNSUPPORTED_MEDIA_TYPE = 'UnsupportedMediaType', 18 | RANGE_NOT_SATISFIABLE = 'RangeNotSatisfiable', 19 | EXPECTATION_FAILED = 'ExpectationFailed', 20 | IM_A_TEAPOT = 'ImATeapot', 21 | MISDIRECTED_REQUEST = 'MisdirectedRequest', 22 | UNPROCESSABLE_ENTITY = 'UnprocessableEntity', 23 | LOCKED = 'Locked', 24 | FAILED_DEPENDENCY = 'FailedDependency', 25 | TOO_EARLY = 'TooEarly', 26 | UPGRADE_REQUIRED = 'UpgradeRequired', 27 | PRECONDITION_REQUIRED = 'PreconditionRequired', 28 | TOO_MANY_REQUESTS = 'TooManyRequests', 29 | REQUEST_HEADER_FIELDS_TOO_LARGE = 'RequestHeaderFieldsTooLarge', 30 | UNAVAILABLE_FOR_LEGAL_REASONS = 'UnavailableForLegalReasons', 31 | INTERNAL_SERVER_ERROR = 'InternalServerError', 32 | NOT_IMPLEMENTED = 'NotImplemented', 33 | BAD_GATEWAY = 'BadGateway', 34 | SERVICE_UNAVAILABLE = 'ServiceUnavailable', 35 | GATEWAY_TIMEOUT = 'GatewayTimeout', 36 | HTTP_VERSION_NOT_SUPPORTED = 'HTTPVersionNotSupported', 37 | VARIANT_ALSO_NEGOTIATES = 'VariantAlsoNegotiates', 38 | INSUFFICIENT_STORAGE = 'InsufficientStorage', 39 | LOOP_DETECTED = 'LoopDetected', 40 | BANDWIDTH_LIMIT_EXCEEDED = 'BandwidthLimitExceeded', 41 | NOT_EXTENDED = 'NotExtended', 42 | NETWORK_AUTHENTICATION_REQUIRED = 'NetworkAuthenticationRequired' 43 | } 44 | 45 | const ERROR_STATUS: Record = { 46 | [ERROR_TYPES.BAD_REQUEST]: 400, 47 | [ERROR_TYPES.UNAUTHORIZED]: 401, 48 | [ERROR_TYPES.PAYMENT_REQUIRED]: 402, 49 | [ERROR_TYPES.FORBIDDEN]: 403, 50 | [ERROR_TYPES.NOT_FOUND]: 404, 51 | [ERROR_TYPES.METHOD_NOT_ALLOWED]: 405, 52 | [ERROR_TYPES.NOT_ACCEPTABLE]: 406, 53 | [ERROR_TYPES.PROXY_AUTHENTICATION_REQUIRED]: 407, 54 | [ERROR_TYPES.REQUEST_TIMEOUT]: 408, 55 | [ERROR_TYPES.CONFLICT]: 409, 56 | [ERROR_TYPES.GONE]: 410, 57 | [ERROR_TYPES.LENGTH_REQUIRED]: 411, 58 | [ERROR_TYPES.PRECONDITION_FAILED]: 412, 59 | [ERROR_TYPES.PAYLOAD_TOO_LARGE]: 413, 60 | [ERROR_TYPES.URI_TOO_LONG]: 414, 61 | [ERROR_TYPES.UNSUPPORTED_MEDIA_TYPE]: 415, 62 | [ERROR_TYPES.RANGE_NOT_SATISFIABLE]: 416, 63 | [ERROR_TYPES.EXPECTATION_FAILED]: 417, 64 | [ERROR_TYPES.IM_A_TEAPOT]: 418, 65 | [ERROR_TYPES.MISDIRECTED_REQUEST]: 421, 66 | [ERROR_TYPES.UNPROCESSABLE_ENTITY]: 422, 67 | [ERROR_TYPES.LOCKED]: 423, 68 | [ERROR_TYPES.FAILED_DEPENDENCY]: 424, 69 | [ERROR_TYPES.TOO_EARLY]: 425, 70 | [ERROR_TYPES.UPGRADE_REQUIRED]: 426, 71 | [ERROR_TYPES.PRECONDITION_REQUIRED]: 428, 72 | [ERROR_TYPES.TOO_MANY_REQUESTS]: 429, 73 | [ERROR_TYPES.REQUEST_HEADER_FIELDS_TOO_LARGE]: 431, 74 | [ERROR_TYPES.UNAVAILABLE_FOR_LEGAL_REASONS]: 451, 75 | [ERROR_TYPES.INTERNAL_SERVER_ERROR]: 500, 76 | [ERROR_TYPES.NOT_IMPLEMENTED]: 501, 77 | [ERROR_TYPES.BAD_GATEWAY]: 502, 78 | [ERROR_TYPES.SERVICE_UNAVAILABLE]: 503, 79 | [ERROR_TYPES.GATEWAY_TIMEOUT]: 504, 80 | [ERROR_TYPES.HTTP_VERSION_NOT_SUPPORTED]: 505, 81 | [ERROR_TYPES.VARIANT_ALSO_NEGOTIATES]: 506, 82 | [ERROR_TYPES.INSUFFICIENT_STORAGE]: 507, 83 | [ERROR_TYPES.LOOP_DETECTED]: 508, 84 | [ERROR_TYPES.BANDWIDTH_LIMIT_EXCEEDED]: 509, 85 | [ERROR_TYPES.NOT_EXTENDED]: 510, 86 | [ERROR_TYPES.NETWORK_AUTHENTICATION_REQUIRED]: 511 87 | } as const 88 | 89 | type MethodType = Record HttpError> 90 | 91 | type MetaData = { [key: string]: any } 92 | 93 | class HttpError extends Error { 94 | status: number 95 | data?: MetaData 96 | code: string 97 | 98 | constructor(status: number, message: string, data?: MetaData) { 99 | super(message) 100 | this.status = status 101 | this.data = data 102 | this.code = this.getKeyByValueAndFormat(status) ?? 'Unhandled Error' 103 | } 104 | 105 | private getKeyByValueAndFormat(value: number): string | undefined { 106 | const key = Object.keys(ERROR_STATUS).find((k) => ERROR_STATUS[k as ERROR_TYPES] === value) 107 | return key ? this.formatKey(key) : undefined 108 | } 109 | 110 | private formatKey(key: string): string { 111 | return key.replace(/([A-Z])/g, ' $1').trim() 112 | } 113 | 114 | private static create(status: number, message: string, data?: MetaData): HttpError { 115 | return new HttpError(status, message, data) 116 | } 117 | 118 | static createMethods(): MethodType { 119 | const methods: MethodType = {} as MethodType 120 | Object.entries(ERROR_STATUS).forEach(([errorType, status]) => { 121 | const createError = (message: string, data?: MetaData) => this.create(status, message, data) 122 | methods[errorType as ERROR_TYPES] = createError 123 | methods[status] = createError 124 | }) 125 | return methods 126 | } 127 | } 128 | 129 | export const Errors = HttpError.createMethods() 130 | -------------------------------------------------------------------------------- /src/routes/sample.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | const router = express.Router() 3 | 4 | // Add Controllers & Validators 5 | import Controller from '../controllers/sample' 6 | import Validator from '../validators/sample' 7 | import { checkToken, checkRole } from '../middlewares/check_auth' 8 | 9 | // (action) (verb) (URI) 10 | // create: POST - /samples 11 | // list: GET - /samples 12 | // details: GET - /samples/:sampleId 13 | // update: PUT - /samples/:sampleId 14 | // delete: DELETE - /samples/:sampleId 15 | // a secure action: POST - /samples/:sampleId/secure-action 16 | 17 | // ---------------------------------- Define All Sample Routes Here ---------------------------------- 18 | 19 | /** 20 | * @openapi 21 | * paths: 22 | * /samples/: 23 | * post: 24 | * summary: Create a new sample 25 | * tags: [Samples] 26 | * requestBody: 27 | * required: true 28 | * content: 29 | * application/json: 30 | * schema: 31 | * $ref: '#/components/schemas/Sample' 32 | * responses: 33 | * "200": 34 | * $ref: '#/components/responses/Success' 35 | * "400": 36 | * $ref: '#/components/responses/BadRequest' 37 | */ 38 | router.route('').post(Validator.create, Controller.create) 39 | 40 | /** 41 | * @openapi 42 | * paths: 43 | * /samples/: 44 | * get: 45 | * summary: Get list of all Samples 46 | * tags: [Samples] 47 | * responses: 48 | * "200": 49 | * description: Gets a list of samples as an array of objects 50 | * content: 51 | * application/json: 52 | * schema: 53 | * type: object 54 | * properties: 55 | * success: 56 | * type: boolean 57 | * description: Response Status 58 | * result: 59 | * type: array 60 | * items: 61 | * type: object 62 | * properties: 63 | * total: 64 | * type: integer 65 | * list: 66 | * $ref: '#/components/schemas/Sample' 67 | * "400": 68 | * $ref: '#/components/responses/BadRequest' 69 | */ 70 | router.route('').get(Validator.list, Controller.list) 71 | 72 | /** 73 | * @openapi 74 | * paths: 75 | * /samples/{sampleId}: 76 | * get: 77 | * summary: Sample Details 78 | * tags: [Samples] 79 | * parameters: 80 | * - name: sampleId 81 | * in: path 82 | * description: Sample ID 83 | * required: true 84 | * schema: 85 | * type: string 86 | * responses: 87 | * "200": 88 | * $ref: '#/components/responses/Success' 89 | * "400": 90 | * $ref: '#/components/responses/BadRequest' 91 | * "404": 92 | * $ref: '#/components/responses/NotFound' 93 | */ 94 | router.route('/:sampleId').get(Validator.details, Controller.details) 95 | 96 | /** 97 | * @openapi 98 | * paths: 99 | * /samples/{sampleId}: 100 | * put: 101 | * summary: Sample Update 102 | * tags: [Samples] 103 | * parameters: 104 | * - name: sampleId 105 | * in: path 106 | * description: Sample ID 107 | * required: true 108 | * schema: 109 | * type: string 110 | * responses: 111 | * "200": 112 | * $ref: '#/components/responses/Success' 113 | * "400": 114 | * $ref: '#/components/responses/BadRequest' 115 | * "404": 116 | * $ref: '#/components/responses/NotFound' 117 | */ 118 | router.route('/:sampleId').put(Validator.update, Controller.update) 119 | // router.route('/:sampleId').patch(Validator.update, Controller.update) 120 | 121 | /** 122 | * @openapi 123 | * paths: 124 | * /samples/{sampleId}: 125 | * delete: 126 | * summary: Delete Sample 127 | * tags: [Samples] 128 | * parameters: 129 | * - name: sampleId 130 | * in: path 131 | * description: Sample ID 132 | * required: true 133 | * schema: 134 | * type: string 135 | * responses: 136 | * "200": 137 | * $ref: '#/components/responses/Success' 138 | * "400": 139 | * $ref: '#/components/responses/BadRequest' 140 | * "404": 141 | * $ref: '#/components/responses/NotFound' 142 | */ 143 | router.route('/:sampleId').delete(Validator.delete, Controller.delete) 144 | 145 | /** 146 | * @openapi 147 | * paths: 148 | * /samples/{sampleId}/secure-action: 149 | * post: 150 | * summary: Secure Action For Sample 151 | * tags: [Samples] 152 | * parameters: 153 | * - name: sampleId 154 | * in: path 155 | * description: Sample ID 156 | * required: true 157 | * schema: 158 | * type: string 159 | * responses: 160 | * "200": 161 | * $ref: '#/components/responses/Success' 162 | * "400": 163 | * $ref: '#/components/responses/BadRequest' 164 | * "401": 165 | * $ref: '#/components/responses/Unauthorized' 166 | * "404": 167 | * $ref: '#/components/responses/NotFound' 168 | */ 169 | router 170 | .route('/:sampleId/secure-action') 171 | .post(checkToken, checkRole(), Validator.secureAction, Controller.secureAction) 172 | 173 | export default router 174 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "ES2021", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | "lib": ["dom", "es2021"], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./dist", /* Redirect output structure to the directory. */ 18 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | "strictNullChecks": true, /* Enable strict null checks. */ 31 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | "strictPropertyInitialization": false, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | "typeRoots": [ /* List of folders to include type definitions from. */ 49 | "./types", 50 | "./node_modules/@types" 51 | ], 52 | // "types": [], /* Type declaration files to be included in compilation. */ 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | 58 | /* Source Map Options */ 59 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 62 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 63 | 64 | /* Experimental Options */ 65 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 66 | "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 67 | 68 | /* Advanced Options */ 69 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 70 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 71 | 72 | // Resolve Json Module 73 | "resolveJsonModule": true 74 | }, 75 | "exclude": [ "./dist", "./node_modules", "./__tests__", "./.docker" ] 76 | } 77 | --------------------------------------------------------------------------------