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

```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<SuccessResponseType<UserProfileDto>> {
  // 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<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

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

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

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

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