Skip to content

Commit

Permalink
feat: lab2
Browse files Browse the repository at this point in the history
HTTP app with routing
  • Loading branch information
devule committed Dec 7, 2022
2 parents 606c9a6 + 5a8069f commit 1553d5d
Show file tree
Hide file tree
Showing 30 changed files with 7,734 additions and 1,381 deletions.
1 change: 1 addition & 0 deletions .github/workflows/code-quality-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ jobs:
npm run lint
npm run format
npm run typecheck
npm run test
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
npm run format:fix
npm run lint:fix
npm run typecheck
npm run test
8,196 changes: 6,843 additions & 1,353 deletions package-lock.json

Large diffs are not rendered by default.

49 changes: 36 additions & 13 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@
"main": "dist/main.js",
"type": "module",
"scripts": {
"start": "ts-node src/main.ts",
"start:dev": "nodemon --watch \"src/**/*\" src/main.ts",
"start:prod": "node dist/main.js",
"start": "ts-node --experimental-specifier-resolution=node src/main.ts",
"start:dev": "nodemon --experimental-specifier-resolution=node --watch \"src/**/*\" src/main.ts",
"start:prod": "node --experimental-specifier-resolution=node dist/main.js",
"build": "tsc",
"format": "prettier --check \"src/**/*{.js,.ts}\" --ignore-path .gitignore",
"format:fix": "prettier --write \"src/**/*{.js,.ts}\" --ignore-path .gitignore",
"lint": "eslint --ignore-path .gitignore --cache \"src/**/*{.js,.ts}\"",
"lint:fix": "eslint --ignore-path .gitignore --cache \"src/**/*{.js,.ts}\" --fix",
"typecheck": "tsc --noEmit --project tsconfig.json",
"prepare": "husky install",
"prebuild": "rimraf dist"
"prebuild": "rimraf dist",
"test": "jest --passWithNoTests"
},
"repository": {
"type": "git",
Expand All @@ -33,21 +34,43 @@
"homepage": "https://github.com/f1ctashka/nodeJS-labs#readme",
"private": true,
"devDependencies": {
"@types/node": "^18.11.7",
"@typescript-eslint/eslint-plugin": "^5.41.0",
"@typescript-eslint/parser": "^5.41.0",
"eslint": "^8.26.0",
"@types/jest": "^29.2.4",
"@types/node": "^18.11.11",
"@typescript-eslint/eslint-plugin": "^5.45.1",
"@typescript-eslint/parser": "^5.45.1",
"eslint": "^8.29.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-sonarjs": "^0.16.0",
"husky": "^8.0.1",
"eslint-plugin-sonarjs": "^0.17.0",
"husky": "^8.0.2",
"jest": "^29.3.1",
"nodemon": "^2.0.20",
"prettier": "^2.7.1",
"prettier": "^2.8.1",
"rimraf": "^3.0.2",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1",
"typescript": "^4.8.4"
"typescript": "^4.9.3"
},
"dependencies": {
"dotenv": "^16.0.3"
"dotenv": "^16.0.3",
"http-status": "^1.5.3",
"reflect-metadata": "^0.1.13"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
18 changes: 18 additions & 0 deletions src/core/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { HttpAdapter } from './http.adapter';
import { Router } from './router';

export class App {
private readonly http = new HttpAdapter();
private readonly router = new Router();

public listen = this.http.listen.bind(this.http);
public close = this.http.close.bind(this.http);
public registerController = this.router.registerController.bind(this.router);
public registerControllers = this.router.registerControllers.bind(
this.router
);

constructor() {
this.http.setRequestsHandler(this.router.handleRequest.bind(this.router));
}
}
28 changes: 28 additions & 0 deletions src/core/body-parsers/parse-body.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ContentType } from '../enums/content-type.enum';
import { parseJson } from './parse-json';
import { parsePlainText } from './parse-plain-text';
import { parseUrlencoded } from './parse-urlencoded';

type ParserFn = (
rawBody: string
) =>
| string
| Record<string, unknown>
| Promise<string | Record<string, unknown>>;

const parsersMap: Record<ContentType, ParserFn> = {
[ContentType.JSON]: parseJson,
[ContentType.PlainText]: parsePlainText,
[ContentType.Urlencoded]: parseUrlencoded,
};

export async function parseBody(
contentType: ContentType,
rawBody: string
): Promise<ReturnType<typeof parsersMap[typeof contentType]>> {
const parser = parsersMap[contentType];

if (!parser) throw new Error('Parser not found for ' + contentType);

return parser(rawBody);
}
12 changes: 12 additions & 0 deletions src/core/body-parsers/parse-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { HttpException } from '../http-exception';
import HttpStatus from 'http-status';

export function parseJson<
TBody extends Record<string, unknown> = Record<string, unknown>
>(rawBody: string): TBody {
try {
return JSON.parse(rawBody);
} catch {
throw new HttpException(HttpStatus.BAD_REQUEST, 'Invalid JSON body');
}
}
3 changes: 3 additions & 0 deletions src/core/body-parsers/parse-plain-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function parsePlainText(rawBody: string): string {
return rawBody;
}
3 changes: 3 additions & 0 deletions src/core/body-parsers/parse-urlencoded.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function parseUrlencoded(rawBody: string) {
return Object.fromEntries(new URLSearchParams(rawBody));
}
6 changes: 6 additions & 0 deletions src/core/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const IS_CONTROLLER_METADATA = Symbol('is_controller');
export const CONTROLLER_PREFIX_METADATA = Symbol('controller_prefix');

export const IS_ROUTE_METADATA = Symbol('is_route');
export const ROUTE_METHOD_METADATA = Symbol('route_method');
export const ROUTE_PATH_METADATA = Symbol('route_path');
20 changes: 20 additions & 0 deletions src/core/decorators/controller.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {
IS_CONTROLLER_METADATA,
CONTROLLER_PREFIX_METADATA,
} from '../constants';
import { normalizePath } from '../utils/normalize-path.util';

export function Controller(): ClassDecorator;
export function Controller(prefix: string): ClassDecorator;
export function Controller(prefix = '/'): ClassDecorator {
const normalizedPrefix = normalizePath(prefix);

return (target: object) => {
Reflect.defineMetadata(IS_CONTROLLER_METADATA, true, target);
Reflect.defineMetadata(
CONTROLLER_PREFIX_METADATA,
normalizedPrefix,
target
);
};
}
55 changes: 55 additions & 0 deletions src/core/decorators/route.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { HttpMethod } from '../enums/http-method.enum';
import {
IS_ROUTE_METADATA,
ROUTE_METHOD_METADATA,
ROUTE_PATH_METADATA,
} from '../constants';

export interface RouteMetadata {
[ROUTE_PATH_METADATA]?: string;
[ROUTE_METHOD_METADATA]?: HttpMethod;
}

const defaultRouteMetadata: RouteMetadata = {
[ROUTE_METHOD_METADATA]: HttpMethod.Get,
[ROUTE_PATH_METADATA]: '/',
};

export function Route(
metadata: RouteMetadata = defaultRouteMetadata
): MethodDecorator {
const pathMetadata = metadata[ROUTE_PATH_METADATA];
const path = pathMetadata?.length
? pathMetadata
: defaultRouteMetadata[ROUTE_PATH_METADATA];
const method =
metadata[ROUTE_METHOD_METADATA] ||
defaultRouteMetadata[ROUTE_METHOD_METADATA];

return (
target: object,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
) => {
Reflect.defineMetadata(IS_ROUTE_METADATA, true, descriptor.value);
Reflect.defineMetadata(ROUTE_PATH_METADATA, path, descriptor.value);
Reflect.defineMetadata(ROUTE_METHOD_METADATA, method, descriptor.value);

return descriptor;
};
}

const createRouteDecorator = (method: HttpMethod) => {
return (path?: string): MethodDecorator => {
return Route({
[ROUTE_METHOD_METADATA]: method,
[ROUTE_PATH_METADATA]: path,
});
};
};

export const Get = createRouteDecorator(HttpMethod.Get);

export const Post = createRouteDecorator(HttpMethod.Post);

export const Put = createRouteDecorator(HttpMethod.Put);
5 changes: 5 additions & 0 deletions src/core/enums/content-type.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum ContentType {
PlainText = 'text/plain',
JSON = 'application/json',
Urlencoded = 'application/x-www-form-urlencoded',
}
5 changes: 5 additions & 0 deletions src/core/enums/http-method.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum HttpMethod {
Get = 'GET',
Post = 'POST',
Put = 'PUT',
}
5 changes: 5 additions & 0 deletions src/core/http-exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class HttpException extends Error {
constructor(public statusCode: number, public message: string) {
super(message || 'Internal Server Error');
}
}
76 changes: 76 additions & 0 deletions src/core/http.adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
createServer,
IncomingMessage,
Server,
ServerResponse,
} from 'node:http';

