Controllers
Controllers handle HTTP requests and coordinate between models, services, and responses. ZinTrust supports multiple controller patterns to fit different architectural preferences and use cases.
Table of Contents
- Controllers
Creating Controllers
Generate a new controller using the CLI:
zin add controller UserController
zin add controller api/ProductController
zin add controller admin/DashboardControllerControllers are stored in `app/Controllers/` and can be organized into subdirectories.
Controller Patterns
ZinTrust supports three main controller patterns:
1. Sealed Namespace (Recommended)
The sealed namespace pattern uses `Object.freeze()` with a factory function for immutability and testability:
import type { IRequest, IResponse } from '@zintrust/core';
export const UserController = Object.freeze({
create(): {
index: (req: IRequest, res: IResponse) => Promise<void>;
show: (req: IRequest, res: IResponse) => Promise<void>;
} {
return {
async index(req: IRequest, res: IResponse): Promise<void> {
// Implementation
},
async show(req: IRequest, res: IResponse): Promise<void> {
// Implementation
},
};
},
});
export default UserController;2. Plain Object (Simplest)
import type { IRequest, IResponse } from '@zintrust/core';
export const UserController = {
async index(req: IRequest, res: IResponse): Promise<void> {
// Implementation
},
async show(req: IRequest, res: IResponse): Promise<void> {
// Implementation
},
};
export default UserController;3. Factory with Dependency Injection
For controllers that need dependencies:
import type { IRequest, IResponse } from '@zintrust/core';
import type { UserService } from '@app/Services/UserService';
export const createUserController = (userService: UserService) => ({
async index(req: IRequest, res: IResponse): Promise<void> {
const users = await userService.getAllUsers();
res.json({ data: users });
},
async show(req: IRequest, res: IResponse): Promise<void> {
const user = await userService.getUserById(req.params.id);
if (!user) {
res.setStatus(404).json({ error: 'User not found' });
return;
}
res.json({ data: user });
},
});Using Models vs QueryBuilder
ZinTrust provides two approaches for database operations: Model-based (ORM) and QueryBuilder-based (SQL builder).
Model-Based Approach
Use models when you need:
- Object-oriented data access
- Relationships between entities
- Model observers/hooks
- Attribute accessors and mutators
import { User } from '@app/Models/User';
import type { IRequest, IResponse } from '@zintrust/core';
import { Logger } from '@config/logger';
export const UserController = {
/**
* List all users (Model approach)
*/
async index(req: IRequest, res: IResponse): Promise<void> {
try {
const users = await User.all();
res.json({ data: users });
} catch (error) {
Logger.error('Error fetching users:', error);
res.setStatus(500).json({ error: 'Failed to fetch users' });
}
},
/**
* Get a single user
*/
async show(req: IRequest, res: IResponse): Promise<void> {
try {
const user = await User.find(req.params.id);
if (!user) {
res.setStatus(404).json({ error: 'User not found' });
return;
}
res.json({ data: user });
} catch (error) {
Logger.error('Error fetching user:', error);
res.setStatus(500).json({ error: 'Failed to fetch user' });
}
},
/**
* Create a new user
*/
async store(req: IRequest, res: IResponse): Promise<void> {
try {
const { name, email, password } = req.getBody() as {
name: string;
email: string;
password: string;
};
const user = User.create({ name, email, password });
await user.save();
res.setStatus(201).json({
message: 'User created',
data: user.toJSON(),
});
} catch (error) {
Logger.error('Error creating user:', error);
res.setStatus(500).json({ error: 'Failed to create user' });
}
},
/**
* Update a user
*/
async update(req: IRequest, res: IResponse): Promise<void> {
try {
const user = await User.find(req.params.id);
if (!user) {
res.setStatus(404).json({ error: 'User not found' });
return;
}
const { name, email } = req.getBody() as {
name?: string;
email?: string;
};
if (name) user.setAttribute('name', name);
if (email) user.setAttribute('email', email);
await user.save();
res.json({
message: 'User updated',
data: user.toJSON(),
});
} catch (error) {
Logger.error('Error updating user:', error);
res.setStatus(500).json({ error: 'Failed to update user' });
}
},
/**
* Delete a user
*/
async destroy(req: IRequest, res: IResponse): Promise<void> {
try {
const user = await User.find(req.params.id);
if (!user) {
res.setStatus(404).json({ error: 'User not found' });
return;
}
await user.delete();
res.json({ message: 'User deleted' });
} catch (error) {
Logger.error('Error deleting user:', error);
res.setStatus(500).json({ error: 'Failed to delete user' });
}
},QueryBuilder Approach
Use QueryBuilder when you need:
- Direct SQL control
- Complex joins and aggregations
- Optimal performance
- Bulk operations
Example from `app/Controllers/UserQueryBuilderController.ts`:
import { useEnsureDbConnected } from '@orm/Database';
import { QueryBuilder } from '@orm/QueryBuilder';
import { Sanitizer } from '@security/Sanitizer';
import { Schema, Validator } from '@validation/Validator';
import type { IRequest, IResponse } from '@zintrust/core';
import { Logger } from '@config/logger';
export const UserQueryBuilderController = {
/**
* List users (QueryBuilder approach)
*/
async index(req: IRequest, res: IResponse): Promise<void> {
try {
const subject = typeof req.user?.sub === 'string' ? req.user.sub : undefined;
if (!subject) {
res.setStatus(401).json({ error: 'Unauthorized' });
return;
}
const db = await useEnsureDbConnected();
const users = await QueryBuilder.create('users', db)
.select('id', 'name', 'email', 'created_at', 'updated_at')
.where('id', '=', subject)
.limit(1)
.get();
res.json({ data: users });
} catch (error) {
Logger.error('Error fetching users:', error);
res.setStatus(500).json({ error: 'Failed to fetch users' });
}
},
/**
* Show a specific user
*/
async show(req: IRequest, res: IResponse): Promise<void> {
try {
const db = await useEnsureDbConnected();
// Sanitize untrusted input
const id = Sanitizer.digitsOnly(req.params.id);
if (!id) {
res.setStatus(400).json({ error: 'Invalid user ID' });
return;
}
const user = await QueryBuilder.create('users', db)
.select('id', 'name', 'email', 'created_at', 'updated_at')
.where('id', '=', id)
.limit(1)
.first();
if (!user) {
res.setStatus(404).json({ error: 'User not found' });
return;
}
res.json({ data: user });
} catch (error) {
Logger.error('Error fetching user:', error);
res.setStatus(500).json({ error: 'Failed to fetch user' });
}
},
};QueryBuilder with Complex Queries
export const ReportController = {
/**
* Get sales report with aggregations
*/
async salesReport(req: IRequest, res: IResponse): Promise<void> {
try {
const db = await useEnsureDbConnected();
const report = await QueryBuilder.create('orders', db)
.select(
'DATE(created_at) as date',
'COUNT(*) as total_orders',
'SUM(total_amount) as revenue'
)
.where('status', '=', 'completed')
.where('created_at', '>=', '2024-01-01')
.groupBy('DATE(created_at)')
.orderBy('date', 'DESC')
.get();
res.json({ data: report });
} catch (error) {
Logger.error('Error generating report:', error);
res.setStatus(500).json({ error: 'Failed to generate report' });
}
},
/**
* Get user orders with joins
*/
async userOrders(req: IRequest, res: IResponse): Promise<void> {
try {
const db = await useEnsureDbConnected();
const userId = Sanitizer.digitsOnly(req.params.id);
const orders = await QueryBuilder.create('orders', db)
.select(
'orders.id',
'orders.total_amount',
'orders.status',
'orders.created_at',
'users.name as user_name',
'users.email as user_email'
)
.join('users', 'users.id = orders.user_id')
.where('orders.user_id', '=', userId)
.orderBy('orders.created_at', 'DESC')
.get();
res.json({ data: orders });
} catch (error) {
Logger.error('Error fetching orders:', error);
res.setStatus(500).json({ error: 'Failed to fetch orders' });
}
},
};Authentication Controllers
Example from `app/Controllers/AuthController.ts`:
import { Auth } from '@/features/Auth';
import { Logger } from '@config/logger';
import { JwtManager } from '@security/JwtManager';
import { TokenRevocation } from '@security/TokenRevocation';
import { useEnsureDbConnected } from '@orm/Database';
import { QueryBuilder } from '@orm/QueryBuilder';
import type { IRequest, IResponse } from '@zintrust/core';
export const AuthController = Object.freeze({
create() {
return {
/**
* Login with email and password
*/
async login(req: IRequest, res: IResponse): Promise<void> {
const { email, password } = req.getBody() as {
email: string;
password: string;
};
const ipAddress = req.getRaw().socket.remoteAddress ?? 'unknown';
try {
const db = await useEnsureDbConnected();
const user = await QueryBuilder.create('users', db)
.where('email', '=', email)
.limit(1)
.first();
if (!user) {
Logger.warn('Failed login attempt', { email, ip: ipAddress, reason: 'user_not_found' });
return res.setStatus(401).json({ error: 'Invalid credentials' });
}
const passwordValid = await Auth.compare(password, user.password as string);
if (!passwordValid) {
Logger.warn('Failed login attempt', {
email,
ip: ipAddress,
reason: 'invalid_password',
});
return res.setStatus(401).json({ error: 'Invalid credentials' });
}
const token = JwtManager.signAccessToken({
sub: String(user.id),
email: user.email,
});
Logger.info('Successful login', { userId: user.id, email, ip: ipAddress });
res.json({
token,
token_type: 'Bearer',
user: {
id: user.id,
name: user.name,
email: user.email,
},
});
} catch (error) {
Logger.error('Login failed', error);
res.setStatus(500).json({ error: 'Login failed' });
}
},
/**
* Register a new user
*/
async register(req: IRequest, res: IResponse): Promise<void> {
const { name, email, password } = req.getBody() as {
name: string;
email: string;
password: string;
};
try {
const db = await useEnsureDbConnected();
// Check if email exists
const existing = await QueryBuilder.create('users', db)
.where('email', '=', email)
.limit(1)
.first();
if (existing) {
Logger.warn('Duplicate email registration attempt', { email });
return res.setStatus(409).json({ error: 'Email already registered' });
}
// Hash password
const passwordHash = await Auth.hash(password);
// Insert user
await QueryBuilder.create('users', db).insert({
name,
email,
password: passwordHash,
});
Logger.info('User registered successfully', { email });
res.setStatus(201).json({ message: 'Registered' });
} catch (error) {
Logger.error('Registration failed', error);
res.setStatus(500).json({ error: 'Registration failed' });
}
},
/**
* Logout and revoke token
*/
async logout(req: IRequest, res: IResponse): Promise<void> {
const authHeader = req.getHeader('authorization');
TokenRevocation.revoke(authHeader);
res.json({ message: 'Logged out' });
},
/**
* Refresh access token
*/
async refresh(req: IRequest, res: IResponse): Promise<void> {
const user = req.user;
if (!user) {
return res.setStatus(401).json({ error: 'Unauthorized' });
}
const token = JwtManager.signAccessToken(user);
res.json({ token, token_type: 'Bearer' });
},
};
},
});
export default AuthController;CRUD Operations
RESTful CRUD Pattern
export const ProductController = {
/**
* GET /products - List all products
*/
async index(req: IRequest, res: IResponse): Promise<void> {
const db = await useEnsureDbConnected();
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 20;
const offset = (page - 1) * limit;
const products = await QueryBuilder.create('products', db)
.select('*')
.limit(limit)
.offset(offset)
.orderBy('created_at', 'DESC')
.get();
const total = await QueryBuilder.create('products', db).count('*', 'total').first();
res.json({
data: products,
pagination: {
page,
limit,
total: total?.total || 0,
pages: Math.ceil((total?.total || 0) / limit),
},
});
},
/**
* GET /products/:id - Show single product
*/
async show(req: IRequest, res: IResponse): Promise<void> {
const product = await Product.find(req.params.id);
if (!product) {
res.setStatus(404).json({ error: 'Product not found' });
return;
}
res.json({ data: product });
},
/**
* POST /products - Create new product
*/
async store(req: IRequest, res: IResponse): Promise<void> {
const product = Product.create(req.getBody());
await product.save();
res.setStatus(201).json({
message: 'Product created',
data: product,
});
},
/**
* PUT /products/:id - Update product
*/
async update(req: IRequest, res: IResponse): Promise<void> {
const product = await Product.find(req.params.id);
if (!product) {
res.setStatus(404).json({ error: 'Product not found' });
return;
}
product.fill(req.getBody());
await product.save();
res.json({ message: 'Product updated', data: product });
},
/**
* DELETE /products/:id - Delete product
*/
async destroy(req: IRequest, res: IResponse): Promise<void> {
const product = await Product.find(req.params.id);
if (!product) {
res.setStatus(404).json({ error: 'Product not found' });
return;
}
await product.delete();
res.json({ message: 'Product deleted' });
},
};Request Handling
Accessing Request Data
export const ExampleController = {
async handle(req: IRequest, res: IResponse): Promise<void> {
// Get route parameters
const userId = req.params.id;
const postId = req.params.postId;
// Get query parameters
const page = req.query.page;
const search = req.query.q;
// Get request body
const body = req.getBody();
// Get headers
const contentType = req.getHeader('content-type');
const authorization = req.getHeader('authorization');
// Get authenticated user
const user = req.user;
// Get uploaded file
const file = req.file('avatar');
// Get all files
const files = req.files('documents');
},
};Response Helpers
export const ExampleController = {
async examples(req: IRequest, res: IResponse): Promise<void> {
// JSON response
res.json({ message: 'Success', data: { id: 1 } });
// JSON with custom status
res.setStatus(201).json({ message: 'Created' });
// Error responses
res.setStatus(400).json({ error: 'Bad Request' });
res.setStatus(404).json({ error: 'Not Found' });
res.setStatus(500).json({ error: 'Internal Server Error' });
// Redirect
res.redirect('/login');
// Set headers
res.setHeader('X-Custom-Header', 'value');
// Download file
res.download('/path/to/file.pdf');
},
};Validation & Sanitization
Defense-in-Depth Security
Always sanitize inputs, even after middleware validation:
import { Sanitizer } from '@security/Sanitizer';
import { Schema, Validator } from '@validation/Validator';
export const SecureController = {
async store(req: IRequest, res: IResponse): Promise<void> {
try {
const body = req.getBody() as Record<string, unknown>;
// Layer 1: Sanitize untrusted inputs
const name = Sanitizer.nameText(body.name);
const email = Sanitizer.email(body.email);
const password = Sanitizer.safePasswordChars(body.password);
// Layer 2: Validate schema
const schema = Schema.create()
.required('name')
.string('name')
.minLength('name', 1)
.maxLength('name', 100)
.required('email')
.string('email')
.email('email')
.required('password')
.string('password')
.minLength('password', 8);
Validator.validate({ name, email, password }, schema);
// Safe to use validated data
const db = await useEnsureDbConnected();
await QueryBuilder.create('users', db).insert({
name,
email,
password: await Auth.hash(password),
});
res.setStatus(201).json({ message: 'User created' });
} catch (error) {
if (error.name === 'SanitizerError') {
return res.setStatus(400).json({ error: error.message });
}
if (error.name === 'ValidationError') {
return res.setStatus(422).json({ errors: error.toObject() });
}
Logger.error('Error creating user:', error);
res.setStatus(500).json({ error: 'Failed to create user' });
}
},
};Error Handling
Structured Error Handling
const isValidationError = (error: unknown): error is ValidationError => {
return error?.name === 'ValidationError' && typeof error.toObject === 'function';
};
const isSanitizerError = (error: unknown): error is SanitizerError => {
return error?.name === 'SanitizerError';
};
export const RobustController = {
async store(req: IRequest, res: IResponse): Promise<void> {
try {
// Your logic here
} catch (error) {
// Handle specific errors
if (isSanitizerError(error)) {
return res.setStatus(400).json({ error: error.message });
}
if (isValidationError(error)) {
return res.setStatus(422).json({ errors: error.toObject() });
}
// Handle database errors
if (error.code === 'ER_DUP_ENTRY') {
return res.setStatus(409).json({ error: 'Duplicate entry' });
}
// Generic error
Logger.error('Unexpected error:', error);
res.setStatus(500).json({ error: 'Internal server error' });
}
},
};Best Practices
1. Keep Controllers Thin
Move business logic to services:
// ❌ Fat Controller
export const OrderController = {
async create(req: IRequest, res: IResponse): Promise<void> {
// 100 lines of business logic...
},
};
// ✅ Thin Controller
export const OrderController = {
async create(req: IRequest, res: IResponse): Promise<void> {
const orderData = req.getBody();
const order = await OrderService.createOrder(orderData);
res.setStatus(201).json({ data: order });
},
};2. Use TypeScript Types
interface CreateUserRequest {
name: string;
email: string;
password: string;
}
interface UserResponse {
id: number;
name: string;
email: string;
created_at: string;
}
export const UserController = {
async store(req: IRequest, res: IResponse): Promise<void> {
const data = req.getBody() as CreateUserRequest;
// Type-safe operations
},
};3. Input Sanitization
Always sanitize route parameters and user inputs:
import { Sanitizer } from '@security/Sanitizer';
export const SecureController = {
async show(req: IRequest, res: IResponse): Promise<void> {
// ❌ Unsafe
const unsafeId = req.params.id;
// ✅ Safe
const id = Sanitizer.digitsOnly(unsafeId);
if (!id) {
return res.setStatus(400).json({ error: 'Invalid ID' });
}
},
};4. Logging & Monitoring
import { Logger } from '@config/logger';
export const MonitoredController = {
async processPayment(req: IRequest, res: IResponse): Promise<void> {
const startTime = Date.now();
try {
Logger.info('Processing payment', { userId: req.user?.sub });
// Process payment
Logger.info('Payment processed', {
userId: req.user?.sub,
duration: Date.now() - startTime,
});
res.json({ success: true });
} catch (error) {
Logger.error('Payment processing failed', {
userId: req.user?.sub,
error: error.message,
duration: Date.now() - startTime,
});
res.setStatus(500).json({ error: 'Payment failed' });
}
},
};Summary
- Choose the right pattern: Use Models for ORM features, QueryBuilder for complex SQL
- Keep controllers thin: Delegate business logic to services
- Validate and sanitize: Defense-in-depth security approach
- Handle errors gracefully: Structured error handling with proper logging
- Type safety: Use TypeScript interfaces for request/response data
- Log everything: Track requests, errors, and performance metrics