Skip to content

Controllers

SemitraController is the request coordinator.

It owns orchestration, not persistence rules or response schemas.

Every controller instance has access to:

  • request
  • params
  • env
  • ctx
  • route
  • runtime
  • currentTenant
  • locals

Application controllers can also hydrate currentUser.

The example app centralizes current-user hydration once:

import { SemitraController } from "@semitra/cli";
export default class ApplicationController extends SemitraController {
static {
this.beforeAction("hydrateCurrentUser");
}
hydrateCurrentUser(): void {
const role = this.request.headers.get("x-semitra-role");
if (!role) {
this.setCurrentUser(null);
return;
}
const id = this.request.headers.get("x-semitra-user-id") ?? "edge-user";
this.setCurrentUser({ id, role });
}
}
  • parse and validate input
  • load records
  • authorize actions
  • scope collections
  • dispatch jobs or events
  • render responses

The example app’s posts controller shows the full Semitra controller pattern:

import { s } from "@semitra/cli";
import ApplicationController from "../../application_controller.ts";
import Post from "../../../models/post.ts";
import PostResource from "../../../resources/post_resource.ts";
const CreatePostParams = s.object({
post: s.object({
title: s.string().min(3),
content: s.string().optional()
})
});
export default class PostsController extends ApplicationController {
static {
this.useResource(PostResource, { only: ["create"] });
this.verifyAuthorized({ only: ["create"] });
}
async create() {
const input = this.params.require(CreatePostParams);
const Posts = this.model(Post);
await this.authorize(Post, "create");
const post = Posts.build(input.post);
if (!(await post.save())) {
return this.renderErrors(post.errors);
}
return this.renderResource(post, { status: 201 });
}
}

That is the intended split of responsibilities: the controller validates, authorizes, builds the record through the request-bound model class, and then renders the resource. It does not inline persistence rules or serialization.

Controllers can render:

  • renderJson(data)
  • renderResource(record)
  • renderResourceCollection(records)
  • renderErrors(errors)
  • renderText(body)
  • renderCsv(body)
  • renderBinary(body)
  • head(status)

If a controller action returns undefined, Semitra treats it as an empty 204 response.

Controllers use this.model(Model) instead of reaching into the model class raw.

That binds the record layer to:

  • resolved adapters
  • current tenant
  • tenant database provider
  • request runtime context

Semitra includes controller guardrails that enforce policy discipline:

  • verifyAuthorized()
  • verifyPolicyScoped()

These are useful when you want action methods to fail fast if a required authorization step was skipped.