How AirForm Uses Pydantic for Form Validation: A Complete Guide
AirForm is a thin wrapper around Pydantic BaseModel that converts declarative data models into HTML form handlers with automatic validation, error handling, and rendering capabilities.
AirForm, part of the feldroy/air open-source repository, leverages Pydantic's robust type system to handle HTML form validation declaratively. By binding a Pydantic BaseModel to an AirForm class, developers can validate incoming request data, capture validation errors, and render HTML forms with error messages using minimal boilerplate code.
Binding Pydantic Models to AirForm Classes
At the core of AirForm's architecture is the AirForm class defined in src/air/forms.py. To create a form, you subclass AirForm and assign a Pydantic BaseModel to the class attribute model (line 65).
from pydantic import BaseModel
import air
class LoginModel(BaseModel):
username: str
password: str
class LoginForm(air.AirForm):
model = LoginModel
This binding allows AirForm to delegate all type checking, coercion, and validation logic to Pydantic while maintaining a reference to the model schema for rendering HTML fields.
Validating Incoming Form Data with Pydantic
When a form is submitted, AirForm uses Pydantic to validate the raw form data against the bound model schema.
The validate() Method Implementation
The validate() method in src/air/forms.py (line 82) accepts either a dict or starlette.datastructures.FormData object. It attempts to instantiate the Pydantic model by calling self.model(**form_data).
If instantiation succeeds, self.is_valid is set to True and the populated model instance is stored in self.data. If Pydantic raises a ValidationError, AirForm catches the exception, stores the error list in self.errors, and sets self.is_valid to False.
Async Request Handling with from_request()
For FastAPI or Starlette applications, AirForm provides the from_request() classmethod (line 32 in src/air/forms.py). This async helper extracts the form payload using await request.form(), instantiates the form class, runs validation, and returns a ready-to-use form object.
@app.post("/login")
async def login(request: air.Request) -> air.Html:
form = await LoginForm.from_request(request)
if form.is_valid:
return air.Html(air.H1(f"Welcome {form.data.username}!"))
return air.Html(air.H1("Login failed"), air.Form(form.render()))
Rendering Forms with Pydantic Validation Errors
AirForm's render() method (line 84 in src/air/forms.py) generates HTML by inspecting the Pydantic model's model_fields attribute. It maps each field to an HTML input widget, using the field's type annotations and constraints to determine the appropriate input type.
When validation fails, render() accesses self.errors to inject aria-invalid attributes and display error messages next to the relevant fields. It also preserves user input by checking self.submitted_data (or initial_data), ensuring that form values persist after a failed validation attempt.
Creating Forms Programmatically with AirModel.to_form()
The AirModel class in src/air/models.py provides a convenience wrapper around BaseModel that exposes the to_form() method (line 22). This allows developers to generate an AirForm subclass dynamically without manually declaring a separate form class.
import air
class ContactModel(air.AirModel):
name: str
email: str = air.AirField(type="email")
message: str = air.AirField(min_length=10)
# Generate form class dynamically
ContactForm = ContactModel.to_form()
This approach reduces boilerplate when the form fields map directly to the model schema without custom validation logic.
Practical Implementation Examples
Manual Form Validation
For scenarios requiring explicit form classes, bind your Pydantic model to an AirForm subclass:
from pydantic import BaseModel
import air
class RegistrationModel(BaseModel):
username: str
email: str
age: int
class RegistrationForm(air.AirForm):
model = RegistrationModel
@app.post("/register")
async def register(request: air.Request):
form = await RegistrationForm.from_request(request)
if form.is_valid:
user_data = form.data # RegistrationModel instance
return air.Html(f"Registered {user_data.username}")
return air.Html(form.render())
Pre-populated Form Data
Initialize forms with existing data for edit views:
# Existing user data from database
user_data = {"username": "alice", "email": "alice@example.com", "age": 30}
form = RegistrationForm(user_data)
html_output = form.render() # Form inputs pre-filled with user_data
Custom Widget Rendering
Inject custom HTML generation logic for CSS framework integration:
def tailwind_widget(*, model, data=None, errors=None, includes=None):
# Custom HTML generation with Tailwind classes
return f"<div class='space-y-4'>{model.__name__} fields here</div>"
CustomForm = RegistrationModel.to_form(widget=tailwind_widget)
Summary
- AirForm acts as a thin orchestration layer around Pydantic
BaseModel, delegating all type validation and coercion to Pydantic's engine while adding HTML form handling capabilities. - Form classes declare their data schema by setting the
modelclass attribute to a Pydantic model, as implemented insrc/air/forms.py. - The
validate()method instantiates the Pydantic model with submitted data, capturingValidationErrorexceptions into a user-friendlyerrorsdictionary accessible viaself.errors. - The
from_request()async classmethod streamlines FastAPI/Starlette integration by extracting form data and running validation in a single call. - Form rendering inspects
model.model_fieldsto generate HTML inputs, automatically displaying validation errors and preserving submitted values throughself.submitted_data. - The
AirModel.to_form()shortcut insrc/air/models.pygenerates form classes dynamically, eliminating boilerplate for simple use cases.
Frequently Asked Questions
How does AirForm handle Pydantic validation errors?
When the validate() method in src/air/forms.py calls the Pydantic model constructor, it wraps the call in a try-except block to catch Pydantic's ValidationError. If validation fails, AirForm extracts the error messages and stores them in the self.errors attribute while setting self.is_valid to False. This error dictionary maps field names to their respective error messages, which the render() method uses to display inline validation feedback next to the appropriate form fields.
Can I use AirForm with FastAPI's Request object?
Yes, AirForm provides the from_request() classmethod specifically for FastAPI and Starlette integration. Located at line 32 in src/air/forms.py, this async method accepts a Starlette Request object, extracts the form data using await request.form(), instantiates the form class, and runs validation automatically. This returns a fully populated form instance ready for checking is_valid or rendering error messages, eliminating the need to manually extract and pass form data.
What is the difference between AirForm and AirModel?
AirForm is the base form handler class defined in src/air/forms.py that requires manual binding to a Pydantic model via the model class attribute. AirModel, defined in src/air/models.py, is a thin wrapper around Pydantic's BaseModel that provides the to_form() convenience method. This method dynamically generates an AirForm subclass, eliminating the need to manually declare a separate form class when the form fields directly mirror the model schema.
How do I customize form rendering in AirForm?
AirForm supports custom widgets through the widget parameter in the to_form() method or by overriding the render() method in a subclass. The default widget inspects model.model_fields to generate HTML inputs, but you can inject a custom function that receives the model, data, errors, and includes parameters to generate arbitrary HTML. This allows integration with CSS frameworks like Tailwind or Bootstrap by generating the appropriate class attributes and HTML structure while still leveraging Pydantic's validation logic.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →