Skip to content

Commit

Permalink
feat: update template structures & code quality
Browse files Browse the repository at this point in the history
  • Loading branch information
phucvinh57 committed Aug 7, 2023
1 parent 8b49c1c commit 114ed2e
Show file tree
Hide file tree
Showing 31 changed files with 228 additions and 158 deletions.
3 changes: 1 addition & 2 deletions .barrelsby.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
"./src/dtos/in",
"./src/dtos/out",
"./src/configs",
"./src/middlewares",
"./src/services",
"./src/hooks",
"./src/interfaces",
"./src/repositories",
"./src/utils"
Expand Down
8 changes: 5 additions & 3 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ module.exports = {
coverageDirectory: 'coverage',

// An array of regexp pattern strings used to skip coverage collection
coveragePathIgnorePatterns: ['index.ts', '/node_modules/'],
coveragePathIgnorePatterns: ['index.ts', '/node_modules/', 'dist'],

// An object that configures minimum threshold enforcement for coverage results
coverageThreshold: {
Expand All @@ -40,12 +40,14 @@ module.exports = {
'\\.(ts)$': 'ts-jest'
},
moduleNameMapper: {
'@services': 'src/services',
'@configs': 'src/configs',
'@services': '<rootDir>/src/services',
'@configs': '<rootDir>/src/configs',
'@constants': '<rootDir>/src/constants',
'@dtos/common': '<rootDir>/src/dtos/common.dto.ts',
'@dtos/in': '<rootDir>/src/dtos/in',
'@dtos/out': '<rootDir>/src/dtos/out',
'@hooks': '<rootDir>/src/hooks',
'@routes': '<rootDir>/src/routes',
'@utils': '<rootDir>/src/utils.ts'
},
roots: ['<rootDir>'],
Expand Down
47 changes: 21 additions & 26 deletions src/Server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import fastify from 'fastify';
import fastify, { FastifyInstance } from 'fastify';
import type { FastifyCookieOptions } from '@fastify/cookie';
import { CORS_WHITE_LIST, envs, loggerConfig, swaggerConfig, swaggerUIConfig } from '@configs';
import { apiPlugin, authPlugin } from './plugins';
import { CORS_WHITE_LIST, customErrorHandler, envs, loggerConfig, swaggerConfig, swaggerUIConfig } from '@configs';
import { apiPlugin, authPlugin } from './routes';

export function createServer(config: ServerConfig) {
export function createServer(config: ServerConfig): FastifyInstance {
const app = fastify({ logger: loggerConfig[envs.NODE_ENV] });
global.logger = app.log;

app.register(import('@fastify/sensible'));
app.register(import('@fastify/helmet'));
Expand All @@ -19,39 +18,35 @@ export function createServer(config: ServerConfig) {
} as FastifyCookieOptions);

// Swagger on production will be turned off in the future
if (envs.NODE_ENV === 'development' || envs.NODE_ENV === 'staging' || envs.NODE_ENV === 'production') {
if (envs.isDev) {
app.register(import('@fastify/swagger'), swaggerConfig);
app.register(import('@fastify/swagger-ui'), swaggerUIConfig);
}

app.register(authPlugin, { prefix: '/auth' });
app.register(apiPlugin, { prefix: '/api' });

app.ready().then(() => {
app.swagger({ yaml: true });
app.log.info(`Swagger documentation is on http://${config.host}:${config.port}/docs`);
});
app.setErrorHandler(customErrorHandler);

const shutdown = async () => {
await app.close();
};

const listen = () => {
app.listen(
{
host: config.host,
port: config.port
},
function (err) {
if (err) {
app.log.error(err);
}
}
);
process.on('SIGINT', () => {
app.log.info('Exited program');
process.exit(0);
const start = async () => {
await app.listen({
host: config.host,
port: config.port
});
await app.ready();
if (!envs.isProd) {
app.swagger({ yaml: true });
app.log.info(`Swagger documentation is on http://${config.host}:${config.port}/docs`);
}
};

return {
...app,
listen
start,
shutdown
};
}
File renamed without changes.
4 changes: 2 additions & 2 deletions src/configs/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { str, cleanEnv } from 'envalid';
configEnv();

export const envs = cleanEnv(process.env, {
NODE_ENV: str<Environment>({
NODE_ENV: str<NodeEnv>({
devDefault: 'development',
choices: ['development', 'test', 'production', 'staging']
choices: ['development', 'test', 'production']
}),
JWT_SECRET: str(),
COOKIE_SECRET: str(),
Expand Down
76 changes: 76 additions & 0 deletions src/configs/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { TRY_LATER } from '@constants';
import { FastifyError, FastifyReply, FastifyRequest } from 'fastify';

export function customErrorHandler(err: FastifyError, _req: FastifyRequest, res: FastifyReply) {
if (err.statusCode === undefined || err.statusCode >= 500) {
err.message = TRY_LATER;
return res.send(err);
}
if (!err.validation || err.validation.length === 0) {
return res.send(err);
}

const validation = err.validation[0];
if (validation.keyword === 'required') {
err.message = `${validation.params.missingProperty[0].toUpperCase() + validation.params.missingProperty.slice(1)} is required !`;
return res.send(err);
}
// Error occurs on PathParams
else if (validation.instancePath.length === 0) {
err.message = 'Invalid path parameters !';
return res.send(err);
}

const instanceAccesses = validation.instancePath.split('/');
let rawErrorField: string;

// Occurs if error on an item of an array
if (!isNaN(parseInt(instanceAccesses[instanceAccesses.length - 1]))) {
// If not have field name of input
if (instanceAccesses[instanceAccesses.length - 2].length === 0) rawErrorField = 'item';
else {
rawErrorField = `An item of${
instanceAccesses[instanceAccesses.length - 2][0].toUpperCase() + instanceAccesses[instanceAccesses.length - 2].slice(1)
}`;
}
} else rawErrorField = instanceAccesses[instanceAccesses.length - 1];

const errorField = rawErrorField
// For snake case
.replaceAll('_', ' ')
// For camel case
.replace(/([A-Z])/g, ' $1')
.trim()
.split(' ')
.map((word) => word[0].toLowerCase() + word.slice(1))
.join(' ');

const capitalizedErrorField = errorField[0].toUpperCase() + errorField.slice(1);

if (validation.keyword === 'maxLength' || validation.keyword === 'minLength') {
err.message = `${capitalizedErrorField} ${validation.message ? validation.message : 'is invalid'} !`;
} else if (validation.keyword === 'enum') {
err.message = `${capitalizedErrorField} must be one of allowed values: ${
validation.params.allowedValues instanceof Array
? validation.params.allowedValues.map((value: string) => `"${value}"`).join(', ')
: validation.params.allowedValues
} !`;
} else if (validation.keyword === 'minItems' || validation.keyword === 'maxItems') {
err.message = `${capitalizedErrorField} ${err.message} !`;
} else if (validation.keyword === 'minimum') {
err.message = `${capitalizedErrorField} must be greater than or equal ${validation.params.limit} !`;
} else if (validation.keyword === 'maximum') {
err.message = `${capitalizedErrorField} must be less than or equal ${validation.params.limit} !`;
} else if (validation.keyword === 'type') {
err.message = `${capitalizedErrorField} ${err.message} !`;
} else if (validation.keyword === 'uniqueItems') {
err.message = `Items of ${errorField} must be unique !`;
} else if (validation.keyword === 'format') {
err.message = `${capitalizedErrorField} has invalid format !`;
} else if (validation.keyword === 'const') {
err.message = `${capitalizedErrorField} has invalid value !`;
}
// Default
else err.message = 'Bad request !';
return res.send(err);
}
1 change: 1 addition & 0 deletions src/configs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
*/

export * from './env';
export * from './errorHandler';
export * from './logger';
export * from './swagger';
30 changes: 18 additions & 12 deletions src/configs/logger.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { FastifyError, FastifyLoggerOptions } from 'fastify';
import { FastifyError } from 'fastify';
import { PinoLoggerOptions } from 'fastify/types/logger';

const errorSerialize = (err: FastifyError) => {
const isInternalServerError = !err.statusCode || (err.statusCode && err.statusCode);
const isInternalServerError = !err.statusCode || err.statusCode >= 500;
return {
type: err.name,
stack: isInternalServerError && err.stack ? err.stack : 'null',
Expand All @@ -11,7 +11,7 @@ const errorSerialize = (err: FastifyError) => {
};
};

export const loggerConfig: Record<Environment, boolean | (FastifyLoggerOptions & PinoLoggerOptions)> = {
export const loggerConfig: Record<NodeEnv, PinoLoggerOptions> = {
development: {
transport: {
target: 'pino-pretty',
Expand All @@ -22,16 +22,22 @@ export const loggerConfig: Record<Environment, boolean | (FastifyLoggerOptions &
},
serializers: { err: errorSerialize }
},
staging: {
test: {
serializers: { err: errorSerialize }
},
// In production, save log to files.
// Can write a plugin to use centralize logging services, if need
production: {
transport: {
target: 'pino-pretty',
options: {
translateTime: 'dd/mm/yy HH:MM:ss',
ignore: 'pid,hostname'
}
targets: ['info', 'warn', 'error', 'fatal'].map((logLevel) => ({
target: 'pino/file',
level: logLevel,
options: {
destination: process.cwd() + `/logs/${logLevel}.log`,
mkdir: true
}
}))
},
serializers: { err: errorSerialize }
},
production: { serializers: { err: errorSerialize } },
test: false
}
};
1 change: 0 additions & 1 deletion src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,3 @@ export * from './constraints';
export * from './cookie';
export * from './crypt';
export * from './errorMessages';
export * from './swagger';
4 changes: 0 additions & 4 deletions src/constants/swagger.ts

This file was deleted.

14 changes: 9 additions & 5 deletions src/global.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { FastifyBaseLogger } from 'fastify';
import 'fastify';

declare global {
// eslint-disable-next-line no-var
var logger: FastifyBaseLogger;
declare module 'fastify' {
interface FastifyRequest {
userId: string;
}
interface FastifyInstance {
start: () => Promise<void>;
shutdown: () => Promise<void>;
}
}
export {};
28 changes: 14 additions & 14 deletions src/handlers/auth.handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { compare, hash } from 'bcrypt';
import { prisma } from '@repositories';
import { cookieOptions, DUPLICATED_EMAIL, LOGIN_FAIL, SALT_ROUNDS, USER_NOT_FOUND } from '@constants';
Expand All @@ -7,52 +6,53 @@ import { envs } from '@configs';
import { User } from '@prisma/client';
import { AuthInputDto } from '@dtos/in';
import { AuthResultDto } from '@dtos/out';
import { Handler } from '@interfaces';

async function login(request: FastifyRequest<{ Body: AuthInputDto }>, reply: FastifyReply): Result<AuthResultDto> {
const login: Handler<AuthResultDto, { Body: AuthInputDto }> = async (req, res) => {
const user = await prisma.user.findUnique({
select: {
id: true,
email: true,
password: true
},
where: { email: request.body.email }
where: { email: req.body.email }
});
if (!user) return reply.badRequest(USER_NOT_FOUND);
if (!user) return res.badRequest(USER_NOT_FOUND);

const correctPassword = await compare(request.body.password, user.password);
if (!correctPassword) return reply.badRequest(LOGIN_FAIL);
const correctPassword = await compare(req.body.password, user.password);
if (!correctPassword) return res.badRequest(LOGIN_FAIL);

const userToken = jwt.sign({ userId: user.id }, envs.JWT_SECRET);
reply.setCookie('token', userToken, cookieOptions);
res.setCookie('token', userToken, cookieOptions);

return {
id: user.id,
email: user.email
};
}
};

async function signup(request: FastifyRequest<{ Body: AuthInputDto }>, reply: FastifyReply): Promise<AuthResultDto | void> {
const hashPassword = await hash(request.body.password, SALT_ROUNDS);
const signup: Handler<AuthResultDto, { Body: AuthInputDto }> = async (req, res) => {
const hashPassword = await hash(req.body.password, SALT_ROUNDS);
let user: User;
try {
user = await prisma.user.create({
data: {
email: request.body.email,
email: req.body.email,
password: hashPassword
}
});
} catch (err) {
return reply.badRequest(DUPLICATED_EMAIL);
return res.badRequest(DUPLICATED_EMAIL);
}

const userToken = jwt.sign({ userId: user.id }, envs.JWT_SECRET);
reply.setCookie('token', userToken, cookieOptions);
res.setCookie('token', userToken, cookieOptions);

return {
id: user.id,
email: user.email
};
}
};

export const authHandler = {
login,
Expand Down
11 changes: 5 additions & 6 deletions src/handlers/user.handler.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
import { USER_NOT_FOUND } from '@constants';
import { prisma } from '@repositories';
import { UserDto } from '@dtos/out';
import { FastifyReply } from 'fastify';
import { AuthRequest } from '@interfaces';
import { Handler } from '@interfaces';

async function getUserById(request: AuthRequest, reply: FastifyReply): Result<UserDto> {
const userId: string = request.headers.userId;
const getUserById: Handler<UserDto> = async (req, res) => {
const userId = req.userId;
const user = await prisma.user.findUnique({
select: {
id: true,
email: true
},
where: { id: userId }
});
if (user === null) return reply.badRequest(USER_NOT_FOUND);
if (user === null) return res.badRequest(USER_NOT_FOUND);
return user;
}
};

export const usersHandler = {
getUserById
Expand Down
2 changes: 1 addition & 1 deletion src/middlewares/index.ts → src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
* @file Automatically generated by barrelsby.
*/

export * from './auth.middleware';
export * from './verifyToken.hook';
Loading

0 comments on commit 114ed2e

Please sign in to comment.