Skip to content

Controllers ​

Controllers group related request handlers into a class. They live in app/controller/ and are referenced from routes as [Controller, 'method'] tuples. A controller's job is small: read input, call a service, return data. The framework handles the response envelope and errors.

ts
// app/controller/GreetingController.ts
import type { HttpContext } from '@hazejs/core'
import { inject } from '@hazejs/core'
import { GreetingService } from '~/app/service/GreetingService'

export class GreetingController {
  private greeting = inject(GreetingService)

  index(ctx: HttpContext) {
    return { message: this.greeting.greet(ctx.query('name') ?? 'hazejs') }
  }
}
ts
// route/api.ts
r.get('/hello', [GreetingController, 'index'])

Generate a controller with the CLI:

bash
npx haze make:controller Post            # app/controller/PostController.ts
npx haze make:controller Post --resource # with index/store/show/update/destroy
npx haze make:controller admin/User      # app/controller/admin/UserController.ts

Action signature ​

Every action receives the request's HttpContext as its only argument and may be async. Whatever it returns becomes the response (see Returning values).

To get types for data validated on the route, pass the schemas as the generic parameter:

ts
// app/controller/UserController.ts
import type { HttpContext } from '@hazejs/core'
import type { UserIdParams } from '~/app/validate/user'
import { User } from '~/app/model/User'

type WithId = HttpContext<{ params: typeof UserIdParams }>

export class UserController {
  show(ctx: WithId) {
    return User.findOrFail(ctx.validated.params.id) // id is a number here
  }
}

Dependency injection ​

Controllers are created by the service container, so fields can call inject(). By default a controller is request-scoped: each request gets a new instance, which can safely depend on other request-scoped objects. Override this with static scope if you need to:

ts
// app/controller/HealthController.ts
import { Scope } from '@hazejs/core'

export class HealthController {
  static scope = Scope.Singleton

  index() {
    return { ok: true }
  }
}

The HttpContext ​

HttpContext is a thin wrapper around h3's H3Event. The most useful members:

MemberDescription
ctx.param(name) / ctx.params()Route parameters (strings)
ctx.query(name) / ctx.query()Query string (single value or the whole object)
await ctx.body()Parsed body: JSON, urlencoded or multipart
await ctx.file(name) / await ctx.files(name)Uploaded files (UploadedFile)
ctx.header(name)Request header
ctx.cookie(name) / await ctx.signedCookie(name)Cookies
ctx.validatedOutput of route-level .validate()
ctx.method, ctx.path, ctx.url, ctx.ip, ctx.reqRequest info
ctx.routeThe matched route: method, path pattern (/api/users/:id), name and the meta set with .meta(); undefined when no route matched. Lets route middleware (audit logs, per-route limits) read what the route declared
ctx.status(code), ctx.setHeader(name, value)Response status and headers
ctx.created(data), ctx.noContent(), ctx.redirect(url, status?)Response helpers
ctx.setCookie(), ctx.setSignedCookie(), ctx.deleteCookie()Write cookies
ctx.authAuthentication (await ctx.auth.user())
ctx.can(), ctx.cannot(), ctx.authorize()Authorization checks
ctx.sessionSession (requires the session middleware)
ctx.get(key) / ctx.set(key, value)Request-scoped state shared between middleware and handlers
ctx.make(key)Resolve from the request container
ctx.logger, ctx.requestId, ctx.configRequest logger, request ID, effective config
ctx.onResponse(hook)Hook into the final Response
ctx.eventThe underlying H3Event, for any h3 utility

Reading input is covered in Requests & Validation, response helpers in Responses.

Authentication and authorization ​

ts
// app/controller/PostController.ts
import type { HttpContext } from '@hazejs/core'
import { validate } from '@hazejs/core'
import { Post } from '~/app/model/Post'
import { UpdatePostSchema } from '~/app/validate/post'

export class PostController {
  async show(ctx: HttpContext) {
    const post = await Post.findOrFail(Number(ctx.param('id')))
    return { post, canEdit: await ctx.can('update', post) }
  }

  async update(ctx: HttpContext) {
    const post = await Post.findOrFail(Number(ctx.param('id')))
    await ctx.authorize('update', post) // 401 for guests, 403 when denied
    post.fill(await validate(UpdatePostSchema, await ctx.body()))
    return post.save()
  }

  me(ctx: HttpContext) {
    return ctx.auth.user() // pair with .middleware('auth') to require a login
  }
}

See Authentication and Authorization.

Getting the context elsewhere ​

Inside a request, deeper code (services, repositories) can call useContext() instead of passing ctx around:

ts
// app/service/AuditService.ts
import { useContext } from '@hazejs/core'

export class AuditService {
  actor() {
    return useContext().header('x-user')
  }
}

Controller middleware ​

Declare middleware on the controller with a static middleware array. Entries are middleware references or { use, only, except } objects filtered by action name:

ts
// app/controller/PostController.ts
import type { ControllerMiddleware, MiddlewareRef } from '@hazejs/core'

export class PostController {
  static middleware: Array<MiddlewareRef | ControllerMiddleware> = [
    'throttle:api',
    { use: 'auth', except: ['index', 'show'] },
    { use: ['can:create,Post'], only: ['store'] },
  ]

  // ...
}

Controller middleware runs after global, group and route middleware. haze route:list shows the final list for each route.

Resource controllers ​

A resource controller implements some or all of index, store, show, update and destroy, and is registered with r.resource():

ts
// app/controller/UserController.ts
import type { HttpContext } from '@hazejs/core'
import type { UserIdParams } from '~/app/validate/user'
import { validate } from '@hazejs/core'
import { User } from '~/app/model/User'
import { CreateUserSchema, UpdateUserSchema } from '~/app/validate/user'

type WithId = HttpContext<{ params: typeof UserIdParams }>

export class UserController {
  index(ctx: HttpContext) {
    return User.order('id', 'desc').paginate(Number(ctx.query('page') ?? 1), 20)
  }

  show(ctx: WithId) {
    return User.findOrFail(ctx.validated.params.id)
  }

  async store(ctx: HttpContext) {
    const input = await validate(CreateUserSchema, await ctx.body())
    return ctx.created(await User.create(input))
  }

  async update(ctx: WithId) {
    const user = await User.findOrFail(ctx.validated.params.id)
    await ctx.authorize('update', user)
    user.fill(await validate(UpdateUserSchema, await ctx.body()))
    return user.save()
  }

  async destroy(ctx: WithId) {
    const user = await User.findOrFail(ctx.validated.params.id)
    await ctx.authorize('delete', user)
    await user.delete()
    return ctx.noContent()
  }
}
ts
// route/api.ts
r.resource('/users', UserController).validateMember({ params: UserIdParams })

Only the methods that exist on the class are registered. See Resource routes.

Returning values ​

Return valueResult
Plain object, array, number, boolean, null / undefined, or an object with toJSON() (models, Date)Wrapped in { code, message, data }
ctx.created(data)Same, with status 201
ctx.noContent()Status 204
String, stream, binarySent as-is
Response (or ctx.redirect())Sent as-is
raw(value)value without the envelope
ts
// app/controller/ExportController.ts
import { raw, Storage } from '@hazejs/core'

export class ExportController {
  csv() {
    return new Response('id,name\n1,Ada\n', { headers: { 'content-type': 'text/csv' } })
  }

  report() {
    return Storage.download('reports/2026.csv', 'report.csv')
  }

  legacy() {
    return raw({ ok: true }) // { "ok": true }
  }
}

To fail, throw an exception such as new NotFoundException(); it becomes an error response in the same format. See Responses and Errors & Logging.

Released under the MIT License.