Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lab2 #2

Merged
merged 21 commits into from
Dec 7, 2022
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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,256 changes: 6,866 additions & 1,390 deletions package-lock.json

Large diffs are not rendered by default.

33 changes: 28 additions & 5 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",
nosovk marked this conversation as resolved.
Show resolved Hide resolved
"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,6 +34,7 @@
"homepage": "https://github.com/f1ctashka/nodeJS-labs#readme",
"private": true,
"devDependencies": {
"@types/jest": "^29.2.2",
"@types/node": "^18.11.7",
"@typescript-eslint/eslint-plugin": "^5.41.0",
"@typescript-eslint/parser": "^5.41.0",
Expand All @@ -41,13 +43,34 @@
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-sonarjs": "^0.16.0",
"husky": "^8.0.1",
"jest": "^29.3.1",
"nodemon": "^2.0.20",
"prettier": "^2.7.1",
"rimraf": "^3.0.2",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1",
"typescript": "^4.8.4"
},
"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 {
devule marked this conversation as resolved.
Show resolved Hide resolved
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;
}
59 changes: 59 additions & 0 deletions src/core/path-pattern.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { normalizePath } from './utils/normalize-path.util';

export class PathPattern {
public static parse(path: string): RegExp {
let pattern = '^';
path = normalizePath(path);
path = path.replace(/\*+/, '*');
path = path.replace(/\?+/, '?');

const pathSlices = path.split('/');
// If the path begins with / (.split -> ['', 'users'])
if (pathSlices[0] === '') pathSlices.shift();

for (const [index, slice] of pathSlices.entries()) {
const isOptional = slice.endsWith('?');
const isLast = index === pathSlices.length - 1;

if (isOptional && !isLast) {
throw new Error(
'An optional parameter should be at the end of the pattern'
);
}

if (slice === '*') {
// Wildcard
if (!isLast)
throw new Error('The wildcard should be at the end of the pattern');

pattern += '/(?<wildcard>.*)';
} else if (slice.startsWith(':')) {
// Parameter
// Slice end: if ends with '?' -> isOptional == 1
const paramName = slice.slice(1, slice.length - Number(isOptional));

pattern += isOptional
? `(?:/(?<${paramName}>[^/]+?))?`
: `/(?<${paramName}>[^/]+?)`;
} else {
pattern += `/${slice}`;
}
}

pattern += '$';

return new RegExp(pattern, 'i');
}

public static check(
path: string,
pattern: RegExp
): { matches: boolean; params: Record<string, unknown> } {
if (!pattern.test(path)) return { matches: false, params: {} };

return {
matches: true,
params: pattern.exec(path)?.groups || {},
};
}
}