Skip to content

Commit 1553d5d

Browse files
authored
feat: lab2
HTTP app with routing
2 parents 606c9a6 + 5a8069f commit 1553d5d

30 files changed

+7734
-1381
lines changed

.github/workflows/code-quality-checks.yml

+1
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ jobs:
3737
npm run lint
3838
npm run format
3939
npm run typecheck
40+
npm run test

.husky/pre-commit

+1
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
npm run format:fix
55
npm run lint:fix
66
npm run typecheck
7+
npm run test

package-lock.json

+6,843-1,353
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+36-13
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,18 @@
55
"main": "dist/main.js",
66
"type": "module",
77
"scripts": {
8-
"start": "ts-node src/main.ts",
9-
"start:dev": "nodemon --watch \"src/**/*\" src/main.ts",
10-
"start:prod": "node dist/main.js",
8+
"start": "ts-node --experimental-specifier-resolution=node src/main.ts",
9+
"start:dev": "nodemon --experimental-specifier-resolution=node --watch \"src/**/*\" src/main.ts",
10+
"start:prod": "node --experimental-specifier-resolution=node dist/main.js",
1111
"build": "tsc",
1212
"format": "prettier --check \"src/**/*{.js,.ts}\" --ignore-path .gitignore",
1313
"format:fix": "prettier --write \"src/**/*{.js,.ts}\" --ignore-path .gitignore",
1414
"lint": "eslint --ignore-path .gitignore --cache \"src/**/*{.js,.ts}\"",
1515
"lint:fix": "eslint --ignore-path .gitignore --cache \"src/**/*{.js,.ts}\" --fix",
1616
"typecheck": "tsc --noEmit --project tsconfig.json",
1717
"prepare": "husky install",
18-
"prebuild": "rimraf dist"
18+
"prebuild": "rimraf dist",
19+
"test": "jest --passWithNoTests"
1920
},
2021
"repository": {
2122
"type": "git",
@@ -33,21 +34,43 @@
3334
"homepage": "https://github.com/f1ctashka/nodeJS-labs#readme",
3435
"private": true,
3536
"devDependencies": {
36-
"@types/node": "^18.11.7",
37-
"@typescript-eslint/eslint-plugin": "^5.41.0",
38-
"@typescript-eslint/parser": "^5.41.0",
39-
"eslint": "^8.26.0",
37+
"@types/jest": "^29.2.4",
38+
"@types/node": "^18.11.11",
39+
"@typescript-eslint/eslint-plugin": "^5.45.1",
40+
"@typescript-eslint/parser": "^5.45.1",
41+
"eslint": "^8.29.0",
4042
"eslint-config-prettier": "^8.5.0",
4143
"eslint-plugin-prettier": "^4.2.1",
42-
"eslint-plugin-sonarjs": "^0.16.0",
43-
"husky": "^8.0.1",
44+
"eslint-plugin-sonarjs": "^0.17.0",
45+
"husky": "^8.0.2",
46+
"jest": "^29.3.1",
4447
"nodemon": "^2.0.20",
45-
"prettier": "^2.7.1",
48+
"prettier": "^2.8.1",
4649
"rimraf": "^3.0.2",
50+
"ts-jest": "^29.0.3",
4751
"ts-node": "^10.9.1",
48-
"typescript": "^4.8.4"
52+
"typescript": "^4.9.3"
4953
},
5054
"dependencies": {
51-
"dotenv": "^16.0.3"
55+
"dotenv": "^16.0.3",
56+
"http-status": "^1.5.3",
57+
"reflect-metadata": "^0.1.13"
58+
},
59+
"jest": {
60+
"moduleFileExtensions": [
61+
"js",
62+
"json",
63+
"ts"
64+
],
65+
"rootDir": "src",
66+
"testRegex": ".*\\.spec\\.ts$",
67+
"transform": {
68+
"^.+\\.(t|j)s$": "ts-jest"
69+
},
70+
"collectCoverageFrom": [
71+
"**/*.(t|j)s"
72+
],
73+
"coverageDirectory": "../coverage",
74+
"testEnvironment": "node"
5275
}
5376
}

