# 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](#overview) - [Architecture](#architecture) - [Creating a New Controller](#creating-a-new-controller) - [BaseController Pattern](#basecontroller-pattern) - [Authentication](#authentication) - [Request Handling](#request-handling) - [Response Formatting](#response-formatting) - [DTOs and Type Definitions](#dtos-and-type-definitions) - [File Uploads](#file-uploads) - [Rate Limiting](#rate-limiting) - [Error Handling](#error-handling) - [Testing Controllers](#testing-controllers) - [Build and Development](#build-and-development) - [Troubleshooting](#troubleshooting) - [Migration Lessons Learned](#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? 1. **Type Safety**: OpenAPI spec is generated from TypeScript types, eliminating drift 2. **Active Maintenance**: tsoa is actively maintained (vs. unmaintained swagger-jsdoc) 3. **Reduced Duplication**: No more parallel JSDoc + TypeScript definitions 4. **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 ```typescript // 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> { 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(404, 'Example not found') public async getExample(@Path() id: number): Promise> { 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(400, 'Validation error') @Response(401, 'Not authenticated') public async createExample( @Body() requestBody: CreateExampleRequest, @Request() request: ExpressRequest, ): Promise> { 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(401, 'Not authenticated') @Response(404, 'Example not found') public async deleteExample( @Path() id: number, @Request() request: ExpressRequest, ): Promise { 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: ```bash # 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript 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> { // request.user is populated by tsoaAuthentication.ts const user = request.user as UserProfile; return this.success(toUserProfileDto(user)); } ``` ### Requiring Admin Role ```typescript import { requireAdminRole } from '../middleware/tsoaAuthentication'; @Delete('users/{id}') @Security('bearerAuth') public async deleteUser( @Path() id: string, @Request() request: ExpressRequest, ): Promise { const user = request.user as UserProfile; requireAdminRole(user); // Throws 403 if not admin await userService.deleteUser(id); return this.noContent(); } ``` ### How Authentication Works 1. tsoa sees `@Security('bearerAuth')` decorator 2. tsoa calls `expressAuthentication()` from `src/middleware/tsoaAuthentication.ts` 3. The function extracts and validates the JWT token 4. User profile is fetched from database and attached to `request.user` 5. If authentication fails, an `AuthenticationError` is thrown ## Request Handling ### Path Parameters ```typescript @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 ```typescript @Get() public async listItems( @Query() page?: number, @Query() limit?: number, @Query() status?: 'active' | 'inactive', @Query() search?: string, ): Promise<...> { ... } ``` ### Request Body ```typescript interface CreateItemRequest { name: string; description?: string; } @Post() public async createItem( @Body() requestBody: CreateItemRequest, ): Promise<...> { ... } ``` ### Headers ```typescript @Get() public async getWithHeader( @Header('X-Custom-Header') customHeader?: string, ): Promise<...> { ... } ``` ### Accessing Express Request/Response ```typescript 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 ```typescript // Returns: { "success": true, "data": {...} } return this.success({ id: 1, name: 'Item' }); ``` ### Created Response (201) ```typescript // Sets status 201 and returns success response return this.created(newItem); ``` ### Paginated Response ```typescript // Returns: { "success": true, "data": [...], "meta": { "pagination": {...} } } return this.paginated(items, { page: 1, limit: 20, total: 100 }); ``` ### No Content (204) ```typescript // Sets status 204 with no body return this.noContent(); ``` ### Error Response Prefer throwing errors rather than returning error responses: ```typescript 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: ```typescript 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`: ```typescript // 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: ```typescript // 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: ```typescript 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> { 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: ```typescript import { Middlewares } from 'tsoa'; import { loginLimiter, registerLimiter } from '../config/rateLimiters'; @Post('login') @Middlewares(loginLimiter) @SuccessResponse(200, 'Login successful') @Response(429, 'Too many login attempts') public async login(@Body() body: LoginRequest): Promise<...> { ... } @Post('register') @Middlewares(registerLimiter) @SuccessResponse(201, 'User registered') @Response(429, 'Too many registration attempts') public async register(@Body() body: RegisterRequest): Promise<...> { ... } ``` ## Error Handling ### Throwing Errors Use the error classes from `base.controller.ts`: ```typescript 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: ```json { "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 ```typescript // 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 ```typescript 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: ```typescript // 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 1. Create or modify controller 2. Run `npm run tsoa:spec && npm run tsoa:routes` 3. Run `npm run type-check` to verify 4. Run tests ### NPM Scripts ```json { "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: ```bash # 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. ```typescript // Before: GeoJSONPoint with coordinates: [number, number] // After: AddressDto with latitude, longitude as separate fields ``` ### Route Not Found (404) **Problem**: New endpoint returns 404. **Solution**: 1. Ensure controller file matches glob pattern: `src/controllers/**/*.controller.ts` 2. Regenerate routes: `npm run tsoa:routes` 3. Verify the route is in `src/routes/tsoa-generated.ts` ### Authentication Not Working **Problem**: `request.user` is undefined. **Solution**: 1. Ensure `@Security('bearerAuth')` decorator is on the method 2. Verify `tsoaAuthentication.ts` is correctly configured in `tsoa.json` 3. Check the Authorization header format: `Bearer ` ### 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. ```typescript const mockRequest = { user: mockUserProfile, log: mockLogger, } as any; ``` ## Migration Lessons Learned ### What Worked Well 1. **BaseController Pattern**: Provides consistent response formatting and familiar helpers 2. **Incremental Migration**: Controllers can be migrated one at a time 3. **Type-First Design**: Defining request/response types first makes implementation clearer 4. **Shared DTOs**: Centralizing DTOs in `common.dto.ts` prevents duplicate type errors ### Challenges Encountered 1. **Tuple Types**: tsoa cannot serialize TypeScript tuples. Solution: Flatten to separate fields. 2. **Passport Integration**: OAuth callbacks use redirect-based flows that don't fit tsoa's JSON model. Solution: Keep OAuth callbacks in Express routes. 3. **Test Type Errors**: Mock objects don't perfectly match Express types. Solution: Accept `as any` casts in tests. 4. **Build Pipeline**: Must regenerate routes when controllers change. Solution: Add to prebuild script. ### Recommendations for Future Controllers 1. Start with the DTO/request/response types 2. Use `@SuccessResponse` and `@Response` decorators for all status codes 3. Add JSDoc comments for OpenAPI descriptions 4. Keep controller methods thin - delegate to service layer 5. Test controllers in isolation by mocking services ## Related Documentation - [ADR-018: API Documentation Strategy](../adr/0018-api-documentation-strategy.md) - [ADR-059: Dependency Modernization](../adr/0059-dependency-modernization.md) - [ADR-028: API Response Standardization](../adr/0028-api-response-standardization.md) - [CODE-PATTERNS.md](./CODE-PATTERNS.md) - [TESTING.md](./TESTING.md) - [tsoa Documentation](https://tsoa-community.github.io/docs/)