Massive Dependency Modernization Project
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3m58s
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3m58s
This commit is contained in:
@@ -2,9 +2,11 @@
|
||||
|
||||
**Date**: 2025-12-12
|
||||
|
||||
**Status**: Accepted
|
||||
**Status**: Superseded
|
||||
|
||||
**Implemented**: 2026-01-11
|
||||
**Superseded By**: This ADR was updated in February 2026 to reflect the migration from swagger-jsdoc to tsoa. The original approach using JSDoc annotations has been replaced with a decorator-based controller pattern.
|
||||
|
||||
**Implemented**: 2026-02-12
|
||||
|
||||
## Context
|
||||
|
||||
@@ -16,139 +18,296 @@ Key requirements:
|
||||
2. **Code-Documentation Sync**: Documentation should stay in sync with the actual code to prevent drift.
|
||||
3. **Low Maintenance Overhead**: The documentation approach should be "fast and lite" - minimal additional work for developers.
|
||||
4. **Security**: Documentation should not expose sensitive information in production environments.
|
||||
5. **Type Safety**: Documentation should be derived from TypeScript types to ensure accuracy.
|
||||
|
||||
### Why We Migrated from swagger-jsdoc to tsoa
|
||||
|
||||
The original implementation used `swagger-jsdoc` to generate OpenAPI specs from JSDoc comments. This approach had several limitations:
|
||||
|
||||
| Issue | Impact |
|
||||
| --------------------------------------- | -------------------------------------------- |
|
||||
| `swagger-jsdoc` unmaintained since 2022 | Security and compatibility risks |
|
||||
| JSDoc duplication with TypeScript types | Maintenance burden, potential for drift |
|
||||
| No runtime validation from schema | Validation logic separate from documentation |
|
||||
| Manual type definitions in comments | Error-prone, no compiler verification |
|
||||
|
||||
## Decision
|
||||
|
||||
We will adopt **OpenAPI 3.0 (Swagger)** for API documentation using the following approach:
|
||||
We adopt **tsoa** for API documentation using a decorator-based controller pattern:
|
||||
|
||||
1. **JSDoc Annotations**: Use `swagger-jsdoc` to generate OpenAPI specs from JSDoc comments in route files.
|
||||
2. **Swagger UI**: Use `swagger-ui-express` to serve interactive documentation at `/docs/api-docs`.
|
||||
3. **Environment Restriction**: Only expose the Swagger UI in development and test environments, not production.
|
||||
4. **Incremental Adoption**: Start with key public routes and progressively add annotations to all endpoints.
|
||||
1. **Controller Classes**: Use tsoa decorators (`@Route`, `@Get`, `@Post`, `@Security`, etc.) on controller classes.
|
||||
2. **TypeScript-First**: OpenAPI specs are generated directly from TypeScript interfaces and types.
|
||||
3. **Swagger UI**: Continue using `swagger-ui-express` to serve interactive documentation at `/docs/api-docs`.
|
||||
4. **Environment Restriction**: Only expose the Swagger UI in development and test environments, not production.
|
||||
5. **BaseController Pattern**: All controllers extend a base class providing response formatting utilities.
|
||||
|
||||
### Tooling Selection
|
||||
|
||||
| Tool | Purpose |
|
||||
| -------------------- | ---------------------------------------------- |
|
||||
| `swagger-jsdoc` | Generates OpenAPI 3.0 spec from JSDoc comments |
|
||||
| `swagger-ui-express` | Serves interactive Swagger UI |
|
||||
| Tool | Purpose |
|
||||
| -------------------- | ----------------------------------------------------- |
|
||||
| `tsoa` (6.6.0) | Generates OpenAPI 3.0 spec from decorators and routes |
|
||||
| `swagger-ui-express` | Serves interactive Swagger UI |
|
||||
|
||||
**Why JSDoc over separate schema files?**
|
||||
**Why tsoa over swagger-jsdoc?**
|
||||
|
||||
- Documentation lives with the code, reducing drift
|
||||
- No separate files to maintain
|
||||
- Developers see documentation when editing routes
|
||||
- Lower learning curve for the team
|
||||
- **Type-safe contracts**: Decorators derive types directly from TypeScript, eliminating duplicate definitions
|
||||
- **Active maintenance**: tsoa has an active community and regular releases
|
||||
- **Route generation**: tsoa generates Express routes automatically, reducing boilerplate
|
||||
- **Validation integration**: Request body types serve as validation contracts
|
||||
- **Reduced duplication**: No more parallel JSDoc + TypeScript type definitions
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### OpenAPI Configuration
|
||||
### tsoa Configuration
|
||||
|
||||
Located in `src/config/swagger.ts`:
|
||||
Located in `tsoa.json`:
|
||||
|
||||
```typescript
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
|
||||
const options: swaggerJsdoc.Options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Flyer Crawler API',
|
||||
version: '1.0.0',
|
||||
description: 'API for the Flyer Crawler application',
|
||||
contact: {
|
||||
name: 'API Support',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: '/api',
|
||||
description: 'API server',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
```json
|
||||
{
|
||||
"entryFile": "server.ts",
|
||||
"noImplicitAdditionalProperties": "throw-on-extras",
|
||||
"controllerPathGlobs": ["src/controllers/**/*.controller.ts"],
|
||||
"spec": {
|
||||
"outputDirectory": "src/config",
|
||||
"specVersion": 3,
|
||||
"securityDefinitions": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
},
|
||||
"basePath": "/api",
|
||||
"specFileBaseName": "tsoa-spec",
|
||||
"name": "Flyer Crawler API",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
apis: ['./src/routes/*.ts'],
|
||||
};
|
||||
|
||||
export const swaggerSpec = swaggerJsdoc(options);
|
||||
"routes": {
|
||||
"routesDir": "src/routes",
|
||||
"basePath": "/api",
|
||||
"middleware": "express",
|
||||
"routesFileName": "tsoa-generated.ts",
|
||||
"esm": true,
|
||||
"authenticationModule": "src/middleware/tsoaAuthentication.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JSDoc Annotation Pattern
|
||||
### Controller Pattern
|
||||
|
||||
Each route handler should include OpenAPI annotations using the `@openapi` tag:
|
||||
Each controller extends `BaseController` and uses tsoa decorators:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @openapi
|
||||
* /health/ping:
|
||||
* get:
|
||||
* summary: Simple ping endpoint
|
||||
* description: Returns a pong response to verify server is responsive
|
||||
* tags:
|
||||
* - Health
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server is responsive
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: pong
|
||||
*/
|
||||
router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response) => {
|
||||
return sendSuccess(res, { message: 'pong' });
|
||||
});
|
||||
import { Route, Tags, Get, Post, Body, Security, SuccessResponse, Response } from 'tsoa';
|
||||
import {
|
||||
BaseController,
|
||||
SuccessResponse as SuccessResponseType,
|
||||
ErrorResponse,
|
||||
} from './base.controller';
|
||||
|
||||
interface CreateUserRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
full_name?: string;
|
||||
}
|
||||
|
||||
@Route('users')
|
||||
@Tags('Users')
|
||||
export class UserController extends BaseController {
|
||||
/**
|
||||
* Create a new user account.
|
||||
* @summary Create user
|
||||
* @param requestBody User creation data
|
||||
* @returns Created user profile
|
||||
*/
|
||||
@Post()
|
||||
@SuccessResponse(201, 'User created')
|
||||
@Response<ErrorResponse>(400, 'Validation error')
|
||||
@Response<ErrorResponse>(409, 'Email already exists')
|
||||
public async createUser(
|
||||
@Body() requestBody: CreateUserRequest,
|
||||
): Promise<SuccessResponseType<UserProfileDto>> {
|
||||
// Implementation
|
||||
const user = await userService.createUser(requestBody);
|
||||
return this.created(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's profile.
|
||||
* @summary Get my profile
|
||||
* @param request Express request with authenticated user
|
||||
* @returns User profile
|
||||
*/
|
||||
@Get('me')
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(200, 'Profile retrieved')
|
||||
@Response<ErrorResponse>(401, 'Not authenticated')
|
||||
public async getMyProfile(
|
||||
@Request() request: Express.Request,
|
||||
): Promise<SuccessResponseType<UserProfileDto>> {
|
||||
const user = request.user as UserProfile;
|
||||
return this.success(toUserProfileDto(user));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Route Documentation Priority
|
||||
### BaseController Helpers
|
||||
|
||||
Document routes in this order of priority:
|
||||
The `BaseController` class provides standardized response formatting:
|
||||
|
||||
1. **Health Routes** - `/api/health/*` (public, critical for operations)
|
||||
2. **Auth Routes** - `/api/auth/*` (public, essential for integration)
|
||||
3. **Gamification Routes** - `/api/achievements/*` (simple, good example)
|
||||
4. **Flyer Routes** - `/api/flyers/*` (core functionality)
|
||||
5. **User Routes** - `/api/users/*` (common CRUD patterns)
|
||||
6. **Remaining Routes** - Budget, Recipe, Admin, etc.
|
||||
```typescript
|
||||
export abstract class BaseController extends Controller {
|
||||
// Success response with data
|
||||
protected success<T>(data: T): SuccessResponse<T> {
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
// Success with 201 Created status
|
||||
protected created<T>(data: T): SuccessResponse<T> {
|
||||
this.setStatus(201);
|
||||
return this.success(data);
|
||||
}
|
||||
|
||||
// Paginated response with metadata
|
||||
protected paginated<T>(data: T[], pagination: PaginationInput): PaginatedResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
meta: { pagination: this.calculatePagination(pagination) },
|
||||
};
|
||||
}
|
||||
|
||||
// Message-only response
|
||||
protected message(message: string): SuccessResponse<{ message: string }> {
|
||||
return this.success({ message });
|
||||
}
|
||||
|
||||
// No content response (204)
|
||||
protected noContent(): void {
|
||||
this.setStatus(204);
|
||||
}
|
||||
|
||||
// Error response (prefer throwing errors instead)
|
||||
protected error(code: string, message: string, details?: unknown): ErrorResponse {
|
||||
return { success: false, error: { code, message, details } };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication with @Security
|
||||
|
||||
tsoa integrates with the existing passport-jwt strategy via a custom authentication module:
|
||||
|
||||
```typescript
|
||||
// src/middleware/tsoaAuthentication.ts
|
||||
export async function expressAuthentication(
|
||||
request: Request,
|
||||
securityName: string,
|
||||
_scopes?: string[],
|
||||
): Promise<UserProfile> {
|
||||
if (securityName !== 'bearerAuth') {
|
||||
throw new AuthenticationError(`Unknown security scheme: ${securityName}`);
|
||||
}
|
||||
|
||||
const token = extractBearerToken(request);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
|
||||
const userProfile = await userRepo.findUserProfileById(decoded.user_id);
|
||||
|
||||
if (!userProfile) {
|
||||
throw new AuthenticationError('User not found');
|
||||
}
|
||||
|
||||
request.user = userProfile;
|
||||
return userProfile;
|
||||
}
|
||||
```
|
||||
|
||||
Usage in controllers:
|
||||
|
||||
```typescript
|
||||
@Get('profile')
|
||||
@Security('bearerAuth')
|
||||
public async getProfile(@Request() req: Express.Request): Promise<...> {
|
||||
const user = req.user as UserProfile;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### DTO Organization
|
||||
|
||||
Shared DTOs are defined in `src/dtos/common.dto.ts` to avoid duplicate type definitions across controllers:
|
||||
|
||||
```typescript
|
||||
// src/dtos/common.dto.ts
|
||||
|
||||
/**
|
||||
* Address with flattened coordinates (tsoa-compatible).
|
||||
* 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;
|
||||
latitude?: number | null; // Flattened from GeoJSONPoint
|
||||
longitude?: number | null; // Flattened from GeoJSONPoint
|
||||
// ...
|
||||
}
|
||||
|
||||
export interface UserDto {
|
||||
user_id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserProfileDto {
|
||||
full_name?: string | null;
|
||||
role: 'admin' | 'user';
|
||||
points: number;
|
||||
user: UserDto;
|
||||
address?: AddressDto | null;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Swagger UI Setup
|
||||
|
||||
In `server.ts`, add the Swagger UI middleware (development/test only):
|
||||
In `server.ts`, the Swagger UI middleware serves the tsoa-generated spec:
|
||||
|
||||
```typescript
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { swaggerSpec } from './src/config/swagger';
|
||||
import tsoaSpec from './src/config/tsoa-spec.json' with { type: 'json' };
|
||||
|
||||
// Only serve Swagger UI in non-production environments
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.use('/docs/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
app.use('/docs/api-docs', swaggerUi.serve, swaggerUi.setup(tsoaSpec));
|
||||
|
||||
// Optionally expose raw JSON spec for tooling
|
||||
// Raw JSON spec for tooling
|
||||
app.get('/docs/api-docs.json', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(swaggerSpec);
|
||||
res.send(tsoaSpec);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Build Integration
|
||||
|
||||
tsoa spec and route generation is integrated into the build pipeline:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"tsoa:spec": "tsoa spec",
|
||||
"tsoa:routes": "tsoa routes",
|
||||
"prebuild": "npm run tsoa:spec && npm run tsoa:routes",
|
||||
"build": "tsc"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response Schema Standardization
|
||||
|
||||
All API responses follow the standardized format from [ADR-028](./0028-api-response-standardization.md):
|
||||
@@ -160,107 +319,144 @@ All API responses follow the standardized format from [ADR-028](./0028-api-respo
|
||||
"data": { ... }
|
||||
}
|
||||
|
||||
// Paginated response
|
||||
{
|
||||
"success": true,
|
||||
"data": [...],
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 100,
|
||||
"totalPages": 5,
|
||||
"hasNextPage": true,
|
||||
"hasPrevPage": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error response
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"message": "Human-readable message"
|
||||
"code": "NOT_FOUND",
|
||||
"message": "User not found"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Define reusable schema components for these patterns:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @openapi
|
||||
* components:
|
||||
* schemas:
|
||||
* SuccessResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* ErrorResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* error:
|
||||
* type: object
|
||||
* properties:
|
||||
* code:
|
||||
* type: string
|
||||
* message:
|
||||
* type: string
|
||||
*/
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Production Disabled**: Swagger UI is not available in production to prevent information disclosure.
|
||||
2. **No Sensitive Data**: Never include actual secrets, tokens, or PII in example values.
|
||||
3. **Authentication Documented**: Clearly document which endpoints require authentication.
|
||||
|
||||
## API Route Tags
|
||||
|
||||
Organize endpoints using consistent tags:
|
||||
|
||||
| Tag | Description | Routes |
|
||||
| Tag | Description | Route Prefix |
|
||||
| ------------ | ---------------------------------- | --------------------- |
|
||||
| Health | Server health and readiness checks | `/api/health/*` |
|
||||
| Auth | Authentication and authorization | `/api/auth/*` |
|
||||
| Users | User profile management | `/api/users/*` |
|
||||
| Flyers | Flyer uploads and retrieval | `/api/flyers/*` |
|
||||
| Achievements | Gamification and leaderboards | `/api/achievements/*` |
|
||||
| Budgets | Budget tracking | `/api/budgets/*` |
|
||||
| Deals | Deal search and management | `/api/deals/*` |
|
||||
| Stores | Store information | `/api/stores/*` |
|
||||
| Recipes | Recipe management | `/api/recipes/*` |
|
||||
| Budgets | Budget tracking | `/api/budgets/*` |
|
||||
| Inventory | User inventory management | `/api/inventory/*` |
|
||||
| Gamification | Achievements and leaderboards | `/api/achievements/*` |
|
||||
| Admin | Administrative operations | `/api/admin/*` |
|
||||
| System | System status and monitoring | `/api/system/*` |
|
||||
|
||||
## Controller Inventory
|
||||
|
||||
The following controllers have been migrated to tsoa:
|
||||
|
||||
| Controller | Endpoints | Description |
|
||||
| ------------------------------- | --------- | ----------------------------------------- |
|
||||
| `health.controller.ts` | 10 | Health checks, probes, service status |
|
||||
| `auth.controller.ts` | 8 | Login, register, password reset, OAuth |
|
||||
| `user.controller.ts` | 30 | User profiles, preferences, notifications |
|
||||
| `admin.controller.ts` | 32 | System administration, user management |
|
||||
| `ai.controller.ts` | 15 | AI-powered extraction and analysis |
|
||||
| `flyer.controller.ts` | 12 | Flyer upload and management |
|
||||
| `store.controller.ts` | 8 | Store information |
|
||||
| `recipe.controller.ts` | 10 | Recipe CRUD and suggestions |
|
||||
| `upc.controller.ts` | 6 | UPC barcode lookups |
|
||||
| `inventory.controller.ts` | 8 | User inventory management |
|
||||
| `receipt.controller.ts` | 6 | Receipt processing |
|
||||
| `budget.controller.ts` | 8 | Budget tracking |
|
||||
| `category.controller.ts` | 4 | Category management |
|
||||
| `deals.controller.ts` | 8 | Deal search and discovery |
|
||||
| `stats.controller.ts` | 6 | Usage statistics |
|
||||
| `price.controller.ts` | 6 | Price history and tracking |
|
||||
| `system.controller.ts` | 4 | System status |
|
||||
| `gamification.controller.ts` | 10 | Achievements, leaderboards |
|
||||
| `personalization.controller.ts` | 6 | User recommendations |
|
||||
| `reactions.controller.ts` | 4 | Item reactions and ratings |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Production Disabled**: Swagger UI is not available in production to prevent information disclosure.
|
||||
2. **No Sensitive Data**: Never include actual secrets, tokens, or PII in example values.
|
||||
3. **Authentication Documented**: Clearly document which endpoints require authentication.
|
||||
4. **Rate Limiting**: Rate limiters are applied via `@Middlewares` decorator.
|
||||
|
||||
## Testing
|
||||
|
||||
Verify API documentation is correct by:
|
||||
|
||||
1. **Manual Review**: Navigate to `/docs/api-docs` and test each endpoint.
|
||||
2. **Spec Validation**: Use OpenAPI validators to check the generated spec.
|
||||
3. **Integration Tests**: Existing integration tests serve as implicit documentation verification.
|
||||
3. **Controller Tests**: Each controller has comprehensive test coverage (369 controller tests total).
|
||||
4. **Integration Tests**: 345 integration tests verify endpoint behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Single Source of Truth**: Documentation lives with the code and stays in sync.
|
||||
- **Interactive Exploration**: Developers can try endpoints directly from the UI.
|
||||
- **SDK Generation**: OpenAPI spec enables automatic client SDK generation.
|
||||
- **Onboarding**: New developers can quickly understand the API surface.
|
||||
- **Low Overhead**: JSDoc annotations are minimal additions to existing code.
|
||||
- **Type-safe API contracts**: tsoa decorators derive types from TypeScript, eliminating duplicate definitions
|
||||
- **Single Source of Truth**: Documentation lives with the code and stays in sync
|
||||
- **Active Maintenance**: tsoa is actively maintained with regular releases
|
||||
- **Interactive Exploration**: Developers can try endpoints directly from Swagger UI
|
||||
- **SDK Generation**: OpenAPI spec enables automatic client SDK generation
|
||||
- **Reduced Boilerplate**: tsoa generates Express routes automatically
|
||||
|
||||
### Negative
|
||||
|
||||
- **Maintenance Required**: Developers must update annotations when routes change.
|
||||
- **Build Dependency**: Adds `swagger-jsdoc` and `swagger-ui-express` packages.
|
||||
- **Initial Investment**: Existing routes need annotations added incrementally.
|
||||
- **Learning Curve**: Decorator-based controller pattern differs from Express handlers
|
||||
- **Generated Code**: `tsoa-generated.ts` must be regenerated when controllers change
|
||||
- **Build Step**: Adds `tsoa spec && tsoa routes` to the build pipeline
|
||||
|
||||
### Mitigation
|
||||
|
||||
- Include documentation checks in code review process.
|
||||
- Start with high-priority routes and expand coverage over time.
|
||||
- Use TypeScript types to reduce documentation duplication where possible.
|
||||
- **Migration Guide**: Created comprehensive TSOA-MIGRATION-GUIDE.md for developers
|
||||
- **BaseController**: Provides familiar response helpers matching existing patterns
|
||||
- **Incremental Adoption**: Existing Express routes continue to work alongside tsoa controllers
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/config/swagger.ts` - OpenAPI configuration
|
||||
- `src/routes/*.ts` - Route files with JSDoc annotations
|
||||
- `server.ts` - Swagger UI middleware setup
|
||||
| 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` | Individual domain controllers |
|
||||
| `src/dtos/common.dto.ts` | Shared DTO definitions |
|
||||
| `src/middleware/tsoaAuthentication.ts` | JWT authentication handler |
|
||||
| `src/routes/tsoa-generated.ts` | tsoa-generated Express routes |
|
||||
| `src/config/tsoa-spec.json` | Generated OpenAPI 3.0 spec |
|
||||
| `server.ts` | Swagger UI middleware setup |
|
||||
|
||||
## Migration History
|
||||
|
||||
| Date | Change |
|
||||
| ---------- | --------------------------------------------------------------- |
|
||||
| 2025-12-12 | Initial ADR created with swagger-jsdoc approach |
|
||||
| 2026-01-11 | Began implementation with swagger-jsdoc |
|
||||
| 2026-02-12 | Completed migration to tsoa, superseding swagger-jsdoc approach |
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-059](./0059-dependency-modernization.md) - Dependency Modernization (tsoa migration plan)
|
||||
- [ADR-003](./0003-standardized-input-validation-using-middleware.md) - Input Validation (Zod schemas)
|
||||
- [ADR-028](./0028-api-response-standardization.md) - Response Standardization
|
||||
- [ADR-001](./0001-standardized-error-handling.md) - Error Handling
|
||||
- [ADR-016](./0016-api-security-hardening.md) - Security Hardening
|
||||
- [ADR-048](./0048-authentication-strategy.md) - Authentication Strategy
|
||||
|
||||
308
docs/adr/0059-dependency-modernization.md
Normal file
308
docs/adr/0059-dependency-modernization.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# ADR-059: Dependency Modernization Plan
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-02-12
|
||||
**Implemented**: 2026-02-12
|
||||
|
||||
## Context
|
||||
|
||||
NPM audit and security scanning identified deprecated dependencies requiring modernization:
|
||||
|
||||
| Dependency | Current | Issue | Replacement |
|
||||
| --------------- | ------- | ----------------------- | --------------------------------------- |
|
||||
| `swagger-jsdoc` | 6.2.8 | Unmaintained since 2022 | `tsoa` (decorator-based OpenAPI) |
|
||||
| `rimraf` | 6.1.2 | Legacy cleanup utility | Node.js `fs.rm()` (native since v14.14) |
|
||||
|
||||
**Constraints**:
|
||||
|
||||
- Existing `@openapi` JSDoc annotations in 20 route files
|
||||
- ADR-018 compliance (API documentation strategy)
|
||||
- Zero-downtime migration (phased approach)
|
||||
- Must maintain Express 5.x compatibility
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. swagger-jsdoc → tsoa Migration
|
||||
|
||||
**Architecture**: tsoa controller classes + Express integration (no replacement of Express routing layer).
|
||||
|
||||
```text
|
||||
Current: Route Files → JSDoc Annotations → swagger-jsdoc → OpenAPI Spec
|
||||
Future: Controller Classes → @Route/@Get decorators → tsoa → OpenAPI Spec + Route Registration
|
||||
```
|
||||
|
||||
**Controller Pattern**: Base controller providing common utilities:
|
||||
|
||||
```typescript
|
||||
// src/controllers/base.controller.ts
|
||||
export abstract class BaseController {
|
||||
protected sendSuccess<T>(res: Response, data: T, status = 200) {
|
||||
return sendSuccess(res, data, status);
|
||||
}
|
||||
protected sendError(
|
||||
res: Response,
|
||||
code: ErrorCode,
|
||||
msg: string,
|
||||
status: number,
|
||||
details?: unknown,
|
||||
) {
|
||||
return sendError(res, code, msg, status, details);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Express Integration Strategy**: tsoa generates routes.ts; wrap with Express middleware pipeline:
|
||||
|
||||
```typescript
|
||||
// server.ts integration
|
||||
import { RegisterRoutes } from './src/generated/routes';
|
||||
RegisterRoutes(app); // tsoa registers routes with existing Express app
|
||||
```
|
||||
|
||||
### 2. rimraf → fs.rm() Migration
|
||||
|
||||
**Change**: Replace `rimraf coverage .coverage` script with Node.js native API.
|
||||
|
||||
```json
|
||||
// package.json (before)
|
||||
"clean": "rimraf coverage .coverage"
|
||||
|
||||
// package.json (after)
|
||||
"clean": "node -e \"import('fs/promises').then(fs => Promise.all([fs.rm('coverage', {recursive:true,force:true}), fs.rm('.coverage', {recursive:true,force:true})]))\""
|
||||
```
|
||||
|
||||
**Alternative**: Create `scripts/clean.mjs` for maintainability:
|
||||
|
||||
```javascript
|
||||
// scripts/clean.mjs
|
||||
import { rm } from 'fs/promises';
|
||||
await Promise.all([
|
||||
rm('coverage', { recursive: true, force: true }),
|
||||
rm('.coverage', { recursive: true, force: true }),
|
||||
]);
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Infrastructure (Tasks 1-4)
|
||||
|
||||
| Task | Description | Dependencies |
|
||||
| ---- | ---------------------------------------------- | ------------ |
|
||||
| 1 | Install tsoa, configure tsoa.json | None |
|
||||
| 2 | Create BaseController with utility methods | Task 1 |
|
||||
| 3 | Configure Express integration (RegisterRoutes) | Task 2 |
|
||||
| 4 | Set up tsoa spec generation in build pipeline | Task 3 |
|
||||
|
||||
### Phase 2: Controller Migration (Tasks 5-14)
|
||||
|
||||
Priority order matches ADR-018:
|
||||
|
||||
| Task | Route File | Controller Class | Dependencies |
|
||||
| ---- | ----------------------------------------------------------------------------------------------------------------- | ---------------------- | ------------ |
|
||||
| 5 | health.routes.ts | HealthController | Task 4 |
|
||||
| 6 | auth.routes.ts | AuthController | Task 4 |
|
||||
| 7 | gamification.routes.ts | AchievementsController | Task 4 |
|
||||
| 8 | flyer.routes.ts | FlyersController | Task 4 |
|
||||
| 9 | user.routes.ts | UsersController | Task 4 |
|
||||
| 10 | budget.routes.ts | BudgetController | Task 4 |
|
||||
| 11 | recipe.routes.ts | RecipeController | Task 4 |
|
||||
| 12 | store.routes.ts | StoreController | Task 4 |
|
||||
| 13 | admin.routes.ts | AdminController | Task 4 |
|
||||
| 14 | Remaining routes (deals, price, upc, inventory, ai, receipt, category, stats, personalization, reactions, system) | Various | Task 4 |
|
||||
|
||||
### Phase 3: Cleanup and rimraf (Tasks 15-18)
|
||||
|
||||
| Task | Description | Dependencies |
|
||||
| ---- | -------------------------------- | ------------------- |
|
||||
| 15 | Create scripts/clean.mjs | None |
|
||||
| 16 | Update package.json clean script | Task 15 |
|
||||
| 17 | Remove rimraf dependency | Task 16 |
|
||||
| 18 | Remove swagger-jsdoc + types | Tasks 5-14 complete |
|
||||
|
||||
### Phase 4: Verification (Tasks 19-24)
|
||||
|
||||
| Task | Description | Dependencies |
|
||||
| ---- | --------------------------------- | ------------ |
|
||||
| 19 | Run type-check | Tasks 15-18 |
|
||||
| 20 | Run unit tests | Task 19 |
|
||||
| 21 | Run integration tests | Task 20 |
|
||||
| 22 | Verify OpenAPI spec completeness | Task 21 |
|
||||
| 23 | Update ADR-018 (reference tsoa) | Task 22 |
|
||||
| 24 | Update CLAUDE.md (swagger → tsoa) | Task 23 |
|
||||
|
||||
### Task Dependency Graph
|
||||
|
||||
```text
|
||||
[1: Install tsoa]
|
||||
|
|
||||
[2: BaseController]
|
||||
|
|
||||
[3: Express Integration]
|
||||
|
|
||||
[4: Build Pipeline]
|
||||
|
|
||||
+------------------+------------------+
|
||||
| | | | |
|
||||
[5] [6] [7] [8] [9-14]
|
||||
Health Auth Gamif Flyer Others
|
||||
| | | | |
|
||||
+------------------+------------------+
|
||||
|
|
||||
[18: Remove swagger-jsdoc]
|
||||
|
|
||||
[15: clean.mjs] -----> [16: Update pkg.json]
|
||||
|
|
||||
[17: Remove rimraf]
|
||||
|
|
||||
[19: type-check]
|
||||
|
|
||||
[20: unit tests]
|
||||
|
|
||||
[21: integration tests]
|
||||
|
|
||||
[22: Verify OpenAPI]
|
||||
|
|
||||
[23: Update ADR-018]
|
||||
|
|
||||
[24: Update CLAUDE.md]
|
||||
```
|
||||
|
||||
### Critical Path
|
||||
|
||||
**Minimum time to completion**: Tasks 1 → 2 → 3 → 4 → 5 (or any controller) → 18 → 19 → 20 → 21 → 22 → 23 → 24
|
||||
|
||||
**Parallelization opportunities**:
|
||||
|
||||
- Tasks 5-14 (all controller migrations) can run in parallel after Task 4
|
||||
- Tasks 15-17 (rimraf removal) can run in parallel with controller migrations
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### tsoa Configuration
|
||||
|
||||
```json
|
||||
// tsoa.json
|
||||
{
|
||||
"entryFile": "server.ts",
|
||||
"noImplicitAdditionalProperties": "throw-on-extras",
|
||||
"controllerPathGlobs": ["src/controllers/**/*.controller.ts"],
|
||||
"spec": {
|
||||
"outputDirectory": "src/generated",
|
||||
"specVersion": 3,
|
||||
"basePath": "/api/v1"
|
||||
},
|
||||
"routes": {
|
||||
"routesDir": "src/generated",
|
||||
"middleware": "express"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Decorator Migration Example
|
||||
|
||||
**Before** (swagger-jsdoc):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @openapi
|
||||
* /health/ping:
|
||||
* get:
|
||||
* summary: Simple ping endpoint
|
||||
* tags: [Health]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server is responsive
|
||||
*/
|
||||
router.get('/ping', validateRequest(emptySchema), handler);
|
||||
```
|
||||
|
||||
**After** (tsoa):
|
||||
|
||||
```typescript
|
||||
@Route('health')
|
||||
@Tags('Health')
|
||||
export class HealthController extends BaseController {
|
||||
@Get('ping')
|
||||
@SuccessResponse(200, 'Server is responsive')
|
||||
public async ping(): Promise<{ message: string }> {
|
||||
return { message: 'pong' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Zod Integration
|
||||
|
||||
tsoa uses its own validation. Options:
|
||||
|
||||
1. **Replace Zod with tsoa validation** - Use `@Body`, `@Query`, `@Path` decorators with TypeScript types
|
||||
2. **Hybrid approach** - Keep Zod schemas, call `validateRequest()` within controller methods
|
||||
3. **Custom template** - Generate tsoa routes that call Zod validation middleware
|
||||
|
||||
**Recommended**: Option 1 for new controllers; gradually migrate existing Zod schemas.
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
| --------------------------------------- | ---------- | ------ | ------------------------------------------- |
|
||||
| tsoa/Express 5.x incompatibility | Medium | High | Test in dev container before migration |
|
||||
| Missing OpenAPI coverage post-migration | Low | Medium | Compare generated specs before/after |
|
||||
| Authentication middleware integration | Medium | Medium | Test @Security decorator with passport-jwt |
|
||||
| Test regression from route changes | Low | High | Run full test suite after each controller |
|
||||
| Build time increase (tsoa generation) | Low | Low | Add to npm run build; cache generated files |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Type-safe API contracts**: tsoa decorators derive types from TypeScript
|
||||
- **Reduced duplication**: No more parallel JSDoc + TypeScript type definitions
|
||||
- **Modern tooling**: Active tsoa community (vs. unmaintained swagger-jsdoc)
|
||||
- **Native Node.js**: fs.rm() is built-in, no external dependency
|
||||
- **Smaller dependency tree**: Remove rimraf (5 transitive deps) + swagger-jsdoc (8 transitive deps)
|
||||
|
||||
### Negative
|
||||
|
||||
- **Learning curve**: Decorator-based controller pattern differs from Express handlers
|
||||
- **Migration effort**: 20 route files require conversion
|
||||
- **Generated code**: `src/generated/routes.ts` must be version-controlled or regenerated on build
|
||||
|
||||
### Neutral
|
||||
|
||||
- **Build step change**: Add `tsoa spec && tsoa routes` to build pipeline
|
||||
- **Testing approach**: May need to adjust test structure for controller classes
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. Update swagger-jsdoc to fork/successor
|
||||
|
||||
**Rejected**: No active fork; community has moved to tsoa, fastify-swagger, or NestJS.
|
||||
|
||||
### 2. NestJS migration
|
||||
|
||||
**Rejected**: Full framework migration (Express → NestJS) is disproportionate to the problem scope.
|
||||
|
||||
### 3. fastify-swagger
|
||||
|
||||
**Rejected**: Requires Express → Fastify migration; out of scope.
|
||||
|
||||
### 4. Keep rimraf, accept deprecation warning
|
||||
|
||||
**Rejected**: Native fs.rm() is trivial replacement; no reason to maintain deprecated dependency.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------ | ------------------------------------- |
|
||||
| `tsoa.json` | tsoa configuration |
|
||||
| `src/controllers/base.controller.ts` | Base controller with utilities |
|
||||
| `src/controllers/*.controller.ts` | Individual domain controllers |
|
||||
| `src/generated/routes.ts` | tsoa-generated Express routes |
|
||||
| `src/generated/swagger.json` | Generated OpenAPI 3.0 spec |
|
||||
| `scripts/clean.mjs` | Native fs.rm() replacement for rimraf |
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR-018](./0018-api-documentation-strategy.md) - API Documentation Strategy (will be updated)
|
||||
- [ADR-003](./0003-standardized-input-validation-using-middleware.md) - Input Validation (Zod integration)
|
||||
- [ADR-028](./0028-api-response-standardization.md) - Response Standardization (BaseController pattern)
|
||||
- [ADR-001](./0001-standardized-error-handling.md) - Error Handling (error utilities in BaseController)
|
||||
@@ -23,7 +23,7 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
|
||||
**[ADR-003](./0003-standardized-input-validation-using-middleware.md)**: Standardized Input Validation using Middleware (Accepted)
|
||||
**[ADR-008](./0008-api-versioning-strategy.md)**: API Versioning Strategy (Accepted - Phase 2 Complete)
|
||||
**[ADR-018](./0018-api-documentation-strategy.md)**: API Documentation Strategy (Accepted)
|
||||
**[ADR-018](./0018-api-documentation-strategy.md)**: API Documentation Strategy (Superseded - tsoa migration complete)
|
||||
**[ADR-022](./0022-real-time-notification-system.md)**: Real-time Notification System (Accepted)
|
||||
**[ADR-028](./0028-api-response-standardization.md)**: API Response Standardization and Envelope Pattern (Implemented)
|
||||
|
||||
@@ -74,6 +74,7 @@ This directory contains a log of the architectural decisions made for the Flyer
|
||||
**[ADR-045](./0045-test-data-factories-and-fixtures.md)**: Test Data Factories and Fixtures (Accepted)
|
||||
**[ADR-047](./0047-project-file-and-folder-organization.md)**: Project File and Folder Organization (Proposed)
|
||||
**[ADR-057](./0057-test-remediation-post-api-versioning.md)**: Test Remediation Post-API Versioning (Accepted)
|
||||
**[ADR-059](./0059-dependency-modernization.md)**: Dependency Modernization - tsoa Migration (Accepted)
|
||||
|
||||
## 9. Architecture Patterns
|
||||
|
||||
|
||||
@@ -4,22 +4,24 @@ Common code patterns extracted from Architecture Decision Records (ADRs). Use th
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Pattern | Key Function/Class | Import From |
|
||||
| ------------------ | ------------------------------------------------- | ------------------------------------- |
|
||||
| Error Handling | `handleDbError()`, `NotFoundError` | `src/services/db/errors.db.ts` |
|
||||
| Repository Methods | `get*`, `find*`, `list*` | `src/services/db/*.db.ts` |
|
||||
| API Responses | `sendSuccess()`, `sendPaginated()`, `sendError()` | `src/utils/apiResponse.ts` |
|
||||
| Transactions | `withTransaction()` | `src/services/db/connection.db.ts` |
|
||||
| Validation | `validateRequest()` | `src/middleware/validation.ts` |
|
||||
| Authentication | `authenticateJWT` | `src/middleware/auth.ts` |
|
||||
| Caching | `cacheService` | `src/services/cache.server.ts` |
|
||||
| Background Jobs | Queue classes | `src/services/queues.server.ts` |
|
||||
| Feature Flags | `isFeatureEnabled()`, `useFeatureFlag()` | `src/services/featureFlags.server.ts` |
|
||||
| Pattern | Key Function/Class | Import From |
|
||||
| -------------------- | ------------------------------------------------- | ------------------------------------- |
|
||||
| **tsoa Controllers** | `BaseController`, `@Route`, `@Security` | `src/controllers/base.controller.ts` |
|
||||
| Error Handling | `handleDbError()`, `NotFoundError` | `src/services/db/errors.db.ts` |
|
||||
| Repository Methods | `get*`, `find*`, `list*` | `src/services/db/*.db.ts` |
|
||||
| API Responses | `sendSuccess()`, `sendPaginated()`, `sendError()` | `src/utils/apiResponse.ts` |
|
||||
| Transactions | `withTransaction()` | `src/services/db/connection.db.ts` |
|
||||
| Validation | `validateRequest()` | `src/middleware/validation.ts` |
|
||||
| Authentication | `authenticateJWT`, `@Security('bearerAuth')` | `src/middleware/auth.ts` |
|
||||
| Caching | `cacheService` | `src/services/cache.server.ts` |
|
||||
| Background Jobs | Queue classes | `src/services/queues.server.ts` |
|
||||
| Feature Flags | `isFeatureEnabled()`, `useFeatureFlag()` | `src/services/featureFlags.server.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [tsoa Controllers](#tsoa-controllers)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Repository Patterns](#repository-patterns)
|
||||
- [API Response Patterns](#api-response-patterns)
|
||||
@@ -32,6 +34,159 @@ Common code patterns extracted from Architecture Decision Records (ADRs). Use th
|
||||
|
||||
---
|
||||
|
||||
## tsoa Controllers
|
||||
|
||||
**ADR**: [ADR-018](../adr/0018-api-documentation-strategy.md), [ADR-059](../adr/0059-dependency-modernization.md)
|
||||
|
||||
All API endpoints are implemented as tsoa controller classes that extend `BaseController`. This pattern provides type-safe OpenAPI documentation generation and standardized response formatting.
|
||||
|
||||
### Basic Controller Structure
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Route,
|
||||
Tags,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Path,
|
||||
Query,
|
||||
Security,
|
||||
SuccessResponse,
|
||||
Response,
|
||||
} from 'tsoa';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import {
|
||||
BaseController,
|
||||
SuccessResponse as SuccessResponseType,
|
||||
ErrorResponse,
|
||||
} from './base.controller';
|
||||
|
||||
interface CreateItemRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ItemResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@Route('items')
|
||||
@Tags('Items')
|
||||
export class ItemController extends BaseController {
|
||||
/**
|
||||
* Get an item by ID.
|
||||
* @summary Get item
|
||||
* @param id Item ID
|
||||
*/
|
||||
@Get('{id}')
|
||||
@SuccessResponse(200, 'Item retrieved')
|
||||
@Response<ErrorResponse>(404, 'Item not found')
|
||||
public async getItem(@Path() id: number): Promise<SuccessResponseType<ItemResponse>> {
|
||||
const item = await itemService.getItemById(id);
|
||||
return this.success(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new item. Requires authentication.
|
||||
* @summary Create item
|
||||
*/
|
||||
@Post()
|
||||
@Security('bearerAuth')
|
||||
@SuccessResponse(201, 'Item created')
|
||||
@Response<ErrorResponse>(401, 'Not authenticated')
|
||||
public async createItem(
|
||||
@Body() body: CreateItemRequest,
|
||||
@Request() request: ExpressRequest,
|
||||
): Promise<SuccessResponseType<ItemResponse>> {
|
||||
const user = request.user as UserProfile;
|
||||
const item = await itemService.createItem(body, user.user.user_id);
|
||||
return this.created(item);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### BaseController Response Helpers
|
||||
|
||||
```typescript
|
||||
// Success response (200)
|
||||
return this.success(data);
|
||||
|
||||
// Created response (201)
|
||||
return this.created(data);
|
||||
|
||||
// Paginated response
|
||||
const { page, limit } = this.normalizePagination(queryPage, queryLimit);
|
||||
return this.paginated(items, { page, limit, total });
|
||||
|
||||
// Message-only response
|
||||
return this.message('Operation completed');
|
||||
|
||||
// No content (204)
|
||||
return this.noContent();
|
||||
```
|
||||
|
||||
### Authentication with @Security
|
||||
|
||||
```typescript
|
||||
import { Security, Request } from 'tsoa';
|
||||
import { requireAdminRole } from '../middleware/tsoaAuthentication';
|
||||
|
||||
// Require authentication
|
||||
@Get('profile')
|
||||
@Security('bearerAuth')
|
||||
public async getProfile(@Request() req: ExpressRequest): Promise<...> {
|
||||
const user = req.user as UserProfile;
|
||||
return this.success(user);
|
||||
}
|
||||
|
||||
// Require admin role
|
||||
@Delete('users/{id}')
|
||||
@Security('bearerAuth')
|
||||
public async deleteUser(@Path() id: string, @Request() req: ExpressRequest): Promise<void> {
|
||||
requireAdminRole(req.user as UserProfile);
|
||||
await userService.deleteUser(id);
|
||||
return this.noContent();
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling in Controllers
|
||||
|
||||
```typescript
|
||||
import { NotFoundError, ValidationError, ForbiddenError } from './base.controller';
|
||||
|
||||
// Throw errors - they're handled by the global error handler
|
||||
throw new NotFoundError('Item', id); // 404
|
||||
throw new ValidationError([], 'Invalid'); // 400
|
||||
throw new ForbiddenError('Admin only'); // 403
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```typescript
|
||||
import { Middlewares } from 'tsoa';
|
||||
import { loginLimiter } from '../config/rateLimiters';
|
||||
|
||||
@Post('login')
|
||||
@Middlewares(loginLimiter)
|
||||
@Response<ErrorResponse>(429, 'Too many attempts')
|
||||
public async login(@Body() body: LoginRequest): Promise<...> { ... }
|
||||
```
|
||||
|
||||
### Regenerating Routes
|
||||
|
||||
After modifying controllers, regenerate the tsoa routes:
|
||||
|
||||
```bash
|
||||
npm run tsoa:spec && npm run tsoa:routes
|
||||
```
|
||||
|
||||
**Full Guide**: See [TSOA-MIGRATION-GUIDE.md](./TSOA-MIGRATION-GUIDE.md) for comprehensive documentation.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
**ADR**: [ADR-001](../adr/0001-standardized-error-handling.md)
|
||||
|
||||
899
docs/development/TSOA-MIGRATION-GUIDE.md
Normal file
899
docs/development/TSOA-MIGRATION-GUIDE.md
Normal file
@@ -0,0 +1,899 @@
|
||||
# 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/)
|
||||
Reference in New Issue
Block a user