Controllers
Controllers handle HTTP requests using TSOA decorators for type-safe routing.
Controller Structure
Controllers live in src/controllers/v1/ and are organized by domain.
import { Controller, Get, Post, Route, Security, Tags, Body } from "tsoa"
import { Inject } from "typescript-ioc"
@Route("v1/books")
@Tags("Books")
@Security({ bearer: [], apiKey: [] })
export class BookController extends Controller {
constructor(@Inject private bookService: BookService) {
super()
}
@Get("/")
public async getBooks(): Promise<Book[]> {
return this.bookService.findAll()
}
@Post("/")
public async createBook(@Body() body: CreateBookRequest): Promise<Book> {
return this.bookService.create(body)
}
}Key Decorators
Route & Tags
@Route("v1/resource") // Base path
@Tags("Resource") // OpenAPI groupingHTTP Methods
@Get("/")
@Get("/{id}")
@Post("/")
@Put("/{id}")
@Patch("/{id}")
@Delete("/{id}")Parameters
@Path() id: string // URL parameter
@Query() limit?: number // Query string
@Body() body: CreateRequest // Request body
@Header() token: string // HTTP headerSecurity
@Security({ bearer: [], apiKey: [] }) // JWT or API key
@Security("bearer") // JWT onlyRoute Guards
Apply middleware for additional security:
@Route("v1/accounts/{accountId}/members")
@Middlewares(new AccountRouteGuard().getHandler())
export class MemberController extends Controller {
// Only accessible if user has account access
}Available Guards
AccountRouteGuard- Validates account membershipAccessGuard- Permission-based (requiresRole)SubscriptionGuard- Tier-based restrictions
Regenerating Routes
After modifying controllers, regenerate TSOA routes:
pnpm generate:openapiThis updates:
src/routes.ts- Express routessrc/swagger.json- OpenAPI spec
Neither of these files are committed, no generated files should be commited to source for ease of pull request reviews.
Best Practices
- One controller per resource
- Use services for business logic - Controllers should be thin
- Validate with types - TSOA validates based on TypeScript types
- Document with JSDoc - Adds descriptions to OpenAPI
- Apply guards at class level when possible
CRUD Patterns
Standard patterns for common controller operations.
Type Helpers
import { New, Properties, Update } from "../../../data/entities/new"
import { Page } from "../../../data/models/page"
import { QueryInfo } from "../../../data/typeormRepository"| Type | Purpose |
|---|---|
New<Entity> | Entity without id (for create) |
Update<Entity> | Partial entity without id (for update) |
Properties<Entity> | Entity as plain object (response type) |
Page<T> | Paginated wrapper for lists |
QueryInfo<keyof Entity> | Query options (search, filters, ordering) |
Find (Paginated Search)
POST /find with optional query body and pagination:
@Post("find")
@OperationId("findBooks")
@SuccessResponse(200, "Books")
@Response<ApiError>(400, "Bad request", undefined)
public async find(
@Body() body?: QueryInfo<keyof Book>,
@Query() page: number = 1,
@Query() limit: number = 20,
): Promise<Page<Properties<Book>>> {
return (await this.bookRepository.find({
page,
limit,
...body,
})) as Page<Properties<Book>>;
}List (Non-Paginated)
POST /list for getting arrays without pagination metadata:
@Post("/list")
@OperationId("listBooks")
@SuccessResponse(200, "Books")
public async list(
@Body() body?: QueryInfo<keyof Book>,
@Query() limit: number = 20,
): Promise<Properties<Book>[]> {
return await this.bookRepository.findAll(body ?? {}, undefined, limit);
}Get by ID
GET /{id} with null check throwing ApiError.notFound():
@Get("{bookId}")
@OperationId("getBook")
@SuccessResponse(200, "Book")
@Response<ApiError>(404, "Book not found", undefined)
public async get(@Path() bookId: string): Promise<Properties<Book>> {
const book = await this.bookRepository.findOne(bookId, {
with: ["authors", "genres", "editions"],
});
if (!book) {
throw ApiError.notFound("Book not found");
}
return book as Properties<Book>;
}Create
POST / with New<Entity> body, returns 201:
@Post()
@OperationId("createBook")
@SuccessResponse(201, "Book")
@Response<ApiError>(400, "Bad request", undefined)
public async post(@Body() book: New<Book>): Promise<Properties<Book>> {
this.setStatus(201);
return (await this.bookRepository.upsert(book as Book)) as Properties<Book>;
}Update
PATCH /{id} with Update<Entity> body, merges ID into body:
@Patch("{bookId}")
@OperationId("updateBook")
@SuccessResponse(200, "Book")
@Response<ApiError>(400, "Bad request", undefined)
@Response<ApiError>(404, "Book not found", undefined)
public async patch(
@Path() bookId: string,
@Body() book: Update<Book>,
): Promise<Properties<Book>> {
return (await this.bookRepository.upsert({
...book,
id: bookId,
} as Book)) as Properties<Book>;
}Delete
DELETE /{id} returning void with 204:
@Delete("{bookId}")
@OperationId("deleteBook")
@SuccessResponse(204, "No content")
@Response<ApiError>(404, "Book not found", undefined)
public async delete(@Path() bookId: string): Promise<void> {
await this.bookRepository.delete(bookId);
this.setStatus(204);
}Responses & Errors
Always document expected responses:
@Response<ApiError>(400, "Bad request", undefined)
@Response<ApiError>(401, "Unauthorized", undefined)
@Response<ApiError>(404, "Not found", undefined)
@Response<ApiError>(500, "Internal server error", undefined)Throw ApiError for error responses:
import { ApiError } from "../../../apiError"
throw ApiError.notFound("Book not found")
throw ApiError.badRequest("Invalid ISBN format")
throw ApiError.unauthorized("Access denied")