Controllers

Organise your route handlers into controller files using plain functions, classes, or decorator-based classes.

Introduction

Controllers are where your request-handling logic lives. tekir gives you three ways to write controllers, pick whichever fits your style:

  • Functional controllers: export plain functions from a file, wire them in start/routes.ts with router.get() etc.
  • Resource controllers: export a class with RESTful method names, register with router.resource() to auto-generate all CRUD routes.
  • Decorator controllers: use @Controller, @Get, @Post from @tekir/http-decorators for a more declarative approach.

All three receive the same HttpContext object and produce the same result. The difference is purely organisational.

Functional Controllers

The simplest approach: export named functions from a file, then import and wire them to routes manually. No classes, no decorators, no magic.

Basic Example

core/controllers/user_controller.ts
import type { HttpContext } from '@tekir/core'
import { db } from '#services'

export async function index({ response }: HttpContext) {
  const users = await db.query('SELECT * FROM users')
  return response.ok(users)
}

export async function show({ params, response }: HttpContext) {
  const user = await db.queryOne('SELECT * FROM users WHERE id = ?', [params.id])
  if (!user) return response.notFound({ message: 'User not found' })
  return response.ok(user)
}

export async function store({ body, response }: HttpContext) {
  const user = await db.run('INSERT INTO users (name, email) VALUES (?, ?)', [body.name, body.email])
  return response.created(user)
}

export async function update({ params, body, response }: HttpContext) {
  await db.run('UPDATE users SET name = ? WHERE id = ?', [body.name, params.id])
  return response.ok({ id: params.id, ...body })
}

export async function destroy({ params, response }: HttpContext) {
  await db.run('DELETE FROM users WHERE id = ?', [params.id])
  return response.noContent()
}

Then wire them up in your routes file:

start/routes.ts
import type { TekirApp } from '@tekir/core'
import * as UserController from '~/controllers/user_controller'
import * as PostController from '~/controllers/post_controller'

export default function ({ router }: TekirApp) {
  // Each route points to a specific function
  router.get('/api/users', UserController.index)
  router.get('/api/users/:id', UserController.show)
  router.post('/api/users', UserController.store)
  router.put('/api/users/:id', UserController.update)
  router.delete('/api/users/:id', UserController.destroy)

  router.get('/api/posts', PostController.index)
  router.get('/api/posts/:id', PostController.show)
  router.post('/api/posts', PostController.store)
}

Grouping Routes

Use router.group() to share a prefix and middleware across related routes:

start/routes.ts
import type { TekirApp } from '@tekir/core'
import * as UserController from '~/controllers/user_controller'
import * as PostController from '~/controllers/post_controller'
import { authenticate, silentAuth, guest } from '@tekir/auth'

export default function ({ router }: TekirApp) {
  // Group under /api/users with shared prefix
  router.group(() => {
    router.get('/', UserController.index)
    router.get('/:id', UserController.show)
    router.post('/', UserController.store)
    router.put('/:id', UserController.update)
    router.delete('/:id', UserController.destroy)
  }).prefix('/api/users')

  // Groups can have middleware + name prefix
  router.group(() => {
    router.get('/', PostController.index)
    router.get('/:id', PostController.show)
    router.post('/', PostController.store).use(authenticate)
  }).prefix('/api/posts').as('posts')
}

Route Middleware

Chain .use() on any route to add per-route middleware, or apply it at the group level to cover all routes in the group:

start/routes.ts
import type { TekirApp } from '@tekir/core'
import * as UserController from '~/controllers/user_controller'
import { authenticate, silentAuth, guest } from '@tekir/auth'
import { validate } from '@tekir/validator'
import { z } from 'zod'

const createUserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email()
})

