Skip to content

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.

typescript
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

typescript
@Route("v1/resource")  // Base path
@Tags("Resource")      // OpenAPI grouping

HTTP Methods

typescript
@Get("/")
@Get("/{id}")
@Post("/")
@Put("/{id}")
@Patch("/{id}")
@Delete("/{id}")

Parameters

typescript
@Path() id: string           // URL parameter
@Query() limit?: number      // Query string
@Body() body: CreateRequest  // Request body
@Header() token: string      // HTTP header

Security

typescript
@Security({ bearer: [], apiKey: [] })  // JWT or API key
@Security("bearer")                     // JWT only

Route Guards

Apply middleware for additional security:

typescript
@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 membership
  • AccessGuard - Permission-based (requires Role)
  • SubscriptionGuard - Tier-based restrictions

Regenerating Routes

After modifying controllers, regenerate TSOA routes:

bash
pnpm generate:openapi

This updates:

  • src/routes.ts - Express routes
  • src/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

  1. One controller per resource
  2. Use services for business logic - Controllers should be thin
  3. Validate with types - TSOA validates based on TypeScript types
  4. Document with JSDoc - Adds descriptions to OpenAPI
  5. Apply guards at class level when possible

CRUD Patterns

Standard patterns for common controller operations.

Type Helpers

typescript
import { New, Properties, Update } from "../../../data/entities/new"
import { Page } from "../../../data/models/page"
import { QueryInfo } from "../../../data/typeormRepository"
TypePurpose
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)

POST /find with optional query body and pagination:

typescript
@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:

typescript
@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():

typescript
@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:

typescript
@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:

typescript
@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:

typescript
@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:

typescript
@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:

typescript
import { ApiError } from "../../../apiError"

throw ApiError.notFound("Book not found")
throw ApiError.badRequest("Invalid ISBN format")
throw ApiError.unauthorized("Access denied")

Built with VitePress