Skip to content

Rule-Based Validation

Most forms need nothing beyond the Pydantic validation in the Validation chapter. The library also has a rule-based layer, FormValidator, for three situations:

  • A ready-made check that isn't a Pydantic type, such as PhoneRule.
  • Rules you switch on or off per route without changing the model, for example a stricter check on an admin page.
  • The same rules for live validation and the submit route; see Live Validation.

Rules run on the submitted data alongside the model's own validation, and their errors are shown the same way.

Model and rules together

models.py
from pydantic_schemaforms import Field, FormModel, FormValidator, PhoneRule


class SignupForm(FormModel):
    username: str = Field(title='Username', min_length=3)
    phone: str = Field('', title='Phone', ui_element='tel')
    age: int = Field(title='Age', ge=0)
    parental_consent: bool = Field(False, title='A parent agrees')


def minors_need_consent(data: dict) -> tuple[bool, dict]:
    age = data.get('age')
    if age is not None and int(age) < 18 and not data.get('parental_consent'):
        return False, {'parental_consent': 'A parent must agree for anyone under 18'}
    return True, {}


rules = FormValidator()
rules.field('phone').add_rule(PhoneRule())
rules.add_cross_field_rule(minors_need_consent)
main.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from models import SignupForm, rules

from pydantic_schemaforms import html, render_form_html_async

app = FastAPI()


@app.get('/signup', response_class=HTMLResponse)
async def show_signup():
    return await render_form_html_async(SignupForm, submit_url='/signup')


@app.post('/signup', response_class=HTMLResponse)
async def submit_signup(request: Request):
    result = SignupForm.validate(await request.form(), submit_url='/signup')
    rules_ok, rule_errors = rules.validate(result.original_data)
    if not (result.is_valid and rules_ok):
        return await result.render_with_errors_async(errors={**rule_errors, **result.errors})
    username = result.data['username']
    return html(t'<p>Welcome, {username}!</p>')
main.py
from flask import Flask, request
from models import SignupForm, rules

from pydantic_schemaforms import html, render_form_html

app = Flask(__name__)


@app.get('/signup')
def show_signup():
    return render_form_html(SignupForm, submit_url='/signup')


@app.post('/signup')
def submit_signup():
    result = SignupForm.validate(request.form, submit_url='/signup')
    rules_ok, rule_errors = rules.validate(result.original_data)
    if not (result.is_valid and rules_ok):
        return result.render_with_errors(errors={**rule_errors, **result.errors})
    username = result.data['username']
    return html(t'<p>Welcome, {username}!</p>')

The route validates with the model first, then runs the rules on result.original_data, the submission decoded into the same nested shape as the model. If either fails, both sets of errors are passed to render_with_errors(errors=...), so the user sees every problem at once.

Rules skip empty values, so an optional field is only checked when it's filled in; add RequiredRule() when a value must be present.

Field rules

rules.field("phone") returns that field's rule list; add_rule() adds to it and returns it again, so rules chain:

rules.field("username").add_rule(MinLengthRule(3)).add_rule(MaxLengthRule(20))

Every rule takes an optional message= to replace its default text. The full list (RequiredRule, MinLengthRule, MaxLengthRule, EmailRule, EmailDeliverabilityRule, PhoneRule, NumericRangeRule, DateRangeRule, RegexRule, CustomRule) is in Validation Rules.

CustomRule wraps any function that returns True for a valid value:

from pydantic_schemaforms import CustomRule

rules.field("username").add_rule(
    CustomRule(lambda value: not value.lower().startswith("admin"),
               message="Usernames can't start with 'admin'")
)

Cross-field rules

add_cross_field_rule() takes a function that gets all the data and returns (is_valid, errors), where errors maps a field name to its message. That puts the message on the field the user has to change, like minors_need_consent above.

Running rules on their own

rules.validate(data) returns (is_valid, errors) without a model, with each field's messages as a list:

is_valid, errors = rules.validate({"phone": "abc", "age": 15})
# (False, {"phone": ["Please enter a valid phone number"],
#          "parental_consent": ["A parent must agree for anyone under 18"]})
Quick standalone checks

create_email_validator() and create_password_strength_validator() return functions that check a single value and give back a ValidationResponse with is_valid, errors, and for passwords also warnings and suggestions ("Add a number (0-9)"). They're handy for an API endpoint or a live check of one field.