Skip to content

Commit

Permalink
feat: add utils
Browse files Browse the repository at this point in the history
  • Loading branch information
saisilinus committed Dec 14, 2021
1 parent 1de1d8e commit ef73bd4
Show file tree
Hide file tree
Showing 17 changed files with 1,349 additions and 1,009 deletions.
2 changes: 0 additions & 2 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
node_modules
bin

tsconfig.json
5 changes: 3 additions & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
"node": true,
"jest": true
},
"extends": ["airbnb-base", "plugin:jest/recommended", "plugin:security/recommended", "plugin:prettier/recommended"],
"extends": ["airbnb-base", "airbnb-typescript/base", "plugin:jest/recommended", "plugin:security/recommended", "plugin:prettier/recommended"],
"plugins": ["jest", "security", "prettier"],
"parserOptions": {
"ecmaVersion": 2018
"ecmaVersion": 2018,
"project": "./tsconfig.json"
},
"rules": {
"no-console": "error",
Expand Down
6 changes: 6 additions & 0 deletions eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['airbnb-typescript/base'],
};
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,19 @@
"prettier"
],
"devDependencies": {
"@types/compression": "^1.7.2",
"@types/cookie-parser": "^1.4.2",
"@types/cors": "2.8.8",
"@types/express": "^4.17.13",
"@types/morgan": "^1.9.3",
"@types/node": "^16.11.12",
"@types/passport-jwt": "^3.0.6",
"@typescript-eslint/eslint-plugin": "^5.7.0",
"@typescript-eslint/parser": "^5.7.0",
"coveralls": "^3.1.1",
"eslint": "^8.4.1",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^16.1.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-jest": "^25.3.0",
Expand All @@ -63,8 +71,7 @@
"nodemon": "^2.0.15",
"prettier": "^2.5.1",
"supertest": "^6.1.6",
"tslint": "^6.1.3",
"tslint-config-airbnb": "^5.11.2"
"typescript": "^4.5.4"
},
"dependencies": {
"bcryptjs": "^2.4.3",
Expand Down
47 changes: 47 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import express from 'express';
import helmet from 'helmet';
import xss from 'xss-clean';
import ExpressMongoSanitize from 'express-mongo-sanitize';
import compression from 'compression';
import cors from 'cors';
import passport from 'passport';
import cookieParser from 'cookie-parser';
import config from './config/config';
import { successHandler, errorHandler } from './config/morgan';
import jwtStrategy from './config/passport';

const app = express();

if (config.env !== 'test') {
app.use(successHandler);
app.use(errorHandler);
}

// set security HTTP headers
app.use(helmet());

// use cookie parser for jwt
app.use(cookieParser());

// enable cors
app.use(cors());
app.options('*', cors());

// parse json request body
app.use(express.json());

// parse urlencoded request body
app.use(express.urlencoded({ extended: true }));

// sanitize request data
app.use(xss());
app.use(ExpressMongoSanitize());

// gzip compression
app.use(compression());

// jwt authentication
app.use(passport.initialize());
passport.use('jwt', jwtStrategy);

export default app;
64 changes: 64 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Joi from 'joi';
import path from 'path';
import dotenv from 'dotenv';

dotenv.config({ path: path.join(__dirname, '../../.env') });

const envVarsSchema = Joi.object()
.keys({
NODE_ENV: Joi.string().valid('production', 'development', 'test').required(),
PORT: Joi.number().default(3000),
MONGODB_URL: Joi.string().required().description('Mongo DB url'),
JWT_SECRET: Joi.string().required().description('JWT secret key'),
JWT_ACCESS_EXPIRATION_MINUTES: Joi.number().default(30).description('minutes after which access tokens expire'),
JWT_REFRESH_EXPIRATION_DAYS: Joi.number().default(30).description('days after which refresh tokens expire'),
JWT_RESET_PASSWORD_EXPIRATION_MINUTES: Joi.number()
.default(10)
.description('minutes after which reset password token expires'),
JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: Joi.number()
.default(10)
.description('minutes after which verify email token expires'),
SMTP_HOST: Joi.string().description('server that will send the emails'),
SMTP_PORT: Joi.number().description('port to connect to the email server'),
SMTP_USERNAME: Joi.string().description('username for email server'),
SMTP_PASSWORD: Joi.string().description('password for email server'),
EMAIL_FROM: Joi.string().description('the from field in the emails sent by the app'),
})
.unknown();

const { value: envVars, error } = envVarsSchema.prefs({ errors: { label: 'key' } }).validate(process.env);

if (error) {
throw new Error(`Config validation error: ${error.message}`);
}

export default {
env: envVars.NODE_ENV,
port: envVars.PORT,
mongoose: {
url: envVars.MONGODB_URL + (envVars.NODE_ENV === 'test' ? '-test' : ''),
options: {
useCreateIndex: true,
useNewUrlParser: true,
useUnifiedTopology: true,
},
},
jwt: {
secret: envVars.JWT_SECRET,
accessExpirationMinutes: envVars.JWT_ACCESS_EXPIRATION_MINUTES,
refreshExpirationDays: envVars.JWT_REFRESH_EXPIRATION_DAYS,
resetPasswordExpirationMinutes: envVars.JWT_RESET_PASSWORD_EXPIRATION_MINUTES,
verifyEmailExpirationMinutes: envVars.JWT_VERIFY_EMAIL_EXPIRATION_MINUTES,
},
email: {
smtp: {
host: envVars.SMTP_HOST,
port: envVars.SMTP_PORT,
auth: {
user: envVars.SMTP_USERNAME,
pass: envVars.SMTP_PASSWORD,
},
},
from: envVars.EMAIL_FROM,
},
};
31 changes: 31 additions & 0 deletions src/config/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import winston from 'winston';
import config from './config';

interface LoggingInfo {
level: string;
message: string;
}

const enumerateErrorFormat = winston.format((info: LoggingInfo) => {
if (info instanceof Error) {
Object.assign(info, { message: info.stack });
}
return info;
});

const logger = winston.createLogger({
level: config.env === 'development' ? 'debug' : 'info',
format: winston.format.combine(
enumerateErrorFormat(),
config.env === 'development' ? winston.format.colorize() : winston.format.uncolorize(),
winston.format.splat(),
winston.format.printf((info: LoggingInfo) => `${info.level}: ${info.message}`)
),
transports: [
new winston.transports.Console({
stderrLevels: ['error'],
}),
],
});

export default logger;
20 changes: 20 additions & 0 deletions src/config/morgan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import morgan from 'morgan';
import { Request, Response } from 'express';
import config from './config';
import logger from './logger';

morgan.token('message', (req: Request, res: Response) => res.locals['errorMessage'] || '');

const getIpFormat = () => (config.env === 'production' ? ':remote-addr - ' : '');
const successResponseFormat = `${getIpFormat()}:method :url :status - :response-time ms`;
const errorResponseFormat = `${getIpFormat()}:method :url :status - :response-time ms - message: :message`;

export const successHandler = morgan(successResponseFormat, {
skip: (req: Request, res: Response) => res.statusCode >= 400,
stream: { write: (message: string) => logger.info(message.trim()) },
});

export const errorHandler = morgan(errorResponseFormat, {
skip: (req: Request, res: Response) => res.statusCode < 400,
stream: { write: (message: string) => logger.error(message.trim()) },
});
45 changes: 45 additions & 0 deletions src/config/passport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Request } from 'express';
import { Strategy as JwtStrategy } from 'passport-jwt';
import tokenTypes from './tokens';
import config from './config';

