Files
flyer-crawler.projectium.com/docs/development/TSOA-MIGRATION-GUIDE.md
Torben Sorensen 2d2cd52011
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3m58s
Massive Dependency Modernization Project
2026-02-13 00:34:22 -08:00

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

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

// 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

  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

@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

  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

{
  "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:

  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 <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

  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