Skip to content

Controllers

Controllers are the presentation layer in KickJS. They handle HTTP requests, delegate to use cases or services, and send responses. A controller is a class decorated with @Controller() that defines route handlers using method decorators.

Defining a Controller

ts
import { Controller, Get, Post, Put, Delete, Patch, Autowired } from '@forinda/kickjs-core'
import { RequestContext } from '@forinda/kickjs-http'

@Controller()
export class TodoController {
  @Autowired() private createTodoUseCase!: CreateTodoUseCase

  @Post('/', { body: createTodoSchema })
  async create(ctx: RequestContext) {
    const result = await this.createTodoUseCase.execute(ctx.body)
    ctx.created(result)
  }

  @Get('/')
  async list(ctx: RequestContext) {
    ctx.json(await this.listTodosUseCase.execute())
  }

  @Get('/:id')
  async getById(ctx: RequestContext) {
    const result = await this.getTodoUseCase.execute(ctx.params.id)
    if (!result) return ctx.notFound('Todo not found')
    ctx.json(result)
  }

  @Delete('/:id')
  async remove(ctx: RequestContext) {
    await this.deleteTodoUseCase.execute(ctx.params.id)
    ctx.noContent()
  }
}

@Controller Decorator

@Controller(path?) registers the class in the DI container as a singleton and marks it as a controller. The optional path serves as metadata only (used by adapters like Swagger for OpenAPI spec generation) — it is not baked into the Express router.

ts
@Controller('/admin')
export class AdminController { ... }

Route Prefix: Module, Not Controller

The route prefix for a controller comes from the module's routes().path, not from @Controller(). This is the single source of truth for where routes are mounted:

ts
// Module defines the mount prefix
class AdminModule implements AppModule {
  register(container: Container) { ... }
  routes() {
    return { path: '/admin', router: buildRoutes(AdminController) }
  }
}

@Controller()  // no path needed — module handles the prefix
export class AdminController {
  @Get('/stats')   // resolves to /api/v1/admin/stats
  async stats(ctx: RequestContext) { ... }
}

WARNING

Do not set the same path on both the module and the controller. The module path is the mount prefix — the controller path is metadata only. Setting both would have previously caused path doubling (e.g. /api/v1/admin/admin/stats).

Route Decorators

Five HTTP method decorators are available, each accepting an optional path and an optional validation schema:

ts
@Get(path?, validation?)
@Post(path?, validation?)
@Put(path?, validation?)
@Delete(path?, validation?)
@Patch(path?, validation?)

The validation argument accepts Zod schemas for body, query, and params:

ts
@Post('/', { body: createTodoSchema })
@Put('/:id', { body: updateTodoSchema })
@Get('/search', { query: searchQuerySchema })

When validation is provided, the framework runs the validate() middleware before the handler. See the Validation page for details.

RequestContext

Every handler receives a RequestContext instance that wraps the raw Express request and response. It is generic over body, params, and query types:

ts
class RequestContext<TBody = any, TParams = any, TQuery = any>

Request data

PropertyTypeDescription
bodyTBodyParsed request body
paramsTParamsRoute parameters (e.g. /:id)
queryTQueryQuery string parameters
headersIncomingHttpHeadersRequest headers
requestIdstring | undefinedValue of x-request-id header
fileanySingle uploaded file (with @FileUpload)
filesany[] | undefinedArray of uploaded files

Query string parsing

The qs() method parses structured query parameters (filters, sort, pagination):

ts
@Get('/')
async list(ctx: RequestContext) {
  const parsed = ctx.qs({
    filterable: ['status', 'priority'],
    sortable: ['createdAt', 'title'],
  })
  // parsed.filters, parsed.sort, parsed.pagination, parsed.search
}

Metadata store

ctx.set(key, value) and ctx.get<T>(key) provide a per-request key-value store. Middleware can attach data (e.g. authenticated user) for handlers to read.

Response helpers

MethodStatusDescription
ctx.json(data, status?)200JSON response
ctx.created(data)201Created resource
ctx.noContent()204No body
ctx.notFound(message?)404Not found error
ctx.badRequest(message)400Bad request error
ctx.html(content, status?)200HTML response
ctx.download(buffer, filename, type?)--File download
ctx.render(template, data?)200Render a template (requires ViewAdapter)

Pagination

ctx.paginate() parses query params, calls your fetcher, and returns a standardized paginated response:

ts
@Get('/')
@ApiQueryParams({ filterable: ['status'], sortable: ['createdAt'] })
async list(ctx: RequestContext) {
  return ctx.paginate(
    async (parsed) => {
      const data = await this.repo.findPaginated(parsed)
      return data // { data: T[], total: number }
    },
    { filterable: ['status'], sortable: ['createdAt'] },
  )
}

Response shape:

json
{
  "data": [...],
  "meta": {
    "page": 1,
    "limit": 10,
    "total": 42,
    "totalPages": 5,
    "hasNext": true,
    "hasPrev": false
  }
}

Template Rendering

Render server-side templates using the configured view engine (requires ViewAdapter):

ts
@Get('/dashboard')
async dashboard(ctx: RequestContext) {
  ctx.render('dashboard', { user: ctx.req.user, title: 'Dashboard' })
}

Server-Sent Events

ctx.sse() starts an SSE stream for real-time updates:

ts
@Get('/events')
async stream(ctx: RequestContext) {
  const sse = ctx.sse()

  const interval = setInterval(() => {
    sse.send({ time: new Date().toISOString() }, 'tick')
  }, 1000)

  sse.onClose(() => clearInterval(interval))
}

SSE helpers:

MethodDescription
sse.send(data, event?, id?)Send an event to the client
sse.comment(text)Send a keep-alive comment
sse.onClose(fn)Register disconnect callback
sse.close()End the stream

Middleware on Controllers

Use @Middleware() at the class or method level. See Middleware for the full guide.

ts
import { Controller, Get, Middleware } from '@forinda/kickjs-core'

@Controller()
@Middleware(authMiddleware)          // runs on all routes in this controller
export class SecureController {

  @Get('/public')
  @Middleware(rateLimitMiddleware)   // runs only on this route
  async publicEndpoint(ctx: RequestContext) {
    ctx.json({ ok: true })
  }
}

Dependency Injection

Use @Autowired() for property injection. Dependencies are resolved lazily from the DI container:

ts
@Controller()
export class TodoController {
  @Autowired() private todoService!: TodoService
  @Autowired() private logger!: AppLogger
}

For constructor injection with interface tokens, use @Inject():

ts
constructor(
  @Inject(TODO_REPOSITORY) private readonly repo: ITodoRepository,
) {}

Released under the MIT License.