├── .eslintignore ├── .dockerignore ├── src ├── util │ ├── logger │ │ ├── index.js │ │ ├── logger.js │ │ └── options.js │ ├── index.js │ ├── network │ │ ├── index.js │ │ └── jwt.js │ └── db │ │ └── index.js ├── cron │ └── README.md ├── model │ ├── index.js │ ├── schema │ │ ├── index.js │ │ ├── sportsAppIntegration.js │ │ ├── discount.js │ │ └── sportsAppUserIntegration.js │ ├── discountRepository.js │ ├── sportsAppRepository │ │ ├── integration.js │ │ ├── index.js │ │ └── userIntegration.js │ └── README.md ├── web │ ├── routes │ │ ├── versionCheck.js │ │ ├── index.js │ │ ├── discount.js │ │ └── healthCheck.js │ ├── middlewares │ │ ├── 404.js │ │ ├── modelInitializer.js │ │ ├── error.js │ │ ├── auth.js │ │ └── index.js │ ├── index.js │ └── swagger.json ├── index.js └── exceptions │ ├── index.js │ ├── ModelException.js │ ├── UnauthorizedException.js │ ├── ValidationException.js │ └── Exception.js ├── index.js ├── .editorconfig ├── config ├── schema │ ├── server.js │ └── logger.js ├── env.js ├── index.js ├── custom-environment-variables.json └── default.json ├── .eslintrc.json ├── dev.env ├── tests ├── sample.test.js └── oauth2.test.js ├── Dockerfile ├── Makefile ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── compose.dev.yml ├── .gitlab-ci.yml ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | migrations/ -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .* 2 | LICENSE 3 | VERSION 4 | README.md 5 | node_modules/ 6 | tests/ 7 | logs/ -------------------------------------------------------------------------------- /src/util/logger/index.js: -------------------------------------------------------------------------------- 1 | import loggerModule from './logger.js'; 2 | 3 | export default loggerModule; 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import './config/env.js'; // Ensure its invoked before everything else, otherwise it becomes sequence dependent 2 | import './src/index.js'; 3 | -------------------------------------------------------------------------------- /src/cron/README.md: -------------------------------------------------------------------------------- 1 | Placeholder for periodic tasks, potentially you want to use https://github.com/kelektiv/node-cron for implementing cron like scheduling 2 | -------------------------------------------------------------------------------- /src/model/index.js: -------------------------------------------------------------------------------- 1 | import sportsAppRepository from './sportsAppRepository/index.js'; 2 | import discountRepository from './discountRepository.js'; 3 | export default { 4 | sportsAppRepository, 5 | discountRepository, 6 | }; 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /src/model/schema/index.js: -------------------------------------------------------------------------------- 1 | export * as sportsAppIntegrationSchema from './sportsAppIntegration.js'; 2 | export * as sportsAppUserIntegrationSchema from './sportsAppUserIntegration.js'; 3 | export * as discount from './discount.js'; 4 | -------------------------------------------------------------------------------- /src/util/index.js: -------------------------------------------------------------------------------- 1 | import loggerModule from './logger/index.js'; 2 | import * as dbModule from './db/index.js'; 3 | import * as networkModule from './network/index.js'; 4 | 5 | export const logger = loggerModule(); 6 | export const db = dbModule; 7 | export const network = networkModule; 8 | -------------------------------------------------------------------------------- /src/web/routes/versionCheck.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | 4 | const pkgJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); 5 | 6 | export default async (req, res) => { 7 | res.status(200).json({version: pkgJson.version}); 8 | }; 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import {logger as log} from './util/index.js'; 2 | import {Exception} from './exceptions/index.js'; 3 | 4 | process.on('uncaughtException', (err) => { 5 | log.error(new Exception({message: 'UNHANDLED-ERROR', innerError: err})); 6 | process.exit(1); 7 | }); 8 | 9 | import './web/index.js'; 10 | -------------------------------------------------------------------------------- /src/web/routes/index.js: -------------------------------------------------------------------------------- 1 | import * as healthCheckRoutes from './healthCheck.js'; 2 | import versionCheck from './versionCheck.js'; 3 | import {getPricesByDiscount, createDiscount} from './discount.js'; 4 | 5 | export default { 6 | healthCheckRoutes, 7 | versionCheck, 8 | getPricesByDiscount, 9 | createDiscount, 10 | }; 11 | -------------------------------------------------------------------------------- /config/schema/server.js: -------------------------------------------------------------------------------- 1 | import joi from 'joi'; 2 | 3 | export default joi.object({ 4 | envName: joi.string() 5 | .allow(...['development', 'test', 'production']) 6 | .default('development'), 7 | listenOnPort: joi.number() 8 | .default(80), 9 | externalHostName: joi.string() 10 | .default('localhost'), 11 | }).unknown(); 12 | -------------------------------------------------------------------------------- /src/exceptions/index.js: -------------------------------------------------------------------------------- 1 | import ExceptionImport from './Exception.js'; 2 | import ModelExceptionImport from './ModelException.js'; 3 | import UnauthorizedExceptionImport from './UnauthorizedException.js'; 4 | 5 | export const Exception = ExceptionImport; 6 | export const ModelException = ModelExceptionImport; 7 | export const UnauthorizedException = UnauthorizedExceptionImport; 8 | -------------------------------------------------------------------------------- /src/model/discountRepository.js: -------------------------------------------------------------------------------- 1 | import * as schema from './schema/index.js'; 2 | 3 | export default (dbConnection) => ({ 4 | getDiscount: async (code, lean = true) => { 5 | const result = schema 6 | .discount.connect(dbConnection) 7 | .findOne({code}); 8 | 9 | return (lean) ? result.lean() : result; 10 | }, 11 | 12 | createDiscount: async (discount) => schema 13 | .discount.connect(dbConnection) 14 | .create(discount), 15 | }); 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "google" 8 | ], 9 | "parserOptions": { 10 | "ecmaVersion": 12, 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | "max-len": ["error", { 15 | "code": 120, 16 | "ignoreUrls": true, 17 | "ignoreTrailingComments": true, 18 | "ignoreComments": true 19 | }] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/model/schema/sportsAppIntegration.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | export const schema = new mongoose.Schema({ 4 | provider: { 5 | type: String, 6 | required: true, 7 | unique: true, // This actually works only when mongoose autocreates DB 8 | }, 9 | connectionParams: { 10 | type: Map, 11 | of: String, 12 | required: true, 13 | }, 14 | }, 15 | {collection: 'sportsAppIntegrations'}); 16 | 17 | export const connect = (connection) => connection.model('sportsAppIntegrations', schema); 18 | -------------------------------------------------------------------------------- /config/schema/logger.js: -------------------------------------------------------------------------------- 1 | import joi from 'joi'; 2 | 3 | export default joi.object({ 4 | dir: joi.string() 5 | .default('logs'), 6 | fileName: joi.string() 7 | .default('combined.log'), 8 | level: joi.object({ 9 | file: joi.string() 10 | .allow(...[null, 'error', 'warn', 'info', 'verbose', 'debug', 'silly']) 11 | .default('info'), 12 | console: joi.string() 13 | .allow(...['error', 'warn', 'info', 'verbose', 'debug', 'silly']) 14 | .default('info'), 15 | }).unknown(), 16 | }).unknown(); 17 | -------------------------------------------------------------------------------- /src/model/sportsAppRepository/integration.js: -------------------------------------------------------------------------------- 1 | import * as schema from '../schema/index.js'; 2 | 3 | export default (dbConnection) => ({ 4 | getIntegration: async (integrationName, lean = true) => { 5 | const result = schema 6 | .sportsAppIntegrationSchema.connect(dbConnection) 7 | .findOne({provider: integrationName}); 8 | 9 | return (lean) ? result.lean() : result; 10 | }, 11 | 12 | createIntegration: async (integration) => schema 13 | .sportsAppIntegrationSchema.connect(dbConnection) 14 | .create(integration), 15 | }); 16 | -------------------------------------------------------------------------------- /dev.env: -------------------------------------------------------------------------------- 1 | LISTEN_ON_PORT=3000 2 | 3 | # Full connection string used as priority, if want to use partials, delete next line 4 | # MONGO_FULL_CONN_STRING=mongodb://mongodb:27017/{dbName} 5 | MONGO_SCHEMA=mongodb 6 | MONGO_HOST=mongodb 7 | MONGO_USER=root 8 | MONGO_PASSWORD=rootPassXXX 9 | MONGO_PORT=27017 10 | MONGO_DATABASE={dbName} 11 | MONGO_PARAMS="authSource=admin" 12 | 13 | APP_OAUTH2_TOKEN_HOST=https://rayvid.eu.auth0.com 14 | APP_OAUTH2_AUDIENCE=https://github.com/Rayvid/men-microservice-skeleton 15 | APP_OAUTH2_JWKS_URI=https://rayvid.eu.auth0.com/.well-known/jwks.json 16 | 17 | SENTRY_DSN= 18 | -------------------------------------------------------------------------------- /config/env.js: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | const secretEnv = process.env.ENV_FILE || '/run/secrets/env'; 6 | const secretPath = path.resolve(secretEnv); 7 | 8 | if (process.env.NODE_ENV !== 'production') { 9 | try { 10 | dotenv.config({path: path.resolve('./dev.env')}); 11 | // eslint-disable-next-line no-console 12 | console.log('INFO: dev config loaded, shouldn\'t happen on prod!'); 13 | } catch (err) { 14 | // Ignore that, really 15 | } 16 | } 17 | 18 | if (fs.existsSync(secretPath)) { 19 | dotenv.config({path: secretPath}); 20 | } 21 | -------------------------------------------------------------------------------- /src/model/schema/discount.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | export const schema = new mongoose.Schema({ 4 | code: { 5 | type: String, 6 | required: true, 7 | unique: true, // This actually works only when mongoose autocreates DB 8 | }, 9 | wallets: new mongoose.Schema({ 10 | hero: { 11 | type: Map, 12 | of: String, 13 | required: true, 14 | }, 15 | }), 16 | prices: { 17 | type: Map, 18 | of: String, 19 | required: true, 20 | }, 21 | }, 22 | {collection: 'discounts'}); 23 | 24 | export const connect = (connection) => connection.model('discounts', schema); 25 | -------------------------------------------------------------------------------- /src/model/sportsAppRepository/index.js: -------------------------------------------------------------------------------- 1 | import integration from './integration.js'; 2 | import userIntegration from './userIntegration.js'; 3 | 4 | export default (dbConnection) => { 5 | const integrationModel = integration(dbConnection); 6 | const userIntegrationModel = userIntegration(dbConnection); 7 | 8 | return { 9 | createIntegration: integrationModel.createIntegration, 10 | getStravaIntegration: integrationModel.getIntegration.bind(null, 'strava'), 11 | getUserStravaIntegration: userIntegrationModel.getUserIntegration.bind(null, 'strava'), 12 | saveUserToken: userIntegrationModel.saveOrUpdateUserIntegration, 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/web/middlewares/404.js: -------------------------------------------------------------------------------- 1 | import {logger as log} from '../../util/index.js'; 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | export default async (req, res, next) => { 5 | const parseForwardedFor = (value) => { 6 | if (!value) { 7 | return undefined; 8 | } 9 | 10 | const commaIndex = value.indexOf(','); 11 | return commaIndex === -1 ? value : value.substr(0, commaIndex); 12 | }; 13 | 14 | log.warn(`404 (Not found) - ${req.originalUrl} - ${req.method}` + 15 | ` - ${parseForwardedFor(req.headers['x-forwarded-for']) || req.connection.remoteAddress}`); 16 | next(); // Pass further, to actually render 404 response 17 | }; 18 | -------------------------------------------------------------------------------- /tests/sample.test.js: -------------------------------------------------------------------------------- 1 | // Placeholder for global tests 2 | // it's a good idea to have local tests folders inside src to do contextual unittesting 3 | import '../config/env.js'; // Ensure its invoked before everything else, otherwise it becomes sequence dependant 4 | import chai from 'chai'; 5 | import chaiAsPromised from 'chai-as-promised'; 6 | 7 | chai.use(chaiAsPromised); 8 | chai.should(); 9 | 10 | describe('Array', () => { 11 | describe('#indexOf()', () => { 12 | it('should return -1 when the value is not present', () => { 13 | [1, 2, 3].indexOf(5).should.equal(-1); 14 | [1, 2, 3].indexOf(0).should.equal(-1); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | import Sentry from '@sentry/node'; 2 | import config from 'config'; 3 | import joi from 'joi'; 4 | import serverSchema from './schema/server.js'; 5 | import loggerSchema from './schema/logger.js'; 6 | 7 | const sentryConfig = config.get('sentry'); 8 | if (sentryConfig.dsn) { 9 | Sentry.init({dsn: sentryConfig.dsn}); 10 | } 11 | 12 | export const db = config.get('db'); 13 | export const server = joi.attempt(config.get('server'), serverSchema); 14 | export const logger = joi.attempt(config.get('logger'), loggerSchema); 15 | export const sentry = sentryConfig; 16 | export const mongo = config.get('db.mongo'); 17 | export const oauth2 = config.get('oauth2'); 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM bitnami/node:16 2 | ARG ENV=production 3 | ENV NODE_ENV=$ENV 4 | 5 | WORKDIR /usr/src/app 6 | COPY package*.json /usr/src/app/ 7 | 8 | ARG NPMRC_CONTENT="//registry.npmjs.org/:_authToken=pass_this_arg_if_need_to_access_private_packages\n" 9 | RUN printf "$NPMRC_CONTENT" > ~/.npmrc 10 | 11 | RUN npm install 12 | COPY . . 13 | 14 | # Start fresh from lighweight image and transfer files build in prev step (helps to keep it minimal and not expose registry token and other secrets used in build) 15 | FROM bitnami/node:16-prod 16 | WORKDIR /usr/src/app 17 | COPY --from=0 /usr/src/app . 18 | EXPOSE 3000 19 | CMD ["sh", "-c", "npm run docker:${NODE_ENV:-production}"] 20 | -------------------------------------------------------------------------------- /src/exceptions/ModelException.js: -------------------------------------------------------------------------------- 1 | import Exception from './Exception.js'; 2 | 3 | /** 4 | * @export 5 | * @class ModelException 6 | * @extends {Exception} 7 | */ 8 | export default class ModelException extends Exception { 9 | /** 10 | * Creates an instance of ModelException. 11 | * @param {*} params 12 | * @param {string} [defaultParams={ 13 | * message: 'Model exception', 14 | * statusCode: 500, 15 | * }] 16 | * @memberof ModelException 17 | */ 18 | constructor( 19 | params, 20 | defaultParams = { 21 | message: 'Model exception', 22 | statusCode: 500, 23 | }, 24 | ) { 25 | super(params, defaultParams); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL:=/bin/bash 2 | 3 | up: 4 | docker-compose -f compose.dev.yml up 5 | 6 | build: 7 | -docker-compose -f compose.dev.yml stop 8 | -docker-compose -f compose.dev.yml rm --force 9 | docker-compose -f compose.dev.yml build --no-cache 10 | 11 | login: 12 | docker login registry.gitlab.com 13 | 14 | clean: 15 | -docker system prune -f 16 | 17 | reset-local-docker-system: # Use with care! 18 | -docker kill $(shell docker ps -q) 19 | -docker rm $(shell docker ps -a -q) 20 | -docker rmi -f $(shell docker images -q -f dangling=true) 21 | -docker rmi -f $(shell docker images -q) 22 | -docker volume prune -f 23 | -docker system prune -f 24 | 25 | kill-all: 26 | -docker kill $(shell docker ps -q) 27 | -------------------------------------------------------------------------------- /src/exceptions/UnauthorizedException.js: -------------------------------------------------------------------------------- 1 | import Exception from './Exception.js'; 2 | 3 | /** 4 | * @export 5 | * @class UnauthorizedException 6 | * @extends {Exception} 7 | */ 8 | export default class UnauthorizedException extends Exception { 9 | /** 10 | * Creates an instance of UnauthorizedException. 11 | * @param {*} params 12 | * @param {string} [defaultParams={ 13 | * message: 'Unauthorized', 14 | * statusCode: 401, 15 | * }] 16 | * @memberof UnauthorizedException 17 | */ 18 | constructor( 19 | params, 20 | defaultParams = { 21 | message: 'Unauthorized', 22 | statusCode: 401, 23 | }, 24 | ) { 25 | super(params, defaultParams); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/util/network/index.js: -------------------------------------------------------------------------------- 1 | import {ClientCredentials} from 'simple-oauth2'; 2 | import {oauth2 as oauth2Config} from '../../../config/index.js'; 3 | import jwt from './jwt.js'; 4 | 5 | export const parseAndValidateJwt = jwt; 6 | export const oauth2ClientGetAccessToken = async (clientId, clientSecret, scopes) => { 7 | const oauth2 = new ClientCredentials({ 8 | client: { 9 | id: clientId, 10 | secret: clientSecret, 11 | }, 12 | auth: { 13 | tokenHost: oauth2Config.tokenHost, 14 | tokenPath: oauth2Config.tokenPath, 15 | }, 16 | }); 17 | 18 | const tokenConfig = { 19 | audience: oauth2Config.audience, 20 | scope: scopes, 21 | }; 22 | 23 | return (await oauth2.getToken(tokenConfig)).token.access_token; 24 | }; 25 | -------------------------------------------------------------------------------- /src/web/middlewares/modelInitializer.js: -------------------------------------------------------------------------------- 1 | import {db} from '../../util/index.js'; 2 | import model from '../../model/index.js'; 3 | 4 | export default (app) => { 5 | // To not even initialize db where its not needed, models are lazy, populated by getModels 6 | app.use(async (req, res, next) => { 7 | res.locals.getModels = async () => { 8 | const discoutRepo = await model.discountRepository(await db.getConnection('Discount')); 9 | const sportsAppRepo = await model.sportsAppRepository(await db.getConnection('SportsApp')); 10 | res.locals.getModels = async () => ({ 11 | discount: discoutRepo, 12 | sportsApp: sportsAppRepo, 13 | }); 14 | return res.locals.getModels(); 15 | }; 16 | 17 | next(); 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /src/exceptions/ValidationException.js: -------------------------------------------------------------------------------- 1 | import Exception from './Exception.js'; 2 | 3 | // TODO sample of validation and bubbling fields 4 | /** 5 | * @export 6 | * @class ValidationException 7 | * @extends {Exception} 8 | */ 9 | export default class ValidationException extends Exception { 10 | /** 11 | * Creates an instance of ValidationException. 12 | * @param {*} params 13 | * @param {string} [defaultParams={ 14 | * message: 'Parameter(s) not valid', 15 | * statusCode: 400, 16 | * }] 17 | * @memberof ValidationException 18 | */ 19 | constructor( 20 | params, 21 | defaultParams = { 22 | message: 'Parameter(s) not valid', 23 | statusCode: 400, 24 | }, 25 | ) { 26 | super(params, defaultParams); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Lint & Test 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [16.4] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: do lint/test 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm install 28 | - run: npm run lint 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /src/model/schema/sportsAppUserIntegration.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const schema = new mongoose.Schema({ 4 | userId: { 5 | type: String, // uuid 6 | required: true, 7 | }, 8 | integrationId: { 9 | type: mongoose.Schema.Types.ObjectId, 10 | required: true, 11 | }, 12 | accessToken: { 13 | type: String, 14 | required: true, 15 | }, 16 | expiresIn: { 17 | type: Number, 18 | default: null, 19 | }, 20 | refreshToken: { 21 | type: String, 22 | default: null, 23 | }, 24 | authPayload: { 25 | type: Object, 26 | default: null, 27 | }, 28 | lastSyncTimestamp: { 29 | type: Number, 30 | default: null, 31 | }, 32 | }, 33 | {collection: 'sportsAppUserIntegrations'}); 34 | 35 | export const connect = (connection) => connection.model('sportsAppUserIntegrations', schema); 36 | -------------------------------------------------------------------------------- /src/web/routes/discount.js: -------------------------------------------------------------------------------- 1 | export const getPricesByDiscount = async (req, res) => { 2 | const models = await res.locals.getModels(); 3 | const discounts = await models.discount.getDiscount(req.query.code); 4 | 5 | if (discounts) { 6 | res.status(200).json(discounts); 7 | } else { 8 | res.status(200).json({ 9 | 'wallets': { 10 | 'hero': { 11 | 'common': 'wallet_common_no_discount', 12 | 'uncommon': 'wallet_uncommon_no_discount', 13 | 'rare': 'wallet_rare_no_discount', 14 | 'legendary': 'wallet_legendary_no_discount', 15 | }, 16 | }, 17 | 'prices': { 18 | 'common': 100, 19 | 'uncommon': 200, 20 | 'rare': 400, 21 | 'legendary': 800, 22 | }, 23 | }); 24 | } 25 | }; 26 | 27 | export const createDiscount = async (req, res) => { 28 | const models = await res.locals.getModels(); 29 | const discount = await models.discount.createDiscount(req.body); 30 | 31 | res.status(200).json(discount); 32 | }; 33 | 34 | -------------------------------------------------------------------------------- /src/model/README.md: -------------------------------------------------------------------------------- 1 | # Purpose of this folder 2 | 3 | Model generally is about describing all bussiness oriented logic, including all data access you do to achieve your bussiness goals. So usually this folder is quite unique for each microservice. 4 | 5 | We do demonstrate access to data using mongoose as ORM there. Its up to you how to convert mongoose schemas into actual repositories/models, current approach is provided as an example, to demonstrate how mongoose schemas could potentially be mapped into actual "bussiness language". 6 | 7 | Side notes: 8 | - It's always good practice to keep all write operations in single place. If you will spread write logic into diff code places or, even worse, diff code bases - that would become maintainance headache quite soon. 9 | - We do prefer throw (promises) across this codebase instead callbacks, because exception wrapping, bubbling field errors and better maintainability/consistency. Just do not use throw instead return - throw is for exceptional situations (not occuring during normal operation) 10 | -------------------------------------------------------------------------------- /config/custom-environment-variables.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "envName": "NODE_ENV", 4 | "listenOnPort": "LISTEN_ON_PORT", 5 | "externalHostname": "EXTERNAL_HOST_NAME" 6 | }, 7 | "logger": { 8 | "dir": "LOG_DIR", 9 | "fileName": "LOG_FILE_NAME", 10 | "level": { 11 | "file": "LOG_LEVEL_FILE", 12 | "console": "LOG_LEVEL_CONSOLE" 13 | } 14 | }, 15 | "db": { 16 | "mongo": { 17 | "fullConnString": "MONGO_FULL_CONN_STRING", 18 | "schema": "MONGO_SCHEMA", 19 | "host": "MONGO_HOST", 20 | "user": "MONGO_USER", 21 | "password": "MONGO_PASSWORD", 22 | "port": "MONGO_PORT", 23 | "database": "MONGO_DATABASE", 24 | "params": "MONGO_PARAMS", 25 | "poolSize": "MONGO_POOL_SIZE" 26 | } 27 | }, 28 | "sentry": { 29 | "dsn": "SENTRY_DSN" 30 | }, 31 | "oauth2": { 32 | "tokenHost": "APP_OAUTH2_TOKEN_HOST", 33 | "tokenPath": "APP_OAUTH2_TOKEN_PATH", 34 | "audience": "APP_OAUTH2_AUDIENCE", 35 | "jwksURI": "APP_OAUTH2_JWKS_URI" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | nodemon.json 2 | 3 | # Logs 4 | *.log* 5 | _ttd_log_/ 6 | logs/ 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # next.js build output 58 | .next 59 | 60 | .idea 61 | .vscode 62 | -------------------------------------------------------------------------------- /src/web/middlewares/error.js: -------------------------------------------------------------------------------- 1 | import {logger as log} from '../../util/index.js'; 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | export default (error, req, res, next) => { 5 | const parseForwardedFor = (value) => { 6 | if (!value) { 7 | return undefined; 8 | } 9 | 10 | const commaIndex = value.indexOf(','); 11 | return commaIndex === -1 ? value : value.substr(0, commaIndex); 12 | }; 13 | 14 | const errorObj = { 15 | message: error.message, 16 | description: error.description, 17 | fields: error.fields, 18 | stack: process.env.NODE_ENV !== 'production' ? error.stack : undefined, 19 | }; 20 | 21 | const reqInfo = `${req.originalUrl} - ${req.method}` + 22 | ` - ${parseForwardedFor(req.headers['x-forwarded-for']) || req.connection.remoteAddress}`; 23 | log.error({ 24 | status: error.statusCode || 500, 25 | message: errorObj.message, 26 | description: error.description, 27 | reqInfo, 28 | fields: errorObj.fields, 29 | stack: error.stack, 30 | }); 31 | 32 | // TODO if accept text/html - output nice error screen 33 | res.status(error.statusCode || 500).json(errorObj); 34 | }; 35 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "envName": "development", 4 | "listenOnPort": 3000, 5 | "externalHostname": "localhost" 6 | }, 7 | "logger": { 8 | "dir": "logs", 9 | "fileName": "combined.log", 10 | "level": { 11 | "__comment__": "Set to null to remove file transport alltogether, its not really needed when in microservice mode, set it to warn or other value if you want it anyway", 12 | "file": null, 13 | "console": "info" 14 | } 15 | }, 16 | "db": { 17 | "mongo": { 18 | "fullConnString": "", 19 | "schema": "mongodb", 20 | "host": "mongo", 21 | "user": "", 22 | "password": "", 23 | "port": "", 24 | "database": "{dbName}", 25 | "params": "", 26 | "poolSize": 5 27 | } 28 | }, 29 | "sentry": { 30 | "dsn": "" 31 | }, 32 | "oauth2": { 33 | "_": "To login to remote server", 34 | "tokenHost": "https://auth.yourhost", 35 | "tokenPath": "/oauth/token", 36 | "audience": "https://github.com/Rayvid/men-microservice-skeleton", 37 | "__": "To verify incomnig requests", 38 | "jwksURI": "https://auth.yourhost/jwks" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/util/logger/logger.js: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import DailyRotateFile from 'winston-daily-rotate-file'; 3 | import * as defaultOptions from './options.js'; 4 | 5 | export default (options = defaultOptions) => { 6 | const transports = [ 7 | new winston.transports.Console(options.console), 8 | ]; 9 | if (options.file) { 10 | transports.push(new DailyRotateFile(options.file)); 11 | } 12 | const logger = winston.createLogger({ 13 | transports, 14 | format: winston.format.combine( 15 | winston.format.timestamp(), 16 | winston.format.printf((info) => 17 | /* If not string - look for inspect, otherwise just stringify */ 18 | // eslint-disable-next-line no-nested-ternary, implicit-arrow-linebreak 19 | `${info.timestamp} ${info.level}: ${(info.message && typeof info.message !== 'string') ? 20 | // Not sure if it can happen, but handle objects inside message too 21 | JSON.stringify(info.message) : 22 | !info.message || info.capturedStack ? 23 | JSON.stringify(info) : 24 | info.message}`), 25 | ), 26 | exitOnError: false, // Logger should't decide about to exit or not 27 | }); 28 | 29 | return logger; 30 | }; 31 | -------------------------------------------------------------------------------- /src/web/routes/healthCheck.js: -------------------------------------------------------------------------------- 1 | import * as exceptions from '../../exceptions/index.js'; 2 | import {logger as log} from '../../util/index.js'; 3 | 4 | // eslint-disable-next-line no-unused-vars 5 | export const healthCheck = async (req, res) => { 6 | let result = {status: 'healthy'}; 7 | const models = await res.locals.getModels(); 8 | try { 9 | try { 10 | await models.sportsApp.createIntegration({provider: 'strava', connectionParams: {param1: '1', param2: '2'}}); 11 | } catch (err) { 12 | // It can fail if run multiple times due uniqueness - it is fine 13 | log.warn(new exceptions.Exception( 14 | { 15 | message: 'Create integration failed (because of duplicate record?)', innerError: err, 16 | })); 17 | } 18 | 19 | result = { 20 | status: 'healthy', 21 | // eslint-disable-next-line no-underscore-dangle 22 | stravaId: (await models.sportsApp.getStravaIntegration())._id, 23 | }; 24 | } catch (err) { 25 | throw new exceptions.Exception({message: 'Health check failed', innerError: err}); 26 | } 27 | 28 | res.status(200).json(result); 29 | }; 30 | 31 | // eslint-disable-next-line no-unused-vars 32 | export const sentryPing = async (req, res) => { 33 | throw new exceptions.Exception({message: 'Pinging sentry'}); 34 | }; 35 | -------------------------------------------------------------------------------- /compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | services: 3 | your_service_name: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | args: 8 | ENV: "development" # To install nodemon and other dev stuff 9 | NPMRC_CONTENT: "//registry.npmjs.org/:_authToken=your_token_for_private_packages" 10 | ports: 11 | - "3000:3000" 12 | - "9229:9229" 13 | depends_on: 14 | - mongodb 15 | environment: 16 | NODE_ENV: "development" # To start nodemon, enable dev.env, stacktraces and scope bypass 17 | DEV_BYPASS_SCOPES: "discounts:write.all" # To debug wo having to do authentication, only for DEV 18 | DEV_ENFORCE_TOKEN_PAYLOAD: "{\"scope\": \"discounts:write.all\" }" # Thats for handlers checking token payload, only for DEV 19 | env_file: 20 | - ./dev.env 21 | volumes: 22 | - .:/usr/src/app 23 | - /usr/src/app/node_modules 24 | - /usr/src/app/logs # Consider redirecting to /tmp:... if you have permission issues when writing to logs on linux 25 | mongodb: 26 | image: mongo:5.0 27 | ports: 28 | - "27777:27017" # Using another port to not conflict with host 29 | environment: 30 | - MONGO_INITDB_ROOT_USERNAME=root 31 | - MONGO_INITDB_ROOT_PASSWORD=rootPassXXX 32 | command: --bind_ip localhost,mongodb,127.0.0.1 33 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | services: 2 | - docker:dind 3 | 4 | stages: 5 | - pre_test 6 | - test 7 | - build 8 | 9 | lint: 10 | image: node:12.16 11 | stage: pre_test 12 | script: 13 | - npm install 14 | - npm run lint 15 | 16 | test: 17 | image: node:12.16 18 | stage: test 19 | script: 20 | - npm install 21 | - npm run test 22 | 23 | build: 24 | image: docker:latest 25 | stage: build 26 | only: 27 | - branches 28 | except: 29 | - master 30 | script: 31 | - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY 32 | - > 33 | sed -i 's/git-commit-hash/'${CI_COMMIT_SHA}'/' package.json 34 | - docker build --cache-from ${CI_REGISTRY_IMAGE}:latest --tag ${CI_REGISTRY_IMAGE}:latest --tag ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA} --build-arg NPMRC_CONTENT="$NPMRC_CONTENT" . 35 | - docker push ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA} 36 | 37 | push: 38 | image: docker:latest 39 | stage: build 40 | only: 41 | - master 42 | script: 43 | - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY 44 | - > 45 | sed -i 's/git-commit-hash/'${CI_COMMIT_SHA}'/' package.json 46 | - docker build --cache-from ${CI_REGISTRY_IMAGE}:latest --tag ${CI_REGISTRY_IMAGE}:latest --tag ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA} --build-arg NPMRC_CONTENT="$NPMRC_CONTENT" . 47 | - docker push ${CI_REGISTRY_IMAGE}:latest 48 | - docker push ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA} 49 | -------------------------------------------------------------------------------- /src/web/middlewares/auth.js: -------------------------------------------------------------------------------- 1 | import _ from 'underscore'; 2 | import {UnauthorizedException} from '../../exceptions/index.js'; 3 | import {network, logger as log} from '../../util/index.js'; 4 | 5 | export const validateAuthHeader = async (authorizationHeader, scope) => { 6 | if (!authorizationHeader || authorizationHeader.length < 7) { 7 | throw new UnauthorizedException({ 8 | message: 'Authorization failed', 9 | description: 'Authorization header too short (not set at all?)', 10 | }); 11 | } 12 | if (authorizationHeader.substring(0, 7).toLowerCase() !== 'bearer ') { 13 | throw new UnauthorizedException({message: 'Authorization failed', description: 'Token is not a bearer'}); 14 | } 15 | 16 | return network.parseAndValidateJwt(authorizationHeader.substring(7), scope); 17 | }; 18 | 19 | export const validateAuth = async (scope, req, res, next) => { 20 | if (!req.headers.authorization && 21 | process.env.NODE_ENV === 'development' && 22 | process.env.DEV_BYPASS_SCOPES && 23 | _.intersection(scope.split(' '), process.env.DEV_BYPASS_SCOPES.split(' ')).length > 0) { 24 | log.warn('Bypassing authorization header check - should not happen on prod!'); 25 | 26 | if (process.env.DEV_ENFORCE_TOKEN_PAYLOAD) { 27 | log.warn('Enforcing token payload - should not happen on prod!'); 28 | res.locals.token = {payload: JSON.parse(process.env.DEV_ENFORCE_TOKEN_PAYLOAD)}; 29 | } 30 | 31 | next(); 32 | return; 33 | } 34 | 35 | res.locals.token = await validateAuthHeader(req.headers.authorization, scope); 36 | next(); 37 | }; 38 | -------------------------------------------------------------------------------- /src/web/middlewares/index.js: -------------------------------------------------------------------------------- 1 | import Sentry from '@sentry/node'; 2 | import express from 'express'; 3 | import expressWinston from 'express-winston'; 4 | import * as config from '../../../config/index.js'; 5 | import {logger as log} from '../../util/index.js'; 6 | import modelInitializer from './modelInitializer.js'; 7 | import errorHandler from './error.js'; 8 | import notFoundHandler from './404.js'; 9 | import * as authHandler from './auth.js'; 10 | 11 | export const validateAuth = authHandler.validateAuth.bind(null, undefined); 12 | export const validateAuthScope = (scope) => authHandler.validateAuth.bind(null, scope); 13 | 14 | // Those trigger before every handler 15 | export const beforeHandler = [ 16 | (app) => { 17 | app.use((req, res, next) => { 18 | // Shortcut for common scenarious to not consume cycles and not spam log 19 | if (req.method === 'OPTIONS') { 20 | res.sendStatus(204); 21 | } else if (req.url === '/favicon.ico' || req.url === '/robots.txt') { 22 | res.sendStatus(404); 23 | } else { 24 | next(); 25 | } 26 | }); 27 | }, 28 | (app) => { 29 | if (config.sentry.dsn) { 30 | app.use(Sentry.Handlers.requestHandler()); 31 | } 32 | }, 33 | (app) => app.use(expressWinston.logger(log)), 34 | (app) => app.use(express.json({limit: '10mb'})), 35 | modelInitializer, 36 | ]; 37 | 38 | // Those trigger after after unhandled 39 | export const afterHandler = [ 40 | (app) => app.use(notFoundHandler), 41 | (app) => { 42 | if (config.sentry.dsn) { 43 | app.use(Sentry.Handlers.errorHandler()); 44 | } 45 | }, 46 | (app) => app.use(errorHandler), 47 | ]; 48 | 49 | -------------------------------------------------------------------------------- /src/util/logger/options.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import winston from 'winston'; 4 | import * as config from '../../../config/index.js'; 5 | 6 | const logDir = path.join(process.cwd(), config.logger.dir); 7 | if (!fs.existsSync(logDir)) { 8 | fs.mkdirSync(logDir); 9 | } 10 | 11 | export const file = config.logger.level.file ? { 12 | level: config.logger.level.file, 13 | filename: path.join(logDir, config.logger.fileName), 14 | handleExceptions: true, 15 | datePattern: 'YYYY-MM-DD', 16 | maxSize: '5m', 17 | maxFiles: '5d', 18 | } : undefined; 19 | 20 | export const console = { 21 | level: config.logger.level.console, 22 | handleExceptions: true, 23 | // Specialized formatter for console - for better readability 24 | format: winston.format.combine( 25 | winston.format.colorize(), 26 | winston.format.timestamp(), 27 | winston.format.printf((info) => { 28 | const {level, timestamp} = info; 29 | 30 | /* eslint-disable no-param-reassign */ 31 | delete info.level; 32 | delete info.timestamp; 33 | /* eslint-enable no-param-reassign */ 34 | 35 | /* If not string - look for inspect, otherwise just stringify */ 36 | // eslint-disable-next-line no-nested-ternary, implicit-arrow-linebreak 37 | return `${timestamp} ${level}: ${(info.message && typeof info.message !== 'string') ? 38 | // Not sure if it can happen, but handle objects inside message too 39 | JSON.stringify(info.message).replace(/\\n/g, '\n') : 40 | !info.message || info.capturedStack ? 41 | JSON.stringify(info).replace(/\\n/g, '\n') : 42 | info.message}`; 43 | }), 44 | ), 45 | }; 46 | -------------------------------------------------------------------------------- /tests/oauth2.test.js: -------------------------------------------------------------------------------- 1 | // Placeholder for global tests 2 | // it's good idea to have local tests folders inside src to do contextual unittesting 3 | import '../config/env.js'; // Ensure its invoked before everything else, otherwise it becomes sequence dependant 4 | import chai from 'chai'; 5 | import chaiAsPromised from 'chai-as-promised'; 6 | import {oauth2ClientGetAccessToken} from '../src/util/network/index.js'; 7 | import {validateAuthHeader} from '../src/web/middlewares/auth.js'; 8 | import {UnauthorizedException} from '../src/exceptions/index.js'; 9 | 10 | chai.use(chaiAsPromised); 11 | chai.should(); 12 | 13 | describe('Oauth2', () => { 14 | describe('Get access token', () => { 15 | it('it should be able to fetch access token from auth0', async () => { 16 | // hardcoding some credentials generously served by auth0, for test purposes 17 | // You should always pass those trough environment! 18 | await (validateAuthHeader(`bearer ${await oauth2ClientGetAccessToken( 19 | '2isgefBsD9SJ1o7vJZn1x6iC1tmRMwcA', 20 | 'mdNv5pjLD6pFi6HzIDJbt8UgQf0vwCCWvKJ-3BDRdrs7lVI0-hvMWXbTSMtaKJmC', 21 | 'discounts:write.all', 22 | )}`, 'discounts:write.all')); 23 | }); 24 | 25 | it('it should throw on invalid scope', async () => { 26 | // hardcoding some credentials generously served by auth0, for test purposes 27 | // You should always pass those trough environment! 28 | validateAuthHeader(`bearer ${await oauth2ClientGetAccessToken( 29 | '2isgefBsD9SJ1o7vJZn1x6iC1tmRMwcA', 30 | 'mdNv5pjLD6pFi6HzIDJbt8UgQf0vwCCWvKJ-3BDRdrs7lVI0-hvMWXbTSMtaKJmC', 31 | 'test', 32 | )}`, 'tset').should.be.rejectedWith(UnauthorizedException); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/web/index.js: -------------------------------------------------------------------------------- 1 | // This file is all about defining and running http server instance 2 | // Some infrastructure code leak there is ok, just try to keep it at minimum 3 | import express from 'express'; 4 | import 'express-async-errors'; 5 | import swaggerUi from 'swagger-ui-express'; 6 | 7 | import * as middlewares from './middlewares/index.js'; 8 | import * as config from '../../config/index.js'; 9 | import {logger as log} from '../util/index.js'; 10 | import routes from './routes/index.js'; 11 | 12 | import path from 'path'; 13 | import fs from 'fs'; 14 | 15 | const swaggerDoc = JSON.parse(fs.readFileSync( 16 | path.join(process.cwd(), 'src/web/swagger.json').replace( 17 | 'HOST_AND_PORT', 18 | config.server.externalHostName + (config.server.listenOnPort != 80 ? 19 | config.server.listenOnPort.toString() : 20 | '')))); 21 | 22 | const app = express(); 23 | 24 | middlewares.beforeHandler.forEach((_) => _(app)); 25 | 26 | // TODO move swagger init into special folder and make it environment aware (dynamic) 27 | app.use('/swagger', swaggerUi.serve, swaggerUi.setup(swaggerDoc)); 28 | 29 | // Application routes, for simple microservices just plainly listing it in one place is fine 30 | app.get('/health', routes.healthCheckRoutes.healthCheck); 31 | app.get('/health/sentry', routes.healthCheckRoutes.sentryPing); 32 | app.get('/version', routes.versionCheck); 33 | 34 | app.get('/getPriceAndWalletsByDiscount', routes.getPricesByDiscount); 35 | app.post('/createDiscount', middlewares.validateAuthScope('discounts:write.all'), routes.createDiscount); 36 | 37 | middlewares.afterHandler.forEach((_) => _(app)); 38 | 39 | app.listen(config.server.listenOnPort, () => { 40 | log.info(`App listening on ${config.server.listenOnPort}, pid = ${process.pid}`); 41 | }); 42 | -------------------------------------------------------------------------------- /src/util/db/index.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import {mongo as mongoConfig} from '../../../config/index.js'; 3 | 4 | /** 5 | * Generates a mongo connection url 6 | * @param {Object} dbName if connection string allows dynamically replace dbName 7 | * @return {*} 8 | */ 9 | const generateConnectionUrl = (dbName) => { 10 | if (mongoConfig.fullConnString) { 11 | return mongoConfig.fullConnString.replace('{dbName}', dbName); 12 | } 13 | 14 | const auth = (mongoConfig.user && mongoConfig.user !== '' && 15 | mongoConfig.password && mongoConfig.password !== '') ? 16 | `${mongoConfig.user}:${mongoConfig.password}@` : ''; 17 | const port = mongoConfig.schema.indexOf('srv') === -1 /* No port for srv */ && mongoConfig.port ? 18 | `:${mongoConfig.port}` : 19 | ''; 20 | const database = `/${dbName || mongoConfig.database}`; 21 | const params = (mongoConfig.params) ? `?${mongoConfig.params}` : ''; 22 | return `${mongoConfig.schema}://${auth}${mongoConfig.host}${port}${database}${params}`; 23 | }; 24 | 25 | const options = { 26 | keepAlive: true, 27 | keepAliveInitialDelay: 30000, 28 | maxPoolSize: (mongoConfig.poolSize && parseInt(mongoConfig.poolSize, 10)) || 5, 29 | useNewUrlParser: true, 30 | useUnifiedTopology: true, 31 | }; 32 | 33 | // Mongoose connection management - default one does not support multidatabase 34 | const connections = {}; 35 | const gracefulExit = () => Object 36 | .values(connections) 37 | .map((connection) => connection.close && connection.close(() => process.exit(0))); 38 | // If the Node process ends, close all the connections 39 | process.on('SIGINT', gracefulExit).on('SIGTERM', gracefulExit); 40 | 41 | export const getConnection = async (database) => { 42 | let connection = connections[database]; 43 | 44 | if (typeof connection === 'undefined' || connection === null) { 45 | connection = await mongoose.createConnection(generateConnectionUrl(database), options); 46 | 47 | connections[database] = connection; 48 | } 49 | 50 | return connection; 51 | }; 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "men-microservice-skeleton", 3 | "version": "1.0.0-git-commit-hash", 4 | "description": "MEN Microservice project templace - fastest way to kickoff your NodeJS + MongoDB microservice", 5 | "type": "module", 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "node index.js", 9 | "__comment__": "Delay is needed to nodemon not to crash each second time because 9229 busy", 10 | "__window_specifics__": "If nodemon doesnt pickup changes automatically -L flag might help", 11 | "dev": "npx cross-env NODE_ENV=development npx nodemon --delay 2 --inspect=0.0.0.0:9229 index.js -L", 12 | "docker:production": "npx cross-env NODE_ENV=production npm start", 13 | "docker:development": "npx cross-env NODE_ENV=development npm run dev", 14 | "lint": "npx eslint .", 15 | "test": "npx cross-env NODE_ENV=test mocha \"./{,!(node_modules)/**/}*.test.js\"" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/Rayvid/men-microservice-skeleton" 20 | }, 21 | "keywords": [ 22 | "men", 23 | "mean", 24 | "mern", 25 | "microservice" 26 | ], 27 | "author": "Rayvid", 28 | "license": "SEE LICENSE IN JUSTTAKEANDUSEIT", 29 | "homepage": "https://github.com/Rayvid/men-microservice-skeleton", 30 | "dependencies": { 31 | "@sentry/node": "^6.7.2", 32 | "config": "^3.3.6", 33 | "cross-env": "^7.0.3", 34 | "dotenv": "^10.0.0", 35 | "express": "^4.18.1", 36 | "express-async-errors": "^3.1.1", 37 | "express-winston": "^4.2.0", 38 | "joi": "^17.4.0", 39 | "jsonwebtoken": "^9.0.0", 40 | "jwks-rsa": "^2.1.3", 41 | "mongoose": "^6.5.4", 42 | "simple-oauth2": "^4.2.0", 43 | "swagger-ui-express": "^4.1.6", 44 | "underscore": "^1.13.1", 45 | "winston": "^3.7.2", 46 | "winston-daily-rotate-file": "^4.6.1" 47 | }, 48 | "devDependencies": { 49 | "chai": "^4.3.4", 50 | "chai-as-promised": "^7.1.1", 51 | "eslint": "^7.29.0", 52 | "eslint-config-google": "^0.14.0", 53 | "mocha": "^9.0.1", 54 | "nodemon": "^2.0.18" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/util/network/jwt.js: -------------------------------------------------------------------------------- 1 | import _ from 'underscore'; 2 | import jwksClient from 'jwks-rsa'; 3 | import jwt from 'jsonwebtoken'; 4 | import {UnauthorizedException} from '../../exceptions/index.js'; 5 | import * as config from '../../../config/index.js'; 6 | 7 | const client = jwksClient({ 8 | cache: true, 9 | jwksUri: config.oauth2.jwksURI, 10 | }); 11 | 12 | const readIdentityServerPubKey = async (header) => { 13 | let resolveHook; 14 | let rejectHook; 15 | const promise = new Promise((resolve, reject) => { 16 | resolveHook = resolve; 17 | rejectHook = reject; 18 | }); 19 | 20 | if (!header.kid) { 21 | rejectHook(new Error('No kid is present in header')); 22 | } 23 | 24 | client.getSigningKey(header.kid, (err, key) => { 25 | if (err) { 26 | rejectHook(err); 27 | } else { 28 | resolveHook(key.publicKey || key.rsaPublicKey); 29 | } 30 | }); 31 | 32 | return promise; 33 | }; 34 | 35 | export default async (tokenRaw, scope) => { 36 | const token = jwt.decode(tokenRaw, {complete: true}); 37 | if (!token) { 38 | throw new UnauthorizedException({message: 'Authorization failed', description: 'Invalid JWT'}); 39 | } 40 | token.raw = tokenRaw; 41 | 42 | // We expect token.header, token.payload and token.raw to be filled after this point 43 | try { 44 | const publicKey = await readIdentityServerPubKey(token.header); 45 | let resolveHook; 46 | let rejectHook; 47 | const promise = new Promise((resolve, reject) => { 48 | resolveHook = resolve; 49 | rejectHook = reject; 50 | }); 51 | jwt.verify( 52 | token.raw, 53 | publicKey, 54 | {ignoreExpiration: false, ignoreNotBefore: false}, 55 | (err, decoded) => { 56 | if (err) { 57 | rejectHook(err); 58 | } else { 59 | resolveHook(decoded); 60 | } 61 | }, 62 | ); 63 | 64 | await promise; 65 | 66 | if (scope && (!token.payload.scope || 67 | _.intersection(scope.split(' '), token.payload.scope.split(' ')).length === 0)) { 68 | throw new UnauthorizedException({ 69 | message: 'Access denied', 70 | description: `No access to scope - '${scope}'`, 71 | statusCode: 403, 72 | }); 73 | } 74 | 75 | return token; 76 | } catch (err) { 77 | throw new UnauthorizedException({ 78 | message: 'Request authorization header validation attempt failed', 79 | description: err.message, 80 | innerError: err, 81 | }); 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /src/web/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "description": "No transpiler (M)EN microservice skeleton", 5 | "version": "1.0", 6 | "title": "men-skeleton" 7 | }, 8 | "host": "${HOST_AND_PORT}", 9 | "basePath": "/", 10 | "consumes": [ 11 | "application/json" 12 | ], 13 | "produces": [ 14 | "application/json" 15 | ], 16 | "components": { 17 | "securitySchemes": { 18 | "bearerAuth": { 19 | "type": "http", 20 | "scheme": "bearer", 21 | "bearerFormat": "JWT" 22 | } 23 | } 24 | }, 25 | "security": [ 26 | { 27 | "bearerAuth": [] 28 | } 29 | ], 30 | "paths": { 31 | "/health": { 32 | "get": { 33 | "summary": "Healtcheck", 34 | "parameters": [], 35 | "responses": { 36 | "200": { 37 | "description": "Successful response", 38 | "content": { 39 | "application/json": {} 40 | } 41 | }, 42 | "400": { 43 | "description": "Invalid parameter (see fields for for validation errors)", 44 | "content": { 45 | "application/json": {} 46 | } 47 | }, 48 | "401": { 49 | "description": "Authorization information is missing or invalid", 50 | "content": { 51 | "application/json": {} 52 | } 53 | }, 54 | "403": { 55 | "description": "Permission denied", 56 | "content": { 57 | "application/json": {} 58 | } 59 | }, 60 | "5XX": { 61 | "description": "Unexpected error", 62 | "content": { 63 | "application/json": {}, 64 | "text/plain": {} 65 | } 66 | } 67 | } 68 | }, 69 | "x-summary": "Healthcheck (for loadbalancer or other http based heartbeat)" 70 | }, 71 | "/version": { 72 | "get": { 73 | "summary": "Version", 74 | "parameters": [], 75 | "responses": { 76 | "200": { 77 | "description": "Successful response", 78 | "content": { 79 | "application/json": {} 80 | } 81 | }, 82 | "400": { 83 | "description": "Invalid parameter (see fields for for validation errors)", 84 | "content": { 85 | "application/json": {} 86 | } 87 | }, 88 | "401": { 89 | "description": "Authorization information is missing or invalid", 90 | "content": { 91 | "application/json": {} 92 | } 93 | }, 94 | "403": { 95 | "description": "Permission denied", 96 | "content": { 97 | "application/json": {} 98 | } 99 | }, 100 | "5XX": { 101 | "description": "Unexpected error", 102 | "content": { 103 | "application/json": {}, 104 | "text/plain": {} 105 | } 106 | } 107 | } 108 | }, 109 | "x-summary": "To fetch current microservice version" 110 | } 111 | }, 112 | "definitions": {} 113 | } 114 | -------------------------------------------------------------------------------- /src/model/sportsAppRepository/userIntegration.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import * as schema from '../schema/index.js'; 3 | import integrationRepoInitializer from './integration.js'; 4 | import {ModelException} from '../../exceptions/index.js'; 5 | 6 | export default (dbConnection) => { 7 | // Using mongoose constructor is mandatory when constructing mongoose objects. 8 | // It ensures schema validation logic is performed 9 | const SportsAppUserIntegrationFactory = schema 10 | .sportsAppUserIntegrationSchema 11 | .connect(dbConnection); 12 | const integrationRepo = integrationRepoInitializer(dbConnection); 13 | 14 | return { 15 | getUserIntegration: 16 | // Lean is recommended 17 | // - saves alot of resources by returning plain object instead mongoose wrapper 18 | async (integrationName, userId, lean = true) => { 19 | const integration = await integrationRepo.getIntegration(integrationName); 20 | 21 | if (!integration) { 22 | throw new ModelException({message: `Non existent integration - '${integrationName}'`}); 23 | } 24 | 25 | const result = schema 26 | .sportsAppUserIntegrationSchema 27 | .connect(dbConnection) 28 | // eslint-disable-next-line no-underscore-dangle 29 | .findOne({userId, integrationId: integration._id}); 30 | 31 | return (lean) ? result.lean() : result; 32 | }, 33 | 34 | saveOrUpdateUserIntegration: 35 | async ({userId, integrationId, accessToken, expiresIn, refreshToken, authPayload}) => { 36 | // Keeping create and update separately you will save alot of code lines and complexity, 37 | // just want to demonstrate thats possible using mongoose to merge these two if badly needed - so called upsert 38 | 39 | const lastSyncTimestamp = Math.floor(new Date() / 1000); 40 | const sportsAppUserIntegration = new SportsAppUserIntegrationFactory({ 41 | userId, 42 | integrationId, 43 | accessToken, 44 | expiresIn, 45 | refreshToken, 46 | authPayload, 47 | lastSyncTimestamp, 48 | }); 49 | 50 | // TODO use some promisification lib instead custom code 51 | let promiseComplete; 52 | let promiseReject; 53 | const validationPromise = new Promise((complete, reject) => { 54 | promiseComplete = complete; 55 | promiseReject = reject; 56 | }); 57 | sportsAppUserIntegration.validate((err) => { 58 | if (err) { 59 | promiseReject(err); 60 | } else { 61 | promiseComplete(); 62 | } 63 | }); 64 | await validationPromise; 65 | 66 | // Delete _id property, set by mongoose in prev operation 67 | /* eslint-disable no-underscore-dangle */ 68 | let objectToUpdate = {}; 69 | objectToUpdate = Object.assign(objectToUpdate, sportsAppUserIntegration._doc); 70 | delete objectToUpdate._id; 71 | /* eslint-enable no-underscore-dangle */ 72 | 73 | return schema 74 | .sportsAppUserIntegrationSchema.connect(dbConnection) 75 | .findOneAndUpdate({ 76 | userId, 77 | // eslint-disable-next-line new-cap 78 | integrationId: mongoose.Types.ObjectId(integrationId.toString()), 79 | }, objectToUpdate, {upsert: true}); 80 | }, 81 | }; 82 | }; 83 | -------------------------------------------------------------------------------- /src/exceptions/Exception.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | 4 | const pkgJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); 5 | 6 | // It's recommended to extend this class to be able to pass trough it to callbacks, etc. 7 | /** 8 | * @export 9 | * @class Exception 10 | * @extends {Error} 11 | */ 12 | export default class Exception extends Error { 13 | /** 14 | * Creates an instance of Exception. 15 | * @param {*} params 16 | * @param {string} [defaultParams={ 17 | * message: 'Exception occured', 18 | * description: undefined, 19 | * statusCode: 500, 20 | * innerError: undefined, 21 | * fields: undefined, // Server validation scenarios and similar, to display field specific issues 22 | * doNotaugmentStack: false, // True will save resources if you use throw instead return 23 | * }] 24 | * @memberof Exception 25 | */ 26 | constructor( 27 | // Supported initialization patterns: 28 | // new Exception(err) 29 | // new Exception({message: '...', innerError: err}) 30 | params, 31 | defaultParams = { 32 | message: 'Exception occured', 33 | description: undefined, 34 | statusCode: 500, 35 | innerError: undefined, 36 | fields: undefined, // Server validation scenarios and similar, to display field specific issues 37 | doNotaugmentStack: false, // True will save resources if you use throw instead return 38 | }, 39 | ) { 40 | let constructorParameters = defaultParams; 41 | if (params && !params.stack) { 42 | constructorParameters = params; // Parameters must be object 43 | } else if (params.stack) { 44 | constructorParameters.innerError = params; // If constructed from exception alone 45 | } 46 | super(constructorParameters.message || defaultParams.message); 47 | this.errorMessage = (constructorParameters.message || defaultParams.message); 48 | 49 | if (!constructorParameters.doNotaugmentStack) { 50 | // Capturing stack trace and excluding constructor call from it. 51 | // This could require some explanation: 52 | // implementing exception bubbling in async code is still painfull in Node. 53 | // it was somewhat resolved with --async-stack-traces 54 | // but OO Exception approach (wrapping) is more versatile. 55 | // So we are using dumb stacktrace concatenation to achieve that 56 | // 57 | // In case you are not fimiliar what exception wrapping is: 58 | // try { 59 | // await someAsyncCode... 60 | // } catch (err) { 61 | // throw new Exception(err); 62 | // } 63 | Error.captureStackTrace(this, this.constructor); 64 | if (constructorParameters.innerError) { 65 | this.capturedStack = this.capturedStack ? 66 | `${this.capturedStack}\n${constructorParameters.innerError.stack}` : 67 | constructorParameters.innerError.stack; 68 | } 69 | } 70 | 71 | if (constructorParameters.innerError) { 72 | this.cause = constructorParameters.innerError; // 4 Sentry to see original cause 73 | } 74 | 75 | // Add module name in case you will need to handle exception by exact source 76 | this.name = `${pkgJson.name.toUpperCase()}.${this.constructor.name}`; 77 | 78 | // Most commonly it will be HTTP status code, 79 | // but can be any other convention dictated by library throwing it 80 | this.statusCode = constructorParameters.statusCode || defaultParams.statusCode; 81 | 82 | // To bubble description from originated exception 83 | this.description = constructorParameters.description || 84 | (constructorParameters.innerError && constructorParameters.innerError.description); 85 | 86 | // To bubble fields from originated exception 87 | this.fields = constructorParameters.fields || 88 | (constructorParameters.innerError && constructorParameters.innerError.fields); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # No transpiler (M)EN microservice project template - fastest way to kickoff your microservice 2 | 3 | Micro service project template based on Node 16.4 with strong focus on KISS principles. Really straightforward, just checkout and `docker compose -f compose.dev.yml up`. Mongo initialization is lazy, on first call, so can be used w/o actual mongo server running. 4 | 5 | It slowly evolved as a result of my own experience of solving similar problems in multiple projects, with teams of very different skill level. So keeping KISS principles is very important in this project. Well, you will probably find oauth2 stuff, model folder and exceptions not that simple, but at least you can find comments/readme explaining need for additional complexity there. 6 | 7 | ## Launch locally 8 | 9 | `make build up`, navigate to http://localhost:3000/swagger/. By default it runs in nodemon mode and detects changes. 10 | 11 | On windows you can install make for git-bash/MinGW using this instruction https://gist.github.com/evanwill/0207876c3243bbb6863e65ec5dc3f058. 12 | 13 | You can connect mongodb on docker using this connection string: `mongodb://root:rootPassXXX@127.0.0.1:27777/?authSource=admin` 14 | 15 | ## Debug locally 16 | 17 | Standard vscode Docker extension provides `Docker: Attach to Node` debug mode, which work just fine out of the box. 18 | 19 | ### `npm start` w/o docker 20 | 21 | Yes it does work, you can even start w/o having database up, thanks to lazy db connection creation approach. 22 | 23 | ## Configuration 24 | 25 | Configuration is set up in this order (later ones superseeds earlier ones): 26 | - Default values comes from `config/default.json` 27 | - If `NODE_ENV` is not `production` - `dev.env` in project root gets loaded into environment 28 | - Attempts read environment file defined in `ENV_FILE` or `/run/secrets/env` if it's undefined 29 | - Picks up environment vars (you can set those in compose or dev.env) 30 | - Command line arguments comes as highest priority https://github.com/lorenwest/node-config/wiki/Command-Line-Overrides 31 | 32 | note: env vars are mapped to config scheme in `config/custom-environment-variables.json` 33 | 34 | Handling configuration this way allows developers to update configuration wo headache and let's override config during CI/CD using variety of DevOps methods. 35 | 36 | `dev.env` file is for **development**, so no production values allowed there. 37 | 38 | **Never push sensitive credentials to git!** 39 | 40 | ## Modules vs infrastructure code duplication 41 | 42 | Some infra code duplication in microservices is ok. It allows you to finetune particular behaviour w/o making logic branch in the module, but you should be moving independent reusable blocks into the modules normally - so potentially exceptions and some utils moved from skeleton into the standalone modules in the future. 43 | 44 | ## Code style this project is compatible with 45 | 46 | ``` 47 | { 48 | "env": { 49 | "browser": true, 50 | "es2021": true 51 | }, 52 | "extends": [ 53 | "google" 54 | ], 55 | "parserOptions": { 56 | "ecmaVersion": 12, 57 | "sourceType": "module" 58 | }, 59 | "rules": { 60 | "max-len": ["error", { 61 | "code": 120, 62 | "ignoreUrls": true, 63 | "ignoreTrailingComments": true, 64 | "ignoreComments": true 65 | }] 66 | } 67 | } 68 | ``` 69 | I know, not everybody loves semicolons, sorry about that ;) 70 | 71 | ## Mongo as data store 72 | 73 | Mongoose used as ORM, but connection initialization approach tweaked to support multidatabase in single microservice (yes i know, thats quite rare, but still - happens) and to be lazy - to microservice to start faster and this project template to be usefull when mongo isn't actually used. 74 | 75 | ## Extending Error to become Exception 76 | 77 | it's a complicated topic, but I think the support of inner Exception's (wrapping) and bubbling `fields` property from innermost exception to outermost (e.g. model fails validation and throws with `fields` containing validation errors, controller wraps with additional info and rethrows, frontend pickups fields and shows error messages), comes vhandy in high level scenarios, for low level stuff - its totally fine (and in fact, more portable) to keep using Error's. 78 | 79 | ## Sentry friendly 80 | 81 | Sentry will see entire exception path (nested exceptions too) when provided Exception classes, or inherited ones, are used. 82 | 83 | ## CI ready 84 | 85 | Gitlab - autobind mservice version to commit hash, lint, test. Push artifact to gitlab image repository. 86 | 87 | Github - just lint and test for now. 88 | 89 | ## Logging built in 90 | 91 | Based on winston, extended to support provided Exception classes (or inherited ones) which in order allows you to see full exception trace when Exception gets thrown. 92 | 93 | ## (Unit) tests 94 | 95 | Some sample global tests folder included to kickoff easily from there. Mocha + Chai FTW. 96 | 97 | ## JWT 98 | 99 | Both middleware to validate JWT and utility to get access token using client credentials flow, are present. 100 | 101 | Middleware usage-cases: 102 | * `validateAuth` - to just validate if JWT is issued by right authority, like `app.get('/version', middlewares.validateAuth, routes.versionCheck);` 103 | * `validateAuthScope(scope)` - for validating if JWT is issued by right authority and contains required scope (or multiple, space separated, scopes), like `app.get('/version', middlewares.validateAuthScope('tooling:version.read'), routes.versionCheck);` 104 | 105 | Token payload is transfered to res.locals.token.payload - in case you want to check claims manually. 106 | 107 | note: theres way to bypass scopes check in dev mode, to speedup developement - check compose.dev.yml DEV_BYPASS_SCOPES env variable 108 | 109 | ## health/version/sentryPing endpoints 110 | 111 | Some standard infrastructure endpoints example 112 | --------------------------------------------------------------------------------