export default function ({ router }: TekirApp) {
  // Per-route middleware with .use()
  router.get('/api/users', UserController.index)
  router.post('/api/users', UserController.store).use([authenticate, validate({ body: createUserSchema })])

  // Group-level middleware applies to all routes in the group
  router.group(() => {
    router.put('/api/users/:id', UserController.update)
    router.delete('/api/users/:id', UserController.destroy)
  }).use(authenticate)
}

Resource Controllers

A resource controller is a class with standard RESTful method names: index, show, store, update, destroy, create, and edit. Register it with router.resource() and tekir generates all 7 routes automatically.

core/controllers/post_controller.ts
import type { HttpContext } from '@tekir/core'
import { db } from '#services'

export default class PostController {
  async index({ response }: HttpContext) {
    return response.ok(await db.query('SELECT * FROM posts'))
  }

  async show({ params, response }: HttpContext) {
    const post = await db.queryOne('SELECT * FROM posts WHERE id = ?', [params.id])
    if (!post) return response.notFound({ message: 'Post not found' })
    return response.ok(post)
  }

  async store({ body, response }: HttpContext) {
    const post = await db.run('INSERT INTO posts (title, body) VALUES (?, ?)', [body.title, body.body])
    return response.created(post)
  }

  async update({ params, body, response }: HttpContext) {
    await db.run('UPDATE posts SET title = ? WHERE id = ?', [body.title, params.id])
    return response.ok({ id: params.id, ...body })
  }

  async destroy({ params, response }: HttpContext) {
    await db.run('DELETE FROM posts WHERE id = ?', [params.id])
    return response.noContent()
  }
}
start/routes.ts
import type { TekirApp } from '@tekir/core'
import PostController from '~/controllers/post_controller'

export default function ({ router }: TekirApp) {
  // Generates all 7 RESTful routes automatically:
  // GET    /posts          → index
  // GET    /posts/create   → create
  // POST   /posts          → store
  // GET    /posts/:id      → show
  // GET    /posts/:id/edit → edit
  // PUT    /posts/:id      → update
  // DELETE /posts/:id      → destroy
  router.resource('posts', PostController)
}

Resource Options

You can limit which routes are generated and add per-action middleware:

// Only include specific actions
router.resource('posts', PostController).only(['index', 'show', 'store'])

// Exclude specific actions
router.resource('posts', PostController).except(['create', 'edit'])

// API-only (excludes create + edit: the form pages)
router.resource('posts', PostController).apiOnly()

// Per-action middleware
router.resource('posts', PostController).use({
  store: [authenticate],
  update: [authenticate],
  destroy: [authenticate]
})

Decorator Controllers

If you prefer a more declarative style, install @tekir/http-decorators and use decorators to describe your routes. Decorators are an optional package, not part of the core.

@Controller

The @Controller(prefix) decorator marks a class as a controller and sets the URL prefix for all its route methods.

core/controllers/user_controller.ts
import { Controller, Get, Post, Put, Delete } from '@tekir/http-decorators'
import type { HttpContext } from '@tekir/core'
import { db } from '#services'

@Controller('/api/users')
export class UserController {
  @Get('/')
  async index({ response }: HttpContext) {
    return response.ok(await db.query('SELECT * FROM users'))
  }

  @Get('/:id')
  async show({ params, response }: HttpContext) {
    const user = await db.queryOne('SELECT * FROM users WHERE id = ?', [params.id])
    if (!user) return response.notFound({ message: 'User not found' })
    return response.ok(user)
  }

  @Post('/')
  async store({ body, response }: HttpContext) {
    await db.run('INSERT INTO users (name, email) VALUES (?, ?)', [body.name, body.email])
    const user = await db.queryOne('SELECT * FROM users ORDER BY id DESC LIMIT 1')
    return response.created(user)
  }

  @Put('/:id')
  async update({ params, body, response }: HttpContext) {
    await db.run('UPDATE users SET name = ? WHERE id = ?', [body.name, params.id])
    return response.ok(await db.queryOne('SELECT * FROM users WHERE id = ?', [params.id]))
  }

