Controllers & HTTP

Katal uses a controller-per-route pattern. Every route maps to a class that extends Controller. Controllers handle request parsing, optional validation, lifecycle hooks, and response formatting.


The Controller Class

import { Controller } from "katal";
import type { RequestContext } from "katal";

class MyController extends Controller {
  async handle(ctx: RequestContext): Promise<Response> {
    return this.success({ hello: "world" });
  }
}

At minimum you only need to implement handle(). Everything else is optional.


RequestContext

ctx contains everything about the incoming request:

Property Type Description
ctx.request Request Raw Bun Request object
ctx.url URL Parsed URL
ctx.params Record<string, string> Path parameters (:id, :slug, …)
ctx.query Record<string, string> Query string parameters
ctx.body unknown Parsed request body (JSON, form data)
ctx.user unknown Populated by auth middleware
ctx.id string Per-request correlation ID
ctx.response Response \| undefined Available during after hooks

Lifecycle Hooks

beforeHandle()

Called after validation but before handle(). Return a Response to short-circuit the request, or null / undefined to proceed normally.

class AdminController extends Controller {
  protected async beforeHandle(): Promise<Response | null> {
    if (!this.context.user) {
      return this.error("Unauthenticated", 401);
    }
    // @ts-ignore — whatever your user shape is
    if (this.context.user.role !== "admin") {
      return this.error("Forbidden", 403);
    }
    return null; // proceed
  }

  async handle(ctx: RequestContext) {
    return this.success({ admin: true });
  }
}

afterHandle(response)

Called after handle() returns. Receives the response value and must return the final response.

class AuditedController extends Controller {
  protected async afterHandle(response: Response): Promise<Response> {
    console.log("Response status:", response.status);
    return response;
  }

  async handle(_ctx: RequestContext) {
    return this.success({ ok: true });
  }
}

Response Helpers

All helpers are protected methods available inside your controller.

success(data, status?)

Returns HTTP 200 (or custom status) with a standard envelope:

return this.success({ id: 1, name: "Alice" });
// { "success": true, "data": { "id": 1, "name": "Alice" } }

return this.success({ id: 1 }, 201); // Created

json(data, status?)

Returns raw JSON without the success envelope:

return this.json({ token: "abc123" }, 201);

error(message, status?, errors?)

Returns an error envelope:

return this.error("Not found", 404);
// { "success": false, "message": "Not found" }

return this.error("Validation failed", 422, [
  { field: "email", message: "Invalid format" }
]);

validationError(errors)

Shorthand for HTTP 422 validation errors:

return this.validationError([{ field: "name", message: "required" }]);

text(content, status?)

Returns a plain-text response:

return this.text("OK", 200);

redirect(url, status?)

Returns a redirect (default 302):

return this.redirect("https://example.com");
return this.redirect("/new-path", 301);

Schema Validation on Controllers

Declare a protected schema to validate the request body before handle() is called. The framework returns HTTP 422 automatically if validation fails.

class CreatePostController extends Controller {
  protected schema = {
    title:   { required: true,  type: "string" as const, min: 1,  max: 200 },
    content: { required: true,  type: "string" as const, min: 10  },
    tags:    { required: false, type: "array"  as const  },
  };

  async handle(ctx: RequestContext) {
    const { title, content } = ctx.body as { title: string; content: string };
    // if we reach here, body passed validation
    return this.success({ created: true, title }, 201);
  }
}

Validation errors conform to RFC 9457 Problem Details:

{
  "type": "https://tools.ietf.org/html/rfc9457",
  "title": "Validation Failed",
  "status": 422,
  "detail": "One or more validation errors occurred.",
  "errors": [
    { "field": "title", "message": "title is required" }
  ]
}

Reusable Base Controllers

Extract common concerns into a base controller:

abstract class ApiController extends Controller {
  protected async beforeHandle(): Promise<Response | null> {
    // enforce JSON content-type on mutations
    const method = this.context.request.method;
    if (["POST", "PUT", "PATCH"].includes(method)) {
      const ct = this.context.request.headers.get("content-type") ?? "";
      if (!ct.includes("application/json")) {
        return this.error("Content-Type must be application/json", 415);
      }
    }
    return null;
  }
}

class CreateUserController extends ApiController {
  protected schema = {
    name: { required: true, type: "string" as const },
  };

  async handle(ctx: RequestContext) {
    const { name } = ctx.body as { name: string };
    return this.success({ name }, 201);
  }
}

See the Custom Base Controllers example for a full pattern.


Problem Details (RFC 9457)

Katal can produce RFC 9457 compatible error responses for any status code:

import { createProblemDetailsResponse } from "katal";

return createProblemDetailsResponse({
  status:   404,
  title:    "User Not Found",
  detail:   `No user with ID ${id}`,
  instance: ctx.request.url,
  traceId:  ctx.id,
});

Response:

{
  "type": "https://tools.ietf.org/html/rfc9457",
  "title": "User Not Found",
  "status": 404,
  "detail": "No user with ID 42",
  "instance": "https://api.example.com/users/42",
  "traceId": "b3c4d5e6..."
}

Registering Routes

Routes are registered through the Router returned by app.getRouter().

const router = app.getRouter();

router.get("/users",       ListUsersController);
router.post("/users",      CreateUserController);
router.get("/users/:id",   GetUserController);
router.put("/users/:id",   UpdateUserController);
router.patch("/users/:id", PatchUserController);
router.delete("/users/:id", DeleteUserController);

Route-Level Middleware

router.get("/dashboard", DashboardController, {
  middleware: ["auth", "rbac:admin"],
});

Route-Level Validation

Validation defined at the route level is merged with any schema on the controller itself.

router.post("/items", CreateItemController, {
  validation: {
    name:     { required: true,  type: "string" as const },
    quantity: { required: false, type: "number" as const, min: 1 },
  },
});

Route Groups

router.group("/api/v1", (r) => {
  r.group("/products", (r) => {
    r.get("/",       ListProductsController);
    r.post("/",      CreateProductController, { middleware: ["auth"] });
    r.get("/:id",    GetProductController);
    r.put("/:id",    UpdateProductController, { middleware: ["auth"] });
    r.delete("/:id", DeleteProductController, { middleware: ["auth"] });
  });
});

Groups can nest arbitrarily deep. Middleware defined on a group applies to all nested routes.


See Also