Skip to content

Convention-based Routing

Controllers are where you handle HTTP requests. Each controller is a class decorated with @Controller that defines one or more route handler methods. Stratal offers two routing approaches: convention-based routing (covered on this page), where method names map directly to HTTP verbs and paths, and HTTP method decorators, where you use explicit @Get, @Post, etc. decorators for full control over paths and methods.

Decorate a class with @Controller and implement the IController interface:

import { Controller, IController, Route, RouterContext } from 'stratal/router'
import { z } from 'stratal/validation'
@Controller('/api/users')
export class UsersController implements IController {
@Route({
response: z.object({
users: z.array(z.object({ id: z.string(), name: z.string() })),
}),
})
index(ctx: RouterContext) {
return ctx.json({ users: [] })
}
}

The @Controller decorator takes two arguments:

  1. route (required) — the base path for all routes in this controller.
  2. options (optional) — an object with:
OptionTypeDescription
tagsstring[]OpenAPI tags applied to every route in the controller
securitySecurityScheme[]Security schemes applied to every route
hideFromDocsbooleanExclude all routes from the OpenAPI specification
versionstring | string[] | typeof VERSION_NEUTRALAPI version(s) for this controller (see Versioning)
@Controller('/api/admin', {
tags: ['Admin'],
security: ['bearerAuth'],
hideFromDocs: true,
})
export class AdminController implements IController {
// ...
}

Stratal maps method names on the IController interface to HTTP verbs and paths automatically. You only implement the methods you need:

MethodHTTP verbPathDefault status code
index()GET/route200
show()GET/route/:id200
create()POST/route201
update()PUT/route/:id200
patch()PATCH/route/:id200
destroy()DELETE/route/:id200

For a controller at /api/users, defining show() automatically registers GET /api/users/:id. Defining create() registers POST /api/users with a 201 status code.

The @Route decorator configures individual route methods. It accepts a RouteConfig object:

import { Controller, IController, Route, RouterContext } from 'stratal/router'
import { z } from 'stratal/validation'
@Controller('/api/users')
export class UsersController implements IController {
@Route({
body: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
response: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}),
summary: 'Create a new user',
description: 'Registers a new user account',
tags: ['Users'],
})
create(ctx: RouterContext) {
// body is validated before this method runs
// ...
}
@Route({
params: z.object({ id: z.string().uuid() }),
response: z.object({
id: z.string(),
name: z.string(),
}),
})
show(ctx: RouterContext) {
const id = ctx.param('id')
// ...
}
}

The full RouteConfig interface:

PropertyTypeDescription
bodyRouteBodyValidates the request body. Accepts a Zod schema (defaults to application/json) or a { schema, contentType? } object for custom content types.
paramsZodObjectValidates path parameters
queryZodObjectValidates query string parameters
responseRouteResponseSchema for the response body. Accepts a Zod schema (defaults to application/json) or a { schema, description?, contentType? } object.
tagsstring[]OpenAPI tags for this route
securitySecurityScheme[]Security schemes for this route
summarystringShort OpenAPI summary
descriptionstringDetailed OpenAPI description
hideFromDocsbooleanExclude this route from the OpenAPI spec
statusCodenumberOverride the default success status code

Every route handler receives a RouterContext instance as its first argument. It provides methods to read the request and build the response:

// Path parameter
const id = ctx.param('id')
// Query string parameter
const page = ctx.query('page')
// All query parameters as an object
const filters = ctx.query()
// Request header
const auth = ctx.header('Authorization')
// Parsed request body
const data = await ctx.body<CreateUserDto>()
// JSON response
return ctx.json({ id: '1', name: 'Alice' })
// JSON response with custom status
return ctx.json({ id: '1' }, 201)
// Plain text
return ctx.text('OK')
// HTML
return ctx.html('<h1>Hello</h1>')
// Redirect
return ctx.redirect('/api/users/1')
return ctx.redirect('/login', 302)

RouterContext provides three methods for streaming data to the client:

stream(callback, onError?) — Binary / generic streaming

Section titled “stream(callback, onError?) — Binary / generic streaming”
@Get('/download', { response: { schema: z.any(), contentType: 'application/octet-stream' } })
download(ctx: RouterContext) {
return ctx.stream(async (stream) => {
await stream.write(new Uint8Array([72, 101, 108, 108, 111]))
})
}

streamText(callback, onError?) — Text streaming

Section titled “streamText(callback, onError?) — Text streaming”
@Get('/text', { response: { schema: z.any(), contentType: 'text/plain' } })
text(ctx: RouterContext) {
return ctx.streamText(async (stream) => {
await stream.write('hello ')
await stream.write('world')
})
}

streamSSE(callback, onError?) — Server-Sent Events

Section titled “streamSSE(callback, onError?) — Server-Sent Events”
@Get('/events', { response: { schema: z.any(), contentType: 'text/event-stream' } })
events(ctx: RouterContext) {
return ctx.streamSSE(async (stream) => {
await stream.writeSSE({ data: 'hello', event: 'message', id: '1' })
})
}

All three methods accept an optional onError callback that is invoked if the streaming function throws:

return ctx.stream(
() => {
throw new Error('stream error')
},
async (err, stream) => {
await stream.writeln(err.message)
await stream.close()
}
)
// Get and set the request locale
const locale = ctx.getLocale()
ctx.setLocale('fr')
// Access the DI container for the current request
const container = ctx.getContainer()
const service = container.resolve(MyService)

If the six convention-based methods do not fit your needs, you can implement handle() for full control over routing. When handle() is defined, it receives all requests that match the controller’s base path and Stratal skips convention-based routing for that controller.

import { Controller, IController, RouterContext } from 'stratal/router'
@Controller('/api/health')
export class HealthController implements IController {
handle(ctx: RouterContext) {
return ctx.json({ status: 'ok' })
}
}

Guards control whether a request is allowed to proceed. Apply them at the controller level or on individual methods using the @UseGuards decorator:

import { Controller, IController, UseGuards, Route, RouterContext } from 'stratal/router'
import { AuthGuard } from './auth.guard'
import { RolesGuard } from './roles.guard'
@Controller('/api/orders')
@UseGuards(AuthGuard) // applies to all methods
export class OrdersController implements IController {
@Route({ response: orderListSchema })
index(ctx: RouterContext) {
// AuthGuard runs before this method
return ctx.json({ orders: [] })
}
@UseGuards(RolesGuard) // applies only to this method (in addition to controller guards)
@Route({ response: orderSchema })
destroy(ctx: RouterContext) {
// AuthGuard and RolesGuard both run before this method
// ...
}
}

Controller-level guards run first, followed by method-level guards. If any guard returns false, Stratal rejects the request before the handler executes.

For a complete guide on writing guards, see the Guards guide.