-
Notifications
You must be signed in to change notification settings - Fork 3.6k
feat: express decorators for rest apis and websocket #6818
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| node_modules | ||
| build/* | ||
| dist/* | ||
| out/* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| /** @type {import("eslint").Linter.Config} */ | ||
| module.exports = { | ||
| root: true, | ||
| extends: ["@plane/eslint-config/library.js"], | ||
| parser: "@typescript-eslint/parser", | ||
| parserOptions: { | ||
| project: true, | ||
| }, | ||
| }; | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| # @plane/decorators | ||
|
|
||
| A lightweight TypeScript decorator library for building Express.js controllers with a clean, declarative syntax. | ||
|
|
||
| ## Features | ||
|
|
||
| - TypeScript-first design | ||
| - Decorators for HTTP methods (GET, POST, PUT, PATCH, DELETE) | ||
| - WebSocket support | ||
| - Middleware support | ||
| - No build step required - works directly with TypeScript files | ||
|
|
||
| ## Installation | ||
|
|
||
| This package is part of the Plane workspace and can be used by adding it to your project's dependencies: | ||
|
|
||
| ```json | ||
| { | ||
| "dependencies": { | ||
| "@plane/decorators": "*" | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Usage | ||
|
|
||
| ### Basic REST Controller | ||
|
|
||
| ```typescript | ||
| import { Controller, Get, Post, BaseController } from "@plane/decorators"; | ||
| import { Router, Request, Response } from "express"; | ||
|
|
||
| @Controller("/api/users") | ||
| class UserController extends BaseController { | ||
| @Get("/") | ||
| async getUsers(req: Request, res: Response) { | ||
| return res.json({ users: [] }); | ||
| } | ||
|
|
||
| @Post("/") | ||
| async createUser(req: Request, res: Response) { | ||
| return res.json({ success: true }); | ||
| } | ||
| } | ||
|
|
||
| // Register routes | ||
| const router = Router(); | ||
| const userController = new UserController(); | ||
| userController.registerRoutes(router); | ||
| ``` | ||
|
|
||
| ### WebSocket Controller | ||
|
|
||
| ```typescript | ||
| import { | ||
| Controller, | ||
| WebSocket, | ||
| BaseWebSocketController, | ||
| } from "@plane/decorators"; | ||
| import { Request } from "express"; | ||
| import { WebSocket as WS } from "ws"; | ||
|
|
||
| @Controller("/ws/chat") | ||
| class ChatController extends BaseWebSocketController { | ||
| @WebSocket("/") | ||
| handleConnection(ws: WS, req: Request) { | ||
| ws.on("message", (message) => { | ||
| ws.send(`Received: ${message}`); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| // Register WebSocket routes | ||
| const router = require("express-ws")(app).router; | ||
| const chatController = new ChatController(); | ||
| chatController.registerWebSocketRoutes(router); | ||
| ``` | ||
|
|
||
| ## API Reference | ||
|
|
||
| ### Decorators | ||
|
|
||
| - `@Controller(baseRoute: string)` - Class decorator for defining a base route | ||
| - `@Get(route: string)` - Method decorator for HTTP GET endpoints | ||
| - `@Post(route: string)` - Method decorator for HTTP POST endpoints | ||
| - `@Put(route: string)` - Method decorator for HTTP PUT endpoints | ||
| - `@Patch(route: string)` - Method decorator for HTTP PATCH endpoints | ||
| - `@Delete(route: string)` - Method decorator for HTTP DELETE endpoints | ||
| - `@WebSocket(route: string)` - Method decorator for WebSocket endpoints | ||
| - `@Middleware(middleware: RequestHandler)` - Method decorator for applying middleware | ||
|
|
||
| ### Classes | ||
|
|
||
| - `BaseController` - Base class for REST controllers | ||
| - `BaseWebSocketController` - Base class for WebSocket controllers | ||
|
|
||
| ## License | ||
|
|
||
| This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| { | ||
| "name": "@plane/decorators", | ||
| "version": "0.1.0", | ||
| "description": "Controller and route decorators for Express.js applications", | ||
| "license": "AGPL-3.0", | ||
| "private": true, | ||
| "main": "./dist/index.js", | ||
| "module": "./dist/index.mjs", | ||
| "types": "./dist/index.d.ts", | ||
| "files": [ | ||
| "dist/**" | ||
| ], | ||
| "scripts": { | ||
| "build": "tsup src/index.ts --format esm,cjs --dts --external express,ws", | ||
| "dev": "tsup src/index.ts --format esm,cjs --watch --dts --external express,ws", | ||
| "lint": "eslint src --ext .ts,.tsx", | ||
| "lint:errors": "eslint src --ext .ts,.tsx --quiet" | ||
| }, | ||
| "dependencies": { | ||
| "reflect-metadata": "^0.2.2", | ||
| "express": "^4.21.2" | ||
| }, | ||
| "devDependencies": { | ||
| "@plane/eslint-config": "*", | ||
| "@types/express": "^4.17.21", | ||
| "@types/reflect-metadata": "^0.1.0", | ||
| "@plane/typescript-config": "*", | ||
| "@types/node": "^20.14.9", | ||
| "@types/ws": "^8.5.10", | ||
| "tsup": "8.3.0", | ||
| "typescript": "^5.3.3" | ||
| }, | ||
| "peerDependencies": { | ||
| "express": ">=4.21.2", | ||
| "ws": ">=8.0.0" | ||
| }, | ||
| "peerDependenciesMeta": { | ||
| "ws": { | ||
| "optional": true | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { RequestHandler, Router } from "express"; | ||
| import "reflect-metadata"; | ||
|
|
||
| type HttpMethod = | ||
| | "get" | ||
| | "post" | ||
| | "put" | ||
| | "delete" | ||
| | "patch" | ||
| | "options" | ||
| | "head" | ||
| | "ws"; | ||
|
|
||
| interface ControllerInstance { | ||
| [key: string]: unknown; | ||
| } | ||
|
|
||
| interface ControllerConstructor { | ||
| new (...args: any[]): ControllerInstance; | ||
| prototype: ControllerInstance; | ||
| } | ||
|
|
||
| export function registerControllers( | ||
| router: Router, | ||
| Controller: ControllerConstructor, | ||
| ): void { | ||
| const instance = new Controller(); | ||
| const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string; | ||
|
|
||
| Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => { | ||
| if (methodName === "constructor") return; // Skip the constructor | ||
|
|
||
| const method = Reflect.getMetadata( | ||
| "method", | ||
| instance, | ||
| methodName, | ||
| ) as HttpMethod; | ||
| const route = Reflect.getMetadata("route", instance, methodName) as string; | ||
| const middlewares = | ||
| (Reflect.getMetadata( | ||
| "middlewares", | ||
| instance, | ||
| methodName, | ||
| ) as RequestHandler[]) || []; | ||
|
|
||
| if (method && route) { | ||
| const handler = instance[methodName] as unknown; | ||
|
|
||
| if (typeof handler === "function") { | ||
| if (method !== "ws") { | ||
| ( | ||
| router[method] as ( | ||
| path: string, | ||
| ...handlers: RequestHandler[] | ||
| ) => void | ||
| )(`${baseRoute}${route}`, ...middlewares, handler.bind(instance)); | ||
| } | ||
| } | ||
| } | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| // Export individual decorators | ||
| export { Controller, Middleware } from "./rest"; | ||
| export { Get, Post, Put, Patch, Delete } from "./rest"; | ||
| export { WebSocket } from "./websocket"; | ||
| export { registerControllers } from "./controller"; | ||
| export { registerWebSocketControllers } from "./websocket-controller"; | ||
|
|
||
| // Also provide namespaced exports for better organization | ||
| import * as RestDecorators from "./rest"; | ||
| import * as WebSocketDecorators from "./websocket"; | ||
|
|
||
| // Named namespace exports | ||
| export const Rest = RestDecorators; | ||
| export const WebSocketNS = WebSocketDecorators; | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import "reflect-metadata"; | ||
| import { RequestHandler } from "express"; | ||
|
|
||
| // Define valid HTTP methods | ||
| type RestMethod = "get" | "post" | "put" | "patch" | "delete"; | ||
|
|
||
| /** | ||
| * Controller decorator | ||
| * @param baseRoute | ||
| * @returns | ||
| */ | ||
| export function Controller(baseRoute: string = ""): ClassDecorator { | ||
| return function (target: Function) { | ||
| Reflect.defineMetadata("baseRoute", baseRoute, target); | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Factory function to create HTTP method decorators | ||
| * @param method HTTP method to handle | ||
| * @returns Method decorator | ||
| */ | ||
| function createHttpMethodDecorator( | ||
| method: RestMethod | ||
| ): (route: string) => MethodDecorator { | ||
| return function (route: string): MethodDecorator { | ||
| return function ( | ||
| target: object, | ||
| propertyKey: string | symbol, | ||
| descriptor: PropertyDescriptor | ||
| ) { | ||
| Reflect.defineMetadata("method", method, target, propertyKey); | ||
| Reflect.defineMetadata("route", route, target, propertyKey); | ||
| }; | ||
| }; | ||
| } | ||
|
|
||
| // Export HTTP method decorators using the factory | ||
| export const Get = createHttpMethodDecorator("get"); | ||
| export const Post = createHttpMethodDecorator("post"); | ||
| export const Put = createHttpMethodDecorator("put"); | ||
| export const Patch = createHttpMethodDecorator("patch"); | ||
| export const Delete = createHttpMethodDecorator("delete"); | ||
|
|
||
| /** | ||
| * Middleware decorator | ||
| * @param middleware | ||
| * @returns | ||
| */ | ||
| export function Middleware(middleware: RequestHandler): MethodDecorator { | ||
| return function ( | ||
| target: object, | ||
| propertyKey: string | symbol, | ||
| descriptor: PropertyDescriptor, | ||
| ) { | ||
| const middlewares = | ||
| Reflect.getMetadata("middlewares", target, propertyKey) || []; | ||
| middlewares.push(middleware); | ||
| Reflect.defineMetadata("middlewares", middlewares, target, propertyKey); | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| import { Router, Request } from "express"; | ||
| import type { WebSocket } from "ws"; | ||
| import "reflect-metadata"; | ||
|
|
||
| interface ControllerInstance { | ||
| [key: string]: unknown; | ||
| } | ||
|
|
||
| interface ControllerConstructor { | ||
| new (...args: any[]): ControllerInstance; | ||
| prototype: ControllerInstance; | ||
| } | ||
|
|
||
| export function registerWebSocketControllers( | ||
| router: Router, | ||
| Controller: ControllerConstructor, | ||
| existingInstance?: ControllerInstance, | ||
| ): void { | ||
| const instance = existingInstance || new Controller(); | ||
| const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string; | ||
|
|
||
| Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => { | ||
| if (methodName === "constructor") return; // Skip the constructor | ||
|
|
||
| const method = Reflect.getMetadata( | ||
| "method", | ||
| instance, | ||
| methodName, | ||
| ) as string; | ||
| const route = Reflect.getMetadata("route", instance, methodName) as string; | ||
|
|
||
| if (method === "ws" && route) { | ||
| const handler = instance[methodName] as unknown; | ||
|
|
||
| if ( | ||
| typeof handler === "function" && | ||
| typeof (router as any).ws === "function" | ||
| ) { | ||
| (router as any).ws( | ||
| `${baseRoute}${route}`, | ||
| (ws: WebSocket, req: Request) => { | ||
| try { | ||
| handler.call(instance, ws, req); | ||
| } catch (error) { | ||
| console.error( | ||
| `WebSocket error in ${Controller.name}.${methodName}`, | ||
| error, | ||
| ); | ||
| ws.close( | ||
| 1011, | ||
| error instanceof Error | ||
| ? error.message | ||
| : "Internal server error", | ||
| ); | ||
| } | ||
| }, | ||
| ); | ||
| } | ||
|
Comment on lines
+35
to
+58
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Type the WebSocket registration to avoid using |
||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Base controller class for WebSocket endpoints | ||
| */ | ||
| export abstract class BaseWebSocketController { | ||
| protected router: Router; | ||
|
|
||
| constructor() { | ||
| this.router = Router(); | ||
| } | ||
|
|
||
| /** | ||
| * Get the base route for this controller | ||
| */ | ||
| protected getBaseRoute(): string { | ||
| return Reflect.getMetadata("baseRoute", this.constructor) || ""; | ||
| } | ||
|
|
||
| /** | ||
| * Abstract method to handle WebSocket connections | ||
| * Implement this in your derived class | ||
| */ | ||
| abstract handleConnection(ws: WebSocket, req: Request): void; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.