diff --git a/backend/src/controllers/index.ts b/backend/src/controllers/index.ts new file mode 100644 index 0000000..a94d5d5 --- /dev/null +++ b/backend/src/controllers/index.ts @@ -0,0 +1,3 @@ +import TaskController from './task.controller'; + +export const controllers = [TaskController]; diff --git a/backend/src/controllers/task.controller.ts b/backend/src/controllers/task.controller.ts index 1d349ed..ac09596 100644 --- a/backend/src/controllers/task.controller.ts +++ b/backend/src/controllers/task.controller.ts @@ -1,71 +1,72 @@ import { NextFunction, Request, Response } from 'express'; import TaskModel, { TaskType } from '../models/task.model'; +import { Controller, Delete, Get, Post, Put } from '../utils/decorators/controller.decorator'; -const taskModel = new TaskModel(); +@Controller('/task') +export default class TaskController { + private taskModel = new TaskModel(); -export const getAllTask = async ( - req: Request, - res: Response, - next: NextFunction -) => { - try { - const tasks = await taskModel.getAll(); - res.status(200).json(tasks); - } catch (error) { - next(error); + @Get() + async getAllTask(req: Request, res: Response, next: NextFunction) { + try { + const tasks = await this.taskModel.getAll(); + res.status(200).json(tasks); + } catch (error) { + next(error); + } } -}; -export const getTaskById = async ( - req: Request<{ id: string }>, - res: Response, - next: NextFunction -) => { - try { - const task = await taskModel.getById(req.params.id); - res.status(200).json(task); - } catch (error) { - next(error); + @Get('/:id') + async getTaskById( + req: Request<{ id: string }>, + res: Response, + next: NextFunction + ) { + try { + const task = await this.taskModel.getById(req.params.id); + res.status(200).json(task); + } catch (error) { + next(error); + } } -}; -export const createTask = async ( - req: Request<{}, {}, TaskType>, - res: Response<{ id: string }>, - next: NextFunction -) => { - try { - const task = taskModel.validateData(req.body); - const id = await taskModel.create(task); - res.status(201).json({ id }); - } catch (error) { - next(error); + @Post() + async createTask( + req: Request<{}, {}, TaskType>, + res: Response<{ id: string }>, + next: NextFunction + ) { + try { + const task = this.taskModel.validateData(req.body); + const id = await this.taskModel.create(task); + res.status(201).json({ id }); + } catch (error) { + next(error); + } } -}; -export const updateTask = async ( - req: Request<{ id: string }, {}, TaskType>, - res: Response, - next: NextFunction -) => { - try { - const task = taskModel.validateData(req.body); - await taskModel.update(req.params.id, task); - res.status(200); - } catch (error) { - next(error); + @Put('/:id') + async updateTask( + req: Request<{ id: string }, {}, TaskType>, + res: Response, + next: NextFunction + ) { + try { + const task = this.taskModel.validateData(req.body); + await this.taskModel.update(req.params.id, task); + res.status(200); + } catch (error) { + next(error); + } } -}; -export const deleteTask = async ( - req: Request<{ id: string }>, - res: Response, - next: NextFunction -) => { - try { - await taskModel.delete(req.params.id); - res.status(200); - } catch (error) { - next(error); + @Delete('/:id') + async deleteTask(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + await this.taskModel.delete(req.params.id); + res.status(200); + } catch (error) { + next(error); + } } -}; +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 6149969..3af0534 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,17 +1,31 @@ -import express, { NextFunction, Request, Response } from 'express'; +import express, { NextFunction, Request, Response, Router } from 'express'; import { ZodError } from 'zod'; -import taskRouter from './routes/task.route'; +import { controllers } from './controllers'; +import { validateMetadata } from './utils/decorators/controller.decorator'; const port = process.env.PORT || 9453; class Server { private app = express(); - start() { - this.app.use(express.json()); + private registerRoutes() { + controllers.forEach((Controller) => { + const controller = new Controller(); + const { basePath, routers } = validateMetadata(Controller); + const router = Router(); + + routers.forEach(({ method, path, handlerName }) => { + router[method]( + path, + controller[handlerName as keyof typeof controller].bind(controller) + ); + }); - this.app.use('/task', taskRouter); + this.app.use(basePath, router); + }); + } + private errorMiddleware() { this.app.use((err: Error, req: Request, res: Response, next: NextFunction) => { if (err instanceof ZodError) { res.status(400).json({ message: err.errors }); @@ -19,6 +33,14 @@ class Server { res.status(500).json({ message: err.message }); } }); + } + + start() { + this.app.use(express.json()); + + this.registerRoutes(); + + this.errorMiddleware(); this.app.listen(port, () => { console.log(`Server is running on port ${port}`); diff --git a/backend/src/routes/task.route.ts b/backend/src/routes/task.route.ts deleted file mode 100644 index c79757c..0000000 --- a/backend/src/routes/task.route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Router } from 'express'; -import * as taskController from '../controllers/task.controller'; - -const taskRouter = Router(); - -taskRouter.get('/', taskController.getAllTask); - -taskRouter.get('/:id', taskController.getTaskById); - -taskRouter.post('/', taskController.createTask); - -taskRouter.put('/:id', taskController.updateTask); - -taskRouter.delete('/:id', taskController.deleteTask); - -export default taskRouter; diff --git a/backend/src/utils/decorators/controller.decorator.ts b/backend/src/utils/decorators/controller.decorator.ts new file mode 100644 index 0000000..2134482 --- /dev/null +++ b/backend/src/utils/decorators/controller.decorator.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; +import './symbol.polyfill'; + +enum MetadataKeys { + BASE_PATH = 'basePath', + ROUTERS = 'routers', +} + +enum Method { + GET = 'get', + POST = 'post', + PUT = 'put', + DELETE = 'delete', +} + +const routerConfigSchema = z.object({ + method: z.enum([Method.GET, Method.POST, Method.PUT, Method.DELETE]), + path: z.string(), + handlerName: z.string().or(z.symbol()), +}); + +const metadataSchema = z.object({ + [MetadataKeys.BASE_PATH]: z.string(), + [MetadataKeys.ROUTERS]: z.array(routerConfigSchema), +}); + +export const validateMetadata = {}>(target: T) => { + return metadataSchema.parse(target[Symbol.metadata]); +}; + +export const Controller = (basePath: string) => { + return (_: new (...args: any[]) => {}, ctx: ClassDecoratorContext) => { + ctx.metadata[MetadataKeys.BASE_PATH] = basePath; + }; +}; + +const methodDecoratorFactory = (method: Method) => { + return (path: string = '') => { + return (_: Function, ctx: ClassMethodDecoratorContext) => { + const metadataRouters = ctx.metadata[MetadataKeys.ROUTERS]; + const routers: z.infer[] = Array.isArray(metadataRouters) + ? metadataRouters + : []; + + routers.push({ + method, + path, + handlerName: ctx.name, + }); + + ctx.metadata[MetadataKeys.ROUTERS] = routers; + }; + }; +}; + +export const Get = methodDecoratorFactory(Method.GET); +export const Post = methodDecoratorFactory(Method.POST); +export const Put = methodDecoratorFactory(Method.PUT); +export const Delete = methodDecoratorFactory(Method.DELETE); diff --git a/backend/src/utils/decorators/symbol.polyfill.ts b/backend/src/utils/decorators/symbol.polyfill.ts new file mode 100644 index 0000000..8892cb1 --- /dev/null +++ b/backend/src/utils/decorators/symbol.polyfill.ts @@ -0,0 +1,2 @@ +// @see https://github.com/microsoft/TypeScript/issues/53461#issuecomment-1606684134 +(Symbol as { metadata: symbol }).metadata ??= Symbol("Symbol.metadata"); diff --git a/backend/thunder-client.http b/backend/thunder-client.http index 8ec9ffc..3530300 100644 --- a/backend/thunder-client.http +++ b/backend/thunder-client.http @@ -1,10 +1,11 @@ @url = http://localhost:9453 +@id = c3c1dd84-aa3e-4413-b800-448cdbca7034 GET {{url}}/task ### -GET {{url}}/task/2 +GET {{url}}/task/{{id}} ### @@ -20,7 +21,7 @@ Content-Type: application/json ### -PUT {{url}}/task/2 +PUT {{url}}/task/{{id}} Content-Type: application/json { @@ -32,4 +33,4 @@ Content-Type: application/json ### -DELETE {{url}}/task/2 \ No newline at end of file +DELETE {{url}}/task/{{id}} \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 245ec1b..3014fec 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -12,7 +12,7 @@ /* Language and Environment */ "target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["ESNext.Decorators"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */