Controllers#

Controllers organize routes into class-based structures using Stage 3 TC39 decorators. They provide a clean way to group related handlers with shared middleware and dependency injection.

Basic Controller#

Use @Controller to define a route prefix, then @Get, @Post, etc. to register methods:

typescript
import { Controller, Get, Post } from "@buntok/core";
import type { Context } from "@buntok/core";

@Controller("/users")
class UserController {
  @Get("/")
  list(ctx: Context) {
    return ctx.json([{ id: 1, name: "John" }]);
  }

  @Get("/:id")
  detail(ctx: Context) {
    const { id } = ctx.params;
    return ctx.json({ id, name: "John" });
  }

  @Post("/")
  async create(ctx: Context) {
    const body = await ctx.body();
    return ctx.json({ created: true, ...body }, 201);
  }
}
Info
@Controller must be the outermost (topmost) decorator on the class. Stage 3 decorators are evaluated top-to-bottom but applied bottom-to-top, so @Controller (evaluated first, applied last) captures all method routes.

Register Controllers#

Use app.registerController() to register controller classes. It accepts a single class/instance or an array:

typescript
const app = new Buntok();

// Register single controller
app.registerController(UserController);

// Register multiple controllers using array (Overload approach)
app.registerController([UserController, PostController]);

// Register multiple individually
app.registerController(CommentController);
app.registerController(AdminController);

// Works with RouterGroup too
const api = app.group("/api/v1");
api.registerController([UserController, PostController]);

// Start server
app.listen(1212);

HTTP Method Decorators#

DecoratorHTTP Method
@Get(path)GET
@Post(path)POST
@Put(path)PUT
@Patch(path)PATCH
@Delete(path)DELETE
@Options(path)OPTIONS
@Head(path)HEAD
@All(path)All standard methods
@Query(path)QUERY (Bun-specific)

Middleware#

Route-Level Middleware#

Use @Use() to attach middleware to a single route. Multiple @Use() calls stack in declaration order.

typescript
import { Controller, Get, Use } from "@buntok/core";
import { zValidator } from "@buntok/core/middlewares/validator";
import type { Context, ZodCtx } from "@buntok/core";

const auth = async (ctx: Context, next) => {
  const token = ctx.getCookie("token");
  if (!token) return ctx.json({ error: "Unauthorized" }, 401);
  return next();
};

@Controller("/users")
class UserController {
  @Get("/public")
  publicEndpoint(ctx: Context) {
    return ctx.json({ data: "public" });
  }

  @Get("/private")
  @Use(auth)
  privateEndpoint(ctx: Context) {
    return ctx.json({ data: "private" });
  }

  @Get("/validated")
  @Use(zValidator("query", paginationSchema))
  validatedEndpoint(ctx: ZodCtx<{ query: typeof paginationSchema }>) {
    const { page, limit } = ctx.valid("query");
    return ctx.json({ page, limit });
  }
}

Guards#

Use @UseGuard() for simple boolean authorization checks. If any guard returns false, the request is rejected with 403 Forbidden.

typescript
import { Controller, Get, UseGuard } from "@buntok/core";
import type { Context } from "@buntok/core";

const isAdmin = (ctx: Context) => ctx.user?.role === "admin";
const isActive = (ctx: Context) => ctx.user?.status === "active";

@Controller("/admin")
class AdminController {
  @Get("/dashboard")
  @UseGuard(isAdmin, isActive)
  dashboard(ctx: Context) {
    return ctx.json({ secret: "admin data" });
  }
}
Info
Guards are async-compatible - you can perform database lookups or API calls. @UseGuard is syntactic sugar for @Use that returns 403 on failure.

Controllers with Groups#

Controllers can be registered inside route groups. The group prefix is prepended to the controller prefix, and group middleware runs after route-level @Use() middleware.

typescript
const api = app.group("/api/v1");
api.use(cors);
api.use(authMiddleware);

// UserController has @Controller("/users")
// Final routes: GET /api/v1/users, POST /api/v1/users, etc.
api.registerController(UserController);

// Execution order for GET /api/v1/users:
// @Use middlewares → cors → authMiddleware → handler

Inheritance#

Controllers support class inheritance. Child routes override parent routes with the same method name.

typescript
import type { Context } from "@buntok/core";

@Controller("/base")
class Base {
  @Get("/health")
  health(ctx: Context) {
    return ctx.json({ ok: true });
  }

  @Get("/info")
  info(ctx: Context) {
    return ctx.json({ version: "1.0" });
  }
}

@Controller("/users")
class UserController extends Base {
  @Get("/")
  list(ctx: Context) {
    return ctx.json([]);
  }

  // Override parent's /health route
  @Get("/health")
  customHealth(ctx: Context) {
    return ctx.json({ status: "custom" });
  }
}

// Routes:
// GET /users          → list
// GET /users/health   → customHealth (overrides parent)
// GET /users/info     → parent's info

Base Controller (CRUD Scaffold)#

Extend BaseController to get a full CRUD scaffold with pre-built routes:

typescript
import { BaseController } from "@buntok/core";

interface User {
  id: number;
  name: string;
  email: string;
}

interface CreateUser {
  name: string;
  email: string;
}

@Controller("/users")
class UserController extends BaseController<User, CreateUser, Partial<User>> {
  constructor(service: UserService) {
    super(service);
  }
}

// Pre-built routes:
// GET    /       → service.getAll()
// GET    /:id    → service.getById(id)
// POST   /       → service.create(body)
// PUT    /:id    → service.update(id, body)
// DELETE /:id    → service.delete(id)

Overriding Methods#

typescript
import type { Context } from "@buntok/core";

@Controller("/users")
class UserController extends BaseController<User, CreateUser> {
  constructor(private service: UserService) {
    super(service);
  }

  // Override the default getAll behavior
  @Get("/")
  override async list(ctx: Context) {
    const users = await this.service.findAllWithRoles();
    return ctx.success(users);
  }

  // Override parseId for UUID-based IDs
  protected override parseId(id: string): string {
    return id; // Keep as string, don't convert to number
  }
}

Dependency Injection#

Controllers work with the IoC container. Use @Dependencies to declare constructor dependencies, then let the container resolve them automatically.

Recommended: @Dependencies + container.scan()

This is the pattern generated by buntok create. The @Dependencies decorator declares what the controller needs, and container.scan() resolves the entire dependency tree automatically:

typescript
import { Container, Dependencies, Controller, Get } from "@buntok/core";
import type { Context } from "@buntok/core";

class UserService {
  async findAll() {
    return await db.user.findMany();
  }
}

@Dependencies(UserService)
@Controller("/users")
class UserController {
  constructor(private readonly userService: UserService) {}

  @Get("/")
  async list(ctx: Context) {
    const users = await this.userService.findAll();
    return ctx.json(users);
  }
}

// Auto-resolve entire dependency tree
const container = new Container();
container.scan([UserController]);

app.setContainer(container);
app.registerController(UserController);
Info
container.scan() recursively resolves all @Dependencies chains. If UserService depends on UserRepository, the container resolves the full chain automatically.

Manual: useFactory

For more control, register dependencies manually with useFactory:

typescript
const container = new Container();
container.register(UserService, { useClass: UserService });
container.register(UserController, {
  useFactory: (c) => new UserController(c.resolve(UserService)),
});

app.setContainer(container);
app.registerController(container.resolve(UserController));
Info
Without app.setContainer(), controllers are instantiated with new Controller(). Use Container for constructor injection.

How It Works#

Decorators execute once at class-definition time (bootstrap), not per request:

  1. @Get, @Post, etc. push route entries to a temporary array
  2. @Use attaches middleware to the pending route entry
  3. @Controller captures all entries and stores them in a WeakMap
  4. app.registerController() reads the metadata and calls app.registerRoute() for each method

At runtime, the router sees no difference between decorator-based and functional route registrations. Both converge to the same registerRoute() call.

Next Steps#