0.1.0GitHub
The BasicsControllers

The Basics

Controllers

On this page

Introduction

A controller answers the requests of one endpoint. It is a class with a single handle() method, and what it accepts and who may call it is declared right next to it. One class per endpoint keeps each file small: the code that shows a note and the code that updates it live apart, with their own dependencies.

modules/notes/controllers/ShowNoteController.ts
import type { Authenticated } from '@marmeon/auth';
import type { Row } from '@marmeon/database';
import { Controller, type HttpContext } from '@marmeon/http';

export class ShowNoteController extends Controller {
  handle(ctx: HttpContext<Authenticated, { note: Row<'notes'> }>) {
    return this.view('notes/Show', { note: ctx.params.note });
  }
}

A route points to the class: Route.get('/notes/:note', ShowNoteController). The router builds the controller for each request and calls handle() with the request's context. The routing page shows how :note becomes the row.

Writing controllers

Creating a controller

marmeon make:controller writes a controller into a module's controllers/ folder and prints the route to add:

pnpm marmeon make:controller ShowNote --module=notes --view

The name gets Controller appended when it does not end with it. The options choose what comes with it:

OptionWhat it adds
--viewA page in the module's views/ folder, and a controller that renders it.
--requestA request definition next to the controller. A name that starts with Show, List, Index, Search, Get, Find or Browse gets a query schema, any other name a body schema and a redirect back, so --view with it is refused.
--forceOverwrites a file that exists.

The module must exist, and make:module creates it.

Dependency injection

The container builds a controller for each request. Its constructor gets its dependencies injected, with no list to keep: the types of its parameters are enough.

modules/notes/controllers/ListNotesController.ts
import type { Authenticated } from '@marmeon/auth';
import { Controller, type HttpContext } from '@marmeon/http';
import { NoteRepository } from '../NoteRepository.ts';

export class ListNotesController extends Controller {
  readonly #notes: NoteRepository;

  constructor(notes: NoteRepository) {
    super();
    this.#notes = notes;
  }

  async handle(ctx: HttpContext<Authenticated>) {
    const notes = await this.#notes.query().selectAll().where('user_id', '=', ctx.user.id).execute();
    return this.view('notes/Index', { notes });
  }
}

A controller extends Controller, so its constructor calls super() first. The controller lives in the request's scope, and so does every scoped service it gets: the session, the signed-in user, the shared props. Nothing of them reaches another request. The service container page explains injection, and what to do with an interface.

The context

handle() gets the request's context. Its type, HttpContext<State, Params>, says what the controller needs from its route: the state that middleware added, such as ctx.user from authenticate(), and the path's parameters. A route that cannot provide either is a compile error where the route is registered.

PropertyWhat it is
ctx.requestThe raw Request.
ctx.paramsThe path's parameters, strings or bound records.
ctx.queryThe query string, or the parsed query behind a query schema.
ctx.input()The parsed body.
ctx.cookiesThe request's cookies, and the ones to set.
ctx.ipThe client's address.
ctx.expectsJson()Whether the client wants JSON rather than a page.
ctx.routeThe route that matched.

The requests page explains each of them. A controller with a request definition reads its validated body from ctx.body instead of ctx.input().

Requests

Defining what a controller accepts

defineRequest() declares what an endpoint accepts: the schema of its body, the schema of its query, and who may call it. The controller attaches it as static request, and ContextOf types its context:

modules/notes/controllers/UpdateNoteController.ts
import { can } from '@marmeon/auth';
import { Controller, defineRequest, type ContextOf } from '@marmeon/http';
import { rules as r } from '@marmeon/validation';
import { NoteRepository } from '../NoteRepository.ts';
import { NotePolicy } from '../policies/NotePolicy.ts';

export const UpdateNoteRequest = defineRequest({
  schema: r.object({
    title: r.string().trim().required().max(120),
    body: r.string().trim().max(10_000),
  }),
  messages: { 'title.required': 'notes.validation.title_required' },
  authorize: can(NotePolicy, 'update', 'note'),
});

export class UpdateNoteController extends Controller {
  static request = UpdateNoteRequest;

  readonly #notes: NoteRepository;

  constructor(notes: NoteRepository) {
    super();
    this.#notes = notes;
  }

  async handle(ctx: ContextOf<typeof UpdateNoteRequest>) {
    await this.#notes.update(ctx.params.note.id, ctx.body);
    return this.redirect().route('notes.show', { note: ctx.params.note.id });
  }
}

ctx.body is the schema's output: { title: string; body: string }, trimmed and checked. The route must bind note, or the authorize check is a compile error where the controller is registered.

ContextOf takes the route's side of the context from the type of authorize. can() brings the signed-in user and the bound record with it. A request without a check of its own declares what it needs in the parameter's type, as authorize: (_ctx: HttpContext<Authenticated>) => true, or the controller adds it: handle(ctx: ContextOf<typeof Request> & Authenticated).