  @Delete('/:id')
  async destroy({ params, response }: HttpContext) {
    await db.run('DELETE FROM users WHERE id = ?', [params.id])
    return response.noContent()
  }
}

The prefix can include or omit the leading slash; tekir normalises it either way. Pass an empty string or call @Controller() with no arguments to register routes at their exact paths. Pass an array for multiple prefixes.

// Single prefix, every method is under /api/users
@Controller('/api/users')
export class UserController { ... }

// Prefix without leading slash: tekir adds it automatically
@Controller('api/users')
export class UserController { ... }

// Empty prefix: routes registered at exactly their @Get/@Post paths
@Controller()
export class HealthController {
  @Get('/health')
  check() { return { status: 'ok' } }
}

// Multiple prefixes: same controller under multiple paths
@Controller(['/api/v1/users', '/api/v2/users'])
export class UserController {
  @Get('/')
  index({ response }: HttpContext) { return response.ok([]) }
}

Method Decorators

Each HTTP verb has a matching decorator. The first argument is the path relative to the controller prefix. The second argument is an optional options object supporting name and where.

import { Controller, Get, Post, Put, Delete, Patch, Head, Options } from '@tekir/http-decorators'
import type { HttpContext } from '@tekir/core'

@Controller('/api/articles')
export class ArticleController {
  @Get('/')
  index({ response }: HttpContext) { return response.ok([]) }

  @Get('/:id')
  show({ params, response }: HttpContext) { return response.ok({ id: params.id }) }

  @Post('/')
  store({ body, response }: HttpContext) { return response.created(body) }

  @Put('/:id')
  update({ params, body, response }: HttpContext) { return response.ok({ id: params.id, ...body }) }

  @Patch('/:id')
  patch({ params, body, response }: HttpContext) { return response.ok({ id: params.id, ...body }) }

  @Delete('/:id')
  destroy({ response }: HttpContext) { return response.noContent() }

  @Head('/')
  head({ response }: HttpContext) { return response.noContent() }

  @Options('/')
  options({ response }: HttpContext) {
    return response.header('Allow', 'GET, POST, PUT, PATCH, DELETE').noContent()
  }
}

@Middleware

Apply middleware to individual methods with the @Middleware decorator. It accepts an array of middleware functions, applied in order before the handler.

core/controllers/post_controller.ts
import { Controller, Get, Post, Middleware } from '@tekir/http-decorators'
import { validate } from '@tekir/validator'
import type { HttpContext } from '@tekir/core'
import { authenticate, silentAuth, guest } from '@tekir/auth'
import { z } from 'zod'

const createSchema = z.object({
  title: z.string().min(3),
  body:  z.string().min(10)
})

@Controller('/api/posts')
export class PostController {
  @Get('/')
  index({ response }: HttpContext) { return response.ok([]) }

  @Get('/mine')
  @Middleware([authenticate])
  myPosts({ response }: HttpContext) { return response.ok([]) }

  @Post('/')
  @Middleware([authenticate, validate({ body: createSchema })])
  async store({ body, response }: HttpContext) {
    return response.created(body)
  }
}

@Cache

Cache full HTTP responses on a controller method by adding the @Cache decorator. It is a thin wrapper around the cache() middleware from @tekir/cache, so every option there (ttl, vary, key, skip) is available here too. CacheProvider must be registered for the default store to be picked up automatically.

core/controllers/post_controller.ts
import { Controller, Get, Cache } from '@tekir/http-decorators'
import type { HttpContext } from '@tekir/core'

@Controller('/api/posts')
export class PostController {
  // Default 60-second TTL, store inferred from CacheProvider
  @Get('/')
  @Cache({ ttl: 60 })
  async list() {
    return Post.all()
  }

  // Per-post entries via a custom key
  @Get('/:id')
  @Cache({ ttl: 300, key: (ctx) => `post:${ctx.params.id}` })
  async show({ params }: HttpContext) {
    return Post.find(params.id)
  }