const { User } = require('../models');

interface Payload {
sub: string;
iat: number;
exp: number;
type: string;
}

const cookieExtractor = function (req: Request): string {
let token = null;
if (req && req.cookies) {
token = req.cookies.jwt;
}
return token;
};

const jwtOptions = {
secretOrKey: config.jwt.secret,
jwtFromRequest: cookieExtractor,
};

const jwtVerify = async (payload: Payload, done: any) => {
try {
if (payload.type !== tokenTypes.ACCESS) {
throw new Error('Invalid token type');
}
const user = await User.findById(payload.sub);
if (!user) {
return done(null, false);
}
done(null, user);
} catch (error) {
done(error, false);
}
};

const jwtStrategy = new JwtStrategy(jwtOptions, jwtVerify);

export default jwtStrategy;
12 changes: 12 additions & 0 deletions src/config/roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const allRoles = {
user: [],
admin: ['getUsers', 'manageUsers'],
};

const roles = Object.keys(allRoles);
const roleRights = new Map(Object.entries(allRoles));

export default {
roles,
roleRights,
};
6 changes: 6 additions & 0 deletions src/config/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
ACCESS: 'access',
REFRESH: 'refresh',
RESET_PASSWORD: 'resetPassword',
VERIFY_EMAIL: 'verifyEmail',
};
1 change: 1 addition & 0 deletions src/declaration.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module 'xss-clean';
38 changes: 38 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import mongoose, { ConnectOptions } from 'mongoose';
import app from './app';
import config from './config/config';
import logger from './config/logger';

let server: any;
mongoose.connect(config.mongoose.url, config.mongoose.options as ConnectOptions).then(() => {
logger.info('Connected to MongoDB');
server = app.listen(config.port, () => {
logger.info(`Listening to port ${config.port}`);
});
});

const exitHandler = () => {
if (server) {
server.close(() => {
logger.info('Server closed');
process.exit(1);
});
} else {
process.exit(1);
}
};

const unexpectedErrorHandler = (error: string) => {
logger.error(error);
exitHandler();
};

process.on('uncaughtException', unexpectedErrorHandler);
process.on('unhandledRejection', unexpectedErrorHandler);

process.on('SIGTERM', () => {
logger.info('SIGTERM received');
if (server) {
server.close();
}
});
20 changes: 20 additions & 0 deletions src/utils/ApiError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class ApiError extends Error {
statusCode: number;

isOperational: boolean;

override stack?: string;

constructor(statusCode: number, message: string, isOperational = true, stack = '') {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
}

export default ApiError;
7 changes: 7 additions & 0 deletions src/utils/catchAsync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Request, Response, NextFunction } from 'express';

const catchAsync = (fn: any) => (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch((err) => next(err));
};

export default catchAsync;
17 changes: 17 additions & 0 deletions src/utils/pick.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Create an object composed of the picked object properties
* @param {Record<string, any>} object
* @param {string[]} keys
* @returns {Object}
*/
const pick = (object: Record<string, any>, keys: string[]) => {
return keys.reduce((obj: any, key: string) => {
if (object && Object.prototype.hasOwnProperty.call(object, key)) {
// eslint-disable-next-line no-param-reassign
obj[key] = object[key];
}
return obj;
}, {});
};

export default pick;

0 comments on commit ef73bd4

Please sign in to comment.