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

Proposal: Provides the hono/decorators package that allows you to define routes using ECMA Decorators #3941

Open
eavidy opened this issue Feb 20, 2025 · 0 comments
Labels
enhancement New feature or request.

Comments

@eavidy
Copy link

eavidy commented Feb 20, 2025

What is the feature you are proposing?

Description

Not long ago, I came into contact with Hono. I chose Hono because of its great api and Web standards. Just last month, I implemented a nest-hono-adapter, but I found that there is a better way to make the decorator and Hono work together.

My English is not good, so the following sentence may seem a little strange

ECMA decorators are currently at Stage 3. In the future, they will become a standard part of JavaScript syntax. For now, we can use this syntax through TypeScript.
We can leverage decorators and decorator metadata to achieve functionality similar to Nest's decorators. Since Stage 3 decorators do not include parameter decorators, here we only consider using decorators for route definitions and do not consider dependency injection.

Because it is a JavaScript standard, this will not introduce third-party packages.

If you accept the proposal, I am willing to contribute a PR.

Related links

typescript: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#decorators
tc39: https://github.com/tc39/proposal-decorators

A simple example

import { Context, Hono } from "hono";
import { Controller, Post, Get, Use, applyController, ToResponse } from "hono/decorators";
import { compress } from "hono/compress";
import { bodyLimit } from "hono/body-limit";
import { cors } from "hono/cors";

@Use(cors({ origin: "*" }))
@Controller({ basePath: "/api" })
class TestController {
  @Use(compress())
  @Use(bodyLimit({ maxSize: 1024 }))
  @Post("/test1")
  method1(ctx: Context) {
    return ctx.json({ ok: 1 });
  }

  @Get("/test2")
  method2 = () => {};

  @ToResponse((data, ctx) => {
    data.body; // string
    data.title; // string

    //@ts-expect-error Field "content" does not exist
    data.content;

    return ctx.html(
      `<html>
        <head>
          <title>${data.title}</title>
        </head>
        <body>
        ${data.body}
        </body>
      </html>`,
    );
  })
  @Get("/test3")
  method3(ctx: Context) {
    return {
      title: "123",
      body: "abc",
    };
  }
}
const hono = new Hono();
applyController(hono, new TestController());
applyController(hono, userController); //
// Apply more...

await hono.request("/api/test3");

API Design

After applying the decorator, it actually just adds metadata to this class. When applyController() is called, it reads the metadata of this class and then sets up routes and middleware based on the metadata.

Endpoint Decorators

export type EndpointDecoratorTarget = (...args: any[]) => any;
/**
 * @typeParam T Constrains the type of decoration target
 */
export type EndpointDecorator<T extends EndpointDecoratorTarget = EndpointDecoratorTarget> = (
  input: T | undefined,
  context: ClassMethodDecoratorContext<unknown, T> | ClassFieldDecoratorContext<unknown, T>,
) => void;

export declare function Endpoint(path: string, method?: string): EndpointDecorator;

export function Post(path: string): EndpointDecorator {
  return Endpoint(path, "POST");
}
export function Get(path: string): EndpointDecorator {
  return Endpoint(path, "GET");
}

// The same is true of other common methods such as Patch and Put

Endpoint decorators are the foundation of all decorators. Before applying other decorators, an endpoint decorator must be applied, and a method or property can only have one endpoint decorator applied to it.

class Test {
  @Get("/test1")
  @Use() // Throw: Before applying the middleware decorator, you must apply the endpoint decorator
  method1() {}

  @Get("/test2") // Throw: The route cannot be configured twice
  @Get("/test1")
  method2() {}
}

Controller Decorators

export type ControllerDecoratorTarget = new (...args: any[]) => any;

/**
 * @typeParam T Constrains the type of decoration target
 */
export type ControllerDecorator<T extends ControllerDecoratorTarget = ControllerDecoratorTarget> = (
  input: T,
  context: ClassDecoratorContext<T>,
) => void;

export type ControllerOption = {
  /** Inherit the decorator from the parent class */
  extends?: boolean;
  basePath?: string;
};

export declare function Controller(option: ControllerOption): ControllerDecorator;

Controller decorators can define certain behaviors for a group of routes, such as applying middleware, etc.

Middleware Decorators

export type MiddlewareDecoratorTarget = ControllerDecoratorTarget | EndpointDecoratorTarget;
export type MiddlewareDecorator<T extends MiddlewareDecoratorTarget = MiddlewareDecoratorTarget> = (
  input: unknown,
  context: ClassDecoratorContext | ClassMethodDecoratorContext | ClassFieldDecoratorContext,
) => void;

Middleware decorators can be applied to classes, methods, or properties. The order in which requests pass through middleware is from the outside to the inside (opposite to the order in which the decorators are called), which makes it easier to intuitively understand the process from the request to the route handler.

@Use(A)
@Use(B)
@Use(C)
class Controller {
  @Use(D)
  @Use(E)
  @Use(F)
  @Get("/test")
  method() {}
}

The sequence of requests passed through: A > B > C > D > E > F > method() > F > E > D > C > B > A

Transform Decorator

export declare function ToResponse<T>(
  handler: Transformer<T>,
): EndpointDecorator<(...args: any[]) => T | Promise<Awaited<T>>>;
export declare function ToArguments<T extends any[]>(handler: PipeInHandler<T>): EndpointDecorator<(...data: T) => any>;

The transform decorator can transform the Hono's Context object into the parameters required by the controller method, and also convert the object returned by the controller method into a Response object.

class Controller {
  @Get("/test1")
  method1(ctx: Context) {} //If the ToArguments decorator is not applied, the first argument is passed to Context

  @ToArguments(function (ctx: Context) {
    //The returned type is the same as the parameter for method2
    // If types are inconsistent, typescript prompts an exception
    return [1, "abc"];
  })

  //The type of data is the same as that returned by method2
  // If types are inconsistent, typescript prompts an exception
  @ToResponse((data, ctx: Context) => {
    data.body; // string
    data.title; // string

    //@ts-expect-error content not exist
    data.content;

    return ctx.text("ok");
  })
  @Get("/test2")
  method2(size: number, id: string) {
    return {
      title: "123",
      body: "abc",
    };
  }
}

Custom Decorators

Based on the decorators mentioned above, we can define specific responsibility decorators, such as setting variables, parameter validation, and permission validation, etc.
An example of permission validation:

function Roles(...roles: string[]): EndpointDecorator<(...data: T) => any> {
  return Use(async function (ctx: Context, next) {
    // Judgment role ...
  });
}

class Controller {
  @Roles("admin", "root")
  @Get("/")
  method() {}
}

Extends

If a subclass controller class declares @Controller({ extends: true }), then the subclass will inherit the route and middleware configurations of the parent class, otherwise it will ignore all decorators of the parent class.

@Use(bodyLimit({ maxSize: 1024 }))
@Controller({ basePath: "/animal" })
class Animal {
  constructor() {}
  @Get("/eat")
  eat() {
    return "Animal eat";
  }
  @Get("/speak")
  speak() {
    return "Animal speak";
  }
}

class Bird extends Animal {
  @Get("/fly")
  fly() {
    return "Bird fly";
  }
}

@Controller({ extends: true })
class Dog extends Animal {
  @Get("/sleep")
  sleep() {
    return "Dog sleep";
  }
}

If applyController(hono, new Bird()) is called, only /fly will be added, and the middleware defined on Animal will not take effect.
If applyController(hono, new Dog()) is called, only /animal/sleep, /animal/eat, and /animal/speak will be added, and all these requests will pass through the bodyLimit decorator applied on Animal.

We can also modify some settings of the parent class in the subclass.

@Controller({ extends: true, basePath: "" })
class Cat extends Animal {
  @Get("/run")
  run() {
    return "Cat run";
  }
  override eat() {
    return "Cat eat";
  }
  @Get("/speak")
  catSpeak() {
    return "Cat speak";
  }
}

This example rewrites the basePath, the eat() method, and the /speak route.

If applyController(hono, new Cat()) is called, only /run, /eat, and /speak will be added.
GET /eat response Cat eat
GET /speak response Cat speak

The above is my preliminary design

@eavidy eavidy added the enhancement New feature or request. label Feb 20, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request.
Projects
None yet
Development

No branches or pull requests

1 participant