  // Skip caching for authenticated admins
  @Get('/feed')
  @Cache({ ttl: 30, skip: (ctx) => ctx.auth?.user?.role === 'admin' })
  async feed() {
    return Post.published()
  }
}

Parameter Validation

Pass a where object in the route decorator options to validate and optionally cast URL parameters. If the parameter does not satisfy the regex, tekir returns a 404.

import { Controller, Get, Delete } from '@tekir/http-decorators'
import type { HttpContext } from '@tekir/core'

@Controller('/api/users')
export class UserController {
  // :id must be a number: auto-cast before handler runs
  @Get('/:id', {
    where: {
      id: { match: /^\d+$/, cast: Number }
    }
  })
  async show({ params, response }: HttpContext) {
    // params.id is now a number
    return response.ok({ id: params.id })
  }

  // UUID constraint: validation only, no cast
  @Delete('/:token', {
    where: {
      token: {
        match: /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
      }
    }
  })
  async revokeToken({ params, response }: HttpContext) {
    return response.noContent()
  }
}

Registering Decorator Controllers

Decorator controllers must be passed to router.register():

start/routes.ts
import type { TekirApp } from '@tekir/core'
import { UserController } from '~/controllers/user_controller'
import { PostController } from '~/controllers/post_controller'

export default function({ router }: TekirApp) {
  router.register(UserController, PostController)
}

HttpContext

Every handler (functional, resource, or decorator) receives the same HttpContext object. Import the type from @tekir/core.

import type { HttpContext } from '@tekir/core'

// The full HttpContext shape:
interface HttpContext {
  request: TekirRequest          // rich request object (see Request docs)
  response: TekirResponse        // rich response builder (see Response docs)
  params: Record<string, string>       // URL route parameters
  query: Record<string, string | string[]>  // parsed query string
  headers: Record<string, string>      // request headers as plain object
  cookies: Record<string, string>       // parsed cookies
  body: any                            // parsed request body
  route: { pattern: string; name?: string }  // matched route info
  redirect: (url: string, status?: number) => Response  // quick redirect helper
  status: (code: number, body?: any) => Response        // quick status helper
  store: Record<string, any>           // per-request key-value store
  [key: string]: any                   // middleware can attach anything
}

Generating Controllers

The tekir CLI can scaffold a controller with all CRUD methods pre-filled:

# Generate a controller with CRUD methods
tekir make:controller Post

# Created: core/controllers/post_controller.ts
core/controllers/post_controller.ts
import type { HttpContext } from '@tekir/core'

export async function index({ response }: HttpContext) {
  return response.ok([])
}

export async function show({ params, response }: HttpContext) {
  return response.ok({ id: params.id })
}

export async function store({ body, response }: HttpContext) {
  return response.created(body)
}

export async function update({ params, body, response }: HttpContext) {
  return response.ok({ id: params.id, ...body })
}

export async function destroy({ response }: HttpContext) {
  return response.noContent()
}

Add --decorator to generate a decorator-based controller instead:

# Generate a decorator-based controller
tekir make:controller Post --decorator

# Created: core/controllers/post_controller.ts
core/controllers/post_controller.ts
import { Controller, Get, Post, Put, Delete } from '@tekir/http-decorators'
import type { HttpContext } from '@tekir/core'

@Controller('/post')
export class PostController {
  @Get('/')
  index({ response }: HttpContext) {
    return response.ok([])
  }

  @Get('/:id')
  show({ params, response }: HttpContext) {
    return response.ok({ id: params.id })
  }

  @Post('/')
  store({ body, response }: HttpContext) {
    return response.created(body)
  }

  @Put('/:id')
  update({ params, body, response }: HttpContext) {
    return response.ok({ id: params.id, ...body })
  }

  @Delete('/:id')
  destroy({ response }: HttpContext) {
    return response.noContent()
  }
}