Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DB_NAME=blog_test
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ dist/
*.log
.idea/
.vscode/
coverage/
coverage/
.env/
14 changes: 7 additions & 7 deletions example_requests.http
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ POST http://localhost:3000/api/articles
Content-Type: application/json

{
"title": "This is my first article",
"title": "This is my first article draft",
"content": "Test article content"
}

###

@articleId = {{article.response.body.$.id}}

PATCH http://localhost:3000/api/articles/{{articleId}}/publish

###
GET http://localhost:3000/api/articles

Expand All @@ -19,9 +25,3 @@ Content-Type: application/json
{
"body": "Nice!"
}

###

@articleId = {{article.response.body.$.id}}

PATCH http://localhost:3000/api/articles/{{articleId}}/publish
7 changes: 3 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,28 @@
"dependencies": {
"awilix": "^4.3.4",
"dotenv": "^10.0.0",
"express": "^4.17.1",
"fastify": "^3.22.0",
"fastify-swagger": "^4.12.4",
"joi": "^17.4.1",
"lodash.template": "^4.5.0",
"middie": "^5.3.0",
"mongodb": "^4.0.0",
"pino": "^6.12.0",
"pino-http": "^5.5.0",
"swagger-jsdoc": "^6.1.0",
"swagger-ui-express": "^4.1.6",
"tsconfig-paths": "^3.10.1",
"types-joi": "^2.1.0",
"uuid": "^8.3.2",
"uuid-mongodb": "^2.4.4"
},
"devDependencies": {
"@types/express": "^4.17.13",
"@types/jest": "^26.0.24",
"@types/lodash.template": "^4.5.0",
"@types/mongodb": "^3.6.20",
"@types/node": "^16.3.3",
"@types/pino": "^6.3.11",
"@types/supertest": "^2.0.11",
"@types/swagger-jsdoc": "^6.0.1",
"@types/swagger-ui-express": "^4.1.3",
"@typescript-eslint/eslint-plugin": "^4.28.3",
"@typescript-eslint/parser": "^4.28.3",
"eslint": "^7.30.0",
Expand Down
60 changes: 33 additions & 27 deletions src/_boot/server.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import express, { Router, Application, json, urlencoded } from 'express';
import Fastify, { FastifyInstance } from 'fastify';
import { asValue } from 'awilix';
import httpLogger from 'pino-http';
import { createServer } from 'http';
import middie from 'middie';
import { requestId } from '@/_lib/http/middlewares/requestId';
import { requestContainer } from '@/_lib/http/middlewares/requestContainer';
import { errorHandler } from '@/_lib/http/middlewares/errorHandler';
import { makeModule } from '@/context';
import { gracefulShutdown } from '@/_lib/http/middlewares/gracefulShutdown';
import { errorConverters } from '@/_sharedKernel/interface/http/ErrorConverters';
import { ApiRouter } from '@/_lib/http/apiRouter';
import { HttpStatus } from '@/_lib/http/HttpStatus';

type ServerConfig = {
http: {
Expand All @@ -20,49 +22,54 @@ const server = makeModule(
'server',
async ({ app: { onBooted, onReady }, container, config: { cli, http, environment }, logger }) => {
const { register } = container;
const server = express();
const fastifyServer = Fastify();

const httpServer = createServer(server);
await fastifyServer.register(middie);

const { shutdownHook, shutdownHandler } = gracefulShutdown(httpServer);
const { shutdownHook, shutdownHandler } = gracefulShutdown(fastifyServer.server);

server.use(shutdownHandler());
server.use(requestId());
server.use(requestContainer(container));
server.use(httpLogger());
server.use(json());
server.use(urlencoded({ extended: false }));
fastifyServer.use(shutdownHandler());

const rootRouter = Router();
const apiRouter = Router();
fastifyServer.use(requestId());
fastifyServer.use(requestContainer(container));
fastifyServer.use(httpLogger());

rootRouter.use('/api', apiRouter);

server.use(rootRouter);
const apiRouter: ApiRouter = (fn) => {
fastifyServer.register(
(fastify, _, done) => {
fn(fastify);
done();
},
{ prefix: '/api' }
);
};

onBooted(async () => {
server.use((req, res) => {
res.sendStatus(404);
fastifyServer.use((req, res) => {
res.writeHead(HttpStatus.NOT_FOUND).end();
});

server.use(errorHandler(errorConverters, { logger }));
fastifyServer.setErrorHandler(errorHandler(errorConverters, { logger }));
});

if (!cli && environment !== 'test') {
onReady(
async () =>
new Promise<void>((resolve) => {
httpServer.listen(http.port, http.host, () => {
logger.info(`Webserver listening at: http://${http.host}:${http.port}`);
new Promise<void>((resolve, reject) => {
fastifyServer.listen(http.port, http.host, (error, address) => {
if (error) {
return reject(error);
}

logger.info(`Webserver listening at: ${address}`);
resolve();
});
})
);
}

register({
server: asValue(server),
rootRouter: asValue(rootRouter),
server: asValue(fastifyServer),
apiRouter: asValue(apiRouter),
});

Expand All @@ -74,9 +81,8 @@ const server = makeModule(

type ServerRegistry = {
requestId?: string;
server: Application;
rootRouter: Router;
apiRouter: Router;
server: FastifyInstance;
apiRouter: ApiRouter;
};

export { server };
Expand Down
8 changes: 6 additions & 2 deletions src/_boot/swagger.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { makeModule } from '@/context';
import { resolve } from 'path';
import swaggerJSDoc from 'swagger-jsdoc';
import swaggerUi from 'swagger-ui-express';
import fastifySwagger from 'fastify-swagger';

type SwaggerConfig = {
swagger: {
Expand Down Expand Up @@ -29,7 +29,11 @@ const swagger = makeModule('swagger', async ({ container: { build }, config: { h
const swaggerSpec = swaggerJSDoc(options);

build(({ server }) => {
server.use(swagger.docEndpoint, swaggerUi.serve, swaggerUi.setup(swaggerSpec, { explorer: true }));
server.register(fastifySwagger, {
routePrefix: swagger.docEndpoint,
swagger: swaggerSpec,
exposeRoute: true,
});
});
});

Expand Down
10 changes: 10 additions & 0 deletions src/_lib/http/RequestHandler.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { IncomingMessage, ServerResponse } from 'http';

type NextFunction = (err?: any) => void;

type RequestHandler = (req: IncomingMessage, res: ServerResponse, next: NextFunction) => void;

type ErrorHandler = (error: Error, request: FastifyRequest, reply: FastifyReply) => void | Promise<void>;

export { RequestHandler, ErrorHandler };
6 changes: 6 additions & 0 deletions src/_lib/http/apiRouter.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { FastifyInstance } from 'fastify';

type ApiRouterFn = (fastify: FastifyInstance) => void;
type ApiRouter = (fn: ApiRouterFn) => void;

export { ApiRouter };
17 changes: 9 additions & 8 deletions src/_lib/http/handler.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import { RequestHandler } from 'express';
import { asFunction } from 'awilix';
import { AsyncHandler, runAsync } from '@/_lib/http/runAsync';
import { FastifyRequest, FastifyReply } from 'fastify';

type ControllerHandler = (dependencies: any) => AsyncHandler;
type FastifyHandler = (request: FastifyRequest, reply: FastifyReply) => any;

const handler = (handler: ControllerHandler): RequestHandler => {
type ControllerHandler = (dependencies: any) => FastifyHandler;

const handler = (handler: ControllerHandler): FastifyHandler => {
const resolver = asFunction(handler);

return (req, res, next) => {
if (!('container' in req)) {
return (request, reply) => {
if (!('container' in request.raw)) {
throw new Error("Can't find the request container! Have you registered the `requestContainer` middleware?");
}

const injectedHandler = req.container.build(resolver);
const injectedHandler = request.raw.container.build(resolver);

return runAsync(injectedHandler)(req, res, next);
return injectedHandler(request, reply);
};
};

Expand Down
14 changes: 6 additions & 8 deletions src/_lib/http/middlewares/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ErrorRequestHandler } from 'express';
import { Exception } from '@/_lib/errors/BaseError';
import { HttpStatus } from '@/_lib/http/HttpStatus';
import { ErrorHandler } from '@/_lib/http/RequestHandler';

type ErrorConverter<E extends Exception> = {
test: (err: E | any) => err is E;
Expand Down Expand Up @@ -27,25 +28,22 @@ const defaultOptions: ErrorHandlerOptions = {
logger: console,
};

const errorHandler = (
errorMap: ErrorConverter<any>[],
options: Partial<ErrorHandlerOptions> = {}
): ErrorRequestHandler => {
const errorHandler = (errorMap: ErrorConverter<any>[], options: Partial<ErrorHandlerOptions> = {}): ErrorHandler => {
const { logger } = { ...defaultOptions, ...options };
const errorResponseBuilder = makeErrorResponseBuilder(errorMap);

return (err, req, res, next) => {
return (err, request, reply) => {
logger.error(err.stack);

const errorResponse = errorResponseBuilder(err);

if (errorResponse) {
const { status, body } = errorResponse;

return res.status(status).json(typeof body === 'object' ? body : { error: body });
reply.status(status).send(typeof body === 'object' ? body : { error: body });
}

res.status(500).json({ error: err.message });
reply.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ error: err.message });
};
};

Expand Down
6 changes: 3 additions & 3 deletions src/_lib/http/middlewares/gracefulShutdown.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Server } from 'http';
import { logger } from '@/_lib/logger';
import { RequestHandler } from 'express';
import { RequestHandler } from '@/_lib/http/RequestHandler';

type ShutdownMiddleware = {
shutdownHook: () => Promise<void>;
Expand Down Expand Up @@ -39,8 +39,8 @@ const gracefulShutdown = (server: Server, forceTimeout = 30000): ShutdownMiddlew
return next();
}

res.set('Connection', 'close');
res.status(503).send('Server is in the process of restarting.');
res.writeHead(503, { Connection: 'close' });
res.end('Server is in the process of restarting.');
},
shutdownHook,
};
Expand Down
7 changes: 3 additions & 4 deletions src/_lib/http/middlewares/requestContainer.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { asValue } from 'awilix';
import { RequestHandler } from 'express';
import { Container } from '@/container';
import { asValue, AwilixContainer } from 'awilix';
import { RequestHandler } from '@/_lib/http/RequestHandler';

const requestContainer =
(container: Container): RequestHandler =>
<T extends AwilixContainer>(container: T): RequestHandler =>
(req, res, next) => {
const scopedContainer = container.createScope();

Expand Down
2 changes: 1 addition & 1 deletion src/_lib/http/middlewares/requestId.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { v4 } from 'uuid';
import { RequestHandler } from 'express';
import { RequestHandler } from '@/_lib/http/RequestHandler';

const requestId =
(idProvider: () => string = v4): RequestHandler =>
Expand Down
21 changes: 13 additions & 8 deletions src/_lib/http/validation/Paginator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Request } from 'express';
import { FastifyRequest } from 'fastify';
import Joi, { InterfaceFrom } from 'types-joi';
import { ValidationError } from '@/_lib/errors/ValidationError';
import { BadRequestError } from '@/_lib/errors/BadRequestError';
Expand Down Expand Up @@ -29,11 +29,11 @@ type PaginatorOptions<T extends Record<string, any>> = {
};

type Paginator<T extends PaginatorOptions<Record<string, any>>> = {
getPagination: (req: Request) => { page: number; pageSize: number };
getPagination: (req: FastifyRequest) => { page: number; pageSize: number };
getFilter: (
req: Request
req: FastifyRequest
) => T['filter'] extends Joi.BaseSchema<any> ? NonNullable<InterfaceFrom<NonNullable<T['filter']>>> : any;
getSorter: (req: Request) => { field: string; direction: 'asc' | 'desc' }[];
getSorter: (req: FastifyRequest) => { field: string; direction: 'asc' | 'desc' }[];
};

const defaultOptions = {
Expand Down Expand Up @@ -70,9 +70,14 @@ const makePaginator = <T extends PaginatorOptions<any>>(opts: Partial<T> = {}):
const getField = (field: string | FieldConfig): FieldConfig =>
typeof field === 'string' ? { name: field, from: 'query' } : field;

const fromRequest = (req: Request, field: FieldConfig) => req[field.from][field.name];
const fromRequest = (req: FastifyRequest, field: FieldConfig) => {
const fieldValue = req[field.from];
if (typeof fieldValue === 'object' && fieldValue != null) {
return fieldValue[field.name];
}
};

const getPagination = (req: Request): { page: number; pageSize: number } => {
const getPagination = (req: FastifyRequest): { page: number; pageSize: number } => {
const pageField = getField(fields.page);
const pageSizeField = getField(fields.pageSize);

Expand All @@ -91,7 +96,7 @@ const makePaginator = <T extends PaginatorOptions<any>>(opts: Partial<T> = {}):
};
};

const getSorter = (req: Request): { field: string; direction: 'asc' | 'desc' }[] => {
const getSorter = (req: FastifyRequest): { field: string; direction: 'asc' | 'desc' }[] => {
const sortField = getField(fields.sort);
const sortValues = fromRequest(req, sortField);

Expand All @@ -110,7 +115,7 @@ const makePaginator = <T extends PaginatorOptions<any>>(opts: Partial<T> = {}):
};

const getFilter = (
req: Request
req: FastifyRequest
): T['filter'] extends Joi.BaseSchema<any> ? NonNullable<InterfaceFrom<NonNullable<T['filter']>>> : any => {
const filterField = getField(fields.filter);
const filterValue = fromRequest(req, filterField);
Expand Down
Loading