Controladores
Los controladores en Fox Framework son componentes fundamentales que gestionan las solicitudes HTTP y devuelven respuestas apropiadas. Implementan la lógica de negocio de tu aplicación y actúan como intermediarios entre las rutas y los servicios.
Características Principales
- Estructura uniforme: Diseño estandarizado para todos los controladores
- Decoradores: Uso de decoradores para metadatos y comportamientos
- Inyección de dependencias: Integración con el sistema DI del framework
- Middleware: Soporte para middleware a nivel de controlador y método
- Validación: Validación automática de datos de entrada
- Respuestas tipadas: Formato de respuesta consistente con tipado
- Testing: Fácil creación de pruebas unitarias
Controlador Básico
Un controlador básico en Fox Framework tiene esta estructura:
import { Controller, Get, Post, HttpContext, HttpResponse } from '@foxframework/core';
import { UserService } from '../services/user.service';
@Controller('/users')
export class UserController {
// Inyección de dependencias
constructor(private userService: UserService) {}
@Get('/')
async getAllUsers(ctx: HttpContext): Promise<HttpResponse> {
const users = await this.userService.findAll();
return ctx.response.success(users);
}
@Get('/:id')
async getUserById(ctx: HttpContext): Promise<HttpResponse> {
const { id } = ctx.params;
const user = await this.userService.findById(id);
if (!user) {
return ctx.response.notFound('Usuario no encontrado');
}
return ctx.response.success(user);
}
@Post('/')
async createUser(ctx: HttpContext): Promise<HttpResponse> {
const userData = ctx.body;
const newUser = await this.userService.create(userData);
return ctx.response.created(newUser);
}
}Decoradores de Controlador
Fox Framework proporciona varios decoradores para definir controladores y sus métodos:
Decorador de Controlador
@Controller(basePath: string, options?: ControllerOptions)Este decorador define una clase como controlador y establece su ruta base:
import { Controller } from '@foxframework/core';
@Controller('/api/products')
export class ProductController {
// Métodos del controlador
}
// Con opciones
@Controller('/api/admin', {
middleware: [adminAuthMiddleware],
tags: ['admin', 'api']
})
export class AdminController {
// Métodos del controlador
}Decoradores de Métodos HTTP
@Get(path?: string, options?: RouteOptions)
@Post(path?: string, options?: RouteOptions)
@Put(path?: string, options?: RouteOptions)
@Delete(path?: string, options?: RouteOptions)
@Patch(path?: string, options?: RouteOptions)
@Head(path?: string, options?: RouteOptions)
@Options(path?: string, options?: RouteOptions)Estos decoradores definen los métodos de controlador para diferentes verbos HTTP:
import { Controller, Get, Post, Put, Delete } from '@foxframework/core';
@Controller('/posts')
export class PostController {
// GET /posts
@Get('/')
async getAllPosts() { /*...*/ }
// GET /posts/:id
@Get('/:id')
async getPostById() { /*...*/ }
// POST /posts
@Post('/')
async createPost() { /*...*/ }
// PUT /posts/:id
@Put('/:id')
async updatePost() { /*...*/ }
// DELETE /posts/:id
@Delete('/:id')
async deletePost() { /*...*/ }
}Decoradores de Middleware
@UseMiddleware(...middleware: Middleware[])Este decorador aplica middleware a un controlador o método específico:
import { Controller, Get, UseMiddleware } from '@foxframework/core';
import { authMiddleware, loggingMiddleware } from '../middleware';
// Middleware a nivel de controlador
@Controller('/admin')
@UseMiddleware(authMiddleware, loggingMiddleware)
export class AdminController {
// Todos los métodos usarán ambos middlewares
@Get('/dashboard')
async dashboard() { /*...*/ }
// Middleware adicional para un método específico
@Get('/settings')
@UseMiddleware(roleMiddleware('admin'))
async settings() { /*...*/ }
}Decoradores de Validación
@Validate(schema: Schema)Este decorador aplica validación de entrada a los métodos de controlador:
import { Controller, Post, Validate } from '@foxframework/core';
import { Schema } from '@foxframework/validation';
@Controller('/users')
export class UserController {
// Validar datos de entrada
@Post('/')
@Validate(new Schema({
name: { type: 'string', required: true, min: 2, max: 100 },
email: { type: 'email', required: true },
age: { type: 'number', min: 18 }
}))
async createUser(ctx: HttpContext) {
// Los datos ya están validados cuando llegan aquí
const userData = ctx.body;
// ...
}
}Contexto HTTP
El objeto HttpContext proporciona acceso a la información de la solicitud y respuesta:
interface HttpContext {
request: HttpRequest; // Información de la solicitud
response: HttpResponse; // Objeto de respuesta
params: Record<string, string>; // Parámetros de ruta
query: Record<string, string>; // Parámetros de consulta
body: any; // Cuerpo de la solicitud
headers: Record<string, string>; // Cabeceras HTTP
cookies: Record<string, string>; // Cookies
session: Record<string, any>; // Datos de sesión
state: Record<string, any>; // Estado compartido
auth: AuthContext; // Información de autenticación
config: AppConfig; // Configuración de la aplicación
}Ejemplo de uso del contexto:
@Controller('/api')
export class ApiController {
@Get('/profile')
async getProfile(ctx: HttpContext) {
// Acceder a parámetros
const userId = ctx.params.id;
// Acceder a query params
const { page, limit } = ctx.query;
// Acceder a cabeceras
const token = ctx.headers.authorization;
// Acceder a cookies
const sessionId = ctx.cookies.sessionId;
// Usar el contexto de autenticación
if (!ctx.auth.isAuthenticated) {
return ctx.response.unauthorized('No autorizado');
}
// Acceder al usuario actual
const currentUser = ctx.auth.user;
// Usar el estado compartido (establecido por middleware)
const cachedData = ctx.state.cache;
// Configuración
const apiVersion = ctx.config.api.version;
// Devolver respuesta
return ctx.response.success({
user: currentUser,
settings: ctx.session.settings
});
}
}Respuestas HTTP
El objeto HttpResponse facilita la creación de respuestas consistentes:
// Respuestas de éxito
ctx.response.success(data); // 200 OK
ctx.response.created(data); // 201 Created
ctx.response.noContent(); // 204 No Content
ctx.response.accepted(); // 202 Accepted
// Respuestas de error
ctx.response.badRequest(message); // 400 Bad Request
ctx.response.unauthorized(message); // 401 Unauthorized
ctx.response.forbidden(message); // 403 Forbidden
ctx.response.notFound(message); // 404 Not Found
ctx.response.methodNotAllowed(message); // 405 Method Not Allowed
ctx.response.conflict(message); // 409 Conflict
ctx.response.tooMany(message); // 429 Too Many Requests
ctx.response.error(message, status); // Error personalizado
// Redirecciones
ctx.response.redirect(url); // 302 Found
ctx.response.permanentRedirect(url); // 301 Moved PermanentlyTambién puedes personalizar la respuesta:
// Respuesta con cabeceras personalizadas
return ctx.response.success(data, {
headers: {
'Cache-Control': 'max-age=3600',
'X-Custom-Header': 'Value'
}
});
// Respuesta con código de estado personalizado
return ctx.response.send({
message: 'Procesando',
status: 'pending'
}, 202);
// Enviar un archivo
return ctx.response.file('/path/to/file.pdf', {
contentType: 'application/pdf',
download: true,
filename: 'documento.pdf'
});
// Enviar un stream
const fileStream = fs.createReadStream('/path/to/large-file.mp4');
return ctx.response.stream(fileStream, {
contentType: 'video/mp4'
});
// Respuesta JSON con formato estándar
return ctx.response.json({
success: true,
data: result,
meta: {
totalCount: 100,
page: 1
}
});Inyección de Dependencias
Los controladores aprovechan el sistema de inyección de dependencias del framework:
import { Controller, Get, Inject } from '@foxframework/core';
import { UserService } from '../services/user.service';
import { LoggerService } from '../services/logger.service';
import { ConfigService } from '../services/config.service';
@Controller('/users')
export class UserController {
// Inyección por constructor
constructor(
private userService: UserService,
private logger: LoggerService
) {}
// Inyección por propiedad
@Inject()
private config: ConfigService;
@Get('/:id')
async getUser(ctx: HttpContext) {
const userId = ctx.params.id;
this.logger.info(`Fetching user ${userId}`);
const apiKey = this.config.get('api.key');
const user = await this.userService.findById(userId);
return ctx.response.success(user);
}
}Métodos de Controlador
Los métodos de controlador pueden definirse de varias maneras:
// Método asíncrono con HttpContext
@Get('/:id')
async getUser(ctx: HttpContext): Promise<HttpResponse> {
// ...
}
// Con parámetros específicos inyectados
@Post('/')
async createUser(
@Body() userData: any,
@Header('authorization') token: string,
@Param('tenantId') tenantId: string,
@Query('include') include: string
): Promise<HttpResponse> {
// ...
}
// Con parámetros de ruta tipados
@Get('/:id')
async getUserById(@Param('id', { type: 'number' }) id: number): Promise<User> {
// El parámetro id ya está convertido a número
return this.userService.findById(id);
}
// Con auto-inferencia de respuesta
@Get('/products')
async getProducts(): Promise<Product[]> {
// El framework automáticamente envuelve el resultado en una respuesta exitosa
return this.productService.findAll();
}Controladores con Estado
Puedes mantener estado en los controladores para ciertos casos de uso:
@Controller('/cache')
export class CacheController {
// Estado privado del controlador
private cache = new Map<string, any>();
private lastUpdated: Date;
constructor() {
this.lastUpdated = new Date();
}
@Get('/:key')
async getFromCache(ctx: HttpContext) {
const key = ctx.params.key;
if (this.cache.has(key)) {
return ctx.response.success({
data: this.cache.get(key),
fromCache: true,
lastUpdated: this.lastUpdated
});
}
return ctx.response.notFound('Clave no encontrada');
}
@Put('/:key')
async updateCache(ctx: HttpContext) {
const key = ctx.params.key;
const data = ctx.body;
this.cache.set(key, data);
this.lastUpdated = new Date();
return ctx.response.success({ stored: true });
}
}Sin embargo, recuerda que en entornos sin estado como serverless, el estado del controlador se pierde entre peticiones.
Controladores Anidados
Puedes organizar controladores jerárquicamente:
// Controlador base
@Controller('/api')
export class ApiController {
@Get('/version')
getVersion() {
return { version: '1.0.0' };
}
}
// Controlador anidado
@Controller('/users')
export class UserController extends ApiController {
// Hereda getVersion y agrega sus propios métodos
// Ruta: /api/users
@Get('/')
async getUsers() {
// ...
}
}Ciclo de Vida
Los controladores incluyen métodos de ciclo de vida que puedes implementar:
@Controller('/resource')
export class ResourceController {
// Llamado cuando se crea una instancia del controlador
initialize() {
// Configuración inicial
}
// Llamado antes de procesar cada solicitud
beforeRequest(ctx: HttpContext) {
console.log('Procesando solicitud:', ctx.request.url);
}
// Llamado después de procesar cada solicitud
afterRequest(ctx: HttpContext, result: any) {
console.log('Solicitud procesada, respuesta:', result);
}
// Llamado cuando ocurre un error en cualquier método
handleError(ctx: HttpContext, error: Error) {
console.error('Error en controlador:', error);
return ctx.response.error('Error interno', 500);
}
// Métodos normales del controlador
@Get('/')
async getResources() {
// ...
}
}Controladores Especializados
Fox Framework incluye controladores base para casos de uso comunes:
RESTController
import { RESTController } from '@foxframework/core';
import { UserService } from '../services/user.service';
@Controller('/api/users')
export class UserController extends RESTController<User> {
constructor(private userService: UserService) {
super();
}
// Implementa métodos abstractos
async findAll(ctx: HttpContext): Promise<User[]> {
return this.userService.findAll();
}
async findById(id: string, ctx: HttpContext): Promise<User | null> {
return this.userService.findById(id);
}
async create(data: any, ctx: HttpContext): Promise<User> {
return this.userService.create(data);
}
async update(id: string, data: any, ctx: HttpContext): Promise<User | null> {
return this.userService.update(id, data);
}
async delete(id: string, ctx: HttpContext): Promise<boolean> {
return this.userService.delete(id);
}
}El RESTController ya incluye implementaciones para:
GET /api/users- Lista todosGET /api/users/:id- Obtiene por IDPOST /api/users- Crea nuevoPUT /api/users/:id- Actualiza por IDDELETE /api/users/:id- Elimina por ID
ResourceController
import { ResourceController, Validate } from '@foxframework/core';
import { UserSchema } from '../schemas/user.schema';
@Controller('/users')
export class UserController extends ResourceController {
// Define esquemas de validación
protected createSchema = UserSchema;
protected updateSchema = UserSchema.partial();
// Define permisos
protected permissions = {
list: ['users:view'],
show: ['users:view'],
create: ['users:create'],
update: ['users:edit'],
delete: ['users:delete'],
};
// Personaliza comportamientos específicos
async beforeCreate(data: any, ctx: HttpContext) {
// Agregar datos adicionales
data.createdBy = ctx.auth.userId;
data.status = 'active';
return data;
}
async afterDelete(id: string, ctx: HttpContext) {
// Acciones de limpieza
await this.auditService.log('user_deleted', { id, by: ctx.auth.userId });
}
}Controladores para Archivos
import { Controller, Post, Get } from '@foxframework/core';
import { UploadedFile } from '@foxframework/files';
@Controller('/files')
export class FileController {
@Post('/upload')
async uploadFile(ctx: HttpContext) {
const file = await ctx.request.file('document');
if (!file) {
return ctx.response.badRequest('No se proporcionó ningún archivo');
}
// Validar tipo de archivo
if (!file.isImage()) {
return ctx.response.badRequest('El archivo debe ser una imagen');
}
// Guardar archivo
const path = await file.store('uploads', {
name: `${Date.now()}-${file.name}`
});
return ctx.response.success({
filename: file.name,
size: file.size,
path: path
});
}
@Post('/upload-multiple')
async uploadMultiple(ctx: HttpContext) {
const files = await ctx.request.files('documents');
if (!files || files.length === 0) {
return ctx.response.badRequest('No se proporcionaron archivos');
}
const results = [];
for (const file of files) {
// Procesar cada archivo
const path = await file.store('uploads');
results.push({
name: file.name,
size: file.size,
path: path
});
}
return ctx.response.success({ files: results });
}
@Get('/download/:filename')
async downloadFile(ctx: HttpContext) {
const { filename } = ctx.params;
const path = `./uploads/${filename}`;
// Enviar archivo como descarga
return ctx.response.download(path, filename);
}
}Controladores para WebSockets
import { WebSocketController, OnConnect, OnMessage, OnDisconnect } from '@foxframework/websockets';
@WebSocketController('/chat')
export class ChatController {
// Mantiene conexiones activas
private connections = new Map<string, WebSocket>();
@OnConnect()
handleConnection(socket: WebSocket, ctx: WebSocketContext) {
const userId = ctx.auth.userId;
// Almacenar conexión
this.connections.set(userId, socket);
// Broadcast de conexión nueva
this.broadcast({
type: 'user_connected',
userId,
timestamp: new Date()
});
}
@OnMessage()
handleMessage(data: any, socket: WebSocket, ctx: WebSocketContext) {
const userId = ctx.auth.userId;
// Procesar mensaje
const message = {
type: 'chat_message',
userId,
text: data.text,
timestamp: new Date()
};
// Enviar a todos
this.broadcast(message);
}
@OnDisconnect()
handleDisconnect(ctx: WebSocketContext) {
const userId = ctx.auth.userId;
// Eliminar conexión
this.connections.delete(userId);
// Broadcast de desconexión
this.broadcast({
type: 'user_disconnected',
userId,
timestamp: new Date()
});
}
private broadcast(message: any) {
const data = JSON.stringify(message);
for (const socket of this.connections.values()) {
socket.send(data);
}
}
}Testing de Controladores
Fox Framework facilita el testing de controladores:
import { createTestContext } from '@foxframework/testing';
import { UserController } from '../controllers/user.controller';
import { UserService } from '../services/user.service';
describe('UserController', () => {
let controller: UserController;
let mockUserService: Partial<UserService>;
beforeEach(() => {
// Mock del servicio
mockUserService = {
findAll: jest.fn().mockResolvedValue([
{ id: '1', name: 'User 1' },
{ id: '2', name: 'User 2' }
]),
findById: jest.fn().mockImplementation((id) => {
if (id === '1') {
return Promise.resolve({ id: '1', name: 'User 1' });
}
return Promise.resolve(null);
}),
create: jest.fn().mockImplementation((data) => {
return Promise.resolve({ id: '3', ...data });
})
};
// Crear controlador con dependencias mockeadas
controller = new UserController(mockUserService as UserService);
});
test('should get all users', async () => {
// Crear contexto de prueba
const ctx = createTestContext({
method: 'GET',
url: '/users'
});
// Ejecutar método
const response = await controller.getAllUsers(ctx);
// Verificaciones
expect(mockUserService.findAll).toHaveBeenCalled();
expect(response.status).toBe(200);
expect(response.body).toEqual([
{ id: '1', name: 'User 1' },
{ id: '2', name: 'User 2' }
]);
});
test('should get user by id', async () => {
// Crear contexto con parámetros
const ctx = createTestContext({
method: 'GET',
url: '/users/1',
params: { id: '1' }
});
const response = await controller.getUserById(ctx);
expect(mockUserService.findById).toHaveBeenCalledWith('1');
expect(response.status).toBe(200);
expect(response.body).toEqual({ id: '1', name: 'User 1' });
});
test('should return 404 for non-existent user', async () => {
const ctx = createTestContext({
method: 'GET',
url: '/users/999',
params: { id: '999' }
});
const response = await controller.getUserById(ctx);
expect(mockUserService.findById).toHaveBeenCalledWith('999');
expect(response.status).toBe(404);
});
test('should create a new user', async () => {
const ctx = createTestContext({
method: 'POST',
url: '/users',
body: { name: 'New User', email: 'new@example.com' }
});
const response = await controller.createUser(ctx);
expect(mockUserService.create).toHaveBeenCalledWith({
name: 'New User',
email: 'new@example.com'
});
expect(response.status).toBe(201);
expect(response.body).toEqual({
id: '3',
name: 'New User',
email: 'new@example.com'
});
});
});Buenas Prácticas
Estructura Recomendada
src/
controllers/
base.controller.ts # Controlador base
user.controller.ts # Controlador específico
product.controller.ts # Otro controlador
admin/ # Agrupación por área
dashboard.controller.ts
settings.controller.ts
api/ # Agrupación por tipo
v1/
users.controller.ts
v2/
users.controller.tsPrincipios a Seguir
-
Responsabilidad única: Cada controlador debe gestionar un recurso o área específica.
-
Delgados: Los controladores deben ser delgados, delegando la lógica de negocio a servicios.
-
Consistencia: Usa formatos de respuesta consistentes en toda la aplicación.
-
Validación temprana: Valida la entrada lo antes posible con los decoradores de validación.
-
Error handling: Maneja los errores de forma centralizada.
-
Documentación: Usa JSDoc para documentar los parámetros y respuestas.
Ejemplo de Controlador Bien Estructurado
/**
* Controlador para la gestión de productos
*/
@Controller('/api/products')
@UseMiddleware(authMiddleware)
@ApiTags('products')
export class ProductController {
constructor(
private productService: ProductService,
private categoryService: CategoryService,
private logger: LoggerService
) {}
/**
* Obtiene la lista de productos con paginación y filtros
*
* @param {number} page Número de página (por defecto: 1)
* @param {number} limit Elementos por página (por defecto: 20)
* @param {string} category Filtrar por categoría
* @returns {Promise<PaginatedResponse<Product>>} Lista paginada de productos
*/
@Get('/')
@ApiOperation({ summary: 'Get all products', description: 'Returns paginated list of products' })
@ApiQuery({ name: 'page', type: 'number', required: false })
@ApiQuery({ name: 'limit', type: 'number', required: false })
@ApiQuery({ name: 'category', type: 'string', required: false })
async getProducts(ctx: HttpContext): Promise<PaginatedResponse<Product>> {
try {
const { page = 1, limit = 20, category } = ctx.query;
// Convertir a números
const pageNum = parseInt(page as string, 10);
const limitNum = parseInt(limit as string, 10);
// Validar parámetros
if (isNaN(pageNum) || pageNum < 1) {
return ctx.response.badRequest('Página inválida');
}
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
return ctx.response.badRequest('Límite inválido');
}
// Filtros
const filters: ProductFilters = {};
if (category) {
filters.categoryId = await this.resolveCategoryId(category);
}
// Obtener productos
const result = await this.productService.findPaginated({
page: pageNum,
limit: limitNum,
filters
});
return ctx.response.success({
data: result.items,
meta: {
total: result.total,
page: pageNum,
limit: limitNum,
pages: Math.ceil(result.total / limitNum)
}
});
} catch (error) {
this.logger.error('Error getting products', error);
return ctx.response.error('Error al obtener productos', 500);
}
}
/**
* Obtiene un producto por su ID
*/
@Get('/:id')
async getProduct(ctx: HttpContext): Promise<HttpResponse> {
const { id } = ctx.params;
try {
const product = await this.productService.findById(id);
if (!product) {
return ctx.response.notFound('Producto no encontrado');
}
// Transformar la respuesta
const result = this.transformProduct(product);
return ctx.response.success(result);
} catch (error) {
this.logger.error(`Error getting product ${id}`, error);
return ctx.response.error('Error al obtener el producto', 500);
}
}
/**
* Crea un nuevo producto
*/
@Post('/')
@Validate(ProductSchema)
async createProduct(ctx: HttpContext): Promise<HttpResponse> {
// El body ya está validado gracias al decorador @Validate
const productData = ctx.body;
try {
// Agregar datos adicionales
productData.createdBy = ctx.auth.userId;
productData.createdAt = new Date();
const product = await this.productService.create(productData);
// Registrar actividad
this.logger.info(`Product created: ${product.id}`, {
userId: ctx.auth.userId,
productId: product.id
});
return ctx.response.created(product);
} catch (error) {
this.logger.error('Error creating product', error);
return ctx.response.error('Error al crear el producto', 500);
}
}
// Métodos privados de utilidad
private async resolveCategoryId(categorySlug: string): Promise<string> {
const category = await this.categoryService.findBySlug(categorySlug);
return category?.id;
}
private transformProduct(product: Product): ProductResponse {
return {
...product,
price: {
amount: product.price,
formatted: `$${product.price.toFixed(2)}`,
currency: 'USD'
},
url: `/products/${product.slug || product.id}`
};
}
}Conclusión
Los controladores son fundamentales en la arquitectura de Fox Framework, gestionando las solicitudes HTTP y devolviendo respuestas adecuadas. Al separar las responsabilidades, utilizando decoradores para comportamiento reutilizable, y aprovechando el sistema de inyección de dependencias, puedes crear APIs limpias, mantenibles y fáciles de probar.