Serializers
The openviper.serializers package provides a Pydantic v2-based
serialization and validation layer. Serializer is a thin wrapper
over pydantic.BaseModel giving it OpenViper-idiomatic helpers, while
ModelSerializer auto-generates fields directly from an ORM model.
Overview
Use Serializer to:
Validate and deserialize incoming request data.
Serialize outgoing ORM instances to plain dicts / JSON.
Produce OpenAPI-compatible JSON Schema for Swagger UI.
Use ModelSerializer to remove duplication between ORM model
fields and serializer fields - just point Meta.model at your model class.
The two classes share a common API. The main difference is where fields come from:
Feature |
|
|
|---|---|---|
Field source |
Manually declared type annotations |
Auto-generated from |
|
Not required |
Required ( |
ORM |
Not provided |
Built-in, delegates to |
File field handling |
Manual |
Automatic ( |
|
Class variable |
Also set via |
Key Classes & Functions
- class openviper.serializers.Serializer
Base serializer backed by Pydantic v2. Declare fields as annotated class attributes exactly as you would in a
BaseModel.Class variables (set at class level, not instance level):
- readonly_fields: tuple[str, ...]
Fields included in serialized output but stripped before
create()/update()writes. Defaults to().
- writeonly_fields: tuple[str, ...]
Fields accepted on input but excluded from
serialize()output (e.g. passwords). Defaults to().
- PAGE_SIZE: int
Batch size used by
serialize_many()when the input is aQuerySet. Defaults to25.
- MAX_PAGE_SIZE: int
Upper bound for
page_sizeinpaginate(). Defaults to1000.
- permission_classes: list[type[PermissionProtocol]]
List of permission classes evaluated by
check_permissions(). Each class must implement thePermissionProtocolinterface (async has_permission(request, serializer) -> bool). Defaults to[]. When empty, no permission checks are performed.
- validated_data: dict[str, Any]
Validated in-memory input values after instance validation. Fields not supplied by the caller are omitted. Accessing this property before calling
validate()on a stageddata=...serializer raisesRuntimeError.
Permission checks:
- check_permissions() Awaitable[None]
Evaluate all
permission_classesagainst the current request. RaisesPermissionDeniedon the first failing permission. Returns immediately when no request is bound and no ambient user is present (viacurrent_usercontext var).
- permission_denied(request, message=None) None
Raise
PermissionDeniedwith the given message (defaults to"Permission denied.").
Parsing / deserialization:
- classmethod validate_data(data, *, partial=False, context=None) Self
Parse and validate data (dict, ORM object, or mapping). Raises
ValidationErrorwith structured error details on failure.When
partial=Trueevery field becomes optional, which is ideal forPATCHendpoints. The returned instance tracks which fields were supplied viamodel_fields_set; callmodel_dump(excludeunset=True)to get only the changed keys.Pass
contextto make extra data available to field validators (via Pydantic’sValidationInfo.context) and to the serializer instance itself (accessible via.context). Typical usage:context={"request": request}.Partial classes preserve all original
FieldInfoconstraints (min_length,pattern, custom validators, etc.) - only thedefaultis relaxed toNoneso missing fields are accepted.
- validate: SerializerValidationDescriptor
Convenience descriptor that delegates to
validate_data(). Supports both class-level and instance-level calls:# Class-level: validates fresh data serializer = PostSerializer.validate_data(payload) # Instance-level: validates staged data serializer = PostSerializer(data=payload) serializer.validate()
Instance validation also accepts
raise_exception=Trueto raise an immediate HTTP422response carrying the structured validation reasons:serializer = PostSerializer(data=payload) serializer.validate(raise_exception=True)
- classmethod validate_json_string(json_str) Self
Parse a raw JSON string directly (bypasses the
body()call). RaisesValidationErrorif the input exceedsMAX_JSON_STRING_BYTES(1 MiB by default).
- classmethod from_orm(obj) Self
Build a serializer instance from an ORM model instance (or any object with the right attributes).
ForeignKeyfields are automatically unwrapped to their raw foreign-key ID. If the ORM descriptor returns a related model instance, its primary key is extracted so the serializer receives anint(orNone) rather than a nested object.
- classmethod from_orm_many(objs) list[Self]
Build a list of serializer instances from a list of ORM objects.
Internal helpers (used by
serialize_many/paginate):- classmethod get_excluded_fields(exclude=None) frozenset[str]
Return
writeonly_fieldsmerged with exclude. Returns an emptyfrozensetwhen neither is set.
- static serialize_value(value) bool | int | float | str | list | dict | None
Convert a single value to a JSON-serializable type. Handles
Decimal,datetime,date,time,UUID,bytes(base64),LazyFK(unwrapped to FK id), and nestedlist/dictcontainers recursively. Contrib field value types (Country,Money,Point) are delegated to registered contrib serializers viaserialize_contrib_value().
- classmethod obj_to_dict(obj, excl) dict[str, Any]
Map a single ORM object to a JSON-safe dict using direct attribute access (no Pydantic model instantiation). Fields in excl are omitted.
- classmethod build_partial_class() type[Self]
Return a version of this class where every field is optional. The result is cached on
PARTIAL_CLASSES(aWeakKeyDictionary) so it is only created once per serializer class.
- compute_excluded(exclude=None) set[str] | None
Instance-level counterpart of
get_excluded_fields(). Returnswriteonly_fieldsmerged with exclude, orNonewhen the result is empty.
Serialization:
- serialize(*, exclude=None) dict[str, Any]
Return a JSON-safe
dict.writeonly_fieldsare automatically excluded. Pass an additionalexcludeset to drop more fields.bytesvalues are encoded as base64 strings to avoid leaking raw binary data through JSON fallbacks.
- serialize_json(*, exclude=None) bytes
Return JSON bytes via pydantic-core’s Rust encoder (fast path).
- classmethod serialize_many(objs, *, exclude=None) Awaitable[list[dict]]
Serialize a list or QuerySet of ORM objects to a list of dicts.
When objs is a
QuerySet(detected viahasattr(objs, "batch")), objects are fetched inPAGE_SIZE-sized batches to avoid loading the entire result set into memory. Plain lists are handled with a simple comprehension (no DB calls).Performance: Uses direct ORM→dict mapping without intermediate Pydantic model validation, providing ~35-40% faster serialization compared to traditional double-conversion approaches.
- classmethod serialize_many_json(objs, *, exclude=None) Awaitable[bytes]
Like
serialize_many()but returns a JSON bytes array. Also usesPAGE_SIZE-sized batches for QuerySets.Performance: Uses direct ORM→dict mapping (35-40% faster) and optimized JSON encoding for bulk serialization.
- classmethod paginate(qs, *, page=1, page_size=None, cursor=None, base_url='', exclude=None) Awaitable[PaginatedSerializer]
Return a
PaginatedSerializerenvelope for a single page of qs. Usesasyncio.gather()to execute COUNT and data fetch queries concurrently for ~2x faster performance.qs must be a QuerySet (supports
.count(),.offset(),.limit(), and.all()).page is 1-based (default: 1).
page_size defaults to
cls.PAGE_SIZE(default: 25).cursor - optional base64-encoded cursor for keyset pagination (faster for Next/Prev navigation).
base_url - when given,
next/previousURL strings are built. Only relative paths ("/api/users") orhttp/httpsURLs are accepted; absolute URLs with an unexpected scheme or host are silently discarded to prevent open-redirect attacks.exclude - set of field names to omit from serialized output.
Performance notes:
COUNT and data fetch run in parallel via
asyncio.gather().OFFSET-based page jumps (e.g., page 1000) are O(N) and can be slow.
Cursor-based Next/Prev navigation is O(log N) using keyset seeks.
Exclude field computation is cached to avoid repeated set allocation.
# Basic usage result = await PostSerializer.paginate( Post.objects.filter(published=True).order_by("-created_at"), page=2, page_size=20, base_url="/posts" ) # result.count → total matching rows # result.next → "/posts?page=3&page_size=20" # result.previous → "/posts?page=1&page_size=20" # result.next_cursor → base64 cursor for next page (or None) # result.results → list[dict] for page 2 # With cursor for fast sequential navigation cursor = request.query_params.get("cursor") result = await PostSerializer.paginate( Post.objects.order_by("created_at", "id"), page=1, page_size=20, cursor=cursor, base_url="/posts" ) # Click "Next" uses result.next_cursor for O(log N) performance
- class openviper.serializers.PaginatedSerializer
Returned by
Serializer.paginate().Fields:
count- total number of matching objects.next- URL for the next page, orNone.previous- URL for the previous page, orNone.next_cursor- base64-encoded cursor for keyset pagination, orNone.results- list of serialized dicts for the current page.
- class openviper.serializers.ModelSerializer
Extends
Serializerwith automatic field generation from an ORM model. Requires a nestedMetaclass.Meta attributes:
model- the ORM model class (required).fields-"__all__"or a list of field names to include.exclude- list of field names to exclude (alternative tofields).readonly_fields- tuple of field names that are output-only.writeonly_fields- tuple of field names that are input-only.extra_kwargs- dict of{field_name: {"required": False, ...}}overrides applied after field auto-generation.
CRUD helpers (only on
ModelSerializer):- create() Awaitable[Model]
Persist a new model instance from the validated data. Read-only fields and the PK are stripped before the INSERT. Returns the saved model instance. If a required model field is removed by
readonly_fields, a structuredValidationErroris raised before any database write is attempted.
- update(instance) Awaitable[Model]
Apply validated data to an existing instance and save it. Only fields present in
model_fields_set(i.e. fields the caller explicitly provided) are written - safe forPATCHsemantics.
- save(instance=None) Awaitable[dict]
Smart create-or-update. If instance is provided, update it. If the validated data contains a non-
NonePK, fetch the existing record and update it. Otherwise create a new record. Returns the serialized dict of the persisted object.Database integrity failures are translated into serializer validation errors when possible. For example, a duplicate unique value becomes a field error with
type="unique"instead of an unhandled database exception.
File-field helpers (only on
ModelSerializer):- classmethod get_file_fields() MappingProxyType
Return a read-only mapping of file-type ORM fields (
FileFieldandImageField). Cached with@lru_cache(maxsize=512)and wrapped inMappingProxyTypeto prevent cache mutation.
- classmethod validate_file_sizes(data) None
Raise
ValidationErrorif any file value in data exceeds its ORM field size limit. No-op when the model has no file fields.
- classmethod persist_files(data, *, old_instance=None) Awaitable[dict]
Save file values through the storage backend. Returns a copy of data with file values replaced by their stored paths. On update, the previous file is deleted from storage after the new one is persisted.
Validation helpers (only on
ModelSerializer):- classmethod validate_create_data(data) None
Reject creates that cannot satisfy required model fields. Raises
ValidationErrorwithtype="missing"for each required field absent from data.
- classmethod integrity_error_to_validation_error(exc) ValidationError
Map a
sqlalchemy.exc.IntegrityErrorto a structuredValidationError. RecognisesUNIQUEandNOT NULLconstraint failures and maps them to the offending field. Unrecognised integrity errors produce a generictype="integrity_error"on__all__.
Metaclass:
- class openviper.serializers.base.ModelSerializerMeta
Metaclass for
ModelSerializer. ReadsMeta.model,Meta.fields,Meta.exclude,Meta.readonly_fields,Meta.writeonly_fields, andMeta.extra_kwargsat class-creation time and builds Pydanticmodel_fieldsautomatically.
File-field security:
FileFieldandImageFieldvalues are handled automatically:Path traversal prevention - directory components (
../,..\\) are stripped from uploaded filenames; only the basename is retained.Unsafe character rejection - filenames containing null bytes, control characters, or path separators are rejected with a
ValidationError.Unique filenames - a UUID suffix is appended to every upload so concurrent uploads never overwrite each other.
Streaming uploads - file-like objects are streamed directly to the storage backend without buffering the entire payload in memory.
Safe replacement on update - when a file changes, the old file is deleted only after the new file is persisted successfully. Delete failures are logged as warnings rather than swallowed silently.
- openviper.serializers.field_validator(field_name, *, mode='before')
Re-export of
pydantic.field_validator. Attach to a serializer method to add per-field validation logic.
- openviper.serializers.model_validator(*, mode='after')
Re-export of
pydantic.model_validator. Attach to a serializer method for cross-field validation.
- openviper.serializers.computed_field()
Re-export of
pydantic.computed_field. Decorate a property to include computed values inserialize()output.
- openviper.serializers.map_pydantic_errors(exc) ValidationError
Convert a
pydantic.ValidationErrorto an OpenViperValidationError. Each Pydantic error location is flattened to a dot-separatedfieldstring (or"__all__"for root-level errors).
- openviper.serializers.python_type_for_field_by_name(field_class_name) type
Return the Python type for an ORM field class name (e.g.
"CharField"→str). Cached with@lru_cache(maxsize=256). ReturnsAnyfor unrecognised field names.
Contrib Field Serializer Registry
Contrib field packages (countries, currencies, geolocation)
register custom serializers for their value types so the core
Serializer.serialize_value() can convert them to JSON-safe output
without importing optional dependencies.
- openviper.serializers.register_contrib_serializer(type_name, fn)
Register a serializer function for a contrib field value type. Called at import time by each contrib package’s
__init__.py.
- openviper.serializers.serialize_contrib_value(value)
Check registered contrib serializers for a matching value type. Returns
_CONTRIB_SENTINELwhen no match is found, signalling the caller to fall through to the defaultreturn value.
Registered contrib serializers:
Package |
Value type |
JSON output |
Serializer module |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Security Constants
- openviper.serializers.base.MAX_JSON_STRING_BYTES
Maximum allowed size for a JSON input string (default: 1 MiB).
validate_json_string()raisesValidationErrorwhen this limit is exceeded.
- openviper.serializers.base.UNSAFE_FILENAME_CHAR_RE
Compiled regex matching null bytes, control characters (
\\x00–\\x1f), backslashes, and forward slashes in uploaded filenames.persist_files()rejects any filename that matches.
- openviper.serializers.base.ALLOWED_URL_SCHEMES
frozensetof URL schemes ("","http","https") permitted forbase_urlinSerializer.paginate(). Prevents open-redirect attacks via malicious scheme injection.
Structural Protocols
The serializers module defines @runtime_checkable protocols that
describe the minimal interfaces expected from ORM models, managers,
query sets, fields, requests, permissions, and upload values. These
enable strict static typing without coupling to a concrete ORM
implementation:
- class openviper.serializers.base.OrmModelProtocol
Minimal interface that OpenViper ORM models satisfy. Requires
_fields,_table_name,id, and__dict__.
- class openviper.serializers.base.OrmManagerProtocol
Interface for the
objectsmanager. Requiresasync create(),async get(), andasync get_or_none().
- class openviper.serializers.base.QuerySetProtocol
Interface for lazy chainable query builders. Requires
filter(),offset(),limit(),async all(),async count(), andasync batch().
- class openviper.serializers.base.OrmFieldProtocol
Minimal interface for ORM field descriptors. Requires
primary_key,null,auto_now,auto_now_add,default,column_name,name,validate(), andhas_default().
- class openviper.serializers.base.RequestProtocol
Minimal interface for HTTP request objects. Requires
methodandpath.
- class openviper.serializers.base.PermissionProtocol
Interface for permission classes. Requires
async has_permission(request, serializer) -> bool.
- class openviper.serializers.base.UploadValueProtocol
Interface for uploaded file values. Requires
filename,name, andread().
Field Type Mapping
When ModelSerializer auto-generates fields from an ORM model it maps
field class names to Python types as follows:
ORM Field |
Pydantic Type |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
A field is automatically marked optional when the ORM field has
null=True, auto_now=True, auto_now_add=True, a default value,
or is the primary key.
Example Usage
See also
Working projects that use serializers:
examples/ai_moderation_platform/ - pydantic-based serializers with validation
examples/ecommerce_clone/ -
ModelSerializerfor products, orders, reviews
Manual Serializer
from openviper.serializers import Serializer, field_validator
class PostSerializer(Serializer):
title: str
body: str
tags: list[str] = []
@field_validator("title")
@classmethod
def title_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Title cannot be blank.")
return v.strip()
# Validate incoming data
data = PostSerializer.validate({"title": "Hello", "body": "World"})
async def example():
# Serialize an ORM instance
post = await Post.objects.get(id=1)
out = PostSerializer.from_orm(post).serialize()
Model Serializer
from openviper.serializers import ModelSerializer
from myapp.models import Post
class PostSerializer(ModelSerializer):
class Meta:
model = Post
fields = ["id", "title", "body", "created_at"]
readonly_fields = ("id", "created_at")
async def example():
post = await Post.objects.get(id=1)
out = PostSerializer.from_orm(post).serialize()
# {"id": 1, "title": "...", "body": "...", "created_at": "..."}
Exclude Fields
class PostPublicSerializer(ModelSerializer):
class Meta:
model = Post
exclude = ["internal_notes", "admin_flag"]
write_only and read_only Fields
class UserSerializer(ModelSerializer):
class Meta:
model = User
fields = "__all__"
readonly_fields = ("id", "created_at")
writeonly_fields = ("password",) # never appears in serialize()
user = UserSerializer.validate({"username": "alice", "password": "s3cr3t"})
out = user.serialize()
# "password" is absent from out; "id" and "created_at" cannot be set
Cross-Field Validation
from openviper.serializers import Serializer, model_validator
class PasswordChangeSerializer(Serializer):
password: str
confirm_password: str
@model_validator(mode="after")
def passwords_match(self) -> "PasswordChangeSerializer":
if self.password != self.confirm_password:
raise ValueError("Passwords do not match.")
return self
Computed Fields
from openviper.serializers import Serializer, computed_field
class ProductSerializer(Serializer):
price: float
tax_rate: float = 0.2
@computed_field
@property
def price_with_tax(self) -> float:
return round(self.price * (1 + self.tax_rate), 2)
out = ProductSerializer(price=100.0).serialize()
# {"price": 100.0, "tax_rate": 0.2, "price_with_tax": 120.0}
Partial Validation (PATCH)
@router.patch("/posts/{post_id:int}")
async def patch_post(request: Request, post_id: int) -> JSONResponse:
post = await Post.objects.get(id=post_id)
ser = PostSerializer.validate(await request.json(), partial=True)
# Only update the fields the caller sent
changes = ser.model_dump(excludeunset=True)
for key, value in changes.items():
setattr(post, key, value)
await post.save()
return JSONResponse(PostSerializer.from_orm(post).serialize())
serialize_many - Bulk Serialization
async def example():
posts = await Post.objects.filter(is_published=True).all()
# From a plain list - no DB call
data = await PostSerializer.serialize_many(posts)
# From a QuerySet - streamed in PAGE_SIZE batches (default 25)
qs = Post.objects.filter(is_published=True).order_by("-created_at")
data = await PostSerializer.serialize_many(qs)
# Exclude specific fields
data = await PostSerializer.serialize_many(posts, exclude={"body"})
Pagination
from openviper.serializers import ModelSerializer
from openviper.http import JSONResponse, Request
from myapp.models import Post
class PostSerializer(ModelSerializer):
PAGE_SIZE = 20 # override default of 25
class Meta:
model = Post
fields = ["id", "title", "body", "created_at"]
@router.get("/posts")
async def list_posts(request: Request) -> JSONResponse:
page = int(request.query_params.get("page", 1))
page_size = int(request.query_params.get("page_size", PostSerializer.PAGE_SIZE))
cursor = request.query_params.get("cursor") # for fast Next/Prev
qs = Post.objects.filter(is_published=True).order_by("-created_at", "id")
# Uses asyncio.gather() for concurrent COUNT + fetch (~2x faster)
paginated = await PostSerializer.paginate(
qs,
page=page,
page_size=page_size,
cursor=cursor,
base_url="/posts",
)
return JSONResponse(paginated.model_dump())
# {
# "count": 120,
# "next": "/posts?cursor=eyJjcmVhdGVkX2F0IjouLi59&page=3&page_size=20",
# "previous": "/posts?page=1&page_size=20",
# "next_cursor": "eyJjcmVhdGVkX2F0IjouLi4sImlkIjoxMjN9",
# "results": [...]
# }
Performance tips:
COUNT and fetch queries run concurrently (using
asyncio.gather()).Direct page jumps (e.g.,
?page=1000) use OFFSET and are O(N) - slower for deep pages.Sequential navigation via
next_cursoruses keyset pagination - O(log N), fast at any depth.Always include
idas the last ordering field for stable cursor pagination.
Common mistakes:
paginate is a classmethod that accepts a QuerySet, not a list of
already-serialized objects. These patterns will not work:
# WRONG - from_orm_many returns a list, not a QuerySet
instances = PostSerializer.from_orm_many(await Post.objects.all())
result = await PostSerializer.paginate(instances) # TypeError
# WRONG - serialize_many returns a list of dicts
data = await PostSerializer.serialize_many(Post.objects.all())
result = await PostSerializer.paginate(data) # TypeError
# WRONG - awaiting the QuerySet evaluates it to a list
result = await PostSerializer.paginate(
await Post.objects.all(), # list, not QuerySet
page=1, page_size=20,
) # AttributeError: 'list' object has no attribute 'offset'
The correct approach is to pass the unevaluated QuerySet directly:
qs = Post.objects.filter(is_published=True).order_by("-created_at", "id")
result = await PostSerializer.paginate(qs, page=1, page_size=20)
ORM-level pagination:
For more control, paginate at the ORM level before serialization:
@router.get("/posts")
async def list_posts(request: Request) -> JSONResponse:
page = int(request.query_params.get("page", 1))
page_size = int(request.query_params.get("page_size", 20))
qs = Post.objects.filter(is_published=True).order_by("-created_at", "id")
# Paginate at ORM level (also uses asyncio.gather)
page_obj = await qs.paginate(page_number=page, page_size=page_size)
# Serialize only the paginated items
results = await PostSerializer.serialize_many(page_obj.items)
return JSONResponse({
"count": page_obj.total_count,
"page": page_obj.number,
"page_size": page_obj.page_size,
"next_cursor": page_obj.next_cursor,
"results": results,
})
Performance Optimizations
The serializers module includes several performance optimizations that provide significant speed improvements for production workloads:
Direct ORM→Dict Mapping (35-40% faster)
serialize_many(), serialize_many_json(), and paginate() use direct
attribute access instead of double conversion (ORM → Pydantic model → dict).
This eliminates unnecessary validation and model instantiation overhead:
# Old approach (slow):
instances = [cls.model_validate(obj) for obj in objs] # Conversion 1
results = [inst.model_dump(mode="json") for inst in instances] # Conversion 2
# Optimized approach (35-40% faster):
results = [
{fname: getattr(obj, fname, None) for fname in cls.model_fields}
for obj in objs
]
Cached Exclude Field Computation (5-10% reduction)
The get_excluded_fields() helper caches write-only field sets to avoid
repeated set construction on every serialization call:
excl = cls.get_excluded_fields(exclude) # Cached, returns frozenset
LRU-Cached File Fields (Memory Safe)
File field introspection uses @lru_cache(maxsize=512) instead of unbounded
dict caching, preventing memory leaks in applications with dynamic serializer
creation:
@classmethod
@lru_cache(maxsize=512)
def get_file_fields(cls) -> MappingProxyType:
# Cached per serializer class with automatic eviction
Parallel Query Execution (2x faster)
paginate() uses asyncio.gather() to run COUNT and data fetch queries
concurrently, doubling baseline performance:
total, objs = await asyncio.gather(
qs.count(), # Query 1
page_qs.all(), # Query 2 (runs in parallel)
)
Expected Performance Gains
When combined with db query optimizations, these improvements provide:
60-80% faster paginated list endpoints (COUNT + fetch + serialization)
35-40% faster bulk serialization (
serialize_many/serialize_many_json)50% faster file field validation (cached introspection)
Eliminated O(N) allocation overhead for exclude field computation
For highest performance on large lists, consider using cursor-based pagination (keyset seeks) instead of OFFSET, and ensure proper database indexes exist for ordering columns.
ModelSerializer CRUD Helpers
class PostSerializer(ModelSerializer):
class Meta:
model = Post
fields = "__all__"
readonly_fields = ("id", "created_at")
# CREATE
@router.post("/posts")
async def create_post(request: Request) -> JSONResponse:
ser = PostSerializer(data=await request.json())
ser.validate(raise_exception=True)
saved = await ser.save() # calls ser.create() internally
return JSONResponse(saved, status_code=201)
# UPDATE (PUT - full replacement)
@router.put("/posts/{post_id:int}")
async def update_post(request: Request, post_id: int) -> JSONResponse:
post = await Post.objects.get(id=post_id)
ser = PostSerializer.validate(await request.json())
saved = await ser.save(post) # calls ser.update(post) internally
return JSONResponse(saved)
# UPDATE (PATCH - partial)
@router.patch("/posts/{post_id:int}")
async def patch_post(request: Request, post_id: int) -> JSONResponse:
post = await Post.objects.get(id=post_id)
ser = PostSerializer.validate(await request.json(), partial=True)
saved = await ser.save(post)
return JSONResponse(saved)
Nested Serializers
class AuthorSerializer(Serializer):
id: int
username: str
class PostSerializer(Serializer):
id: int
title: str
author: AuthorSerializer # nested - expects author to be an object
tags: list[str] = []
# When building from ORM, use select_related to avoid N+1:
async def example():
post = await Post.objects.select_related("author").get(id=1)
out = PostSerializer.from_orm(post).serialize()
# {"id": 1, "title": "...", "author": {"id": 5, "username": "alice"}, "tags": []}
Error Handling
validate() wraps all Pydantic errors in
ValidationError whose
validation_errors attribute is a list of structured dicts:
from openviper.exceptions import ValidationError
try:
data = PostSerializer.validate({"title": ""})
except ValidationError as exc:
print(exc.validation_errors)
# [{"field": "title", "message": "Title cannot be blank.", "type": "value_error"}]
When a ModelSerializer reaches the database, common persistence
failures are converted into the same structured format:
serializer = PostSerializer(data={"slug": "already-used"})
serializer.validate(raise_exception=True)
await serializer.save()
# duplicate unique values raise:
# [{"field": "slug", "message": "This value must be unique.", "type": "unique"}]
Malformed JSON is separate from serializer validation: request.json()
returns HTTP 400 when the body cannot be parsed at all, while serializers
return HTTP 422 when the JSON is valid but the data is invalid.
Using in a View
from openviper.routing.router import Router
from openviper.http.request import Request
from openviper.http.response import JSONResponse
from openviper.exceptions import ValidationError
router = Router()
@router.post("/posts")
async def create_post(request: Request) -> JSONResponse:
serializer = PostSerializer(data=await request.json())
serializer.validate(raise_exception=True)
saved = await serializer.save()
return JSONResponse(saved, status_code=201)