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:
parent
ae6e5a48fa
commit
993713925a
14 changed files with 690 additions and 7 deletions
4
packages/decorators/.eslintignore
Normal file
4
packages/decorators/.eslintignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
build/*
|
||||
dist/*
|
||||
out/*
|
||||
10
packages/decorators/.eslintrc.js
Normal file
10
packages/decorators/.eslintrc.js
Normal 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,
|
||||
},
|
||||
};
|
||||
|
||||
99
packages/decorators/README.md
Normal file
99
packages/decorators/README.md
Normal 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).
|
||||
42
packages/decorators/package.json
Normal file
42
packages/decorators/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
61
packages/decorators/src/controller.ts
Normal file
61
packages/decorators/src/controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
15
packages/decorators/src/index.ts
Normal file
15
packages/decorators/src/index.ts
Normal 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;
|
||||
|
||||
61
packages/decorators/src/rest.ts
Normal file
61
packages/decorators/src/rest.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
85
packages/decorators/src/websocket-controller.ts
Normal file
85
packages/decorators/src/websocket-controller.ts
Normal 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;
|
||||
}
|
||||
17
packages/decorators/src/websocket.ts
Normal file
17
packages/decorators/src/websocket.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
21
packages/decorators/tsconfig.json
Normal file
21
packages/decorators/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
12
packages/decorators/tsup.config.ts
Normal file
12
packages/decorators/tsup.config.ts
Normal 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,
|
||||
});
|
||||
26
packages/typescript-config/node-library.json
Normal file
26
packages/typescript-config/node-library.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
"files": [
|
||||
"base.json",
|
||||
"nextjs.json",
|
||||
"react-library.json"
|
||||
"react-library.json",
|
||||
"node-library.json"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue