Skip to content

Validation

Advanced validation patterns for models and APIs.


Overview

Aksara provides multiple layers of validation:

  1. Field-level — Single field validation
  2. Model-level — Cross-field validation
  3. Serializer-level — API input validation
  4. Custom validators — Reusable validation logic

Field-Level Validation

Built-in Validators

from aksara import Model, fields
from aksara.validation import (
    MinLength, MaxLength, MinValue, MaxValue,
    Regex, Email, URL, In, NotIn
)

class User(Model):
    username = fields.String(
        max_length=50,
        validators=[
            MinLength(3),
            Regex(r'^[a-zA-Z0-9_]+$', message="Alphanumeric only"),
        ]
    )
    email = fields.Email(validators=[Email()])
    age = fields.Integer(
        validators=[MinValue(13), MaxValue(120)]
    )
    role = fields.String(
        max_length=20,
        validators=[In(["user", "admin", "moderator"])]
    )

Custom Validators

from aksara.validation import Validator, ValidationError

class NoSpaces(Validator):
    """Ensure no spaces in value."""

    message = "Value cannot contain spaces"

    def __call__(self, value):
        if " " in str(value):
            raise ValidationError(self.message)
        return value

class Unique(Validator):
    """Ensure value is unique in database."""

    def __init__(self, model, field_name):
        self.model = model
        self.field_name = field_name

    async def __call__(self, value, instance=None):
        query = self.model.objects.filter(**{self.field_name: value})

        # Exclude current instance on update
        if instance and instance.id:
            query = query.exclude(id=instance.id)

        if await query.exists():
            raise ValidationError(f"{self.field_name} already exists")

        return value

# Usage
class User(Model):
    username = fields.String(
        max_length=50,
        validators=[NoSpaces(), Unique(User, "username")]
    )

Model-Level Validation

Clean Method

class Order(Model):
    start_date = fields.Date()
    end_date = fields.Date()
    quantity = fields.Integer()
    unit_price = fields.Decimal(max_digits=10, decimal_places=2)

    def clean(self):
        """Validate the entire model."""
        errors = {}

        if self.end_date <= self.start_date:
            errors["end_date"] = "End date must be after start date"

        if self.quantity <= 0:
            errors["quantity"] = "Quantity must be positive"

        if errors:
            raise ValidationError(errors)

    async def save(self, **kwargs):
        self.clean()
        return await super().save(**kwargs)

Field Dependencies

class Subscription(Model):
    plan = fields.String(max_length=20, choices=["free", "pro", "enterprise"])
    max_users = fields.Integer()

    PLAN_LIMITS = {
        "free": 5,
        "pro": 50,
        "enterprise": 1000,
    }

    def clean(self):
        max_allowed = self.PLAN_LIMITS.get(self.plan, 0)
        if self.max_users > max_allowed:
            raise ValidationError({
                "max_users": f"Plan '{self.plan}' allows max {max_allowed} users"
            })

Serializer Validation

Field Validators

from aksara.api import ModelSerializer
from aksara.validation import ValidationError

class UserSerializer(ModelSerializer):
    class Meta:
        model = User
        fields = ["email", "username", "password"]

    def validate_username(self, value):
        """Validate username field."""
        if len(value) < 3:
            raise ValidationError("Username must be at least 3 characters")
        return value.lower()

    def validate_password(self, value):
        """Validate password strength."""
        if len(value) < 8:
            raise ValidationError("Password must be at least 8 characters")
        if not any(c.isupper() for c in value):
            raise ValidationError("Password must contain uppercase letter")
        if not any(c.isdigit() for c in value):
            raise ValidationError("Password must contain a digit")
        return value

Cross-Field Validation

class PasswordChangeSerializer(Serializer):
    old_password = String()
    new_password = String()
    confirm_password = String()

    def validate(self, data):
        """Validate all fields together."""
        if data["new_password"] != data["confirm_password"]:
            raise ValidationError({
                "confirm_password": "Passwords do not match"
            })

        if data["old_password"] == data["new_password"]:
            raise ValidationError({
                "new_password": "New password must be different"
            })

        return data

Async Validation

class UserSerializer(ModelSerializer):
    class Meta:
        model = User
        fields = ["email", "username"]

    async def validate_email(self, value):
        """Check email is not already registered."""
        existing = await User.objects.filter(email=value).first()
        if existing:
            raise ValidationError("Email already registered")
        return value

    async def validate(self, data):
        """Cross-field async validation."""
        # Check for banned usernames
        if await BannedUsername.objects.filter(name=data["username"]).exists():
            raise ValidationError({"username": "This username is not allowed"})
        return data

Reusable Validators

Validator Classes

from aksara.validation import Validator, ValidationError

