Custom Validation Overview
Business Rules
Domain-specific validation Complex condition handling
DB Validation
Duplicate checks Referential integrity
External APIs
External service validation Real-time data verification
Reusability
Shared validation logic Consistent rule application
Basic Patterns
Validation Methods
class UserModel extends BaseModelClass {
@api({ httpMethod: "POST" })
async create(params: CreateUserParams): Promise<{ userId: number }> {
// Run validation
await this.validateCreateParams(params);
const wdb = this.getPuri("w");
const [user] = await wdb.table("users").insert(params).returning({ id: "id" });
return { userId: user.id };
}
private async validateCreateParams(params: CreateUserParams): Promise<void> {
// Email duplicate check
const rdb = this.getPuri("r");
const existing = await rdb.table("users").where("email", params.email).first();
if (existing) {
throw new Error("Email already exists");
}
// Username duplicate check
const existingUsername = await rdb.table("users").where("username", params.username).first();
if (existingUsername) {
throw new Error("Username already taken");
}
// Password strength check
if (!/[A-Z]/.test(params.password)) {
throw new Error("Password must contain at least one uppercase letter");
}
if (!/[0-9]/.test(params.password)) {
throw new Error("Password must contain at least one number");
}
}
}
Validator Class
// validators/user.validator.ts
export class UserValidator {
private rdb: any;
constructor(rdb: any) {
this.rdb = rdb;
}
async validateEmail(email: string): Promise<void> {
// Format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error("Invalid email format");
}
// Duplicate validation
const existing = await this.rdb.table("users").where("email", email).first();
if (existing) {
throw new Error("Email already exists");
}
// Domain blacklist check
const domain = email.split("@")[1];
const blockedDomains = ["tempmail.com", "throwaway.com"];
if (blockedDomains.includes(domain)) {
throw new Error("Email domain not allowed");
}
}
async validateUsername(username: string): Promise<void> {
// Length validation
if (username.length < 3 || username.length > 20) {
throw new Error("Username must be 3-20 characters");
}
// Format validation
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
throw new Error("Username can only contain letters, numbers, and underscores");
}
// Reserved words check
const reserved = ["admin", "root", "system", "api", "test"];
if (reserved.includes(username.toLowerCase())) {
throw new Error("Username is reserved");
}
// Duplicate validation
const existing = await this.rdb.table("users").where("username", username).first();
if (existing) {
throw new Error("Username already taken");
}
}
validatePassword(password: string): void {
// Minimum length
if (password.length < 8) {
throw new Error("Password must be at least 8 characters");
}
// Complexity
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(password);
const strength = [hasUppercase, hasLowercase, hasNumber, hasSpecial].filter(Boolean).length;
if (strength < 3) {
throw new Error(
"Password must contain at least 3 of: uppercase, lowercase, number, special character",
);
}
// Common password check
const common = ["password", "123456", "qwerty", "admin"];
if (common.includes(password.toLowerCase())) {
throw new Error("Password is too common");
}
}
}
// Usage
class UserModel extends BaseModelClass {
@api({ httpMethod: "POST" })
async create(params: CreateUserParams): Promise<{ userId: number }> {
const rdb = this.getPuri("r");
const validator = new UserValidator(rdb);
// Validate
await validator.validateEmail(params.email);
await validator.validateUsername(params.username);
validator.validatePassword(params.password);
// Create
const wdb = this.getPuri("w");
const [user] = await wdb.table("users").insert(params).returning({ id: "id" });
return { userId: user.id };
}
}
DB Validation
Duplicate Check
class UserModel extends BaseModelClass {
private async checkDuplicate(field: string, value: any, excludeId?: number): Promise<void> {
const rdb = this.getPuri("r");
let query = rdb.table("users").where(field, value);
if (excludeId) {
query = query.whereNot("id", excludeId);
}
const existing = await query.first();
if (existing) {
throw new Error(`${field} already exists`);
}
}
@api({ httpMethod: "POST" })
async create(params: CreateUserParams): Promise<{ userId: number }> {
await this.checkDuplicate("email", params.email);
await this.checkDuplicate("username", params.username);
// ...
}
@api({ httpMethod: "PUT" })
async update(userId: number, params: UpdateUserParams): Promise<void> {
if (params.email) {
await this.checkDuplicate("email", params.email, userId);
}
if (params.username) {
await this.checkDuplicate("username", params.username, userId);
}
// ...
}
}
Referential Integrity
class PostModel extends BaseModelClass {
private async validateReferences(params: CreatePostParams): Promise<void> {
const rdb = this.getPuri("r");
// Check author exists
const user = await rdb.table("users").where("id", params.userId).first();
if (!user) {
throw new Error("User not found");
}
// Check category exists
if (params.categoryId) {
const category = await rdb.table("categories").where("id", params.categoryId).first();
if (!category) {
throw new Error("Category not found");
}
}
// Check tags exist
if (params.tagIds && params.tagIds.length > 0) {
const tags = await rdb.table("tags").whereIn("id", params.tagIds).select("id");
if (tags.length !== params.tagIds.length) {
throw new Error("Some tags not found");
}
}
}
@api({ httpMethod: "POST" })
async create(params: CreatePostParams): Promise<{ postId: number }> {
await this.validateReferences(params);
// ...
}
}
State Transition Validation
class OrderModel extends BaseModelClass {
private readonly STATE_TRANSITIONS: Record<string, string[]> = {
pending: ["confirmed", "cancelled"],
confirmed: ["processing", "cancelled"],
processing: ["shipped", "cancelled"],
shipped: ["delivered"],
delivered: [],
cancelled: [],
};
private async validateStateTransition(orderId: number, newState: string): Promise<void> {
const rdb = this.getPuri("r");
const order = await rdb.table("orders").where("id", orderId).first();
if (!order) {
throw new Error("Order not found");
}
const currentState = order.state;
const allowedTransitions = this.STATE_TRANSITIONS[currentState] || [];
if (!allowedTransitions.includes(newState)) {
throw new Error(`Cannot transition from ${currentState} to ${newState}`);
}
}
@api({ httpMethod: "PUT" })
async updateState(orderId: number, newState: string): Promise<void> {
await this.validateStateTransition(orderId, newState);
const wdb = this.getPuri("w");
await wdb.table("orders").where("id", orderId).update({ state: newState });
}
}
Business Rule Validation
Date/Time Validation
class EventModel extends BaseModelClass {
private validateEventDates(startDate: Date, endDate: Date): void {
const now = new Date();
// No past dates
if (startDate < now) {
throw new Error("Event start date cannot be in the past");
}
// Start date before end date
if (startDate >= endDate) {
throw new Error("Start date must be before end date");
}
// Maximum duration limit (e.g., 30 days)
const maxDuration = 30 * 24 * 60 * 60 * 1000; // 30 days
if (endDate.getTime() - startDate.getTime() > maxDuration) {
throw new Error("Event duration cannot exceed 30 days");
}
// Minimum preparation time (e.g., 1 day)
const minPreparationTime = 24 * 60 * 60 * 1000; // 1 day
if (startDate.getTime() - now.getTime() < minPreparationTime) {
throw new Error("Event must be scheduled at least 1 day in advance");
}
}
@api({ httpMethod: "POST" })
async create(params: CreateEventParams): Promise<{ eventId: number }> {
this.validateEventDates(new Date(params.startDate), new Date(params.endDate));
// ...
}
}
Quantity/Amount Validation
class OrderModel extends BaseModelClass {
private async validateOrderItems(items: OrderItem[]): Promise<void> {
if (items.length === 0) {
throw new Error("Order must contain at least one item");
}
if (items.length > 100) {
throw new Error("Order cannot contain more than 100 items");
}
const rdb = this.getPuri("r");
for (const item of items) {
// Quantity validation
if (item.quantity <= 0) {
throw new Error("Item quantity must be positive");
}
if (item.quantity > 1000) {
throw new Error("Item quantity cannot exceed 1000");
}
// Product existence and stock check
const product = await rdb.table("products").where("id", item.productId).first();
if (!product) {
throw new Error(`Product ${item.productId} not found`);
}
if (!product.is_available) {
throw new Error(`Product ${product.name} is not available`);
}
if (product.stock < item.quantity) {
throw new Error(`Insufficient stock for ${product.name}. Available: ${product.stock}`);
}
// Price validation (compare client-sent price with actual price)
if (item.price !== product.price) {
throw new Error(`Price mismatch for ${product.name}. Current price: ${product.price}`);
}
}
// Total amount validation
const totalAmount = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
if (totalAmount < 1000) {
throw new Error("Minimum order amount is 1000");
}
if (totalAmount > 10000000) {
throw new Error("Maximum order amount is 10,000,000");
}
}
@api({ httpMethod: "POST" })
async create(params: CreateOrderParams): Promise<{ orderId: number }> {
await this.validateOrderItems(params.items);
// ...
}
}
Permission Validation
class DocumentModel extends BaseModelClass {
private async validateAccess(
documentId: number,
requiredPermission: "read" | "write" | "delete",
): Promise<void> {
const context = Sonamu.getContext();
if (!context.user) {
throw new Error("Authentication required");
}
const rdb = this.getPuri("r");
// Get document
const document = await rdb.table("documents").where("id", documentId).first();
if (!document) {
throw new Error("Document not found");
}
// Owner has all permissions
if (document.owner_id === context.user.id) {
return;
}
// Admin has all permissions
if (context.user.role === "admin") {
return;
}
// Check permission
const permission = await rdb
.table("document_permissions")
.where("document_id", documentId)
.where("user_id", context.user.id)
.first();
if (!permission) {
throw new Error("Access denied");
}
const permissionLevel = {
read: 1,
write: 2,
delete: 3,
};
if (permissionLevel[permission.level] < permissionLevel[requiredPermission]) {
throw new Error(`Insufficient permission: ${requiredPermission} required`);
}
}
@api({ httpMethod: "GET" })
async get(documentId: number): Promise<Document> {
await this.validateAccess(documentId, "read");
const rdb = this.getPuri("r");
return rdb.table("documents").where("id", documentId).first();
}
@api({ httpMethod: "PUT" })
async update(documentId: number, params: UpdateDocumentParams): Promise<void> {
await this.validateAccess(documentId, "write");
const wdb = this.getPuri("w");
await wdb.table("documents").where("id", documentId).update(params);
}
@api({ httpMethod: "DELETE" })
async remove(documentId: number): Promise<void> {
await this.validateAccess(documentId, "delete");
const wdb = this.getPuri("w");
await wdb.table("documents").where("id", documentId).delete();
}
}
External Service Validation
Email Validation (External API)
import axios from "axios";
class EmailValidator {
private apiKey = process.env.EMAIL_VALIDATION_API_KEY;
async validateEmail(email: string): Promise<void> {
try {
const response = await axios.get(`https://api.emailvalidation.com/validate`, {
params: {
email,
apiKey: this.apiKey,
},
timeout: 5000,
});
const { valid, reason } = response.data;
if (!valid) {
throw new Error(`Invalid email: ${reason}`);
}
} catch (error) {
// On API failure, perform basic validation only
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error("Invalid email format");
}
}
}
}
Address Validation (External API)
class AddressValidator {
async validateAddress(address: {
street: string;
city: string;
zipCode: string;
country: string;
}): Promise<void> {
try {
const response = await axios.post("https://api.address-verification.com/verify", address, {
timeout: 5000,
});
if (!response.data.valid) {
throw new Error("Address could not be verified");
}
} catch (error) {
throw new Error("Address validation failed");
}
}
}
Combining Validations
Running Multiple Validations Together
class UserModel extends BaseModelClass {
@api({ httpMethod: "POST" })
async create(params: CreateUserParams): Promise<{ userId: number }> {
// 1. Zod schema validation
const validated = CreateUserSchema.parse(params);
// 2. Custom validation
await this.validateCreateParams(validated);
// 3. Create
const wdb = this.getPuri("w");
const [user] = await wdb.table("users").insert(validated).returning({ id: "id" });
return { userId: user.id };
}
private async validateCreateParams(params: CreateUserParams): Promise<void> {
const rdb = this.getPuri("r");
const validator = new UserValidator(rdb);
// Parallel validation (independent validations)
await Promise.all([
validator.validateEmail(params.email),
validator.validateUsername(params.username),
]);
// Sequential validation (password is synchronous)
validator.validatePassword(params.password);
}
}
Base Validator Class
// validators/base.validator.ts
export abstract class BaseValidator {
protected rdb: any;
constructor(rdb: any) {
this.rdb = rdb;
}
protected async checkUnique(
table: string,
field: string,
value: any,
excludeId?: number,
): Promise<void> {
let query = this.rdb.table(table).where(field, value);
if (excludeId) {
query = query.whereNot("id", excludeId);
}
const existing = await query.first();
if (existing) {
throw new Error(`${field} already exists`);
}
}
protected async checkExists(table: string, id: number): Promise<void> {
const record = await this.rdb.table(table).where("id", id).first();
if (!record) {
throw new Error(`${table} not found`);
}
}
protected validateLength(value: string, min: number, max: number, fieldName: string): void {
if (value.length < min || value.length > max) {
throw new Error(`${fieldName} must be ${min}-${max} characters`);
}
}
protected validatePattern(
value: string,
pattern: RegExp,
fieldName: string,
message: string,
): void {
if (!pattern.test(value)) {
throw new Error(`${fieldName}: ${message}`);
}
}
}
// Usage
class UserValidator extends BaseValidator {
async validateEmail(email: string): Promise<void> {
this.validatePattern(email, /^[^\s@]+@[^\s@]+\.[^\s@]+$/, "Email", "Invalid format");
await this.checkUnique("users", "email", email);
}
}
Cautions
Cautions for custom validation: 1. Run validation before business logic 2. Provide clear error
messages 3. Avoid excessive DB queries 4. Set timeouts for external API calls 5. Consider
validation logic reuse
Next Steps
Automatic Validation
Zod-based validation
Error Handling
Using SonamuError
@api Decorator
API basic usage
Parameters
API parameter definitions