Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3m58s
900 lines
24 KiB
Markdown
900 lines
24 KiB
Markdown
# 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/)
|