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.