src/core/app.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { HttpAdapter } from './http.adapter';
2+
import { Router } from './router';
3+
4+
export class App {
5+
private readonly http = new HttpAdapter();
6+
private readonly router = new Router();
7+
8+
public listen = this.http.listen.bind(this.http);
9+
public close = this.http.close.bind(this.http);
10+
public registerController = this.router.registerController.bind(this.router);
11+
public registerControllers = this.router.registerControllers.bind(
12+
this.router
13+
);
14+
15+
constructor() {
16+
this.http.setRequestsHandler(this.router.handleRequest.bind(this.router));
17+
}
18+
}
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { ContentType } from '../enums/content-type.enum';
2+
import { parseJson } from './parse-json';
3+
import { parsePlainText } from './parse-plain-text';
4+
import { parseUrlencoded } from './parse-urlencoded';
5+
6+
type ParserFn = (
7+
rawBody: string
8+
) =>
9+
| string
10+
| Record<string, unknown>
11+
| Promise<string | Record<string, unknown>>;
12+
13+
const parsersMap: Record<ContentType, ParserFn> = {
14+
[ContentType.JSON]: parseJson,
15+
[ContentType.PlainText]: parsePlainText,
16+
[ContentType.Urlencoded]: parseUrlencoded,
17+
};
18+
19+
export async function parseBody(
20+
contentType: ContentType,
21+
rawBody: string
22+
): Promise<ReturnType<typeof parsersMap[typeof contentType]>> {
23+
const parser = parsersMap[contentType];
24+
25+
if (!parser) throw new Error('Parser not found for ' + contentType);
26+
27+
return parser(rawBody);
28+
}

