24 KiB
tsoa Migration Guide
This guide documents the migration from swagger-jsdoc to tsoa for API documentation and route generation in the Flyer Crawler project.
Table of Contents
- Overview
- Architecture
- Creating a New Controller
- BaseController Pattern
- Authentication
- Request Handling
- Response Formatting
- DTOs and Type Definitions
- File Uploads
- Rate Limiting
- Error Handling
- Testing Controllers
- Build and Development
- Troubleshooting
- Migration Lessons Learned
Overview
What Changed
| Before (swagger-jsdoc) | After (tsoa) |
|---|---|
JSDoc @openapi comments in route files |
TypeScript decorators on controller classes |
| Manual Express route registration | tsoa generates routes automatically |
| Separate Zod validation middleware | tsoa validates from TypeScript types |
| OpenAPI spec from comments | OpenAPI spec from decorators and types |
Why tsoa?
- Type Safety: OpenAPI spec is generated from TypeScript types, eliminating drift
- Active Maintenance: tsoa is actively maintained (vs. unmaintained swagger-jsdoc)
- Reduced Duplication: No more parallel JSDoc + TypeScript definitions
- Route Generation: tsoa generates Express routes, reducing boilerplate
Key Files
| File | Purpose |
|---|---|
tsoa.json |
tsoa configuration |
src/controllers/base.controller.ts |
Base controller with response utilities |
src/controllers/types.ts |
Shared controller type definitions |
src/controllers/*.controller.ts |
Domain controllers |
src/dtos/common.dto.ts |
Shared DTO definitions |
src/middleware/tsoaAuthentication.ts |
JWT authentication handler |
src/routes/tsoa-generated.ts |
Generated Express routes |
src/config/tsoa-spec.json |
Generated OpenAPI 3.0 spec |
Architecture
Request Flow
HTTP Request
|
v
Express Middleware (logging, CORS, body parsing)
|
v
tsoa-generated routes (src/routes/tsoa-generated.ts)
|
v
tsoaAuthentication (if @Security decorator present)
|
v
Controller Method
|
v
Service Layer
|
v
Repository Layer
|
v
Database
Controller Structure
src/controllers/
base.controller.ts # Base class with response helpers
types.ts # Shared type definitions
health.controller.ts # Health check endpoints
auth.controller.ts # Authentication endpoints
user.controller.ts # User management endpoints
...
Creating a New Controller
Step 1: Create the Controller File
// src/controllers/example.controller.ts
import {
Route,
Tags,
Get,
Post,
Put,
Delete,
Body,
Path,
Query,
Request,
Security,
SuccessResponse,
Response,
Middlewares,
} from 'tsoa';
import type { Request as ExpressRequest } from 'express';
import {
BaseController,
SuccessResponse as SuccessResponseType,
ErrorResponse,
PaginatedResponse,
} from './base.controller';
import type { UserProfile } from '../types';
// ============================================================================
// REQUEST/RESPONSE TYPES
// ============================================================================
interface CreateExampleRequest {
/**
* Name of the example item.
* @minLength 1
* @maxLength 255
* @example "My Example"
*/
name: string;
/**
* Optional description.
* @example "This is an example item"
*/
description?: string;
}
interface ExampleResponse {
id: number;
name: string;
description?: string;
created_at: string;
}
// ============================================================================
// CONTROLLER
// ============================================================================
/**
* Example controller demonstrating tsoa patterns.
*/
@Route('examples')
@Tags('Examples')
export class ExampleController extends BaseController {
/**
* List all examples with pagination.
* @summary List examples
* @param page Page number (1-indexed)
* @param limit Items per page (max 100)
* @returns Paginated list of examples
*/
@Get()
@SuccessResponse(200, 'Examples retrieved')
public async listExamples(
@Query() page?: number,
@Query() limit?: number,
): Promise<PaginatedResponse<ExampleResponse>> {
const { page: p, limit: l } = this.normalizePagination(page, limit);
// Call service layer
const { items, total } = await exampleService.listExamples(p, l);
return this.paginated(items, { page: p, limit: l, total });
}
/**
* Get a single example by ID.
* @summary Get example
* @param id Example ID
* @returns The example
*/
@Get('{id}')
@SuccessResponse(200, 'Example retrieved')
@Response<ErrorResponse>(404, 'Example not found')
public async getExample(@Path() id: number): Promise<SuccessResponseType<ExampleResponse>> {
const example = await exampleService.getExampleById(id);
return this.success(example);
}
/**
* Create a new example.
* Requires authentication.
* @summary Create example
* @param requestBody Example data
* @param request Express request
* @returns Created example
*/
@Post()
@Security('bearerAuth')
@SuccessResponse(201, 'Example created')
@Response<ErrorResponse>(400, 'Validation error')
@Response<ErrorResponse>(401, 'Not authenticated')
public async createExample(
@Body() requestBody: CreateExampleRequest,
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<ExampleResponse>> {
const user = request.user as UserProfile;
const example = await exampleService.createExample(requestBody, user.user.user_id);
return this.created(example);
}
/**
* Delete an example.
* Requires authentication.
* @summary Delete example
* @param id Example ID
* @param request Express request
*/
@Delete('{id}')
@Security('bearerAuth')
@SuccessResponse(204, 'Example deleted')
@Response<ErrorResponse>(401, 'Not authenticated')
@Response<ErrorResponse>(404, 'Example not found')
public async deleteExample(
@Path() id: number,
@Request() request: ExpressRequest,
): Promise<void> {
const user = request.user as UserProfile;
await exampleService.deleteExample(id, user.user.user_id);
return this.noContent();
}
}
Step 2: Regenerate Routes
After creating or modifying a controller:
# Generate OpenAPI spec and routes
npm run tsoa:spec && npm run tsoa:routes
# Or use the combined command
npm run prebuild
Step 3: Add Tests
Create a test file at src/controllers/__tests__/example.controller.test.ts.
BaseController Pattern
All controllers extend BaseController which provides:
Response Helpers
// Success response (200)
return this.success(data);
// Created response (201)
return this.created(data);
// Paginated response (200 with pagination metadata)
return this.paginated(items, { page, limit, total });
// Message-only response
return this.message('Operation completed successfully');
// No content response (204)
return this.noContent();
// Error response (prefer throwing errors)
this.setStatus(400);
return this.error('BAD_REQUEST', 'Invalid input', details);
Pagination Helpers
// Normalize pagination with defaults and bounds
const { page, limit } = this.normalizePagination(queryPage, queryLimit);
// page defaults to 1, limit defaults to 20, max 100
// Calculate pagination metadata
const meta = this.calculatePagination({ page, limit, total });
// Returns: { page, limit, total, totalPages, hasNextPage, hasPrevPage }
Error Codes
// Access standard error codes
this.ErrorCode.VALIDATION_ERROR; // 'VALIDATION_ERROR'
this.ErrorCode.NOT_FOUND; // 'NOT_FOUND'
this.ErrorCode.UNAUTHORIZED; // 'UNAUTHORIZED'
this.ErrorCode.FORBIDDEN; // 'FORBIDDEN'
this.ErrorCode.CONFLICT; // 'CONFLICT'
this.ErrorCode.BAD_REQUEST; // 'BAD_REQUEST'
this.ErrorCode.INTERNAL_ERROR; // 'INTERNAL_ERROR'
Authentication
Using @Security Decorator
import { Security, Request } from 'tsoa';
import type { Request as ExpressRequest } from 'express';
import type { UserProfile } from '../types';
@Get('profile')
@Security('bearerAuth')
public async getProfile(
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<UserProfileDto>> {
// request.user is populated by tsoaAuthentication.ts
const user = request.user as UserProfile;
return this.success(toUserProfileDto(user));
}
Requiring Admin Role
import { requireAdminRole } from '../middleware/tsoaAuthentication';
@Delete('users/{id}')
@Security('bearerAuth')
public async deleteUser(
@Path() id: string,
@Request() request: ExpressRequest,
): Promise<void> {
const user = request.user as UserProfile;
requireAdminRole(user); // Throws 403 if not admin
await userService.deleteUser(id);
return this.noContent();
}
How Authentication Works
- tsoa sees
@Security('bearerAuth')decorator - tsoa calls
expressAuthentication()fromsrc/middleware/tsoaAuthentication.ts - The function extracts and validates the JWT token
- User profile is fetched from database and attached to
request.user - If authentication fails, an
AuthenticationErroris thrown
Request Handling
Path Parameters
@Get('{id}')
public async getItem(@Path() id: number): Promise<...> { ... }
// Multiple path params
@Get('{userId}/items/{itemId}')
public async getUserItem(
@Path() userId: string,
@Path() itemId: number,
): Promise<...> { ... }
Query Parameters
@Get()
public async listItems(
@Query() page?: number,
@Query() limit?: number,
@Query() status?: 'active' | 'inactive',
@Query() search?: string,
): Promise<...> { ... }
Request Body
interface CreateItemRequest {
name: string;
description?: string;
}
@Post()
public async createItem(
@Body() requestBody: CreateItemRequest,
): Promise<...> { ... }
Headers
@Get()
public async getWithHeader(
@Header('X-Custom-Header') customHeader?: string,
): Promise<...> { ... }
Accessing Express Request/Response
import type { Request as ExpressRequest } from 'express';
@Post()
public async handleRequest(
@Request() request: ExpressRequest,
): Promise<...> {
const reqLog = request.log; // Pino logger
const cookies = request.cookies; // Cookies
const ip = request.ip; // Client IP
const res = request.res!; // Express response
// Set cookie
res.cookie('name', 'value', { httpOnly: true });
// ...
}
Response Formatting
Standard Success Response
// Returns: { "success": true, "data": {...} }
return this.success({ id: 1, name: 'Item' });
Created Response (201)
// Sets status 201 and returns success response
return this.created(newItem);
Paginated Response
// Returns: { "success": true, "data": [...], "meta": { "pagination": {...} } }
return this.paginated(items, { page: 1, limit: 20, total: 100 });
No Content (204)
// Sets status 204 with no body
return this.noContent();
Error Response
Prefer throwing errors rather than returning error responses:
import { NotFoundError, ValidationError, ForbiddenError } from './base.controller';
// Throw for not found
throw new NotFoundError('Item', id);
// Throw for validation errors
throw new ValidationError([], 'Invalid input');
// Throw for forbidden
throw new ForbiddenError('Admin access required');
If you need manual error response:
this.setStatus(400);
return this.error(this.ErrorCode.BAD_REQUEST, 'Invalid operation', { reason: '...' });
DTOs and Type Definitions
Why DTOs?
tsoa generates OpenAPI specs from TypeScript types. Some types cannot be serialized:
- Tuples:
[number, number](e.g., GeoJSON coordinates) - Complex generics
- Circular references
DTOs flatten these into tsoa-compatible structures.
Shared DTOs
Define shared DTOs in src/dtos/common.dto.ts:
// src/dtos/common.dto.ts
/**
* Address with flattened coordinates.
* GeoJSONPoint uses coordinates: [number, number] which tsoa cannot handle.
*/
export interface AddressDto {
address_id: number;
address_line_1: string;
city: string;
province_state: string;
postal_code: string;
country: string;
// Flattened from GeoJSONPoint.coordinates
latitude?: number | null;
longitude?: number | null;
created_at: string;
updated_at: string;
}
export interface UserDto {
user_id: string;
email: string;
created_at: string;
updated_at: string;
}
Conversion Functions
Create conversion functions to map domain types to DTOs:
// In controller file
function toAddressDto(address: Address): AddressDto {
return {
address_id: address.address_id,
address_line_1: address.address_line_1,
city: address.city,
province_state: address.province_state,
postal_code: address.postal_code,
country: address.country,
latitude: address.location?.coordinates[1] ?? null,
longitude: address.location?.coordinates[0] ?? null,
created_at: address.created_at,
updated_at: address.updated_at,
};
}
Important: Avoid Duplicate Type Names
tsoa requires unique type names across all controllers. If two controllers define an interface with the same name, tsoa will fail.
Solution: Define shared types in src/dtos/common.dto.ts and import them.
File Uploads
tsoa supports file uploads via @UploadedFile and @FormField decorators:
import { Post, Route, UploadedFile, FormField, Security } from 'tsoa';
import multer from 'multer';
// Configure multer
const upload = multer({
storage: multer.diskStorage({
destination: '/tmp/uploads',
filename: (req, file, cb) => {
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}-${file.originalname}`);
},
}),
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
});
@Route('flyers')
@Tags('Flyers')
export class FlyerController extends BaseController {
/**
* Upload a flyer image.
* @summary Upload flyer
* @param file The flyer image file
* @param storeId Associated store ID
* @param request Express request
*/
@Post('upload')
@Security('bearerAuth')
@Middlewares(upload.single('file'))
@SuccessResponse(201, 'Flyer uploaded')
public async uploadFlyer(
@UploadedFile() file: Express.Multer.File,
@FormField() storeId?: number,
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<FlyerDto>> {
const user = request.user as UserProfile;
const flyer = await flyerService.processUpload(file, storeId, user.user.user_id);
return this.created(flyer);
}
}
Rate Limiting
Apply rate limiters using the @Middlewares decorator:
import { Middlewares } from 'tsoa';
import { loginLimiter, registerLimiter } from '../config/rateLimiters';
@Post('login')
@Middlewares(loginLimiter)
@SuccessResponse(200, 'Login successful')
@Response<ErrorResponse>(429, 'Too many login attempts')
public async login(@Body() body: LoginRequest): Promise<...> { ... }
@Post('register')
@Middlewares(registerLimiter)
@SuccessResponse(201, 'User registered')
@Response<ErrorResponse>(429, 'Too many registration attempts')
public async register(@Body() body: RegisterRequest): Promise<...> { ... }
Error Handling
Throwing Errors
Use the error classes from base.controller.ts:
import {
NotFoundError,
ValidationError,
ForbiddenError,
UniqueConstraintError,
} from './base.controller';
// Not found (404)
throw new NotFoundError('User', userId);
// Validation error (400)
throw new ValidationError([], 'Invalid email format');
// Forbidden (403)
throw new ForbiddenError('Admin access required');
// Conflict (409) - e.g., duplicate email
throw new UniqueConstraintError('email', 'Email already registered');
Global Error Handler
Errors are caught by the global error handler in server.ts which formats them according to ADR-028:
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "User not found"
}
}
Authentication Errors
The tsoaAuthentication.ts module throws AuthenticationError with appropriate HTTP status codes:
- 401: Missing token, invalid token, expired token
- 403: User lacks required role
- 500: Server configuration error
Testing Controllers
Test File Location
src/controllers/__tests__/
example.controller.test.ts
auth.controller.test.ts
user.controller.test.ts
...
Test Structure
// src/controllers/__tests__/example.controller.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ExampleController } from '../example.controller';
// Mock dependencies
vi.mock('../../services/exampleService', () => ({
exampleService: {
listExamples: vi.fn(),
getExampleById: vi.fn(),
createExample: vi.fn(),
deleteExample: vi.fn(),
},
}));
import { exampleService } from '../../services/exampleService';
describe('ExampleController', () => {
let controller: ExampleController;
beforeEach(() => {
controller = new ExampleController();
vi.clearAllMocks();
});
describe('listExamples', () => {
it('should return paginated examples', async () => {
const mockItems = [{ id: 1, name: 'Test' }];
vi.mocked(exampleService.listExamples).mockResolvedValue({
items: mockItems,
total: 1,
});
const result = await controller.listExamples(1, 20);
expect(result.success).toBe(true);
expect(result.data).toEqual(mockItems);
expect(result.meta?.pagination).toBeDefined();
expect(result.meta?.pagination?.total).toBe(1);
});
});
describe('createExample', () => {
it('should create example and return 201', async () => {
const mockExample = { id: 1, name: 'New', created_at: '2026-01-01' };
vi.mocked(exampleService.createExample).mockResolvedValue(mockExample);
const mockRequest = {
user: { user: { user_id: 'user-123' } },
} as any;
const result = await controller.createExample({ name: 'New' }, mockRequest);
expect(result.success).toBe(true);
expect(result.data).toEqual(mockExample);
// Note: setStatus is called internally, verify with spy if needed
});
});
});
Testing Authentication
describe('authenticated endpoints', () => {
it('should use user from request', async () => {
const mockRequest = {
user: {
user: { user_id: 'user-123', email: 'test@example.com' },
role: 'user',
},
} as any;
const result = await controller.getProfile(mockRequest);
expect(result.data.user.user_id).toBe('user-123');
});
});
Known Test Limitations
Some test files have type errors with mock objects that are acceptable:
// Type error: 'any' is not assignable to 'Express.Request'
// This is acceptable in tests - the mock has the properties we need
const mockRequest = { user: mockUser } as any;
These type errors do not affect test correctness. The 4603 unit tests and 345 integration tests all pass.
Build and Development
Development Workflow
- Create or modify controller
- Run
npm run tsoa:spec && npm run tsoa:routes - Run
npm run type-checkto verify - Run tests
NPM Scripts
{
"tsoa:spec": "tsoa spec",
"tsoa:routes": "tsoa routes",
"prebuild": "npm run tsoa:spec && npm run tsoa:routes",
"build": "tsc"
}
Watching for Changes
Currently, tsoa routes must be regenerated manually when controllers change. Consider adding a watch script:
# In development, regenerate on save
npm run tsoa:spec && npm run tsoa:routes
Generated Files
| File | Regenerate When |
|---|---|
src/routes/tsoa-generated.ts |
Controller changes |
src/config/tsoa-spec.json |
Controller changes, DTO changes |
These files are committed to the repository for faster builds.
Troubleshooting
"Duplicate identifier" Error
Problem: tsoa fails with "Duplicate identifier" for a type.
Solution: Move the type to src/dtos/common.dto.ts and import it in all controllers.
"Unable to resolve type" Error
Problem: tsoa cannot serialize a complex type (tuples, generics).
Solution: Create a DTO with flattened/simplified structure.
// Before: GeoJSONPoint with coordinates: [number, number]
// After: AddressDto with latitude, longitude as separate fields
Route Not Found (404)
Problem: New endpoint returns 404.
Solution:
- Ensure controller file matches glob pattern:
src/controllers/**/*.controller.ts - Regenerate routes:
npm run tsoa:routes - Verify the route is in
src/routes/tsoa-generated.ts
Authentication Not Working
Problem: request.user is undefined.
Solution:
- Ensure
@Security('bearerAuth')decorator is on the method - Verify
tsoaAuthentication.tsis correctly configured intsoa.json - Check the Authorization header format:
Bearer <token>
Type Mismatch in Tests
Problem: TypeScript errors when mocking Express.Request.
Solution: Use as any cast for mock objects in tests. This is acceptable and does not affect test correctness.
const mockRequest = {
user: mockUserProfile,
log: mockLogger,
} as any;
Migration Lessons Learned
What Worked Well
- BaseController Pattern: Provides consistent response formatting and familiar helpers
- Incremental Migration: Controllers can be migrated one at a time
- Type-First Design: Defining request/response types first makes implementation clearer
- Shared DTOs: Centralizing DTOs in
common.dto.tsprevents duplicate type errors
Challenges Encountered
- Tuple Types: tsoa cannot serialize TypeScript tuples. Solution: Flatten to separate fields.
- Passport Integration: OAuth callbacks use redirect-based flows that don't fit tsoa's JSON model. Solution: Keep OAuth callbacks in Express routes.
- Test Type Errors: Mock objects don't perfectly match Express types. Solution: Accept
as anycasts in tests. - Build Pipeline: Must regenerate routes when controllers change. Solution: Add to prebuild script.
Recommendations for Future Controllers
- Start with the DTO/request/response types
- Use
@SuccessResponseand@Responsedecorators for all status codes - Add JSDoc comments for OpenAPI descriptions
- Keep controller methods thin - delegate to service layer
- Test controllers in isolation by mocking services