feat: express decorators for rest apis and websocket (#6818)

* feat: express decorators for rest apis and websocket

* fix: added package dependency

* fix: refactor decorators
This commit is contained in:
M. Palanikannan 2025-03-26 20:24:05 +05:30 committed by GitHub
parent ae6e5a48fa
commit 993713925a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 690 additions and 7 deletions

View file

@ -0,0 +1,4 @@
node_modules
build/*
dist/*
out/*

View file

@ -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,
},
};

View file

@ -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).

View file

@ -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
}
}
}

View file

@ -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));
}
}
}
});
}

View file

@ -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;

View file

@ -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);
};
}

View file

@ -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",
);
}
},
);
}
}
});
}
/**
* 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;
}

View file

@ -0,0 +1,17 @@
import "reflect-metadata";
/**
* WebSocket method decorator
* @param route
* @returns
*/
export function WebSocket(route: string): MethodDecorator {
return function (
target: object,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
) {
Reflect.defineMetadata("method", "ws", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
}

View file

@ -0,0 +1,21 @@
{
"extends": "@plane/typescript-config/node-library.json",
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"lib": ["ES2020"],
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"./src"
],
"exclude": [
"dist",
"build",
"node_modules"
]
}

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
external: ['express', 'ws'],
treeshake: true,
});

View file

@ -0,0 +1,26 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Node.js Library",
"extends": "./base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2020",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"sourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "build"]
}

View file

@ -6,6 +6,7 @@
"files": [
"base.json",
"nextjs.json",
"react-library.json"
"react-library.json",
"node-library.json"
]
}