class PasswordStrength(Validator):
    """Validate password strength."""

    def __init__(self, min_length=8, require_upper=True, require_digit=True, require_special=False):
        self.min_length = min_length
        self.require_upper = require_upper
        self.require_digit = require_digit
        self.require_special = require_special

    def __call__(self, value):
        errors = []

        if len(value) < self.min_length:
            errors.append(f"At least {self.min_length} characters")

        if self.require_upper and not any(c.isupper() for c in value):
            errors.append("One uppercase letter")

        if self.require_digit and not any(c.isdigit() for c in value):
            errors.append("One digit")

        if self.require_special:
            special = "!@#$%^&*()_+-=[]{}|;:,.<>?"
            if not any(c in special for c in value):
                errors.append("One special character")

        if errors:
            raise ValidationError("Password must contain: " + ", ".join(errors))

        return value

# Usage
class UserSerializer(ModelSerializer):
    password = String(validators=[PasswordStrength(min_length=10, require_special=True)])

Validator Functions

from aksara.validation import validator

@validator
def validate_slug(value):
    """Validate URL-safe slug."""
    import re
    if not re.match(r'^[a-z0-9-]+$', value):
        raise ValidationError("Slug must contain only lowercase letters, numbers, and hyphens")
    return value

@validator
def validate_future_date(value):
    """Ensure date is in the future."""
    from datetime import date
    if value <= date.today():
        raise ValidationError("Date must be in the future")
    return value

# Usage
class Event(Model):
    slug = fields.String(validators=[validate_slug])
    event_date = fields.Date(validators=[validate_future_date])

Conditional Validation

Based on Other Fields

class PaymentSerializer(Serializer):
    payment_type = String(choices=["card", "bank", "crypto"])
    card_number = String(required=False)
    bank_account = String(required=False)
    wallet_address = String(required=False)

    def validate(self, data):
        payment_type = data.get("payment_type")

        if payment_type == "card":
            if not data.get("card_number"):
                raise ValidationError({"card_number": "Required for card payment"})

        elif payment_type == "bank":
            if not data.get("bank_account"):
                raise ValidationError({"bank_account": "Required for bank payment"})

        elif payment_type == "crypto":
            if not data.get("wallet_address"):
                raise ValidationError({"wallet_address": "Required for crypto payment"})

        return data

Based on Instance State

class OrderSerializer(ModelSerializer):
    class Meta:
        model = Order
        fields = ["status", "shipped_date"]

    def validate_status(self, value):
        if self.instance:  # Update
            current = self.instance.status

            valid_transitions = {
                "pending": ["confirmed", "cancelled"],
                "confirmed": ["shipped", "cancelled"],
                "shipped": ["delivered"],
                "delivered": [],
                "cancelled": [],
            }

            if value not in valid_transitions.get(current, []):
                raise ValidationError(f"Cannot transition from {current} to {value}")

        return value

Validation Error Handling

Error Format

from aksara.validation import ValidationError

# Single field error
raise ValidationError("Invalid value")

# Field-specific errors
raise ValidationError({
    "email": "Invalid email format",
    "username": ["Too short", "Contains invalid characters"],
})

# Non-field errors
raise ValidationError({
    "__all__": "Invalid credentials"
})

Custom Error Messages

class User(Model):
    email = fields.Email(
        error_messages={
            "required": "Email address is required",
            "invalid": "Please enter a valid email address",
            "unique": "This email is already registered",
        }
    )

Handling Validation Errors

from aksara.api import ViewSet, action
from aksara.validation import ValidationError

class UserViewSet(ViewSet):
    @action(detail=False, methods=["post"])
    async def register(self, request):
        serializer = UserSerializer(data=request.data)

        try:
            await serializer.is_valid(raise_exception=True)
            user = await serializer.save()
            return {"user": UserSerializer(user).data}

        except ValidationError as e:
            return {"errors": e.detail}, 400

Unique Constraints

Single Field

class User(Model):
    email = fields.Email(unique=True)

# Or with custom message
class User(Model):
    email = fields.Email(
        unique=True,
        error_messages={"unique": "Email already exists"}
    )

Multiple Fields

class Membership(Model):
    user = fields.ForeignKey(User, on_delete=fields.CASCADE)
    organization = fields.ForeignKey(Organization, on_delete=fields.CASCADE)

    class Meta:
        unique_together = [("user", "organization")]

    def clean(self):
        # Check uniqueness manually for better error messages
        existing = await Membership.objects.filter(
            user=self.user,
            organization=self.organization
        ).exclude(id=self.id).exists()

        if existing:
            raise ValidationError("User is already a member of this organization")

Best Practices

Do

  • Validate early (at serializer level for API input)
  • Use clear error messages
  • Keep validators focused and reusable
  • Test edge cases

Don't

  • Don't duplicate validation logic
  • Don't validate in views (use serializers)
  • Don't silently fix invalid data without user knowledge

Validation Order

class MySerializer(ModelSerializer):
    # Validation order:
    # 1. Field-level: validate_<field_name>()
    # 2. Object-level: validate()
    # 3. Unique constraints
    # 4. Model clean()

    def validate_email(self, value):
        # 1. Called first
        return value

    def validate(self, data):
        # 2. Called after all field validators
        return data