OptionWhat it does
schemaValidates the body. Invalid input never reaches the controller.
queryParses the query string. An invalid field falls back to its default, never a 422.
queryOptions{ onInvalid: 'redirect' } sends a GET with an invalid field to its clean URL.
authorizeRuns before validation. false answers 403.
messages, attributesYour own words for the framework's validation messages, and for the fields' names.
liveLets a live validation run a schema of another library.

The validation page covers the rules and messages, and the requests page covers query schemas. A GET route refuses a controller whose request has a body schema, since a GET has no body.

The order of a request

The router runs a request in a fixed order, and builds the controller only when everything before it passed:

  1. The middleware: global, the group's, the route's own. Authentication answers here, before a 404 could tell what exists.
  2. The bindings. A missing record is a 404.
  3. authorize. A refusal is a 403.
  4. The query, parsed by query. This never fails.
  5. The body, validated by schema. Invalid input is a 422, or a redirect back to the form.
  6. handle(ctx), with ctx.body and the parsed ctx.query.

A controller never sees input that failed its schema, and its constructor never runs for a request it would refuse.

Authorization

authorize gets the route's context and a resolve function that builds classes in the request's scope. can() of @marmeon/auth asks a policy, with the record of a bound parameter:

modules/notes/policies/NotePolicy.ts
import type { AuthUser } from '@marmeon/auth';
import type { Row } from '@marmeon/database';

export class NotePolicy {
  update(user: AuthUser, note: Row<'notes'>) {
    return user.id === note.user_id;
  }
}

A refusal answers 403, "This action is unauthorized.", in the request's language. A policy that refuses with deny('reason') shows its own reason instead. A policy's denyAsNotFound() answers 404, so the record's existence stays hidden. The authorization page explains policies and the gate.

Types for the browser

A request's types travel to the views without its code:

TypeWhat it is
ContextOf<typeof UpdateNoteRequest>The controller's context: what authorize needs, plus body and the parsed query.
FormOf<UpdateNoteController>What a form sends, the schema's input. useForm() types its fields with it.
QueryOf<ListNotesController>The parsed query, with its defaults.
PageProps<ShowNoteController>The props of the page the controller renders.

ContextOf takes the request, the others take the controller class's type. A view imports the controller with import type, so no server code reaches the browser.

What a controller returns

handle() returns what the response becomes. The base class has a method for each kind:

ReturnResponse
this.view(name, props)A page: server-rendered HTML for a first visit, the page as JSON for a visit in the app.
this.redirect(url), this.redirect().route(name, params), this.back()A redirect.
this.json(data, status)JSON, typed for the action that calls it.
this.island(name, props)A part of a page that loads by itself. GET routes only.
this.closeLayer()Closes the dialog the request came from.
A ResponseGoes out as it is.
A string, an object, nullHTML, JSON, or an empty 204.

The responses page covers each of them, with headers, cookies, flash messages and downloads.

Work after the response

Some work does not need to hold up the response, such as a mail that is sent anyway. AfterResponse runs it once the response is out:

modules/notes/controllers/ShareNoteController.ts
import type { Authenticated } from '@marmeon/auth';
import type { Row } from '@marmeon/database';
import { AfterResponse, Controller, type HttpContext } from '@marmeon/http';
import { NoteMailer } from '../NoteMailer.ts';

export class ShareNoteController extends Controller {
  readonly #afterResponse: AfterResponse;
  readonly #mailer: NoteMailer;

  constructor(afterResponse: AfterResponse, mailer: NoteMailer) {
    super();
    this.#afterResponse = afterResponse;
    this.#mailer = mailer;
  }

  handle(ctx: HttpContext<Authenticated, { note: Row<'notes'> }>) {
    const { note } = ctx.params;
    this.#afterResponse.defer(() => this.#mailer.sendShared(note));
    return this.back();
  }
}

Deferred work runs outside any open database transaction, one task after the other. An error is logged and never reaches the client. A server that stops gracefully waits up to 8 seconds for deferred work. It is no queue all the same: work that takes longer, or a process that crashes, loses it. Work that must not get lost belongs in a job, which the queues page explains. A request that only reads, such as an island's or a live validation, cannot defer work: it throws in development and in tests, and is dropped everywhere else.

Testing

createTestApp sends requests through the router, so a test checks the controller with its request, its middleware and its bindings:

modules/notes/notes.test.ts
import { createTestApp, type TestApp } from '@marmeon/testing';
import { beforeEach, it } from 'vitest';
import application from '../../bootstrap/app.ts';
import { UserFactory } from '#modules/auth';
import type { UpdateNoteController } from './controllers/UpdateNoteController.ts';
import { NoteRepository } from './NoteRepository.ts';

let app: TestApp;
beforeEach(async () => {
  app = await createTestApp(application, { database: 'refresh' });
});

it('refuses an empty title', async () => {
  const ada = await app.factory(UserFactory).create();
  const note = await app.make(NoteRepository).insert({ user_id: ada.id, title: 'Draft', body: '' });
  await app.actingAs(ada).expectingJson().put(`/notes/${note.id}`, { json: { title: ' ', body: '' } }).assertInvalid<UpdateNoteController>({ title: /required/ });
});

The HTTP tests page covers requests and every assertion.