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