Custom Validation Overview
Business Rules
Domain-specific validationComplex condition handling
DB Validation
Duplicate checksReferential integrity
External APIs
External service validationReal-time data verification
Reusability
Shared validation logicConsistent rule application
Basic Patterns
Validation Methods
Copy
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
Copy
// 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
Copy
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
Copy
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
Copy
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
Copy
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
Copy
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
Copy
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)
Copy
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)
Copy
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
Copy
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
Copy
// 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:
- Run validation before business logic
- Provide clear error messages
- Avoid excessive DB queries
- Set timeouts for external API calls
- Consider validation logic reuse