interface RequestsHandler {
(request: IncomingMessage, response: ServerResponse): void | Promise<void>;
}

export class HttpAdapter {
private readonly httpServer: Server = createServer();
private requestsHandler?: RequestsHandler;

constructor() {
this.setupErrorListeners();
}

public setRequestsHandler(handler: RequestsHandler) {
if (this.requestsHandler) {
this.httpServer.off('request', this.requestsHandler);
}

this.requestsHandler = handler.bind(this);
this.httpServer.on('request', this.requestsHandler);
}

public async listen(port: string | number, hostname?: string): Promise<void> {
return new Promise((resolve) => {
if (hostname) this.httpServer.listen(+port, hostname, resolve);
else this.httpServer.listen(+port, resolve);
});
}

public async close(): Promise<void> {
return new Promise((resolve, reject) => {
this.httpServer.close((error) => {
if (error) reject(error);
resolve();
});
});
}

private setupErrorListeners() {
this.httpServer.on(
'clientError',
(error: NodeJS.ErrnoException, socket) => {
if (error.code === 'ECONNRESET' || !socket.writable) {
return;
}

socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
}
);

this.httpServer.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
console.error('Error: address in use');
}

if (error.code === 'EACCES') {
console.error('Error: port in use, please try another one');
}

this.close()
.then(() => {
process.exit(1);
})
.catch(() => {
console.error('Error: cannot close the server');
process.exit(1);
});
});
}
}
7 changes: 7 additions & 0 deletions src/core/interfaces/request-data.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface RequestData<
TParams extends Record<string, unknown> | unknown = Record<string, unknown>,
TBody = unknown
> {
body: TBody;
params: TParams;
}

0 comments on commit 1553d5d

Please sign in to comment.