src/core/body-parsers/parse-json.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { HttpException } from '../http-exception';
2+
import HttpStatus from 'http-status';
3+
4+
export function parseJson<
5+
TBody extends Record<string, unknown> = Record<string, unknown>
6+
>(rawBody: string): TBody {
7+
try {
8+
return JSON.parse(rawBody);
9+
} catch {
10+
throw new HttpException(HttpStatus.BAD_REQUEST, 'Invalid JSON body');
11+
}
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function parsePlainText(rawBody: string): string {
2+
return rawBody;
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function parseUrlencoded(rawBody: string) {
2+
return Object.fromEntries(new URLSearchParams(rawBody));
3+
}

src/core/constants.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export const IS_CONTROLLER_METADATA = Symbol('is_controller');
2+
export const CONTROLLER_PREFIX_METADATA = Symbol('controller_prefix');
3+
4+
export const IS_ROUTE_METADATA = Symbol('is_route');
5+
export const ROUTE_METHOD_METADATA = Symbol('route_method');
6+
export const ROUTE_PATH_METADATA = Symbol('route_path');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {
2+
IS_CONTROLLER_METADATA,
3+
CONTROLLER_PREFIX_METADATA,
4+
} from '../constants';
5+
import { normalizePath } from '../utils/normalize-path.util';
6+
7+
export function Controller(): ClassDecorator;
8+
export function Controller(prefix: string): ClassDecorator;
9+
export function Controller(prefix = '/'): ClassDecorator {
10+
const normalizedPrefix = normalizePath(prefix);
11+
12+
return (target: object) => {
13+
Reflect.defineMetadata(IS_CONTROLLER_METADATA, true, target);
14+
Reflect.defineMetadata(
15+
CONTROLLER_PREFIX_METADATA,
16+
normalizedPrefix,
17+
target
18+
);
19+
};
20+
}
+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { HttpMethod } from '../enums/http-method.enum';
2+
import {
3+
IS_ROUTE_METADATA,
4+
ROUTE_METHOD_METADATA,
5+
ROUTE_PATH_METADATA,
6+
} from '../constants';
7+
8+
export interface RouteMetadata {
9+
[ROUTE_PATH_METADATA]?: string;
10+
[ROUTE_METHOD_METADATA]?: HttpMethod;
11+
}
12+
13+
const defaultRouteMetadata: RouteMetadata = {
14+
[ROUTE_METHOD_METADATA]: HttpMethod.Get,
15+
[ROUTE_PATH_METADATA]: '/',
16+
};
17+
18+
export function Route(
19+
metadata: RouteMetadata = defaultRouteMetadata
20+
): MethodDecorator {
21+
const pathMetadata = metadata[ROUTE_PATH_METADATA];
22+
const path = pathMetadata?.length
23+
? pathMetadata
24+
: defaultRouteMetadata[ROUTE_PATH_METADATA];
25+
const method =
26+
metadata[ROUTE_METHOD_METADATA] ||
27+
defaultRouteMetadata[ROUTE_METHOD_METADATA];
28+
29+
return (
30+
target: object,
31+
propertyKey: string | symbol,
32+
descriptor: PropertyDescriptor
33+
) => {
34+
Reflect.defineMetadata(IS_ROUTE_METADATA, true, descriptor.value);
35+
Reflect.defineMetadata(ROUTE_PATH_METADATA, path, descriptor.value);
36+
Reflect.defineMetadata(ROUTE_METHOD_METADATA, method, descriptor.value);
37+
38+
return descriptor;
39+
};
40+
}
41+
42+
const createRouteDecorator = (method: HttpMethod) => {
43+
return (path?: string): MethodDecorator => {
44+
return Route({
45+
[ROUTE_METHOD_METADATA]: method,
46+
[ROUTE_PATH_METADATA]: path,
47+
});
48+
};
49+
};
50+
51+
export const Get = createRouteDecorator(HttpMethod.Get);
52+
53+
export const Post = createRouteDecorator(HttpMethod.Post);
54+
55+
export const Put = createRouteDecorator(HttpMethod.Put);

src/core/enums/content-type.enum.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export enum ContentType {
2+
PlainText = 'text/plain',
3+
JSON = 'application/json',
4+
Urlencoded = 'application/x-www-form-urlencoded',
5+
}

src/core/enums/http-method.enum.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export enum HttpMethod {
2+
Get = 'GET',
3+
Post = 'POST',
4+
Put = 'PUT',
5+
}

src/core/http-exception.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export class HttpException extends Error {
2+
constructor(public statusCode: number, public message: string) {
3+
super(message || 'Internal Server Error');
4+
}
5+
}

src/core/http.adapter.ts

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
createServer,
3+
IncomingMessage,
4+
Server,
5+
ServerResponse,
6+
} from 'node:http';
7+
8+
interface RequestsHandler {
9+
(request: IncomingMessage, response: ServerResponse): void | Promise<void>;
10+
}
11+
12+
export class HttpAdapter {
13+
private readonly httpServer: Server = createServer();
14+
private requestsHandler?: RequestsHandler;
15+
16+
constructor() {
17+
this.setupErrorListeners();
18+
}
19+
20+
public setRequestsHandler(handler: RequestsHandler) {
21+
if (this.requestsHandler) {
22+
this.httpServer.off('request', this.requestsHandler);
23+
}
24+
25+
this.requestsHandler = handler.bind(this);
26+
this.httpServer.on('request', this.requestsHandler);
27+
}
28+
29+
public async listen(port: string | number, hostname?: string): Promise<void> {
30+
return new Promise((resolve) => {
31+
if (hostname) this.httpServer.listen(+port, hostname, resolve);
32+
else this.httpServer.listen(+port, resolve);
33+
});
34+
}
35+
36+
public async close(): Promise<void> {
37+
return new Promise((resolve, reject) => {
38+
this.httpServer.close((error) => {
39+
if (error) reject(error);
40+
resolve();
41+
});
42+
});
43+
}
44+
45+
private setupErrorListeners() {
46+
this.httpServer.on(
47+
'clientError',
48+
(error: NodeJS.ErrnoException, socket) => {
49+
if (error.code === 'ECONNRESET' || !socket.writable) {
50+
return;
51+
}
52+
53+
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
54+
}
55+
);
56+
57+
this.httpServer.on('error', (error: NodeJS.ErrnoException) => {
58+
if (error.code === 'EADDRINUSE') {
59+
console.error('Error: address in use');
60+
}
61+
62+
if (error.code === 'EACCES') {
63+
console.error('Error: port in use, please try another one');
64+
}
65+
66+
this.close()
67+
.then(() => {
68+
process.exit(1);
69+
})
70+
.catch(() => {
71+
console.error('Error: cannot close the server');
72+
process.exit(1);
73+
});
74+
});
75+
}
76+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export interface RequestData<
2+
TParams extends Record<string, unknown> | unknown = Record<string, unknown>,
3+
TBody = unknown
4+
> {
5+
body: TBody;
6+
params: TParams;
7+
}

0 commit comments

Comments
 (0)