# django-modern-rest Documentation
Source: /en/latest/index.md
[](https://github.com/wemake-services)[](https://github.com/wemake-services/django-modern-rest)[](https://github.com/wemake-services/django-modern-rest/actions/workflows/test.yml)[](https://codecov.io/gh/wemake-services/django-modern-rest)[](https://github.com/wemake-services/django-modern-rest/blob/master/.github/AI_POLICY.md)[](https://pepy.tech/projects/django-modern-rest)[](https://pypi.org/project/django-modern-rest/)[](https://github.com/wemake-services/wemake-python-styleguide)[](https://deepwiki.com/wemake-services/django-modern-rest)[](https://t.me/django_modern_rest)
Modern REST framework for Django with types and async support!
This guide will walk you through all the details of how to install, use,
and extend `django-modern-rest` framework.
Getting started
[GitHub](https://github.com/wemake-services/django-modern-rest)
#### Testimonials
> The one thing I really love about `django-modern-rest` is its pluggable
> serializers and validators. Frameworks that are tightly coupled
> with `pydantic` can be really painful to work with.
> Using `django-modern-rest` has been a game-changer
> for my productivity. The strict type safety and schema validation
> for both requests and responses mean I spend less time debugging
> and more time building.
> I rarely see frameworks that treat their OpenAPI schema
> as a first-class citizen. `django-modern-rest` not only generates
> a schema that accurately reflects your code,
> but also gives you the tools to verify it.
#### Main features include:
> ##### REST
>
> Semantic REST APIs with 100% typed API and strict schema validation
> for both requests and responses.
>
> You would never miss an important status code in the docs anymore!
> ##### Blazingly Fast!
>
> Built with performance in mind. Import time optimizations,
> only one validation per request, best `json` parsing tools in class.
>
> And `msgspec` support allows users to have
> [x5-15 times faster](https://msgspec.dev/benchmarks)
> APIs than the alternatives.
> ##### Sync and Async support
>
> Fully utilizes best of the both worlds in `django`.
>
> Create your APIs as sync or async, your choice.
> Both `wsgi` and `asgi` are supported.
> ##### Not just schema generation
>
> Of course, OpenAPI schema generation and modification
> are available out of the box.
>
> But, there’s more: we also provide validation
> and testing tools for your schema!
> Powered by [schemathesis](https://github.com/schemathesis/schemathesis)
> ##### Still good old Django
>
> We don’t reinvent the wheel, this is just good old Django.
> We only add fast `json` parsing and schema for requests and responses.
>
> And that’s it. You can still use all packages
> and features from regular Django apps.
> No new concepts to learn, no new APIs to be compatible with.
>
> Just drop this package into any existing Django application!
> ##### Customizable to the core
>
> Every part of the framework can be customized and extended.
>
> Since, there’s no magic happening, it would be really easy to do.
> Our docs and tests provide multiple examples of that.
>
> Public API stability is guaranteed from 1.0.0 release.
## Contributors
Here are our amazing people who made this project possible.
* [](https://github.com/sobolevn)
[sobolevn](https://github.com/sobolevn)
600 contributions
* [](https://github.com/kondratevdev)
[Alexander Kondratev](https://github.com/kondratevdev)
99 contributions
* [](https://github.com/vyhuholl)
[Olga Pichuzhkina](https://github.com/vyhuholl)
49 contributions
* [](https://github.com/milssky)
[Alexey Potapov](https://github.com/milssky)
44 contributions
* [](https://github.com/Satevg)
[Eugene Sattarau](https://github.com/Satevg)
12 contributions
* [](https://github.com/F0RRZZ)
[Marat Khagazheev](https://github.com/F0RRZZ)
11 contributions
* [](https://github.com/Chyrets)
[Uladzislaw](https://github.com/Chyrets)
9 contributions
* [](https://github.com/s3rius)
[Pavel Kirilin](https://github.com/s3rius)
8 contributions
* [](https://github.com/Mr-Sunglasses)
[Kanishk Pachauri](https://github.com/Mr-Sunglasses)
7 contributions
* [](https://github.com/maksimzayats)
[Maksim Zayats](https://github.com/maksimzayats)
7 contributions
* [](https://github.com/MasoNord)
[Diemid](https://github.com/MasoNord)
6 contributions
* [](https://github.com/max0nT)
[Maxim Tetyushkin](https://github.com/max0nT)
5 contributions
* [](https://github.com/azad-mammedov)
[azad-mammedov](https://github.com/azad-mammedov)
5 contributions
* [](https://github.com/gimntut)
[Гимаев Наиль](https://github.com/gimntut)
4 contributions
* [](https://github.com/sesav)
[Sergey Silaev](https://github.com/sesav)
4 contributions
* [](https://github.com/SammySN-car)
[Samar Nathani](https://github.com/SammySN-car)
4 contributions
* [](https://github.com/deliro)
[Roman Kitaev](https://github.com/deliro)
4 contributions
* [](https://github.com/mschoettle)
[Matthias Schoettle](https://github.com/mschoettle)
4 contributions
* [](https://github.com/ApostolFet)
[Ageev Maxim](https://github.com/ApostolFet)
3 contributions
* [](https://github.com/cmdtorch)
[Emil Humbatov](https://github.com/cmdtorch)
3 contributions
* [](https://github.com/RenameMe1)
[Evgeny Demchenko](https://github.com/RenameMe1)
3 contributions
* [](https://github.com/soil0119)
[Sanghun](https://github.com/soil0119)
3 contributions
* [](https://github.com/thesayfulla)
[Sayfulla Mirkhalikov](https://github.com/thesayfulla)
3 contributions
* [](https://github.com/Sehat1137)
[Sehat1137](https://github.com/Sehat1137)
3 contributions
* [](https://github.com/fennr)
[Viktor Merkurev](https://github.com/fennr)
3 contributions
* [](https://github.com/danfimov)
[Dmitrii Anfimov](https://github.com/danfimov)
3 contributions
* [](https://github.com/TakiMoysha)
[Mikhail Popov](https://github.com/TakiMoysha)
2 contributions
* [](https://github.com/thepabloaguilar)
[Pablo Aguilar](https://github.com/thepabloaguilar)
2 contributions
* [](https://github.com/Ryota-Di)
[Reilly](https://github.com/Ryota-Di)
2 contributions
* [](https://github.com/sergey-miryanov)
[Sergey Miryanov](https://github.com/sergey-miryanov)
2 contributions
* [](https://github.com/Tapeline)
[Ilya Solin](https://github.com/Tapeline)
2 contributions
* [](https://github.com/chirizxc)
[chiri](https://github.com/chirizxc)
2 contributions
* [](https://github.com/jarik2014)
[jarik2014](https://github.com/jarik2014)
2 contributions
* [](https://github.com/kodsurfer)
[kodsurfer](https://github.com/kodsurfer)
2 contributions
* [](https://github.com/Nifacy)
[Alexey Grishin](https://github.com/Nifacy)
2 contributions
* [](https://github.com/aazmv)
[amirbek_azimov](https://github.com/aazmv)
2 contributions
* [](https://github.com/golovindev)
[Maxim Golovin](https://github.com/golovindev)
2 contributions
* [](https://github.com/MaxFreedomPollard)
[Max Freedom Pollard](https://github.com/MaxFreedomPollard)
2 contributions
* [](https://github.com/Axiya3749)
[MaierYan](https://github.com/Axiya3749)
2 contributions
* [](https://github.com/kittywaresz)
[Kirill Statsenko](https://github.com/kittywaresz)
2 contributions
* [](https://github.com/hpy1968)
[Henry Yates](https://github.com/hpy1968)
2 contributions
* [](https://github.com/aryansinha1908)
[AryanSinha](https://github.com/aryansinha1908)
2 contributions
* [](https://github.com/AndreyGatsuk)
[AndreyGatsuk](https://github.com/AndreyGatsuk)
2 contributions
* [](https://github.com/AbduazizZiyodov)
[Abduaziz](https://github.com/AbduazizZiyodov)
2 contributions
* [](https://github.com/albertedwardson)
[Albert Eduardovich N.](https://github.com/albertedwardson)
2 contributions
* [](https://github.com/Shaisolaris)
[Shai](https://github.com/Shaisolaris)
1 contribution
* [](https://github.com/ShlokShar)
[Shlok Sharma](https://github.com/ShlokShar)
1 contribution
* [](https://github.com/Shraddhameduri)
[Shraddha Meduri Kishore](https://github.com/Shraddhameduri)
1 contribution
* [](https://github.com/asidowner)
[Sidorenkov Anton](https://github.com/asidowner)
1 contribution
* [](https://github.com/stefanofusai)
[Stefano Fusai](https://github.com/stefanofusai)
1 contribution
* [](https://github.com/Tosinibikunle)
[Tosinibikunle](https://github.com/Tosinibikunle)
1 contribution
* [](https://github.com/tranhoangtu-it)
[Trần Hoàng Tú](https://github.com/tranhoangtu-it)
1 contribution
* [](https://github.com/Nalsher)
[Vladislav Andriyanov](https://github.com/Nalsher)
1 contribution
* [](https://github.com/zeel2104)
[Zeel Desai](https://github.com/zeel2104)
1 contribution
* [](https://github.com/Syed-Zeeyan)
[Zeeyawnn](https://github.com/Syed-Zeeyan)
1 contribution
* [](https://github.com/aayushkc)
[aayushkc](https://github.com/aayushkc)
1 contribution
* [](https://github.com/erensh27)
[abhinav](https://github.com/erensh27)
1 contribution
* [](https://github.com/sh1rokovs)
[Sergey Shirokov](https://github.com/sh1rokovs)
1 contribution
* [](https://github.com/wimble3)
[Sergey Avdeev](https://github.com/wimble3)
1 contribution
* [](https://github.com/sanjeevafk)
[Sanjeev](https://github.com/sanjeevafk)
1 contribution
* [](https://github.com/SaDR4M)
[Sadra Aghajanlo](https://github.com/SaDR4M)
1 contribution
* [](https://github.com/Sabfo)
[Sabfo](https://github.com/Sabfo)
1 contribution
* [](https://github.com/Rusich90)
[Ruslan Alimov](https://github.com/Rusich90)
1 contribution
* [](https://github.com/Posterfo)
[Emre](https://github.com/Posterfo)
1 contribution
* [](https://github.com/PashaWNN)
[Pavel Shishmarev](https://github.com/PashaWNN)
1 contribution
* [](https://github.com/PNoryk)
[Pavel Noryk](https://github.com/PNoryk)
1 contribution
* [](https://github.com/Lancetnik)
[Pastukhov Nikita](https://github.com/Lancetnik)
1 contribution
* [](https://github.com/nikolaysm)
[Nikolay S.](https://github.com/nikolaysm)
1 contribution
* [](https://github.com/nikolay-kovalenko91)
[Nikolay Kovalenko](https://github.com/nikolay-kovalenko91)
1 contribution
* [](https://github.com/Nikolay-Chillev)
[Nikolay Chillev](https://github.com/Nikolay-Chillev)
1 contribution
* [](https://github.com/K-dash)
[Yuki Furukawa](https://github.com/K-dash)
1 contribution
* [](https://github.com/JaeHyuckSa)
[사재혁](https://github.com/JaeHyuckSa)
1 contribution
* [](https://github.com/Ai-chan-0411)
[藍-Ai](https://github.com/Ai-chan-0411)
1 contribution
* [](https://github.com/Peopl3s)
[Максим Мельников](https://github.com/Peopl3s)
1 contribution
* [](https://github.com/panguss)
[Денис](https://github.com/panguss)
1 contribution
* [](https://github.com/vgnatyuk)
[vgnatyuk](https://github.com/vgnatyuk)
1 contribution
* [](https://github.com/vansh-nagar)
[vansh](https://github.com/vansh-nagar)
1 contribution
* [](https://github.com/subnix)
[subnix](https://github.com/subnix)
1 contribution
* [](https://github.com/qwist1233-cpu)
[qwist1233-cpu](https://github.com/qwist1233-cpu)
1 contribution
* [](https://github.com/pomponchik)
[pomponchik](https://github.com/pomponchik)
1 contribution
* [](https://github.com/Parth-Vasave)
[Parth-Vasave](https://github.com/Parth-Vasave)
1 contribution
* [](https://github.com/neocim)
[neocim](https://github.com/neocim)
1 contribution
* [](https://github.com/nanookclaw)
[nanookclaw](https://github.com/nanookclaw)
1 contribution
* [](https://github.com/kanagarajSCK)
[kanagarajSCK](https://github.com/kanagarajSCK)
1 contribution
* [](https://github.com/johnmakarov)
[johnmakarov](https://github.com/johnmakarov)
1 contribution
* [](https://github.com/iccccccccccccc)
[iccccccccccccc](https://github.com/iccccccccccccc)
1 contribution
* [](https://github.com/hydrauluu)
[hydrauluu](https://github.com/hydrauluu)
1 contribution
* [](https://github.com/helo060228)
[helo060228](https://github.com/helo060228)
1 contribution
* [](https://github.com/gas12241)
[gas12241](https://github.com/gas12241)
1 contribution
* [](https://github.com/fwh888)
[fwh888](https://github.com/fwh888)
1 contribution
* [](https://github.com/dghnts)
[dghnts](https://github.com/dghnts)
1 contribution
* [](https://github.com/arimu1)
[arimu1](https://github.com/arimu1)
1 contribution
* [](https://github.com/Aditya-myst)
[Aditya-myst](https://github.com/Aditya-myst)
1 contribution
* [](https://github.com/asm-1)
[asm-1](https://github.com/asm-1)
1 contribution
* [](https://github.com/tomatotomata)
[tomatotomata](https://github.com/tomatotomata)
1 contribution
* [](https://github.com/sr-dotcom)
[sr-dotcom](https://github.com/sr-dotcom)
1 contribution
* [](https://github.com/dgolov)
[dgolov](https://github.com/dgolov)
1 contribution
* [](https://github.com/gokul-debugger)
[gokul-debugger](https://github.com/gokul-debugger)
1 contribution
* [](https://github.com/Girdharilal-aiml)
[Girdharilal-aiml](https://github.com/Girdharilal-aiml)
1 contribution
* [](https://github.com/Evgenmater)
[Evgenmater](https://github.com/Evgenmater)
1 contribution
* [](https://github.com/Shecspi)
[Shecspi](https://github.com/Shecspi)
1 contribution
* [](https://github.com/Stranger6667)
[Stranger6667](https://github.com/Stranger6667)
1 contribution
* [](https://github.com/dimk00z)
[dimk00z](https://github.com/dimk00z)
1 contribution
* [](https://github.com/dzhalaevd)
[dzhalaevd](https://github.com/dzhalaevd)
1 contribution
* [](https://github.com/eoan-ermine)
[eoan-ermine](https://github.com/eoan-ermine)
1 contribution
* [](https://github.com/SemTiOne)
[SemTiOne](https://github.com/SemTiOne)
1 contribution
* [](https://github.com/wrongnull)
[wrongnull](https://github.com/wrongnull)
1 contribution
* [](https://github.com/bagowix)
[bagowix](https://github.com/bagowix)
1 contribution
* [](https://github.com/drlinggg)
[drlinggg](https://github.com/drlinggg)
1 contribution
* [](https://github.com/AsilbekKha)
[AsilbekKha](https://github.com/AsilbekKha)
1 contribution
* [](https://github.com/aryansk)
[aryansk](https://github.com/aryansk)
1 contribution
* [](https://github.com/arturboyun)
[arturboyun](https://github.com/arturboyun)
1 contribution
* [](https://github.com/larryxs)
[larryxs](https://github.com/larryxs)
1 contribution
* [](https://github.com/insspb)
[insspb](https://github.com/insspb)
1 contribution
* [](https://github.com/amasen02)
[amasen02](https://github.com/amasen02)
1 contribution
* [](https://github.com/bernalalexis-try)
[bernalalexis-try](https://github.com/bernalalexis-try)
1 contribution
* [](https://github.com/oek1ng)
[oek1ng](https://github.com/oek1ng)
1 contribution
* [](https://github.com/AlexanderZharyuk)
[AlexanderZharyuk](https://github.com/AlexanderZharyuk)
1 contribution
* [](https://github.com/TurtleOld)
[TurtleOld](https://github.com/TurtleOld)
1 contribution
* [](https://github.com/adityabagla7)
[adityabagla7](https://github.com/adityabagla7)
1 contribution
* [](https://github.com/Act0r1)
[Act0r1](https://github.com/Act0r1)
1 contribution
* [](https://github.com/n05tr0m0)
[n05tr0m0](https://github.com/n05tr0m0)
1 contribution
* [](https://github.com/muratclk)
[muratclk](https://github.com/muratclk)
1 contribution
* [](https://github.com/MukievMukhammad)
[MukievMukhammad](https://github.com/MukievMukhammad)
1 contribution
* [](https://github.com/wized2)
[wized2](https://github.com/wized2)
1 contribution
* [](https://github.com/mikhailofff)
[mikhailofff](https://github.com/mikhailofff)
1 contribution
* [](https://github.com/MikeVL)
[MikeVL](https://github.com/MikeVL)
1 contribution
* [](https://github.com/Dresdn)
[Dresdn](https://github.com/Dresdn)
1 contribution
* [](https://github.com/acostyle)
[acostyle](https://github.com/acostyle)
1 contribution
* [](https://github.com/mvanhorn)
[mvanhorn](https://github.com/mvanhorn)
1 contribution
* [](https://github.com/Matvey1109)
[Matvey1109](https://github.com/Matvey1109)
1 contribution
* [](https://github.com/markparonyan)
[markparonyan](https://github.com/markparonyan)
1 contribution
* [](https://github.com/hxxxi-malog)
[hxxxi-malog](https://github.com/hxxxi-malog)
1 contribution
* [](https://github.com/MaximGit1)
[MaximGit1](https://github.com/MaximGit1)
1 contribution
* [](https://github.com/Laksopan23)
[Laksopan23](https://github.com/Laksopan23)
1 contribution
* [](https://github.com/KardeniaPoyu)
[KardeniaPoyu](https://github.com/KardeniaPoyu)
1 contribution
* [](https://github.com/krisrajaryan27)
[krisrajaryan27](https://github.com/krisrajaryan27)
1 contribution
* [](https://github.com/cicwak)
[cicwak](https://github.com/cicwak)
1 contribution
* [](https://github.com/vedmetskii)
[vedmetskii](https://github.com/vedmetskii)
1 contribution
* [](https://github.com/Eclips4)
[Eclips4](https://github.com/Eclips4)
1 contribution
* [](https://github.com/karpetrosyan)
[karpetrosyan](https://github.com/karpetrosyan)
1 contribution
* [](https://github.com/kstypik)
[kstypik](https://github.com/kstypik)
1 contribution
* [](https://github.com/JuliaBars)
[JuliaBars](https://github.com/JuliaBars)
1 contribution
* [](https://github.com/proofit404)
[proofit404](https://github.com/proofit404)
1 contribution
* [](https://github.com/Izcarmt95)
[Izcarmt95](https://github.com/Izcarmt95)
1 contribution
* [](https://github.com/HarshRajSinghania)
[HarshRajSinghania](https://github.com/HarshRajSinghania)
1 contribution
Source: /en/latest/pages/getting-started.md
## Getting started
### Installation
Works for:
- CPython 3.11+ or PyPy 3.11+
- Django 5.0+
uv
poetry
pip
```bash
uv add django-modern-rest
```
```bash
poetry add django-modern-rest
```
```bash
pip install django-modern-rest
```
Extras for different serializers:
- `'django-modern-rest[pydantic]'` for `pydantic` support
- `'django-modern-rest[attrs]'` for `attrs` support
- `'django-modern-rest[msgspec]'` for `msgspec` support
and the fastest `json` parsing in requests, `jwt`, testing, and OpenAPI
Extras for different features:
- `'django-modern-rest[jwt]'` for
[jwt](https://pyjwt.readthedocs.io) support
- `'django-modern-rest[openapi]'` for
[OpenAPI schema validation](https://github.com/python-openapi/openapi-spec-validator)
and better example generation
> [!IMPORTANT]
> We highly recommend always installing
> [msgspec](https://github.com/jcrist/msgspec), even when using just
> [pydantic](https://github.com/pydantic/pydantic) for APIs.
> We use `msgspec` to parse `json` when it is available
> because it is [the fastest](https://msgspec.dev/benchmarks)
> library out there for this task.
>
> We also recommend always installing [django-stubs](https://github.com/typeddjango/django-stubs)
> for typing Django itself.
> [!NOTE]
> You don’t need to add `'dmr'` to `INSTALLED_APPS`
> unless you want to serve static files for OpenAPI.
### LLMs support
Are you using AI for assisted coding?
We’ve got you covered. Use these files for context to make sure that the LLM
knows our framework:
- [https://django-modern-rest.readthedocs.io/llms.txt](https://django-modern-rest.readthedocs.io/llms.txt)
for indexes with links to different pages and topics
- [https://django-modern-rest.readthedocs.io/llms-full.txt](https://django-modern-rest.readthedocs.io/llms-full.txt)
for complete docs
- Every documentation page is also served as Markdown,
replace `.html` with `.md` in its URL,
or press `M↓` next to the title of any page
We also support
[Context7](https://context7.com/wemake-services/django-modern-rest)
for up-to-date docs for LLMs.
Use cases we officially support:
- Learning `django-modern-rest` with the help
of [DeepWiki](https://deepwiki.com/wemake-services/django-modern-rest)
- AI-guided migrations for any API changes.
Did we break something? Every breaking release ships a migration prompt,
so you can upgrade to a newer version
using an AI tool of your choice
We ship several agent skills inside the package,
install them into your project with a single command:
```bash
uvx library-skills
```
- `$dmr` to enforce `django-modern-rest` best practices
with fast and secure approaches
- `$dmr-upgrade` to upgrade to a newer release
with the official migration prompts
- `$dmr-openapi-skeleton` to generate
a working project boilerplate
from a single `openapi.json` file (the “Spec First” approach)
- `$dmr-from-django-ninja` to help with
migrating from Django Ninja
- `$dmr-from-drf` to help with
migrating from Django REST Framework
- `$dmr-from-dj-rest-auth` to help with
migrating from dj-rest-auth
### Showcase
Let’s see the basics and learn how to use `dmr` in a single example:
msgspec
pydantic
attrs
dataclasses
TypedDict
NamedTuple
We support [`msgspec.Struct`](https://msgspec.dev/api.md#msgspec.Struct)
via `MsgspecSerializer`.
views.py
```python
import uuid
import msgspec
from dmr import Body, Controller
from dmr.plugins.msgspec import MsgspecSerializer
class UserCreateModel(msgspec.Struct):
email: str
class UserModel(UserCreateModel):
uid: uuid.UUID
class UserController(Controller[MsgspecSerializer]):
def post(self, parsed_body: Body[UserCreateModel]) -> UserModel:
return UserModel(uid=uuid.uuid4(), email=parsed_body.email)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "email@example.com"}' -H 'Content-Type: application/json'
> {"email":"email@example.com","uid":"31878480-f5a2-4910-8f4c-318707a574c5"}
> ```
We support [`pydantic.BaseModel`](https://pydantic.dev/docs/validation/latest/api/pydantic/base_model/#pydantic.BaseModel)
via `PydanticSerializer`
and `PydanticFastSerializer`.
> [!TIP]
> `PydanticFastSerializer` is faster,
> but it only works with `json`
> parsers and renderers.
> If you need other formats, use
> `PydanticSerializer` instead.
views.py
```python
import uuid
import pydantic
from dmr import Body, Controller
from dmr.plugins.pydantic import PydanticFastSerializer
class UserCreateModel(pydantic.BaseModel):
email: str
class UserModel(UserCreateModel):
uid: uuid.UUID
class UserController(Controller[PydanticFastSerializer]):
def post(self, parsed_body: Body[UserCreateModel]) -> UserModel:
return UserModel(uid=uuid.uuid4(), email=parsed_body.email)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "email@example.com"}' -H 'Content-Type: application/json'
> {"email":"email@example.com","uid":"c82a4fd2-f57b-4f98-94f6-dcc4c3e544ac"}
> ```
We support [`attrs.define()`](https://www.attrs.org/en/stable/api.html#attrs.define)
via `MsgspecSerializer`.
See [msgspec docs](https://msgspec.dev/supported-types#attrs)
on `attrs` support.
views.py
```python
import uuid
import attrs
from dmr import Body, Controller
from dmr.plugins.msgspec import MsgspecSerializer
@attrs.define
class UserCreateModel:
email: str
@attrs.define
class UserModel(UserCreateModel):
uid: uuid.UUID
class UserController(Controller[MsgspecSerializer]):
def post(self, parsed_body: Body[UserCreateModel]) -> UserModel:
return UserModel(uid=uuid.uuid4(), email=parsed_body.email)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "email@example.com"}' -H 'Content-Type: application/json'
> {"uid":"24c70664-1a79-4685-975f-725654332f16","email":"email@example.com"}
> ```
We support [`dataclasses.dataclass()`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass) via both
`MsgspecSerializer`
and `PydanticSerializer`.
views.py
```python
import dataclasses
import uuid
from dmr import Body, Controller
from dmr.plugins.msgspec import MsgspecSerializer
@dataclasses.dataclass
class UserCreateModel:
email: str
@dataclasses.dataclass
class UserModel(UserCreateModel):
uid: uuid.UUID
class UserController(Controller[MsgspecSerializer]):
def post(self, parsed_body: Body[UserCreateModel]) -> UserModel:
return UserModel(uid=uuid.uuid4(), email=parsed_body.email)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "email@example.com"}' -H 'Content-Type: application/json'
> {"email":"email@example.com","uid":"e50692c3-cc57-4e89-8791-321e4f0f1b57"}
> ```
We support [`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) via both
`MsgspecSerializer`
and `PydanticSerializer`.
views.py
```python
import uuid
from typing_extensions import TypedDict
from dmr import Body, Controller
from dmr.plugins.msgspec import MsgspecSerializer
class UserCreateModel(TypedDict):
email: str
class UserModel(UserCreateModel):
uid: uuid.UUID
class UserController(Controller[MsgspecSerializer]):
def post(self, parsed_body: Body[UserCreateModel]) -> UserModel:
return UserModel(uid=uuid.uuid4(), email=parsed_body['email'])
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "email@example.com"}' -H 'Content-Type: application/json'
> {"uid":"1d1f1dae-5b65-4737-8f7d-f95db205f1c3","email":"email@example.com"}
> ```
We support [`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
via `PydanticSerializer`.
views.py
```python
import uuid
from typing import NamedTuple
from dmr import Body, Controller
from dmr.plugins.pydantic import PydanticFastSerializer
class UserCreateModel(NamedTuple):
email: str
class UserModel(NamedTuple):
email: str
uid: uuid.UUID
class UserController(Controller[PydanticFastSerializer]):
def post(self, parsed_body: Body[UserCreateModel]) -> UserModel:
return UserModel(uid=uuid.uuid4(), email=parsed_body.email)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "email@example.com"}' -H 'Content-Type: application/json'
> ["email@example.com","636dce58-62a7-4e37-ba2e-33697274bf31"]
> ```
> [!IMPORTANT]
> You can choose a serializer per controller, which will give you
> the freedom to pick the best serializer and model for the job.
> `msgspec` gives you more speed,
> while `pydantic` gives you more flexibility.
In this example:
1. We defined regular `pydantic`, `msgspec`, or any other models
we will use for our API
2. We added a component parser for the request’s
`Body`,
which will parse it into the typed model
that we pass to this component as a type parameter
3. Next, we created
a `Controller` class
with `PydanticSerializer`
or `MsgspecSerializer`
to serialize input and output data for us
4. We also defined a `post` API endpoint and returned
a simple model response from it. It will be automatically
transformed into a [`django.http.HttpResponse`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpResponse) instance
by `django-modern-rest`
Now, let’s add our controller to the list of URLs:
urls.py
```python
# Our `path` is an optimized drop-in replacement of `django.urls.path`:
from dmr.routing import Router, path
from examples.getting_started.pydantic_controller import UserController
# Router is just a collection of regular Django urls:
router = Router(
'api/',
[
path(
'user/',
UserController.as_view(),
name='users',
),
],
)
# Just a regular `urlpatterns` definition, Django-style:
urlpatterns = [
router.to_urlpatterns(namespace='api'),
]
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json'
> {"email":"user@wms.org","uid":"b4a7b27b-36e2-47b6-8d6e-46976b858129"}
> ```
Your first `django-modern-rest` API is ready.
Next, you can learn:
- How to generate an OpenAPI schema
- How to handle errors
- How to customize controllers and endpoints
### Full example
If you were ever told that Django is too big and complicated,
that was misleading, to say the least.
Here’s a single-file application
that looks pretty much the same as any other micro-framework, like
FastAPI, Litestar, or Flask.
```python
import secrets
import sys
import uuid
import pydantic
from django.conf import settings
from django.core.management import execute_from_command_line
from dmr import Body, Controller
from dmr.openapi import build_schema
from dmr.openapi.views import OpenAPIJsonView, SwaggerView
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.routing import Router, path
if not settings.configured:
settings.configure(
ROOT_URLCONF=__name__,
ALLOWED_HOSTS='*',
DEBUG=True,
INSTALLED_APPS=['dmr', 'django.contrib.staticfiles'],
STATIC_URL='/static/',
STATICFILES_FINDERS=[
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
],
TEMPLATES=[
{
'APP_DIRS': True,
'BACKEND': 'django.template.backends.django.DjangoTemplates',
},
],
# Secret key for tests, will be new on each run,
# in production it must be the same token, kept in secret:
SECRET_KEY=secrets.token_hex(),
)
class UserCreateModel(pydantic.BaseModel):
email: str
class UserResponseModel(UserCreateModel):
uid: uuid.UUID
class UserController(Controller[PydanticFastSerializer]):
async def post(
self,
parsed_body: Body[UserCreateModel],
) -> UserResponseModel:
return UserResponseModel(uid=uuid.uuid4(), email=parsed_body.email)
router = Router(
'api/',
[
path('user/', UserController.as_view(), name='users'),
],
)
schema = build_schema(router)
urlpatterns = [
router.to_urlpatterns(namespace='api'),
path('docs/openapi.json/', OpenAPIJsonView.as_view(schema), name='openapi'),
path('docs/swagger/', SwaggerView.as_view(schema), name='swagger'),
]
if __name__ == '__main__':
# Use `python THIS_FILE_NAME.py runserver` to run the example.
# Then visit `http://localhost:8000/docs/swagger` to view the docs.
execute_from_command_line(sys.argv)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "djangomodernrest@wemake.services"}' -H 'Content-Type: application/json'
> {"email":"djangomodernrest@wemake.services","uid":"48f05350-6015-46cf-97fe-90e4a3cb642f"}
> ```
You can copy it by clicking “Copy” in the upper-right corner of the example.
The copy button appears when you hover over the code example. Paste it as `example.py`,
install `django-modern-rest`, and run it with:
uv
poetry
pip
```bash
uv run python example.py runserver
```
```bash
poetry run python example.py runserver
```
```bash
python example.py runserver
```
Your API is now live:
- `POST` [http://localhost:8000/api/user/](http://localhost:8000/api/user/) — create a user
Then visit [http://localhost:8000/docs/swagger/](http://localhost:8000/docs/swagger/) for the interactive docs.

That’s it, enjoy your new project!
### But this is too simple for my use case!
What is great about Django is that it scales.
You can start with a single-file app and scale it up to a full-featured
monolith with strict context boundaries, DDD, reusable apps, etc.
We recommend starting new big projects with
[https://github.com/wemake-services/wemake-django-template](https://github.com/wemake-services/wemake-django-template).
It is a strict, security-first, battle-proven, highload-tested
boilerplate for real apps of the modern age.
### Type checking
`django-modern-rest` officially supports all major Python type checkers
in strict mode (listed in order of recommendation):
- [mypy](https://github.com/python/mypy), which has the best support
due to the [django-stubs](https://github.com/typeddjango/django-stubs) plugin
- [pyrefly](https://github.com/facebook/pyrefly)
- [pyright](https://github.com/microsoft/pyright)
- [ty](https://github.com/astral-sh/ty)
Installing [django-stubs](https://github.com/typeddjango/django-stubs) is required for all of them.
### Next up
> ##### Core Concepts
>
> Learn the fundamentals.
> ##### Configuration
>
> Learn how to configure `django-modern-rest`.
Source: /en/latest/pages/core-concepts.md
## Core concepts
To learn `django-modern-rest` you have to learn just a couple of things:
Endpoint
: `Endpoint`
is a single API route. It is defined
by its name, HTTP method, and its Metadata (e.g., what response schema
it returns, what status codes it can return, etc.).
Each endpoint might have different Component types
for parsing the inputs.
Controller
: `Controller`
is a collection of one or more endpoints
with the same set of components.
Controller is a subclass of [`View`](https://docs.djangoproject.com/en/6.1/ref/class-based-views/base/#django.views.generic.base.View), so
it can be used in a routing directly.
Component
: Controllers parse data via components like
`Body`
or `Headers`.
You can write your own components.
Metadata
: A collection of all the things each Endpoint accepts and returns.
It is used for request parsing, response validation, and the OpenAPI schema definition.
Serializer
: `BaseSerializer` subclass
that knows how to load and dump raw data into models.
We have two bundled serializers in plugins:
`pydantic` and `msgspec`. You can write your
own serializers for other libraries.
Routing
: Routing is a mapping of URLs to controllers.
We use Django’s default URL routing.
Controllers might have many URLs, for example:
`/api/v1/users/` and `/api/v2/users/`
Example:
views.py
```python
import uuid
import msgspec
from dmr import Body, Controller, NewHeader, modify
from dmr.plugins.msgspec import MsgspecSerializer
class UserCreateModel(msgspec.Struct):
email: str
class UserModel(UserCreateModel):
uid: uuid.UUID
class UserController( # <- `Controller` definition
Controller[MsgspecSerializer], # <- Passing `Serializer`
):
@modify(headers={'X-Default': NewHeader(value='1')}) # <- extra `Metadata`
def post(
self,
parsed_body: Body[UserCreateModel],
) -> UserModel: # <- `Endpoint` definition
return UserModel(uid=uuid.uuid4(), email=parsed_body.email)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -D - -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json'
> HTTP/1.1 201 Created
> date: Mon, 05 Oct 2026 11:17:14 GMT
> server: uvicorn
> X-Default: 1
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 69
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"email":"user@wms.org","uid":"35f658ab-15b7-43c1-9e30-f48e02653c3e"}
> ```
### Async vs Sync
We support both Django modes: sync and async, the same way regular Django
[supports](https://docs.djangoproject.com/en/stable/topics/async/) them.
We don’t do anything special with the async mode, so any existing
guides, tools, deployment strategies should
just work with `django-modern-rest` if they work for Django.
### Maximum integration with Django
We try to keep Django compatibility as our main goal.
Everything should work by default, starting
from [django-cors-headers](https://pypi.org/project/django-cors-headers)
up to [django-ratelimit](https://pypi.org/project/django-ratelimit).
We also provide Middleware wrapper tools to convert any middleware
response to the required API schema and set the needed `Content-Type`, etc.
We support all existing mixins because
`Controller` is a subclass
of Django’s [`django.views.generic.base.View`](https://docs.djangoproject.com/en/6.1/ref/class-based-views/base/#django.views.generic.base.View) class.
We support all existing decorators because we have
`endpoint_decorator()`
and `dispatch_decorator()` utilities
that can decorate endpoints and controllers.
We even support all existing API [`View`](https://docs.djangoproject.com/en/6.1/ref/class-based-views/base/#django.views.generic.base.View)
objects from other frameworks. See External views.
`django-modern-rest` works best with [django-stubs](https://github.com/typeddjango/django-stubs).
Read next: our Integrations guide.
### Next up
> ##### Using Controller
>
> Learn how controllers work.
> ##### Configuration
>
> Learn how to configure `django-modern-rest`.
Source: /en/latest/pages/using-controller/index.md
## Using controllers
### Creating endpoints
Controllers consist of `Endpoint` objects.
Each HTTP method is an independent endpoint.
The simplest way to create an endpoint is to define a sync
or async method with the right name (any HTTP method verb):
views.py
```python
from dmr import Controller
from dmr.plugins.msgspec import MsgspecSerializer
class MyController(Controller[MsgspecSerializer]):
def post(self) -> str:
return 'ok'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X POST
> "ok"
> ```
There will be several things that `django-modern-rest` will do for you here:
1. It will know that the `post` endpoint will handle the `POST` HTTP method,
it is true for all HTTP methods,
except OPTIONS (click to know why)
2. It will know that `post` will return [`str`](https://docs.python.org/3/builtins/stdtypes.html#str) as a response type spec.
There’s **no** implicit type conversions in `django-modern-rest`.
If your endpoint declares something to be returned, it must return this type
3. It will infer the default status code for `post`, which will be `201` (*CREATED*).
All other endpoints have `200` (*OK*) as the default
4. All this metadata will be used to validate responses from this endpoint.
Returning `[]` from `post` would trigger
`ResponseSchemaError`,
unless Response validation is explicitly turned off
5. The same metadata will be used to render the OpenAPI spec
`django-modern-rest` never creates implicit methods for you.
No `HEAD`, no OPTIONS.
Create them explicitly if you need them.
### Returning responses
We have two general modes of working with responses:
1. Returning just raw data from “raw endpoints”
2. Returning real [`HttpResponse`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpResponse) instances
with granular configuration from “real endpoints”
### Raw endpoints
> [!NOTE]
> “Raw endpoints” always have a response spec generated by default.
> Prefer `modify` in simpler cases.
“Raw endpoints” can be either undecorated
or can use the `modify` decorator
to modify the response spec that will be **generated by default**.
views.py
```python
from http import HTTPStatus
import pydantic
from dmr import Body, Controller, modify
from dmr.plugins.pydantic import PydanticFastSerializer
class UserModel(pydantic.BaseModel):
email: str
class UserController(Controller[PydanticFastSerializer]):
@modify(status_code=HTTPStatus.OK)
def post(self, parsed_body: Body[UserModel]) -> UserModel:
# This response would have an explicit status code `200`:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json'
> {"email":"user@wms.org"}
> ```
Other response specs can be specified via the
`extra_responses` param to `modify`,
the `responses`
`Controller` attribute,
or the `responses` global setting.
The most specific one wins, they are not merged,
see Configuration levels.
Make sure that all responses that can be returned are described!
> [!IMPORTANT]
> Despite the fact that `django-modern-rest` does not have
> its own request and response primitives
> and uses [`HttpRequest`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest)
> and [`HttpResponse`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpResponse),
> users must not return Django responses directly.
>
> Instead, use any of the public APIs:
>
> - `to_response()`
> - `to_error()`
> - `APIError`
>
> In case you don’t have a controller / endpoint instance
> (like in a middleware, for example),
> you can fallback to using the `build_response()`
> lower level primitive.
>
> Why?
>
> 1. You can mess up the default headers / status codes
> 2. You won’t have the right JSON serializer / deserializer,
> which can be both slow and error-prone
### Real endpoints
> [!NOTE]
> No response spec is generated by default for “real endpoints”.
> All response specs must be provided manually.
> This makes it more configurable.
“Real endpoints” can use the `validate`
decorator, the `responses`
`Controller` attribute,
or the `responses`
global setting to specify all possible responses.
The most specific one wins, they are not merged,
see Configuration levels.
To do that we utilize `ResponseSpec`:
views.py
```python
from http import HTTPStatus
import pydantic
from django.http import HttpResponse
from dmr import Body, Controller, ResponseSpec, validate
from dmr.plugins.pydantic import PydanticFastSerializer
class UserModel(pydantic.BaseModel):
email: str
class UserController(Controller[PydanticFastSerializer]):
@validate( # <- describes unique return types from this endpoint
ResponseSpec(
UserModel,
status_code=HTTPStatus.OK,
),
)
def post(self, parsed_body: Body[UserModel]) -> HttpResponse:
# This response would have an explicit status code `200`:
return self.to_response(
parsed_body,
status_code=HTTPStatus.OK,
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json'
> {"email":"user@wms.org"}
> ```
The `@validate` decorator is useful but is not required
for real endpoint declarations.
Instead, you can specify response specs in the `response` field
of the controller / settings.
views.py
```python
from http import HTTPStatus
import pydantic
from django.http import HttpResponse
from dmr import Body, Controller, ResponseSpec
from dmr.plugins.pydantic import PydanticFastSerializer
class UserModel(pydantic.BaseModel):
email: str
class UserController(Controller[PydanticFastSerializer]):
responses = (
# Describes unique return types for this controller:
ResponseSpec(UserModel, status_code=HTTPStatus.OK),
)
def post(self, parsed_body: Body[UserModel]) -> HttpResponse:
# This response would have an explicit status code `200`:
return self.to_response(
parsed_body,
status_code=HTTPStatus.OK,
)
def put(self, parsed_body: Body[UserModel]) -> HttpResponse:
return self.to_response(
parsed_body,
status_code=HTTPStatus.OK,
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json'
> {"email":"user@wms.org"}
>
> $ curl http://127.0.0.1:8000/api/user/ -X PUT -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json'
> {"email":"user@wms.org"}
> ```
If response validation passes, then all is fine!
> [!IMPORTANT]
> At least one explicit response spec is required for `@validate` endpoints.
>
> Note that semantic responses from auth / components / etc
> are not counted when validating real endpoints.
> You still have to use at least one explicit specification declaration.
### Request lifecycle
Here’s the top-level view on how the request / response lifecycle looks like:
```mermaid
---
config:
theme: forest
---
graph
Start[New request] --> BeforeThrottle[Throttling based on IP if any or 429];
BeforeThrottle --> RendererNegotiation[Renderer is negotiated or 406];
RendererNegotiation --> Auth[Auth if any or 401];
Auth --> AfterThrottle[Throttling based on auth if any or 429];
AfterThrottle --> ParserNegotiation[Parser is negotiated if any or 400];
ParserNegotiation --> DataValidation[Request data is validated if any or 400];
DataValidation --> BusinessLogic[Business logic];
BusinessLogic --> Renderer[Response rendering];
Renderer --> ResponseValidation[Response validation if any or 419];
```
Request lifecycle
### Customizing controllers
> [!TIP]
> This is a sneak peek into our advanced API.
> 90% of users will never need this.
`Controller` is built to be customized with a class-level API.
If you need granular control, you can change anything.
- `allowed_http_methods`
to support custom HTTP methods like `QUERY`
or your custom DSLs on top of HTTP
- `csrf_exempt`
to customize whether or not this controller is exempted from CSRF.
See CSRF for more info
- `login_required`
to customize whether or not login is required for this controller.
By default, login is not required for this controller to exempt it
from Django’s `LoginRequiredMiddleware`.
Users should make use of authentication in `django-modern-rest`.
See How authentication works for more details.
- `controller_validator_cls`
to customize how this controller is validated at import time
- `annotations_context`
to customize how this controller resolves its annotations
- `endpoint_cls`
to customize how endpoints are created
You can also customize `Endpoint`
to change how API methods are executed:
- `serializer_context_cls`
to customize how the model for serialization of incoming data is created
- `request_negotiator_cls`
and
`response_negotiator_cls`
to customize how content negotiation works for this endpoint
- `response_validator_cls`
to customize how Response validation works
Basically, everything is customizable.
We define all classes as attributes of the class that uses them,
so you can redefine anything that you want to redefine.
Customizability is a **design goal**.
Check out our Public API
for the most advanced features.
### What’s next?
> ##### Headers and cookies
>
> Learn how to describe response headers and cookies.
> ##### Redirects
>
> Learn how to return HTTP redirect responses.
> ##### Files
>
> Learn how to return file responses.
> ##### Validation
>
> Learn about optional response validation.
Source: /en/latest/pages/using-controller/validation.md
## Response validation
By default, all responses (not just requests!)
are validated at runtime to match the schema.
This allows us to be super strict about schema generation as a pro,
but as a con, it is slower than it could possibly be.
So, you can disable response validation via the configuration:
> [!WARNING]
> Disabling response validation makes sense only
> in production for better performance.
>
> It is not recommended to disable response validation for any other reason.
> Instead, fix your schema errors!
>
> Keep it on in development, but disable
> it in production to get the best of both worlds.
Active validation
Disable per endpoint
Disable per controller
Disable globally
views.py
```python
from http import HTTPStatus
import msgspec
from dmr import APIError, Body, Controller, Headers
from dmr.errors import ErrorType
from dmr.plugins.msgspec import MsgspecSerializer
class UserModel(msgspec.Struct):
email: str
class HeaderModel(msgspec.Struct):
consumer: str = msgspec.field(name='X-API-Consumer')
class UserController(Controller[MsgspecSerializer]):
def post(
self,
parsed_body: Body[UserModel],
parsed_headers: Headers[HeaderModel],
) -> UserModel:
if parsed_headers.consumer != 'my-api':
# Notice that this response is never documented in the spec,
# so, it will raise an error when validation is enabled (default).
raise APIError(
self.format_error(
'Wrong API consumer',
error_type=ErrorType.user_msg,
),
status_code=HTTPStatus.PAYMENT_REQUIRED,
)
# This response will be documented by default:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json' -H 'X-API-Consumer: my-api'
> {"email":"user@wms.org"}
>
> $ curl http://127.0.0.1:8000/api/user/ -D - -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json' -H 'X-API-Consumer: not-my-api'
> HTTP/1.1 422 Unprocessable Entity
> date: Mon, 05 Oct 2026 11:19:01 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 143
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Returned status code 402 is not specified in the list of allowed status codes: [201, 400, 406, 422]","type":"value_error"}]}
> ```
views.py
```python
from http import HTTPStatus
import msgspec
from dmr import APIError, Body, Controller, Headers, modify
from dmr.errors import ErrorType
from dmr.plugins.msgspec import MsgspecSerializer
class UserModel(msgspec.Struct):
email: str
class HeaderModel(msgspec.Struct):
consumer: str = msgspec.field(name='X-API-Consumer')
class UserController(Controller[MsgspecSerializer]):
@modify(validate_responses=False) # <- now, we won't validate this endpoint
def post(
self,
parsed_body: Body[UserModel],
parsed_headers: Headers[HeaderModel],
) -> UserModel:
if parsed_headers.consumer != 'my-api':
# Notice that this response is never documented in the spec,
# but, it won't raise a validation error, because validation is off
raise APIError(
self.format_error(
'Wrong API consumer',
error_type=ErrorType.user_msg,
),
status_code=HTTPStatus.PAYMENT_REQUIRED,
)
# This response will be documented by default:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json' -H 'X-API-Consumer: my-api'
> {"email":"user@wms.org"}
>
> $ curl http://127.0.0.1:8000/api/user/ -D - -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json' -H 'X-API-Consumer: not-my-api'
> HTTP/1.1 402 Payment Required
> date: Mon, 05 Oct 2026 11:19:02 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 59
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Wrong API consumer","type":"user_msg"}]}
> ```
views.py
```python
from http import HTTPStatus
import msgspec
from dmr import APIError, Body, Controller, Headers
from dmr.errors import ErrorType
from dmr.plugins.msgspec import MsgspecSerializer
class UserModel(msgspec.Struct):
email: str
class HeaderModel(msgspec.Struct):
consumer: str = msgspec.field(name='X-API-Consumer')
class UserController(Controller[MsgspecSerializer]):
# Now, we won't validate all endpoints in this controller:
validate_responses = False
def post(
self,
parsed_body: Body[UserModel],
parsed_headers: Headers[HeaderModel],
) -> UserModel:
if parsed_headers.consumer != 'my-api':
# Notice that this response is never documented in the spec,
# but, it won't raise a validation error, because validation is off
raise APIError(
self.format_error(
'Wrong API consumer',
error_type=ErrorType.user_msg,
),
status_code=HTTPStatus.PAYMENT_REQUIRED,
)
# This response will be documented by default:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json' -H 'X-API-Consumer: my-api'
> {"email":"user@wms.org"}
>
> $ curl http://127.0.0.1:8000/api/user/ -D - -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json' -H 'X-API-Consumer: not-my-api'
> HTTP/1.1 402 Payment Required
> date: Mon, 05 Oct 2026 11:19:02 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 59
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Wrong API consumer","type":"user_msg"}]}
> ```
See `dmr.settings.Settings.validate_responses`.
settings.py
```python
>>> from dmr.settings import Settings
>>> DMR_SETTINGS = {Settings.validate_responses: False}
```
### Excluding some status codes
> [!WARNING]
> Added in version 0.15.0.
Sometimes you don’t want to disable the validation completely,
you just have status codes that are not in your schema.
`500` is the usual one.
List them in `exclude_validate_responses`
to let such responses through:
settings.py
```python
>>> from http import HTTPStatus
>>> from dmr.settings import Settings
>>> DMR_SETTINGS = {
... Settings.exclude_validate_responses: {
... HTTPStatus.INTERNAL_SERVER_ERROR,
... },
... }
```
The same works per-controller with
`exclude_validate_responses`
and per-endpoint with the argument of the same name
to `modify` and `validate`.
Setting it to `None` on any level resets all the other levels.
All other status codes are still validated as usual.
See Validating error responses for when this is the right call
and what the alternatives are.
### The right way
The “right way” is not to disable the validation,
but to specify the correct schema to be returned from an endpoint.
views.py
```python
from http import HTTPStatus
import msgspec
from dmr import APIError, Body, Controller, Headers, ResponseSpec, modify
from dmr.errors import ErrorModel, ErrorType
from dmr.plugins.msgspec import MsgspecSerializer
class UserModel(msgspec.Struct):
email: str
class HeaderModel(msgspec.Struct):
consumer: str = msgspec.field(name='X-API-Consumer')
class UserController(Controller[MsgspecSerializer]):
@modify(
extra_responses=[
ResponseSpec(
ErrorModel,
status_code=HTTPStatus.PAYMENT_REQUIRED,
),
],
)
def post(
self,
parsed_body: Body[UserModel],
parsed_headers: Headers[HeaderModel],
) -> UserModel:
if parsed_headers.consumer != 'my-api':
# Notice that this response is now documented in the spec,
# no error will happen, no need to disable the validation.
raise APIError(
self.format_error(
'Wrong API consumer',
error_type=ErrorType.user_msg,
),
status_code=HTTPStatus.PAYMENT_REQUIRED,
)
# This response will be documented by default:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json' -H 'X-API-Consumer: my-api'
> {"email":"user@wms.org"}
>
> $ curl http://127.0.0.1:8000/api/user/ -D - -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json' -H 'X-API-Consumer: not-my-api'
> HTTP/1.1 402 Payment Required
> date: Mon, 05 Oct 2026 11:19:03 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 59
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Wrong API consumer","type":"user_msg"}]}
> ```
And to disable the validation for `production` environment.
Example: [https://github.com/wemake-services/wemake-django-template/blob/c003757fd33ba7dd1a9e7af7c3a175883d0c033b/%7B%7Bcookiecutter.project_name%7D%7D/server/settings/environments/production.py#L86](https://github.com/wemake-services/wemake-django-template/blob/c003757fd33ba7dd1a9e7af7c3a175883d0c033b/%7B%7Bcookiecutter.project_name%7D%7D/server/settings/environments/production.py#L86)
Source: /en/latest/pages/using-controller/headers-and-cookies.md
## Describing response headers and cookies
### Describing headers
You also must specify which headers are returned (if any).
When using “real endpoints”, you can provide `headers` parameter
to `ResponseSpec`
if there are headers you want to describe.
`HeaderSpec` is here to help.
You can create both `required=True`
(always must be present on the response object)
and `required=False` headers (might be missing in some cases):
views.py
```python
import uuid
from http import HTTPStatus
import pydantic
from django.http import HttpResponse
from dmr import Body, Controller, HeaderSpec, ResponseSpec, validate
from dmr.plugins.pydantic import PydanticFastSerializer
class UserModel(pydantic.BaseModel):
email: str
class UserController(Controller[PydanticFastSerializer]):
@validate(
ResponseSpec(
UserModel,
status_code=HTTPStatus.OK,
headers={
'X-Created': HeaderSpec(),
'X-Our-Domain': HeaderSpec(required=False),
},
),
)
def post(self, parsed_body: Body[UserModel]) -> HttpResponse:
uid = uuid.uuid4()
# This response would have an explicit status code `200`
# and one required header `X-Created` and one optional `X-Our-Domain`:
headers = {'X-Created': str(uid)}
if '@ourdomain.com' in parsed_body.email:
headers['X-Our-Domain'] = 'true'
return self.to_response(
parsed_body,
status_code=HTTPStatus.OK,
headers=headers,
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -D - -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json'
> HTTP/1.1 200 OK
> date: Mon, 05 Oct 2026 11:18:52 GMT
> server: uvicorn
> X-Created: a569d16b-c02c-416b-a9a6-2d24a35fb0be
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 24
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"email":"user@wms.org"}
>
> $ curl http://127.0.0.1:8000/api/user/ -D - -X POST -d '{"email": "user@ourdomain.com"}' -H 'Content-Type: application/json'
> HTTP/1.1 200 OK
> date: Mon, 05 Oct 2026 11:18:52 GMT
> server: uvicorn
> X-Created: 39bb05c5-b770-41d0-8bd8-14c1d0c965a3
> X-Our-Domain: true
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 30
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"email":"user@ourdomain.com"}
> ```
> [!NOTE]
> All headers from the response objects are checked. We will report:
>
> - Required headers that exist in the spec, but not on the `response`
> - Any headers that exist on the `response`, but not present in the spec
>
> `Content-Type` header is the only one that is always added automatically.
With “raw endpoints” you can also use
`NewHeader` marker which can set headers
with known values to the final response.
views.py
```python
import pydantic
from dmr import Body, Controller, NewHeader, modify
from dmr.plugins.pydantic import PydanticFastSerializer
class UserModel(pydantic.BaseModel):
email: str
class UserController(Controller[PydanticFastSerializer]):
@modify(
# Add explicit header:
headers={'X-Created': NewHeader(value='true')},
)
def post(self, parsed_body: Body[UserModel]) -> UserModel:
# This response would have an implicit status code `201`
# and new explicit header `{'X-Created': 'true'}`:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -D - -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json'
> HTTP/1.1 201 Created
> date: Mon, 05 Oct 2026 11:18:53 GMT
> server: uvicorn
> X-Created: true
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 24
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"email":"user@wms.org"}
> ```
If you need headers with not static, but dynamic values, use “real endpoints”
and pass `headers` dict to
`to_response()` method.
The last important thing about headers
is `skip_validation` attribute.
It is used to describe headers that:
1. Will be set in the response by someone else outside the framework,
like HTTP proxy or Django’s own middleware.
See [`django.contrib.sessions.middleware.SessionMiddleware`](https://docs.djangoproject.com/en/6.1/ref/middleware/#django.contrib.sessions.middleware.SessionMiddleware)
as a notable example
2. Will be validated to be **NOT** present in the response from our framework.
Since it is designed to be added later, it should not be already present
> [!IMPORTANT]
> Header definitions are case insensitive according to the HTTP spec.
> `Session` and `session` is the same header.
### Describing cookies
> [!WARNING]
> Some may say that returning cookies is not “RESTful”,
> because cookies is an implicit state, that RESTful APIs must not have.
> Be careful, only use this feature when you need to.
>
> See: [https://parottasalna.hashnode.dev/is-it-okay-to-add-cookie-to-a-rest-api](https://parottasalna.hashnode.dev/is-it-okay-to-add-cookie-to-a-rest-api)
We also support setting and validating response cookies.
You can use `NewCookie`
to add new cookies with statically known values to “raw endpoints”.
Or `CookieSpec` with both types
of endpoints to describe response cookies.
views.py
```python
import pydantic
from dmr import Body, Controller, NewCookie, modify
from dmr.plugins.pydantic import PydanticFastSerializer
class UserModel(pydantic.BaseModel):
email: str
class UserController(Controller[PydanticFastSerializer]):
@modify(
# Add explicit cookie:
cookies={'user_created': NewCookie(value='true', max_age=1000)},
)
def post(self, parsed_body: Body[UserModel]) -> UserModel:
# This response would have an implicit status code `201`
# and explicit cookie `user_created` set to `true` with `max-age=1000`
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -D - -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json'
> HTTP/1.1 201 Created
> date: Mon, 05 Oct 2026 11:18:53 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 24
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
> Set-Cookie: user_created=true; expires=Mon, 05 Oct 2026 11:35:33 GMT; Max-Age=1000; Path=/; SameSite=lax
>
> {"email":"user@wms.org"}
> ```
And you can set any cookies to [`django.http.HttpResponse.cookies`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpResponse.cookies)
with “real endpoints”. Since we have strict schemas,
it is required to describe the set cookies with
`CookieSpec`:
views.py
```python
import uuid
from http import HTTPStatus
import pydantic
from django.http import HttpResponse
from dmr import Body, Controller, CookieSpec, NewCookie, ResponseSpec, validate
from dmr.plugins.pydantic import PydanticFastSerializer
class UserModel(pydantic.BaseModel):
email: str
class UserController(Controller[PydanticFastSerializer]):
@validate(
ResponseSpec(
UserModel,
status_code=HTTPStatus.CREATED,
cookies={
'user_id': CookieSpec(),
'session': CookieSpec(max_age=1000, required=False),
},
),
)
def post(self, parsed_body: Body[UserModel]) -> HttpResponse:
uid = uuid.uuid4()
# This response would have one required cookie `user_id`
# and one optional cookie `session`:
cookies = {'user_id': NewCookie(value=str(uid))}
if '@ourdomain.com' in parsed_body.email:
cookies['session'] = NewCookie(value='true', max_age=1000)
return self.to_response(
parsed_body,
cookies=cookies,
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -D - -X POST -d '{"email": "user@ourdomain.com"}' -H 'Content-Type: application/json'
> HTTP/1.1 201 Created
> date: Mon, 05 Oct 2026 11:18:54 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 30
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
> Set-Cookie: user_id=514f7780-dca3-4342-bc2d-007a83860712; Path=/; SameSite=lax
> Set-Cookie: session=true; expires=Mon, 05 Oct 2026 11:35:34 GMT; Max-Age=1000; Path=/; SameSite=lax
>
> {"email":"user@ourdomain.com"}
> ```
> [!TIP]
> A response cookie must match its spec exactly, so keeping the flags
> in two places is a source of bugs. Build the cookie from its spec with
> `NewCookie.from_spec`
> when only the value is known in runtime:
>
> ```python
> >>> from dmr import CookieSpec, NewCookie
>
> >>> session_spec = CookieSpec(httponly=True, secure=True)
> >>> NewCookie.from_spec(session_spec, value='abc')
> NewCookie(path='/', max_age=None, ..., value='abc')
> ```
The last important thing about cookies
is `skip_validation` attribute.
It is used to describe cookies that:
1. Will be set in the response by someone else outside the framework,
like HTTP proxy or Django’s own middleware.
See [`django.contrib.sessions.middleware.SessionMiddleware`](https://docs.djangoproject.com/en/6.1/ref/middleware/#django.contrib.sessions.middleware.SessionMiddleware)
as a notable example
2. Will be validated to be **NOT** present in the response from our framework.
Since it is designed to be added later, it should not be already present
> [!NOTE]
> All cookie parts are validated by default. Except `expires` field,
> because it is relative to the current time.
> [!IMPORTANT]
> Cookie definitions are case sensitive according to the HTTP spec.
> `Session` and `session` are two different cookies.
Source: /en/latest/pages/using-controller/redirects.md
## Returning redirects
We support returning redirects from API endpoints with
`RedirectTo` exception with `modify`:
views.py
```python
from http import HTTPStatus
from typing import Final
from django.http import HttpResponse
from dmr import Controller, HeaderSpec, RedirectTo, modify, validate
from dmr.metadata import ResponseSpec
from dmr.plugins.pydantic import PydanticFastSerializer
_RedirectSpec: Final = ResponseSpec(
None,
status_code=HTTPStatus.FOUND,
headers={'Location': HeaderSpec()},
)
class UserController(Controller[PydanticFastSerializer]):
@validate(_RedirectSpec)
def get(self) -> HttpResponse:
raise RedirectTo('https://example.com/api/new/user/list')
@modify(extra_responses=[_RedirectSpec])
def post(self) -> dict[str, str]:
raise RedirectTo('https://example.com/api/new/user/create')
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -D - -X GET
> HTTP/1.1 302 Found
> date: Mon, 05 Oct 2026 11:18:59 GMT
> server: uvicorn
> Location: https://example.com/api/new/user/list
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 0
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
>
> $ curl http://127.0.0.1:8000/api/user/ -D - -X POST
> HTTP/1.1 302 Found
> date: Mon, 05 Oct 2026 11:18:59 GMT
> server: uvicorn
> Location: https://example.com/api/new/user/create
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 0
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
> ```
We model `RedirectTo` as an exception, because you are not allowed
to return [`HttpResponse`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpResponse) objects
from `modify` endpoints.
> [!NOTE]
> `APIError` does not support `3xx` status codes.
> Redirects are different from regular errors.
The second way is to use
default Django’s [`django.http.HttpResponseRedirect`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpResponseRedirect)
together with `validate`:
views.py
```python
from http import HTTPStatus
from django.http import HttpResponse, HttpResponseRedirect
from dmr import Controller, HeaderSpec, validate
from dmr.metadata import ResponseSpec
from dmr.plugins.pydantic import PydanticFastSerializer
class UserController(Controller[PydanticFastSerializer]):
@validate(
ResponseSpec(
None,
status_code=HTTPStatus.FOUND,
headers={'Location': HeaderSpec()},
),
)
def get(self) -> HttpResponse:
return HttpResponseRedirect(
'https://example.com/api/new/user/list',
content_type='application/json',
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -D - -X GET
> HTTP/1.1 302 Found
> date: Mon, 05 Oct 2026 11:19:00 GMT
> server: uvicorn
> Content-Type: application/json
> Location: https://example.com/api/new/user/list
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 0
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
> ```
Note that in both cases you would need to document `Location` header
in a response spec.
> [!WARNING]
> `RedirectTo` accepts absolute and protocol-relative
> URLs, such as `https://example.com/path` and `//example.com/path`.
> It validates the URL length and scheme, but does not check whether the
> destination host is trusted.
> Do not pass user-provided redirect targets to it without validation,
> because this can create an open redirect vulnerability.
>
> Use Django’s
> [url_has_allowed_host_and_scheme](https://github.com/django/django/blob/73cc09f14f13fedddc14d6ba5b287cb33c24e4a4/django/utils/http.py#L274)
> helper to check untrusted redirect targets against the expected hosts
> and protocol:
>
> views.py
> ```python
> from typing import Never
>
> from django.http import HttpRequest
> from django.utils.http import url_has_allowed_host_and_scheme
>
> from dmr import RedirectTo
>
>
> def redirect_to_next(request: HttpRequest, next_url: str) -> Never:
> url_is_safe = url_has_allowed_host_and_scheme(
> url=next_url,
> allowed_hosts={request.get_host()},
> require_https=request.is_secure(),
> )
> raise RedirectTo(next_url if url_is_safe else '/')
> ```
### API Reference
#### *exception* dmr.response.RedirectTo(redirect_to: str | Promise, \*, status_code: HTTPStatus = HTTPStatus.FOUND, headers: dict[str, str] | None = None)
Special class to redirect from `@modify` styled endpoints.
It is modeled as an exception, because you can’t return `HttpResponse`
subclasses from `modify()` endpoints.
We model this class closely
to match [`django.http.HttpResponseRedirect`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpResponseRedirect).
Usage:
```python
>>> from http import HTTPStatus
>>> from dmr import (
... RedirectTo,
... Controller,
... ResponseSpec,
... modify,
... HeaderSpec,
... )
>>> from dmr.errors import ErrorType
>>> from dmr.plugins.pydantic import PydanticSerializer
>>> class UserController(Controller[PydanticSerializer]):
... @modify(
... extra_responses=[
... ResponseSpec(
... None,
... status_code=HTTPStatus.FOUND,
... headers={'Location': HeaderSpec()},
... ),
... ],
... )
... def get(self) -> str:
... # This API endpoint is deprecated, use new one:
... raise RedirectTo(
... '/api/new/users/',
... status_code=HTTPStatus.FOUND,
... )
```
Source: /en/latest/pages/using-controller/files.md
## Returning files
We support file and other binary responses.
> [!WARNING]
> Returning files via Python and Django in particular
> is very performance inefficient.
> It should not be used for anything serious.
>
> Instead return files with S3-like systems or at least on a proxy-server level.
### Returning file contents
To do so, you indicate that you will return a file with
`dmr.files.FileResponseSpec` and specify a file renderer.
We provide `dmr.renderers.FileRenderer` for this case.
By default, `FileResponseSpec()` describes an inline file response.
It matches Django’s `FileResponse` default behavior:
views.py
```python
import io
from django.http import FileResponse
from dmr import Controller, validate
from dmr.files import FileResponseSpec
from dmr.plugins.pydantic import PydanticSerializer
from dmr.renderers import FileRenderer
class InlineFileController(Controller[PydanticSerializer]):
@validate(
FileResponseSpec(),
renderers=[FileRenderer('text/plain')],
)
def get(self) -> FileResponse:
return FileResponse(
io.BytesIO(b'Hello'),
content_type='text/plain',
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/file/ -D - -X GET
> HTTP/1.1 200 OK
> date: Mon, 05 Oct 2026 11:18:51 GMT
> server: uvicorn
> Content-Type: text/plain
> Content-Length: 5
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> Hello
> ```
### File attachments
Set `as_attachment=True` when Django’s
[`django.http.FileResponse`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.FileResponse) is returned as an attachment. In this mode
[Content-Disposition](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition)
is always set and usually contains the filename sent to the client:
views.py
```python
import pathlib
from typing import Final
from django.http import FileResponse
from dmr import Controller, validate
from dmr.files import FileResponseSpec
from dmr.plugins.pydantic import PydanticSerializer
from dmr.renderers import FileRenderer
_FILEPATH: Final = pathlib.Path('examples/components/receipt.txt')
class FileController(Controller[PydanticSerializer]):
@validate(
FileResponseSpec(as_attachment=True),
renderers=[FileRenderer('text/plain')],
)
def get(self) -> FileResponse:
return FileResponse(
_FILEPATH.open(mode='rb'),
filename='receipt.txt',
as_attachment=True,
content_type='text/plain',
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/file/ -D - -X GET
> HTTP/1.1 200 OK
> date: Mon, 05 Oct 2026 11:18:51 GMT
> server: uvicorn
> Content-Type: text/plain
> Content-Length: 16
> Content-Disposition: attachment; filename="receipt.txt"
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> Example receipt
> ```
The difference comes from the `Content-Disposition` HTTP header:
it tells clients whether the response body is expected to be displayed inline
or downloaded as an attachment.
### API Reference
#### *class* dmr.files.FileBodyLike
Interface that describes objects that can return media type schema.
> [!WARNING]
> Added in version 0.15.0.
##### *abstractmethod classmethod* get_schema(schema: Schema, context: OpenAPIContext) → Schema
Return the OpenAPI schema for this file body.
##### *abstractmethod classmethod* media_type(schema: Schema, model: TypeForm[Any], model_meta: tuple[Any, ...], metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], parser: Parser, context: OpenAPIContext) → MediaType
Provides file request schema for this parser.
> [!WARNING]
> Changed in version 0.16.0: Now accepts *metadata* and *controller_cls* parameters.
#### *class* dmr.files.FileBody
Bases: `FileBodyLike`
Special type that indicates that response returns a file body.
##### *classmethod* get_schema(schema: Schema, context: OpenAPIContext) → Schema
Returns the openapi schema that this object represents.
##### *classmethod* media_type(schema: Schema, model: TypeForm[Any], model_meta: tuple[Any, ...], metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], parser: Parser, context: OpenAPIContext) → MediaType
Returns the media type for the given file.
##### *classmethod* replace_schema(schema: Schema, context: OpenAPIContext) → Schema
Replaces existing generated schema with file-like schema.
Here the most tricky part happens. When we define
`FileMetadata[Info]` as a model to parse, we don’t want
to expose `Info` as a model actually, we want to show
that this is a file in the OpenAPI schema. So, we place known models
with the file specification here.
Unions like `Model | None` have their members replaced.
> [!WARNING]
> Added in version 0.15.0.
#### *class* dmr.files.FileResponseSpec(return_type: type[FileBodyLike] = , \*, status_code: HTTPStatus = HTTPStatus.OK, headers: Mapping[str, ~dmr.headers.HeaderSpec] | None=None, cookies: Mapping[str, CookieSpec] | None=None, limit_to_content_types: Set[str] | None = None, streaming: bool = False, description: str | Promise | None = None, summary: str | Promise | None = None, links: Mapping[str, Link | Reference] | None=None, x_extensions: Mapping[str, ~typing.Any] | None=None, as_attachment: bool = False)
Bases: `ResponseSpec`
Special `ResponseSpec` subclass for files.
Unlike regular `ResponspeSpec` that will create
a real schema for the return type, here we force
to use `dmr.files.FileBodyLike` schema providers,
that know how files will look like in the final schema.
##### as_attachment
Marks responses with `Content-Disposition` header
as required. Use together with `FileResponse(as_attachment=True)`.
* **Type:**
[bool](https://docs.python.org/3/builtins/functions.html#bool)
> [!WARNING]
> Changed in version 0.10.0: Added `as_attachment` parameter that can mark files
> that should be sent via `Content-Disposition` header.
> Similar to Django’s `as_attachment` parameter
> in [`django.http.FileResponse`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.FileResponse).
> [!WARNING]
> Changed in version 0.15.0: Removed `file_body` attribute, now using `return_type` instead
> to generate the response schema.
##### \_\_post_init_\_() → None
Set required headers depending on how files are returned.
##### get_schema(metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], context: OpenAPIContext) → Response
Customize schema for the file response.
#### dmr.files.file_response_headers(headers: Mapping[str, HeaderSpec] | None, \*, as_attachment: bool) → Mapping[str, HeaderSpec]
Build headers expected from `FileResponse`.
Source: /en/latest/pages/using-controller/meta.md
## Defining `OPTIONS` or `meta` method
[RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-options)
defines the `OPTIONS` HTTP method, but sadly Django’s
[`View`](https://docs.djangoproject.com/en/6.1/ref/class-based-views/base/#django.views.generic.base.View) which we use as a base class
for all controllers, already has
[`options()`](https://docs.djangoproject.com/en/6.1/ref/class-based-views/base/#django.views.generic.base.View.options) method.
It would generate a typing error to redefine it with a different
signature that we need for our endpoints.
That’s why we created our own `meta` controller method as a replacement
for older Django’s `options` name.
To use it you have two options:
1. Use `MetaMixin`
or `AsyncMetaMixin`
with the default implementation: we provide `Allow` header
with all the allowed HTTP methods in this controller
2. Define the `meta` endpoint yourself and provide a custom implementation
### Using pre-defined mixins
We have two versions: for sync and async controllers.
Their features are identical:
sync
async
views.py
```python
from dmr import Controller
from dmr.options_mixins import MetaMixin
from dmr.plugins.pydantic import PydanticFastSerializer
class SyncMetaController(MetaMixin, Controller[PydanticFastSerializer]):
def get(self) -> str:
return 'response from GET'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/settings/ -D - -X OPTIONS
> HTTP/1.1 204 No Content
> date: Mon, 05 Oct 2026 11:18:57 GMT
> server: uvicorn
> Allow: GET, OPTIONS
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 0
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
> ```
views.py
```python
from dmr import Controller
from dmr.options_mixins import AsyncMetaMixin
from dmr.plugins.pydantic import PydanticFastSerializer
class AsyncMetaController(AsyncMetaMixin, Controller[PydanticFastSerializer]):
async def get(self) -> str:
return 'response from GET'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/settings/ -D - -X OPTIONS
> HTTP/1.1 204 No Content
> date: Mon, 05 Oct 2026 11:18:58 GMT
> server: uvicorn
> Allow: GET, OPTIONS
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 0
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
> ```
Both of them:
- Provide `meta` method sync or async
- Provide the same response spec for the OpenAPI schema
### Custom meta implementation
Since our mixins do not anything magical, you can write our own version,
if you need a behavior change, for example.
Here’s an example of a custom `meta` implementation:
views.py
```python
from http import HTTPStatus
from django.http import HttpResponse
from dmr import Controller, HeaderSpec, ResponseSpec, validate
from dmr.plugins.msgspec import MsgspecSerializer
class SettingsController(Controller[MsgspecSerializer]):
def get(self) -> str:
return 'default get setting'
def post(self) -> str:
return 'default post setting'
# `meta` response is also validated, schema is required:
@validate(
ResponseSpec(
None,
status_code=HTTPStatus.NO_CONTENT,
headers={'Allow': HeaderSpec()},
),
)
def meta(self) -> HttpResponse: # Handles `OPTIONS` http method
return self.to_response(
None,
status_code=HTTPStatus.NO_CONTENT,
headers={
'Allow': ', '.join(
method for method in sorted(self.api_endpoints.keys())
),
},
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/settings/ -D - -X OPTIONS
> HTTP/1.1 204 No Content
> date: Mon, 05 Oct 2026 11:18:58 GMT
> server: uvicorn
> Allow: GET, OPTIONS, POST
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 0
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
> ```
You would need to:
- Define `meta` method (sync or async) with the desired implementation
- Provide the required response spec
Source: /en/latest/pages/routing.md
## Routing
Our Controller is built without knowing anything
about its future URL. Why so?
1. Because Django already has an amazing URL
[routing system](https://docs.djangoproject.com/en/stable/topics/http/urls/)
and we don’t need to duplicate it
2. Because all controllers might be used in multiple URLs,
for example in `/api/v1/` and `/api/v2/`.
Our design allows any possible customizations
urls.py
```python
# Our `path` is an optimized drop-in replacement of `django.urls.path`:
from dmr.routing import Router, path
from examples.getting_started.pydantic_controller import UserController
# Router is just a collection of regular Django urls:
router = Router(
'api/',
[
path(
'user/',
UserController.as_view(),
name='users',
),
],
)
# Just a regular `urlpatterns` definition, Django-style:
urlpatterns = [
router.to_urlpatterns(namespace='api'),
]
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json'
> {"email":"user@wms.org","uid":"0665bb70-38d4-4d56-8f67-14152c1dd6df"}
> ```
Notice our helper method `to_urlpatterns()`.
It can be used directly, or if some users prefer,
it can be replaced with a raw `path()` call:
```python
urlpatterns = [
path(router.prefix, include((router.urls, 'my_app'), namespace='api')),
]
```
Which is semantically identical.
> [!NOTE]
> If you want to parse path parameters, see Path parameters
> and `dmr.components.Path`.
### Including routers from other apps
> [!WARNING]
> Added in version 0.13.0.
It is common in Django to use multiple apps with their own URLs and views.
They might define their own `Router` instances,
which can be included into the main router.
To do so, use the `include()` method:
urls.py
```python
router = Router(prefix='api/')
router.include(model_simple_urls.router, namespace='model_simple')
router.include(model_fk_urls.router, namespace='model_fk')
router.include(model_cursor_urls.router, namespace='model_cursor')
router.include(middleware_urls.router, namespace='middlewares')
router.include(controllers_urls.router, namespace='controllers')
router.include(negotiations_urls.router, namespace='negotiations')
router.include(jwt_auth_urls.router, namespace='jwt_auth')
router.include(django_session_auth_urls.router, namespace='django_session_auth')
router.include(token_auth_urls.router, namespace='token_auth')
router.include(token_custom_user_urls.router, namespace='token_custom_user')
router.include(etag_urls.router, namespace='etag')
router.include(external_views_urls.router, namespace='external_views')
router.include(allauth_auth_urls.router, namespace='allauth_auth')
# ^ you can include as many routers as you wish!
```
It automatically uses our own faster `path()` function.
Basically, including another router is the same as writing:
```python
router = Router(
prefix='api/',
urls=[
path(
other.router.prefix,
include(
(other_router.router.urls, 'app_name'),
namespace='namespace',
),
),
],
)
```
Use it to reduce boilerplate code.
You can also use `to_urlpatterns()` if you prefer.
It is semantically identical, except for the metadata. `include` correctly
handles nested metadata like `tags`, `deprecated` and `ignore_from_spec`,
while using `to_urlpatterns` ignores it.
So, if you don’t use the router-level metadata,
it does not matter which one you use.
### Handling 404 errors
By default, Django returns HTML 404 pages.
This is not what we want for API endpoints.
Instead, we want to return API responses with proper error structure and
content negotiation (e.g. JSON or XML based on the `Accept` header).
But, we still want HTML 404 pages for non-API views.
> [!IMPORTANT]
> Overriding [`django.conf.urls.handler404`](https://docs.djangoproject.com/en/6.1/ref/urls/#django.conf.urls.handler404) has no effect
> while `DEBUG = True` is set.
>
> This is how Django behaves:
> [https://docs.djangoproject.com/en/stable/ref/views/#the-404-page-not-found-view](https://docs.djangoproject.com/en/stable/ref/views/#the-404-page-not-found-view)
To achieve this, you can use the
`build_404_handler()` helper.
It creates a handler that returns API-style 404 responses for specific path
prefixes (using the same serializer and renderer as your API), and falls back
to Django’s default handler for everything else.
Here is how you can use it in your root `urls.py`
(in your [ROOT_URLCONF](https://docs.djangoproject.com/en/stable/ref/settings/#root-urlconf)):
urls.py
```python
import pydantic
from dmr import Body, Controller
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.routing import Router, build_404_handler, path
class UserCreateModel(pydantic.BaseModel):
email: str
class UserController(Controller[PydanticFastSerializer]):
async def post(self, parsed_body: Body[UserCreateModel]) -> UserCreateModel:
return parsed_body
router = Router(
'api/',
[
path('user/', UserController.as_view(), name='users'),
],
)
urlpatterns = [
router.to_urlpatterns(namespace='api'),
]
handler404 = build_404_handler(router.prefix, serializer=PydanticFastSerializer)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "correct@example.com"}' -H 'Content-Type: application/json'
> {"email":"correct@example.com"}
>
> $ curl http://127.0.0.1:8000/api/wrong/ -D - -X POST -d '{"email": "correct@old-domain.com"}' -H 'Content-Type: application/json'
> HTTP/1.1 404 Not Found
> date: Mon, 05 Oct 2026 11:18:24 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 56
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Page not found","type":"not_found"}]}
> ```
This returns JSON responses for `api/` prefixed paths.
But, will still return regular Django HTML responses for any other path.
### Handling 500 errors
By default, Django returns HTML 500 pages.
This is not what we want for API endpoints.
Instead, we want to return API responses with proper error structure and
content negotiation (e.g. JSON or XML based on the `Accept` header).
But, we still want HTML 500 pages for non-API views.
> [!IMPORTANT]
> Overriding [`django.conf.urls.handler500`](https://docs.djangoproject.com/en/6.1/ref/urls/#django.conf.urls.handler500) has no effect
> while `DEBUG = True` is set.
>
> This is how Django behaves:
> [https://docs.djangoproject.com/en/stable/ref/views/#the-500-server-error-view](https://docs.djangoproject.com/en/stable/ref/views/#the-500-server-error-view)
To achieve this, you can use the
`build_500_handler()` helper.
It creates a handler that returns API-style 500 responses for specific path
prefixes (using the same serializer and renderer as your API), and falls back
to Django’s default handler for everything else.
Here is how you can use it in your root `urls.py`
(in your [ROOT_URLCONF](https://docs.djangoproject.com/en/stable/ref/settings/#root-urlconf)):
urls.py
```python
import pydantic
from dmr import Body, Controller
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.routing import Router, build_500_handler, path
class UserCreateModel(pydantic.BaseModel):
email: str
class UserController(Controller[PydanticFastSerializer]):
async def post(self, parsed_body: Body[UserCreateModel]) -> UserCreateModel:
if parsed_body.email.endswith('@old-domain.com'):
raise RuntimeError('This error will be handled by handler500')
return parsed_body
router = Router(
'api/',
[
path('user/', UserController.as_view(), name='users'),
],
)
urlpatterns = [
router.to_urlpatterns(namespace='api'),
]
handler500 = build_500_handler(router.prefix, serializer=PydanticFastSerializer)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "correct@example.com"}' -H 'Content-Type: application/json'
> {"email":"correct@example.com"}
>
> $ curl http://127.0.0.1:8000/api/user/ -D - -X POST -d '{"email": "correct@old-domain.com"}' -H 'Content-Type: application/json'
> HTTP/1.1 500 Internal Server Error
> date: Mon, 05 Oct 2026 11:18:24 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 68
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Internal server error","type":"internal_error"}]}
> ```
> [!NOTE]
> ### See also
>
> See Error handling if you want to learn how to handle
> different errors based on different levels and fix these `500`
> exceptions.
### Optimized URL Routing
`django-modern-rest` provides
an optimized `dmr.routing.path()` function
that is a **drop-in replacement** for Django’s [`django.urls.path()`](https://docs.djangoproject.com/en/6.1/ref/urls/#django.urls.path).
The custom implementation uses prefix-based pattern matching
for faster routing. Instead of immediately running Django’s regex engine
on every request, it performs a quick prefix check first.
#### Performance Impact
Benchmark results on a MacBook Pro M4 Pro:
- **Best case**: 9% faster (match found in the first few URL patterns)
- **Average case**: 13% faster (match found in the middle of URL patterns list)
- **Worst case**: 31% faster (404 Not Found, all patterns checked)
The prefix-based optimization dramatically reduces regex operations:
- **Static routes**: Simple string comparison (no regex at all)
- **Dynamic routes**: Regex only runs when the prefix matches
- **Failed matches**: Eliminated in one operation (startswith check)
This is especially beneficial for applications with:
- Large number of routes
- High traffic
#### Migration
Simply replace Django’s `path` with `dmr.routing.path()`:
```python
# Instead of ``from django.urls import path``:
from dmr.routing import path
from django.urls import include
urlpatterns = [
path('api/', include('myapp.urls')),
]
```
This is a drop-in replacement with no API changes required.
### External views
`django-modern-rest` can include URLs from other frameworks.
It can also reuse an existing OpenAPI schema
giving you the most freedom possible.
If you need this advanced feature, see the External views guide.
Source: /en/latest/pages/components/index.md
## Components
`django-modern-rest` utilizes component approach to parse all
the unstructured things like headers, body, and cookies
into a strongly typed and validated model.
To use a component, you can just add it as a parameter
to your endpoint method inside a `Controller`.
How does it work?
- In **import time**, when controller is first created,
we iterate over all existing endpoints in this class
- For each endpoint we fetch method annotations and find all
`ComponentParser` objects,
they will be treated as component parsers
- Next, we create a request parsing model during the import time,
with all combined fields to be parsed later
- In **runtime**, when request is received, we provide the needed data
for this single parsing model
- If everything is ok, we call the needed endpoint with the correct data
- If there’s a parsing error we raise
`RequestSerializationError`
and return a beautiful error message for the user
You can use existing ones or create your own.
> [!NOTE]
> All existing components should only be inherited for parsing.
> If you want to change the implementation details
> of a component – create a new one from scratch.
>
> You can still delegate parts of the work to existing ones.
### What is inside a component?
All components consist of two parts:
1. The first one is a `ComponentParser` subclass,
which knows how to provide the required data for itself, build OpenAPI
schemas and etc. For example: `QueryComponent`
2. The second part is a [`typing.Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated) based annotation that
has a component parser instance as metadata.
These annotations will be used by the end users.
For example, `Query`
> [!NOTE]
> Type aliases have first-class support:
> `UserBody: TypeAlias = Body[User]` and `type UserBody = Body[User]`
> both work as component annotations, including aliases of aliases
> and subscripted generic aliases like `type Payload[ModelT] = Body[ModelT]`.
### Default values
Component parameters can have default values, just like any other
Python parameters. When a request has no data for such a component,
the endpoint receives its default value.
Defaults are real Python defaults: they are passed as-is,
without any copies. Regular parameters with defaults,
like `help_text: str = 'default'`, can be mixed with components.
msgspec
pydantic
views.py
```python
from typing import Final
import msgspec
from dmr import Body, Controller, Query
from dmr.plugins.msgspec import MsgspecSerializer
class ProductFilters(msgspec.Struct):
category: str
# Defaults are passed as-is, so structs used as defaults must be frozen:
class Pagination(msgspec.Struct, frozen=True):
page: int = 1
DEFAULT_PAGINATION: Final = Pagination()
class ProductController(Controller[MsgspecSerializer]):
def post(
self,
# Correct: `None` is inside the component annotation
parsed_body: Body[ProductFilters | None] = None,
# Defaults can be of the same type as the model:
parsed_query: Query[Pagination] = DEFAULT_PAGINATION,
) -> dict[str, str | int]:
return {
'category': 'all' if parsed_body is None else parsed_body.category,
'page': parsed_query.page,
}
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/products/ -X POST
> {"category":"all","page":1}
>
> $ curl 'http://127.0.0.1:8000/api/products/?page=2' -X POST -d '{"category": "cars"}' -H 'Content-Type: application/json'
> {"category":"cars","page":2}
> ```
views.py
```python
from typing import Final
import pydantic
from dmr import Body, Controller, Query
from dmr.plugins.pydantic import PydanticSerializer
class ProductFilters(pydantic.BaseModel):
category: str
class Pagination(pydantic.BaseModel):
# Defaults are passed as-is, so models used as defaults must be frozen:
model_config = pydantic.ConfigDict(frozen=True)
page: int = 1
DEFAULT_PAGINATION: Final = Pagination()
class ProductController(Controller[PydanticSerializer]):
def post(
self,
# Correct: `None` is inside the component annotation
parsed_body: Body[ProductFilters | None] = None,
# Defaults can be of the same type as the model:
parsed_query: Query[Pagination] = DEFAULT_PAGINATION,
) -> dict[str, str | int]:
return {
'category': 'all' if parsed_body is None else parsed_body.category,
'page': parsed_query.page,
}
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/products/ -X POST
> {"category":"all","page":1}
>
> $ curl 'http://127.0.0.1:8000/api/products/?page=2' -X POST -d '{"category": "cars"}' -H 'Content-Type: application/json'
> {"category":"cars","page":2}
> ```
What counts as “no data” for each component?
- `Body` has no data when the request body is empty,
for `multipart/form-data` requests: when there are no fields and no files
- `Query` has no data when there are no query parameters
- `Headers` has no data when there are no headers,
which is practically never the case for real requests
- `Cookies` has no data when there are no cookies
- `Path` has no data when the url pattern
has no parameters
- `FileMetadata` has no data when no files were uploaded
Custom components receive the default value as the *default* parameter
of `provide_context_data()`
and decide when to return it.
When the request has some data, it is always validated against the model,
so `Body[Model | None] = None` still returns an error
for invalid bodies, it does not silently fall back to `None`.
> [!NOTE]
> Defaults are passed to the model that parses the whole request,
> so the rules of the serializer’s model type apply:
> [`dataclasses.dataclass()`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass) for `pydantic`
> and [`msgspec.Struct`](https://msgspec.dev/api.md#msgspec.Struct) for `msgspec` reject mutable defaults
> like `[]` and non-frozen model instances during the import time.
> Use `None` or frozen models as defaults.
> [!WARNING]
> The `| None` part must be inside the component annotation,
> so it must be `Body[Model | None] = None`
> and not `Body[Model] | None = None`.
>
> The second form hides the component behind a union,
> so it would not be parsed at all.
> We raise `UnsolvableAnnotationsError`
> during the import time for any annotation that hides a component
> inside other types, like `Body[Model] | None` or `list[Body[Model]]`.
Defaults are also reflected in the OpenAPI schema:
a `Body` with a default is documented
with `required: false`, and parameters of other components
with defaults are documented as not required.
Path parameters are always required by the OpenAPI specification.
### Browse components
> ##### Query
>
> Parsing query parameters.
> ##### Headers
>
> Parsing header parameters.
> ##### Cookies
>
> Parsing cookie parameters.
> ##### Path
>
> Parsing path parameters.
> ##### Body
>
> Parsing request body.
> ##### Files
>
> Uploading files.
### API Reference
#### *class* dmr.components.ComponentParser
Base abstract provider for request components.
##### conditional_types(model: TypeForm[Any], model_meta: tuple[Any, ...]) → Mapping[str, Any]
Provide conditional parsing types based on content type.
Some components parser might define different input models
based on the request’s content type.
This method must return a mapping of content_type to the model.
If this component support this.
##### context_name *: ClassVar[str]*
All subtypes must provide a unique name that will be used to parse context.
We use a single context for all parsing, this component
will live under a dict field with this name.
##### *abstractmethod* get_schema(model: TypeForm[Any], model_meta: tuple[Any, ...], metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], context: OpenAPIContext) → list[Parameter | Reference] | RequestBody
Generate OpenAPI spec for component.
> [!WARNING]
> Changed in version 0.16.0: *serializer* parameter was changed to be *controller_cls*.
##### *abstractmethod* provide_context_data(endpoint: Endpoint, controller: Controller[BaseSerializer], \*, field_model: TypeForm[Any], default: Any = EMPTY) → Any | tuple[Any, ...]
Return unstructured raw values for `serializer.from_python()`.
It must return the same number of elements that has type vars.
Basically, each type var is a model.
Each element in a tuple is the corresponding data for that model.
When this method returns not a tuple and there’s only one type variable,
it also works.
*default* is the default value of the endpoint parameter,
like `parsed_body: Body[Model | None] = None`.
When it is not `EMPTY` and the request
has no data for this component, return *default* as-is.
> [!WARNING]
> Changed in version 0.16.0: Added *default* parameter.
##### provide_response_specs(metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], existing_responses: Mapping[HTTPStatus, ResponseSpec]) → list[ResponseSpec]
Return a list of extra responses that this component produces.
For example, when parsing something, we always have an option
to fail a parsing, if some request does not fit our model.
##### validate(controller_cls: type[Controller[BaseSerializer]], metadata: EndpointMetadata) → None
Validates that the component is correctly defined.
By default does nothing.
Runs in import time.
Source: /en/latest/pages/components/query.md
## Query parameters
You can define `Query` parameters
the same way you define `Headers`,
`Path` and
`Cookies` parameters.
> [!NOTE]
> Parsed `Query` parameter must be named `parsed_query`.
This is how you can parse `Query` parameters:
msgspec
pydantic
views.py
```python
import msgspec
from dmr import Controller, Query
from dmr.plugins.msgspec import MsgspecSerializer
class _QueryModel(msgspec.Struct):
query: str
count: int
class ApiController(Controller[MsgspecSerializer]):
def get(self, parsed_query: Query[_QueryModel]) -> _QueryModel:
return parsed_query
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl 'http://127.0.0.1:8000/api/users/?query=abc&count=10' -X GET
> {"query":"abc","count":10}
>
> $ curl 'http://127.0.0.1:8000/api/users/?query=abc' -D - -X GET
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:17:03 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 103
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Object missing required field `count` - at `$.parsed_query`","type":"value_error"}]}
> ```
views.py
```python
import pydantic
from dmr import Controller, Query
from dmr.plugins.pydantic import PydanticFastSerializer
class _QueryModel(pydantic.BaseModel):
query: str
count: int
class ApiController(Controller[PydanticFastSerializer]):
def get(self, parsed_query: Query[_QueryModel]) -> _QueryModel:
return parsed_query
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl 'http://127.0.0.1:8000/api/users/?query=abc&count=10' -X GET
> {"query":"abc","count":10}
>
> $ curl 'http://127.0.0.1:8000/api/users/?query=abc' -D - -X GET
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:17:05 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 89
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Field required","loc":["parsed_query","count"],"type":"value_error"}]}
> ```
What happens in this example?
1. We define a `Query` model using [`msgspec.Struct`](https://msgspec.dev/api.md#msgspec.Struct)
or [`pydantic.BaseModel`](https://pydantic.dev/docs/validation/latest/api/pydantic/base_model/#pydantic.BaseModel). Other types are also supported:
[`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict), [`dataclasses.dataclass()`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass), etc
2. Next, we use `Query` component,
provide the model as a type parameter,
and subclass it when defining `Controller` type
3. Then we use `self.parsed_query` that will have the correct model type
### Customizing OpenAPI metadata for Query
See Customizing parameter.
### Forcing query params to be a list
Internally query parameters are represented
as `django.utils.datastructures.MultiValueDict` in Django.
It supports setting and getting several values for a single key.
Users can customize how they want their values:
as single values or as lists of values.
To do so, use `__dmr_force_list__` optional attribute.
Set it to [`frozenset`](https://docs.python.org/3/builtins/stdtypes.html#frozenset) of field aliases that need to be lists.
All other values will be regular single values:
views.py
```python
from typing import ClassVar
import msgspec
from dmr import Controller, Query
from dmr.plugins.msgspec import MsgspecSerializer
class _QueryModel(msgspec.Struct):
__dmr_force_list__: ClassVar[frozenset[str]] = frozenset(('query',))
query: list[str]
regular: int
class ApiController(Controller[MsgspecSerializer]):
def get(self, parsed_query: Query[_QueryModel]) -> _QueryModel:
return parsed_query
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl 'http://127.0.0.1:8000/api/users/?query=abc&query=xyz®ular=1®ular=2' -X GET
> {"query":["abc","xyz"],"regular":2}
> ```
We don’t infer `__dmr_force_list__` value in any way,
it is up to users to set.
### Casting nulls
Queries in Django cannot be `None` by default.
So, when some tools send `'null'` as a way to represent `None`,
we need to handle that.
To do so, set the field aliases that should do
that into `__dmr_cast_null__`:
views.py
```python
from typing import ClassVar
import msgspec
from dmr import Controller, Query
from dmr.plugins.msgspec import MsgspecSerializer
class _QueryModel(msgspec.Struct):
__dmr_cast_null__: ClassVar[frozenset[str]] = frozenset(('query',))
query: str | None
regular: str
class ApiController(Controller[MsgspecSerializer]):
def get(self, parsed_query: Query[_QueryModel]) -> _QueryModel:
return parsed_query
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl 'http://127.0.0.1:8000/api/users/?query=abc®ular=null' -X GET
> {"query":"abc","regular":"null"}
>
> $ curl 'http://127.0.0.1:8000/api/users/?query=null®ular=null' -X GET
> {"query":null,"regular":"null"}
> ```
We don’t infer `__dmr_cast_null__` value in any way,
it is up to users to set.
### API Reference
#### dmr.components.Query
Annotated alias for parsing query parameters.
alias of `Annotated`[`_QueryT`, ]
#### *class* dmr.components.QueryComponent
Bases: `ComponentParser`
Parses query params of the request.
For example:
```python
>>> import pydantic
>>> from dmr import Query, Controller
>>> from dmr.plugins.pydantic import PydanticSerializer
>>> class ProductQuery(pydantic.BaseModel):
... category: str
... reversed: bool
>>> class ProductListController(Controller[PydanticSerializer]):
... def get(self, parsed_query: Query[ProductQuery]) -> str:
... return parsed_query.category
```
Will parse a request like `?category=cars&reversed=true`
into `ProductQuery` model.
Parameter for `Query` component must be named `parsed_query`.
##### context_name *: ClassVar[str]* *= 'parsed_query'*
All subtypes must provide a unique name that will be used to parse context.
We use a single context for all parsing, this component
will live under a dict field with this name.
##### get_schema(model: TypeForm[Any], model_meta: tuple[Any, ...], metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], context: OpenAPIContext) → list[Parameter | Reference] | RequestBody
Generate OpenAPI spec for component.
> [!WARNING]
> Changed in version 0.16.0: *serializer* parameter was changed to be *controller_cls*.
##### provide_context_data(endpoint: Endpoint, controller: Controller[BaseSerializer], \*, field_model: TypeForm[Any], default: Any = EMPTY) → dict[str, Any]
Return unstructured raw values for `serializer.from_python()`.
It must return the same number of elements that has type vars.
Basically, each type var is a model.
Each element in a tuple is the corresponding data for that model.
When this method returns not a tuple and there’s only one type variable,
it also works.
*default* is the default value of the endpoint parameter,
like `parsed_body: Body[Model | None] = None`.
When it is not `EMPTY` and the request
has no data for this component, return *default* as-is.
> [!WARNING]
> Changed in version 0.16.0: Added *default* parameter.
Source: /en/latest/pages/components/headers.md
## Header parameters
You can define `Headers` parameters
the same way you define `Query`,
`Path` and
`Cookies` parameters.
> [!NOTE]
> Parsed `Header` parameter must be named `parsed_headers`.
Since most headers use `-` to separate words, but a variable
like `cache-control` is not a valid variable name in Python.
So, you would have to use aliases for field names.
Remember, that headers are also case insensitive:
msgspec
pydantic
views.py
```python
import msgspec
from dmr import Controller, Headers
from dmr.plugins.msgspec import MsgspecSerializer
class _HeadersModel(msgspec.Struct):
cache: str = msgspec.field(name='cache-control')
client_id: int = msgspec.field(name='X-Client-Id', default=-1)
class ApiController(Controller[MsgspecSerializer]):
def get(self, parsed_headers: Headers[_HeadersModel]) -> _HeadersModel:
return parsed_headers
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X GET -H 'Cache-Control: max-age=0'
> {"cache-control":"max-age=0","X-Client-Id":-1}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X GET -H 'Cache-Control: max-age=0' -H 'X-Client-Id: wrong'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:53 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 105
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Expected `int`, got `str` - at `$.parsed_headers.X-Client-Id`","type":"value_error"}]}
> ```
views.py
```python
import pydantic
from dmr import Controller, Headers
from dmr.plugins.pydantic import PydanticFastSerializer
class _HeadersModel(pydantic.BaseModel):
cache: str = pydantic.Field(alias='cache-control')
client_id: int = pydantic.Field(alias='X-Client-Id', default=-1)
class ApiController(Controller[PydanticFastSerializer]):
def get(self, parsed_headers: Headers[_HeadersModel]) -> _HeadersModel:
return parsed_headers
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X GET -H 'Cache-Control: max-age=0'
> {"cache-control":"max-age=0","X-Client-Id":-1}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X GET -H 'Cache-Control: max-age=0' -H 'X-Client-Id: wrong'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:54 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 152
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Input should be a valid integer, unable to parse string as an integer","loc":["parsed_headers","X-Client-Id"],"type":"value_error"}]}
> ```
What happens in this example?
1. We define a `Headers` model using [`msgspec.Struct`](https://msgspec.dev/api.md#msgspec.Struct)
or [`pydantic.BaseModel`](https://pydantic.dev/docs/validation/latest/api/pydantic/base_model/#pydantic.BaseModel). Other types are also supported:
[`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict), [`dataclasses.dataclass()`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass), etc
2. Next, we use `Headers` component,
provide the model as a type parameter,
and subclass it when defining `Controller` type
3. Then we use `self.parsed_headers` that will have the correct model type
### Duplicated headers
By default Django joins several headers into a single value.
This is a limitation of a WSGI protocol.
But, even ASGI workers are forced to do the same in Django for compatibility.
```
X-Tag: a
X-Tag: b
```
Would become:
```python
{'X-Tag': 'a,b'}
```
To force `X-Tag` to be a list you can use `__dmr_split_commas__`.
Specify lower-case header field aliases
which needs to be split by a `','` char:
views.py
```python
from typing import ClassVar
import msgspec
from dmr import Controller, Headers
from dmr.plugins.msgspec import MsgspecSerializer
class _HeadersModel(msgspec.Struct):
__dmr_split_commas__: ClassVar[frozenset[str]] = frozenset(('x-tag',))
tags: list[int] = msgspec.field(name='X-Tag')
class UserController(Controller[MsgspecSerializer]):
def get(self, parsed_headers: Headers[_HeadersModel]) -> _HeadersModel:
return parsed_headers
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X GET -H 'X-Tag: 1' -H 'X-Tag: 2'
> {"X-Tag":[1,2]}
> ```
We don’t infer `__dmr_split_commas__` value in any way,
it is up to users to set.
Each part is stripped of surrounding whitespace,
so `X-Tag: a, b` and `X-Tag: a,b` both give `{'X-Tag': ['a', 'b']}`.
> [!CAUTION]
> Some headers like
>
> ```
> Accept: text/html, application/json
> Cache-Control: no-cache, no-store
> ```
>
> can naturally contain values with the `','` char.
> If you split them, you might get a messed up value.
### Customizing OpenAPI metadata for Headers
See Customizing parameter.
### API Reference
#### dmr.components.Headers
Annotated alias for parsing header parameters.
alias of `Annotated`[`_HeadersT`, ]
#### *class* dmr.components.HeadersComponent
Bases: `ComponentParser`
Parses request headers.
For example:
```python
>>> import pydantic
>>> from dmr import Headers, Controller
>>> from dmr.plugins.pydantic import PydanticSerializer
>>> class AuthHeaders(pydantic.BaseModel):
... token: str = pydantic.Field(alias='X-API-Token')
>>> class UserCreateController(Controller[PydanticSerializer]):
... def get(self, parsed_headers: Headers[AuthHeaders]) -> str:
... return parsed_headers.token
```
Will parse request headers like `Token: secret` into `AuthHeaders`
model.
Parameter for `Headers` component must be named `parsed_headers`.
##### context_name *: ClassVar[str]* *= 'parsed_headers'*
All subtypes must provide a unique name that will be used to parse context.
We use a single context for all parsing, this component
will live under a dict field with this name.
##### get_schema(model: TypeForm[Any], model_meta: tuple[Any, ...], metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], context: OpenAPIContext) → list[Parameter | Reference] | RequestBody
Generate OpenAPI spec for component.
> [!WARNING]
> Changed in version 0.16.0: *serializer* parameter was changed to be *controller_cls*.
##### provide_context_data(endpoint: Endpoint, controller: Controller[BaseSerializer], \*, field_model: TypeForm[Any], default: Any = EMPTY) → Any
Return unstructured raw values for `serializer.from_python()`.
It must return the same number of elements that has type vars.
Basically, each type var is a model.
Each element in a tuple is the corresponding data for that model.
When this method returns not a tuple and there’s only one type variable,
it also works.
*default* is the default value of the endpoint parameter,
like `parsed_body: Body[Model | None] = None`.
When it is not `EMPTY` and the request
has no data for this component, return *default* as-is.
> [!WARNING]
> Changed in version 0.16.0: Added *default* parameter.
Source: /en/latest/pages/components/cookies.md
## Cookie parameters
You can define `Cookies` parameters
the same way you define `Headers`,
`Path` and
`Query` parameters.
> [!NOTE]
> Parsed `Cookie` parameter must be named `parsed_cookies`.
This is how you can parse `Cookies` parameters:
msgspec
pydantic
views.py
```python
import msgspec
from dmr import Controller, Cookies
from dmr.plugins.msgspec import MsgspecSerializer
class _CookiesModel(msgspec.Struct):
cache: str
client_id: int
class ApiController(Controller[MsgspecSerializer]):
def get(self, parsed_cookies: Cookies[_CookiesModel]) -> _CookiesModel:
return parsed_cookies
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X GET --cookie cache=yes --cookie client_id=1
> {"cache":"yes","client_id":1}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X GET --cookie cache=yes --cookie client_id=wrong
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:49 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 103
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Expected `int`, got `str` - at `$.parsed_cookies.client_id`","type":"value_error"}]}
> ```
views.py
```python
import pydantic
from dmr import Controller, Cookies
from dmr.plugins.pydantic import PydanticFastSerializer
class _CookiesModel(pydantic.BaseModel):
cache: str
client_id: int
class ApiController(Controller[PydanticFastSerializer]):
def get(self, parsed_cookies: Cookies[_CookiesModel]) -> _CookiesModel:
return parsed_cookies
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X GET --cookie cache=yes --cookie client_id=1
> {"cache":"yes","client_id":1}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X GET --cookie cache=yes --cookie client_id=wrong
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:50 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 150
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Input should be a valid integer, unable to parse string as an integer","loc":["parsed_cookies","client_id"],"type":"value_error"}]}
> ```
What happens in this example?
1. We define a `Cookies` model using [`msgspec.Struct`](https://msgspec.dev/api.md#msgspec.Struct)
or [`pydantic.BaseModel`](https://pydantic.dev/docs/validation/latest/api/pydantic/base_model/#pydantic.BaseModel). Other types are also supported:
[`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict), [`dataclasses.dataclass()`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass), etc
2. Next, we use `Cookies` component,
provide the model as a type parameter,
and subclass it when defining `Controller` type
3. Then we use `self.parsed_cookies` that will have the correct model type
Cookies are case-sensitive.
### Customizing OpenAPI metadata for Cookies
See Customizing parameter.
### API Reference
#### dmr.components.Cookies
Annotated alias for parsing cookie parameters.
alias of `Annotated`[`_CookiesT`, ]
#### *class* dmr.components.CookiesComponent
Bases: `ComponentParser`
Parses the cookies from [`django.http.HttpRequest.COOKIES`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.COOKIES).
For example:
```python
>>> import pydantic
>>> from dmr import Cookies, Controller
>>> from dmr.plugins.pydantic import PydanticSerializer
>>> class UserSession(pydantic.BaseModel):
... session_id: int
>>> class UserUpdateController(Controller[PydanticSerializer]):
... def get(self, parsed_cookies: Cookies[UserSession]) -> int:
... return parsed_cookies.session_id
```
Will parse a request header like `Cookie: session_id=123`
into a model `UserSession`.
Parameter for `Cookies` component must be named `parsed_cookies`.
> [!NOTE]
> ### See also
>
> [https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cookie)
##### context_name *: ClassVar[str]* *= 'parsed_cookies'*
All subtypes must provide a unique name that will be used to parse context.
We use a single context for all parsing, this component
will live under a dict field with this name.
##### get_schema(model: TypeForm[Any], model_meta: tuple[Any, ...], metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], context: OpenAPIContext) → list[Parameter | Reference] | RequestBody
Generate OpenAPI spec for component.
> [!WARNING]
> Changed in version 0.16.0: *serializer* parameter was changed to be *controller_cls*.
##### provide_context_data(endpoint: Endpoint, controller: Controller[BaseSerializer], \*, field_model: TypeForm[Any], default: Any = EMPTY) → Any
Return unstructured raw values for `serializer.from_python()`.
It must return the same number of elements that has type vars.
Basically, each type var is a model.
Each element in a tuple is the corresponding data for that model.
When this method returns not a tuple and there’s only one type variable,
it also works.
*default* is the default value of the endpoint parameter,
like `parsed_body: Body[Model | None] = None`.
When it is not `EMPTY` and the request
has no data for this component, return *default* as-is.
> [!WARNING]
> Changed in version 0.16.0: Added *default* parameter.
Source: /en/latest/pages/components/path.md
## Path parameters
### Using native Django path params
You don’t have to use `Path` to parse url parameters.
By default Django puts all url parameters
into `self.args` and `self.kwargs`.
Let’s take a look at the full example:
views.py
```python
import uuid
from http import HTTPStatus
from typing import Any, assert_type
import pydantic
from dmr import APIError, Controller
from dmr.errors import ErrorType
from dmr.metadata import ResponseSpec
from dmr.openapi import build_schema
from dmr.openapi.views import OpenAPIJsonView
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.routing import Router, path
class _PostModel(pydantic.BaseModel):
user_id: int
post_id: uuid.UUID
class PostController(Controller[PydanticFastSerializer]):
responses = (
ResponseSpec(
Controller.error_model,
status_code=HTTPStatus.NOT_FOUND,
),
)
def get(self) -> _PostModel:
assert_type(self.kwargs, dict[str, Any])
if self.kwargs['user_id'] <= 0:
# Here we simulate some logical error, when object is not found:
raise APIError(
self.format_error(
'Page not found',
error_type=ErrorType.not_found,
),
status_code=HTTPStatus.NOT_FOUND,
)
return _PostModel(
user_id=self.kwargs['user_id'],
post_id=self.kwargs['post_id'],
)
router = Router(
'api/',
[
# We define a regular Django path:
path(
'user//post//',
PostController.as_view(),
name='user',
),
],
)
schema = build_schema(router)
urlpatterns = [
# Register our router in the final url patterns:
router.to_urlpatterns(namespace='api'),
# Add swagger:
path('docs/openapi.json/', OpenAPIJsonView.as_view(schema), name='openapi'),
]
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/1/post/8b36dfc2-f168-47db-827a-7ae323539936/ -X GET
> {"user_id":1,"post_id":"8b36dfc2-f168-47db-827a-7ae323539936"}
>
> $ curl http://127.0.0.1:8000/api/user/1/post/wrong/ -X GET
> {"detail":[{"msg":"Page not found","type":"not_found"}]}
>
> $ curl http://127.0.0.1:8000/api/user/0/post/8b36dfc2-f168-47db-827a-7ae323539936/ -X GET
> {"detail":[{"msg":"Page not found","type":"not_found"}]}
> ```
What happens here?
1. We define a controller that uses regular `self.kwargs`
dict with path params with no extra parsing from our side
2. We define a custom `ResponseSpec`
instance with `404` as a response code,
`Path` injects this response automatically,
but since we don’t use – we have to do that manually
for our Response validation to work
3. We also show how one can use `APIError`
to raise custom `404` errors when some objects are not found
4. We define an api url with [`django.urls.path()`](https://docs.djangoproject.com/en/6.1/ref/urls/#django.urls.path)
(or with [`django.urls.re_path()`](https://docs.djangoproject.com/en/6.1/ref/urls/#django.urls.re_path))
and a common Django syntax for path parameters:
`'user//post//'`
Django supports multiple pre-defined path converter types:
`int`, `uuid`, `str`, `slug`, `path`.
We have first-class support for all of them, showing all
the possible metadata we possibly can for these converters.
> [!NOTE]
> ### See also
> - [https://docs.djangoproject.com/en/stable/topics/http/urls/](https://docs.djangoproject.com/en/stable/topics/http/urls/)
> - [https://docs.djangoproject.com/en/stable/ref/urlresolvers/](https://docs.djangoproject.com/en/stable/ref/urlresolvers/)
The main downside of this method is that `self.kwargs` is typed
as `dict[str, Any]`. Which is not always ideal.
If you need typed path parameters,
use `Path` component with a model.
> [!NOTE]
> If you are using custom URL converters
> and [`django.urls.register_converter()`](https://docs.djangoproject.com/en/6.1/ref/urls/#django.urls.register_converter),
> we won’t know your url parameter schema type in advance.
> We default to `str` type for all url converters.
>
> However, if you are using a different converter schema type,
> you can use set `__dmr_converter_schema__` attribute
> with the specific type that you need in the schema.
> It can be any annotation that is supported by the controller’s serializer
> that is using this URL.
>
> Setting `__dmr_converter_schema__`
> to `dmr.openapi.generators.component_parsers.ConverterSchema`
> will allow you to add `pattern` or `description` metadata to the schema.
> [!NOTE]
> With [`django.urls.re_path()`](https://docs.djangoproject.com/en/6.1/ref/urls/#django.urls.re_path), url parameters are always typed
> as `str`, because we cannot infer any better type from a regex.
> But, we do copy the sub-pattern of each named group into the schema,
> so `r'^v(?P\d+)/$'` documents `version`
> as `{'type': 'string', 'pattern': '^(?:\d+)$'}`.
>
> Sub-patterns are copied as-is and only wrapped into anchors,
> we don’t translate them. JSON Schema requires ECMA-262 regexes,
> so Python-only syntax might not be supported by all OpenAPI tools.
### Using Path component and parsing models
When do you need to parse path parameters into models?
1. When you need typed path parameter model
2. When they have more metadata than regular Django can provide.
For example: only positive integers. Or `str` with an exact length
3. When you only need `self.kwargs` to be parsed,
because `Path` does not support variadic url args from `self.args`
You can define `Path` parameters
the same way you define `Headers`,
`Query` and
`Cookies` parameters.
> [!NOTE]
> Parsed `Path` parameter must be named `parsed_path`.
This is how you can parse `Path` parameters into a model:
msgspec
pydantic
views.py
```python
from typing import Annotated
import msgspec
from typing_extensions import TypedDict
from dmr import Controller, Path
from dmr.openapi import build_schema
from dmr.openapi.views import OpenAPIJsonView
from dmr.plugins.msgspec import MsgspecSerializer
from dmr.routing import Router, path
class _PathModel(TypedDict):
user_id: Annotated[str, msgspec.Meta(min_length=4, max_length=4)]
post_id: Annotated[int, msgspec.Meta(gt=0)]
class PostController(Controller[MsgspecSerializer]):
def get(self, parsed_path: Path[_PathModel]) -> _PathModel:
return parsed_path
router = Router(
'api/',
[
# We define a regular Django path:
path(
'user//post//',
PostController.as_view(),
name='user',
),
],
)
schema = build_schema(router)
urlpatterns = [
# Register our router in the final url patterns:
router.to_urlpatterns(namespace='api'),
# Add swagger:
path('docs/openapi.json/', OpenAPIJsonView.as_view(schema), name='openapi'),
]
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/abcd/post/1/ -X GET
> {"user_id":"abcd","post_id":1}
>
> $ curl http://127.0.0.1:8000/api/user/abcd/post/0/ -D - -X GET
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:59 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 92
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Expected `int` >= 1 - at `$.parsed_path.post_id`","type":"value_error"}]}
> ```
views.py
```python
from typing import Annotated
import pydantic
from typing_extensions import TypedDict
from dmr import Controller, Path
from dmr.openapi import build_schema
from dmr.openapi.views import OpenAPIJsonView
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.routing import Router, path
class _PathModel(TypedDict):
user_id: Annotated[str, pydantic.Field(min_length=4, max_length=4)]
post_id: Annotated[int, pydantic.Field(gt=0)]
class PostController(Controller[PydanticFastSerializer]):
def get(self, parsed_path: Path[_PathModel]) -> _PathModel:
return parsed_path
router = Router(
'api/',
[
# We define a regular Django path:
path(
'user//post//',
PostController.as_view(),
name='user',
),
],
)
schema = build_schema(router)
urlpatterns = [
# Register our router in the final url patterns:
router.to_urlpatterns(namespace='api'),
# Add swagger:
path('docs/openapi.json/', OpenAPIJsonView.as_view(schema), name='openapi'),
]
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/abcd/post/1/ -X GET
> {"user_id":"abcd","post_id":1}
>
> $ curl http://127.0.0.1:8000/api/user/abcd/post/0/ -D - -X GET
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:17:01 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 106
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Input should be greater than 0","loc":["parsed_path","post_id"],"type":"value_error"}]}
> ```
What happens in this example?
1. We define a `Path` model using [`msgspec.Struct`](https://msgspec.dev/api.md#msgspec.Struct)
or [`pydantic.BaseModel`](https://pydantic.dev/docs/validation/latest/api/pydantic/base_model/#pydantic.BaseModel). Other types are also supported:
[`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict), [`dataclasses.dataclass()`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass), etc
2. Next, we use `Path` component,
provide the model as a type parameter,
and subclass it when defining `Controller` type
3. Then we use `self.parsed_path` that will have the correct model type
What is the difference from the raw `path()` model?
1. `Path` component automatically injects `404` error into the final schema,
no need to manually defined `404` `ResponseSpec`
2. It performs a second validation of the `self.kwargs`
with new extra metadata from the `Path` model
3. It adds `self.parsed_path` attribute
> [!IMPORTANT]
> Make sure that your `path()` URL pattern and `Path` model fields match.
> We don’t automatically validate it.
> [!NOTE]
> `Path` model fields can have default values.
> Frameworks like FastAPI route each view with a single `@app.get(url)`,
> so path parameters with defaults make no sense there.
> But a single controller can be routed to several URLs,
> and these URLs can differ in a single URL parameter:
>
> ```python
> urlpatterns = [
> path('users/', UserController.as_view()),
> path('users//', UserController.as_view()),
> ]
> ```
>
> A default value, like `user_id: int | None = None`,
> makes the same `Path` model work for both URLs.
>
> OpenAPI requires all path parameters to be required,
> so they always have `required: true` in the schema,
> even when they have default values.
### Customizing OpenAPI metadata for Path
See Customizing parameter.
### API Reference
#### dmr.components.Path
Annotated alias for parsing path parameters.
alias of `Annotated`[`_PathT`, ]
#### *class* dmr.components.PathComponent
Bases: `ComponentParser`
Parses the url part of the request.
For example:
```python
>>> import pydantic
>>> from dmr import Path, Controller
>>> from dmr.routing import Router
>>> from dmr.plugins.pydantic import PydanticSerializer
>>> from django.urls import include, path
>>> class UserPath(pydantic.BaseModel):
... user_id: int
>>> class UserUpdateController(Controller[PydanticSerializer]):
... def get(self, parsed_path: Path[UserPath]) -> int:
... return parsed_path.user_id
>>> router = Router(
... 'api/',
... [
... path(
... 'user/',
... UserUpdateController.as_view(),
... name='users',
... ),
... ],
... )
>>> urlpatterns = [
... path(
... router.prefix,
... include((router.urls, 'rest_app'), namespace='api'),
... ),
... ]
```
Will parse a url path like `/user_id/100`
which will be translated into `{'user_id': 100}`
into `UserPath` model.
Parameter for `Path` component must be named `parsed_path`.
It is way stricter than the original Django’s routing system.
For example, django allows to such cases:
- `user_id` is defined as `int` in the `path('user/')`
- `user_id` is defined as `str` in the view function:
`def get(self, request, user_id: str): ...`
In `django-modern-rest` there’s now a way to validate this in runtime.
##### context_name *: ClassVar[str]* *= 'parsed_path'*
All subtypes must provide a unique name that will be used to parse context.
We use a single context for all parsing, this component
will live under a dict field with this name.
##### get_schema(model: TypeForm[Any], model_meta: tuple[Any, ...], metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], context: OpenAPIContext) → list[Parameter | Reference] | RequestBody
Generate OpenAPI spec for component.
> [!WARNING]
> Changed in version 0.16.0: *serializer* parameter was changed to be *controller_cls*.
##### provide_context_data(endpoint: Endpoint, controller: Controller[BaseSerializer], \*, field_model: TypeForm[Any], default: Any = EMPTY) → Any
Return unstructured raw values for `serializer.from_python()`.
It must return the same number of elements that has type vars.
Basically, each type var is a model.
Each element in a tuple is the corresponding data for that model.
When this method returns not a tuple and there’s only one type variable,
it also works.
*default* is the default value of the endpoint parameter,
like `parsed_body: Body[Model | None] = None`.
When it is not `EMPTY` and the request
has no data for this component, return *default* as-is.
> [!WARNING]
> Changed in version 0.16.0: Added *default* parameter.
##### provide_response_specs(metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], existing_responses: Mapping[HTTPStatus, ResponseSpec]) → list[ResponseSpec]
Return a list of extra responses that this component produces.
Path component implies that we are looking for something.
So, it is natural to have 404 in the specification.
Source: /en/latest/pages/components/body.md
## Request body
The body can be anything: JSON, XML,
`application/x-www-form-urlencoded`,
or `multipart/form-data`.
It depends on the `Parser`
that is being used for the endpoint.
> [!NOTE]
> The parsed `Body` parameter must be named `parsed_body`.
### Parsing JSON
Here’s how you can parse `Body` with a model:
msgspec
pydantic
attrs
dataclasses
TypedDict
We support [`msgspec.Struct`](https://msgspec.dev/api.md#msgspec.Struct)
via `MsgspecSerializer`.
views.py
```python
import msgspec
from dmr import Body, Controller
from dmr.plugins.msgspec import MsgspecSerializer
class _User(msgspec.Struct):
username: str
age: int
class UserController(Controller[MsgspecSerializer]):
def put(self, parsed_body: Body[_User]) -> _User:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X PUT -d '{"username": "sobolevn", "age": 27}' -H 'Content-Type: application/json'
> {"username":"sobolevn","age":27}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X PUT -d '{"username": "sobolevn"}' -H 'Content-Type: application/json'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:37 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 100
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Object missing required field `age` - at `$.parsed_body`","type":"value_error"}]}
> ```
We support [`pydantic.BaseModel`](https://pydantic.dev/docs/validation/latest/api/pydantic/base_model/#pydantic.BaseModel)
via `PydanticSerializer`.
views.py
```python
import pydantic
from dmr import Body, Controller
from dmr.plugins.pydantic import PydanticFastSerializer
class _User(pydantic.BaseModel):
username: str
age: int
class UserController(Controller[PydanticFastSerializer]):
def put(self, parsed_body: Body[_User]) -> _User:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X PUT -d '{"username": "sobolevn", "age": 27}' -H 'Content-Type: application/json'
> {"username":"sobolevn","age":27}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X PUT -d '{"username": "sobolevn"}' -H 'Content-Type: application/json'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:38 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 86
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Field required","loc":["parsed_body","age"],"type":"value_error"}]}
> ```
We support [`attrs.define()`](https://www.attrs.org/en/stable/api.html#attrs.define)
via `MsgspecSerializer`.
views.py
```python
import attrs
from dmr import Body, Controller
from dmr.plugins.msgspec import MsgspecSerializer
@attrs.define
class _User:
username: str
age: int
class UserController(Controller[MsgspecSerializer]):
def put(self, parsed_body: Body[_User]) -> _User:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X PUT -d '{"username": "sobolevn", "age": 27}' -H 'Content-Type: application/json'
> {"age":27,"username":"sobolevn"}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X PUT -d '{"username": "sobolevn"}' -H 'Content-Type: application/json'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:39 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 100
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Object missing required field `age` - at `$.parsed_body`","type":"value_error"}]}
> ```
We support [`dataclasses.dataclass()`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass) via both
`MsgspecSerializer`
and `PydanticSerializer`.
views.py
```python
import dataclasses
from dmr import Body, Controller
from dmr.plugins.msgspec import MsgspecSerializer
@dataclasses.dataclass
class _User:
username: str
age: int
class UserController(Controller[MsgspecSerializer]):
def put(self, parsed_body: Body[_User]) -> _User:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X PUT -d '{"username": "sobolevn", "age": 27}' -H 'Content-Type: application/json'
> {"username":"sobolevn","age":27}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X PUT -d '{"username": "sobolevn"}' -H 'Content-Type: application/json'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:40 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 100
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Object missing required field `age` - at `$.parsed_body`","type":"value_error"}]}
> ```
We support [`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) via both
`MsgspecSerializer`
and `PydanticSerializer`.
views.py
```python
from typing_extensions import TypedDict
from dmr import Body, Controller
from dmr.plugins.msgspec import MsgspecSerializer
class _User(TypedDict):
username: str
age: int
class UserController(Controller[MsgspecSerializer]):
def put(self, parsed_body: Body[_User]) -> _User:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X PUT -d '{"username": "sobolevn", "age": 27}' -H 'Content-Type: application/json'
> {"username":"sobolevn","age":27}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X PUT -d '{"username": "sobolevn"}' -H 'Content-Type: application/json'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:41 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 100
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Object missing required field `age` - at `$.parsed_body`","type":"value_error"}]}
> ```
What happens in this example?
1. We define a `Body` model using [`msgspec.Struct`](https://msgspec.dev/api.md#msgspec.Struct),
[`pydantic.BaseModel`](https://pydantic.dev/docs/validation/latest/api/pydantic/base_model/#pydantic.BaseModel),
[`attrs.define()`](https://www.attrs.org/en/stable/api.html#attrs.define),
[`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict), or [`dataclasses.dataclass()`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass).
Basically, the model definition is only limited
by the `BaseSerializer` support
2. Next, we use the `Body` component,
provide the model as a type parameter,
and subclass it when defining the `Controller` type
3. Then we use `self.parsed_body` which will have the correct model type
### Parsing MsgPack
> [!NOTE]
> This feature requires `'django-modern-rest[msgpack]'` to be installed.
MsgPack is a binary, compact and really fast format for modern APIs.
Docs: [https://msgpack.org](https://msgpack.org)
Bodies can be parsed using different `dmr.parsers.Parser` types.
See our Content negotiation guide on more information
about content negotiation.
Here’s how `msgpack` will represent `{"username": "example", "age": 22}`
(since it is a binary format, it will show some random unicode symbols:
- [examples/components/body.msgpack](https://github.com/wemake-services/django-modern-rest/blob/master/docs/examples/components/body.msgpack)
- [examples/components/body_wrong.msgpack](https://github.com/wemake-services/django-modern-rest/blob/master/docs/examples/components/body_wrong.msgpack)
The only visible difference from parsing JSON is specifying a different
`parsers` instance.
views.py
```python
import msgspec
from dmr import Body, Controller
from dmr.plugins.msgspec import MsgpackParser, MsgspecSerializer
class _User(msgspec.Struct):
username: str
age: int
class UserController(Controller[MsgspecSerializer]):
parsers = (MsgpackParser(),)
def put(self, parsed_body: Body[_User]) -> _User:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X PUT --data-binary @examples/components/body.msgpack -H 'Content-Type: application/msgpack'
> {"username":"example","age":22}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X PUT --data-binary @examples/components/body_wrong.msgpack -H 'Content-Type: application/msgpack'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:42 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 100
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Object missing required field `age` - at `$.parsed_body`","type":"value_error"}]}
> ```
### Optional body
Request body can have a default value, in this case it is optional:
msgspec
pydantic
views.py
```python
import msgspec
from dmr import Body, Controller
from dmr.plugins.msgspec import MsgspecSerializer
class ProductFilters(msgspec.Struct):
category: str
class ProductController(Controller[MsgspecSerializer]):
def post(
self,
parsed_body: Body[ProductFilters | None] = None,
) -> str:
return 'all' if parsed_body is None else parsed_body.category
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/products/ -X POST
> "all"
>
> $ curl http://127.0.0.1:8000/api/products/ -X POST -d '{"category": "cars"}' -H 'Content-Type: application/json'
> "cars"
>
> $ curl http://127.0.0.1:8000/api/products/ -D - -X POST -d '{"other": "cars"}' -H 'Content-Type: application/json'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:43 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 105
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Object missing required field `category` - at `$.parsed_body`","type":"value_error"}]}
> ```
views.py
```python
import pydantic
from dmr import Body, Controller
from dmr.plugins.pydantic import PydanticSerializer
class ProductFilters(pydantic.BaseModel):
category: str
class ProductController(Controller[PydanticSerializer]):
def post(
self,
parsed_body: Body[ProductFilters | None] = None,
) -> str:
return 'all' if parsed_body is None else parsed_body.category
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/products/ -X POST
> "all"
>
> $ curl http://127.0.0.1:8000/api/products/ -X POST -d '{"category": "cars"}' -H 'Content-Type: application/json'
> "cars"
>
> $ curl http://127.0.0.1:8000/api/products/ -D - -X POST -d '{"other": "cars"}' -H 'Content-Type: application/json'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:44 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 91
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Field required","loc":["parsed_body","category"],"type":"value_error"}]}
> ```
Requests without a body get `parsed_body=None`,
such bodies are documented with `required: false` in the OpenAPI schema.
Requests with a body are always validated, even when there’s a default.
See Default values to learn more.
### Fast body parsing with msgspec
> [!WARNING]
> Added in version 0.16.0.
> [!TIP]
> `BodyMsgspec` is generally x1.6 faster than `Body`.
By default, `Body` is parsed in two steps:
1. The `Parser` decodes the raw request bytes
into simple python objects, like [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict) and [`list`](https://docs.python.org/3/builtins/stdtypes.html#list),
using no exact shape
2. Then, all components of the request (body, headers, query, etc)
are validated together in a single call with the model that
the `BaseSerializer` builds for the whole request,
see Customizing the serializer context
This is flexible, but the intermediate python objects cost time.
But, `msgspec` can decode bytes directly into a model,
when it knows the shape in advance. It gives a lot of speed: for example,
`msgspec` skips unknown fields and does not parse them from the body payload.
It is a drop-in replacement for `Body` when using
`MsgspecSerializer`:
views.py
```python
import msgspec
from dmr import Controller
from dmr.plugins.msgspec import BodyMsgspec, MsgspecSerializer
class _User(msgspec.Struct, gc=False):
username: str
age: int
class UserController(Controller[MsgspecSerializer]):
def put(self, parsed_body: BodyMsgspec[_User]) -> _User:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X PUT -d '{"username": "sobolevn", "age": 27}' -H 'Content-Type: application/json'
> {"username":"sobolevn","age":27}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X PUT -d '{"username": "sobolevn", "age": "not-a-number"}' -H 'Content-Type: application/json'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:45 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 82
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Expected `int`, got `str` - at `$.age`","type":"value_error"}]}
> ```
What is different from `Body`?
- Only `MsgspecSerializer` is supported,
other serializers raise `EndpointMetadataError`
during the import time.
`pydantic` cannot benefit from this: its models can’t be decoded
by `msgspec`, and validating them from within the decoder
is slower than the regular `Body` in all measured cases
- The body is validated on its own, before all other components
are validated together. When the body is invalid, other components
are not validated at all: the error response only contains
body errors, even if `parsed_headers` or `parsed_query`
are also invalid
- Error locations change: the body is validated as the root object,
not as a part of the whole request, so the `parsed_body` prefix
is gone. `Body` reports `$.parsed_body.age`,
`BodyMsgspec` reports `$.age` for the same error.
Top level errors have no location at all
- Invalid bytes, like malformed `json`, are still reported
as parsing errors, exactly like for `Body`
- Strictness of `msgspec` decoding is controlled by
`strict`
and `strict` attributes,
which are lax by default, just like the regular request validation.
`strict_validation` has no effect
on fast bodies
> [!TIP]
> `BodyMsgspec` works best if there are no other components.
Everything else works the same: defaults (see Default values),
conditional types (see Using different schemes for different content types), OpenAPI schema generation,
and response validation.
The OpenAPI schema is always the same for `Body` and `BodyMsgspec`.
> [!NOTE]
> Only parsers that decode into models really support `BodyMsgspec`:
> `MsgspecJsonParser`
> and `MsgpackParser`.
>
> Custom parsers can also learn to respect the model,
> but it needs a support from the core library’s serializer side.
### Customizing the OpenAPI metadata for Body
See Customizing media types.
### Parsing forms
> [!NOTE]
> We don’t recommend using forms. If you can avoid using this feature
> and switch to JSON, you totally should.
>
> Forms are only needed for compatibility with older APIs, strange libraries,
> or existing workflows.
Here’s an example how one can send `application/x-www-form-urlencoded`
form data to an API endpoint with the help
of `FormUrlEncodedParser`:
views.py
```python
import pydantic
from dmr import Body, Controller
from dmr.parsers import FormUrlEncodedParser
from dmr.plugins.pydantic import PydanticSerializer
class _User(pydantic.BaseModel):
username: str
age: int
class UserController(Controller[PydanticSerializer]):
parsers = (FormUrlEncodedParser(),)
def put(self, parsed_body: Body[_User]) -> _User:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X PUT -d 'username=sobolevn&age=27' -H 'Content-Type: application/x-www-form-urlencoded'
> {"username":"sobolevn","age":27}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X PUT -d 'username=sobolevn&age=wrong' -H 'Content-Type: application/x-www-form-urlencoded'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:46 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 141
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Input should be a valid integer, unable to parse string as an integer","loc":["parsed_body","age"],"type":"value_error"}]}
> ```
> [!TIP]
> If you are using Django 6.1+ and have a lot of form requests,
> you can swap the [`django.http.HttpRequest.multipart_parser_class`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.multipart_parser_class)
> attribute of request objects for faster parsing. We recommend using
> the [multipart](https://github.com/defnull/multipart) package for that.
> It works twice as fast.
### Forcing lists and casting nulls in forms
> [!WARNING]
> All of the features below only work for
> `application/x-www-form-urlencoded` and `multipart/form-data`
> parsers. JSON and other “modern” formats are not affected.
Django’s form parsing algorithm is 20+ years old
at the moment of writing this doc.
There are some known quirks to it.
#### Forcing lists
Django uses `django.utils.datastructures.MultiValueDict`
to store body data when parsing forms. Due to its API,
it does not give `list` objects back easily.
So, when we need a list for a field, we need to force it like this:
views.py
```python
from typing import ClassVar
import pydantic
from dmr import Body, Controller
from dmr.parsers import MultiPartParser
from dmr.plugins.pydantic import PydanticSerializer
class _User(pydantic.BaseModel):
__dmr_force_list__: ClassVar[frozenset[str]] = frozenset(('tags',))
username: str
tags: list[str]
class UserController(Controller[PydanticSerializer]):
parsers = (MultiPartParser(),)
def put(self, parsed_body: Body[_User]) -> _User:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X PUT -F username=sobolevn -F tags=python -F tags=django -H 'Content-Type: multipart/form-data'
> {"username":"sobolevn","tags":["python","django"]}
>
> $ curl http://127.0.0.1:8000/api/users/ -X PUT -F username=sobolevn -F tags=single -H 'Content-Type: multipart/form-data'
> {"username":"sobolevn","tags":["single"]}
> ```
#### Split commas
Another problem that might happen is that some field might
look like `{'foo': 'bar,baz'}`, not `{'foo': ['bar', 'baz']}`.
To solve this, one can use a different magic attribute:
views.py
```python
from typing import ClassVar
import pydantic
from dmr import Body, Controller
from dmr.parsers import MultiPartParser
from dmr.plugins.pydantic import PydanticSerializer
class _User(pydantic.BaseModel):
__dmr_split_commas__: ClassVar[frozenset[str]] = frozenset(('tags',))
username: str
tags: list[str]
class UserController(Controller[PydanticSerializer]):
parsers = (MultiPartParser(),)
def post(self, parsed_body: Body[_User]) -> _User:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X POST -F username=sobolevn -F tags=python,django -H 'Content-Type: multipart/form-data'
> {"username":"sobolevn","tags":["python","django"]}
>
> $ curl http://127.0.0.1:8000/api/users/ -X POST -F username=sobolevn -F tags=single -H 'Content-Type: multipart/form-data'
> {"username":"sobolevn","tags":["single"]}
> ```
> [!WARNING]
> We split all data by `','`. If your data contains `','` as a regular
> value, it might be corrupted.
>
> Be careful when you use this with fields which do not contain `','`,
> like list of ints, UUIDs, or slugs.
#### Casting nulls
It is hard to pass `None` as a value in a form.
To solve the need for `None`, many places offer to pass `'null'` as a string.
We can cast `'null'` back to `None` if `__dmr_cast_null__` is specified.
views.py
```python
from typing import ClassVar
import pydantic
from dmr import Body, Controller
from dmr.parsers import MultiPartParser
from dmr.plugins.pydantic import PydanticSerializer
class _User(pydantic.BaseModel):
__dmr_force_list__: ClassVar[frozenset[str]] = frozenset(('tags',))
__dmr_cast_null__: ClassVar[frozenset[str]] = frozenset((
'promo_id',
'tags',
))
username: str
promo_id: int | None
tags: list[str | None]
class UserController(Controller[PydanticSerializer]):
parsers = (MultiPartParser(),)
def post(self, parsed_body: Body[_User]) -> _User:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X POST -F username=sobolevn -F promo_id=1 -F tags=python -F tags=django -H 'Content-Type: multipart/form-data'
> {"username":"sobolevn","promo_id":1,"tags":["python","django"]}
>
> $ curl http://127.0.0.1:8000/api/users/ -X POST -F username=sobolevn -F promo_id=null -F tags=python -F tags=null -H 'Content-Type: multipart/form-data'
> {"username":"sobolevn","promo_id":null,"tags":["python",null]}
> ```
You can combine this feature with
both `__dmr_split_commas__` and `__dmr_force_list__` as well.
### Conditional models
`django-modern-rest` supports using different request models
for different request content types.
See Using different schemes for different content types to learn more.
### API Reference
#### dmr.components.Body
Annotated alias for parsing requests bodies.
alias of `Annotated`[`_BodyT`, ]
#### *class* dmr.components.BodyComponent(\*, pass_model: bool = False)
Bases: `ComponentParser`
Parses body of the request.
For example:
```python
>>> import pydantic
>>> from dmr import Body, Controller
>>> from dmr.plugins.pydantic import PydanticSerializer
>>> class UserCreateInput(pydantic.BaseModel):
... email: str
... age: int
>>> class UserCreateController(Controller[PydanticSerializer]):
... def post(self, parsed_body: Body[UserCreateInput]) -> str:
... return parsed_body.email
```
Will parse a body like `{'email': 'user@example.org', 'age': 18}` into
`UserCreateInput` model.
Parameter for `Body` component must be named `parsed_body`.
When working with parsers that support
`dmr.parsers.SupportsDjangoDefaultParsing` interface,
you can specify `__dmr_split_commas__` attribute:
it must contain a [`frozenset`](https://docs.python.org/3/builtins/stdtypes.html#frozenset) of field aliases
that will be split by `','` char.
See Using different schemes for different content types to learn more about conditional bodies.
* **Parameters:**
**pass_model** – Whether or not to pass `model`
to the serializer or `Any`.
Plugins that support direct model parsing provide their own aliases,
like `BodyMsgspec`,
and validate that their serializer is used to parse objects faster.
See Fast body parsing with msgspec to learn more.
> [!WARNING]
> Changed in version 0.16.0: Added *pass_model* parameter.
##### conditional_types(model: TypeForm[Any], model_meta: tuple[Any, ...]) → Mapping[str, Any]
Provide conditional parsing types based on content type.
Body model can be conditional based on a content_type.
If [`typing.Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated) is passed together
with `dmr.negotiation.conditional_type()`
we treat the body as conditional. Otherwise, returns an empty dict.
##### context_name *: ClassVar[str]* *= 'parsed_body'*
All subtypes must provide a unique name that will be used to parse context.
We use a single context for all parsing, this component
will live under a dict field with this name.
##### get_schema(model: TypeForm[Any], model_meta: tuple[Any, ...], metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], context: OpenAPIContext) → list[Parameter | Reference] | RequestBody
Generate OpenAPI spec for component.
> [!WARNING]
> Changed in version 0.16.0: *serializer* parameter was changed to be *controller_cls*.
##### provide_context_data(endpoint: Endpoint, controller: Controller[BaseSerializer], \*, field_model: TypeForm[Any], default: Any = EMPTY) → Any
Return unstructured raw values for `serializer.from_python()`.
It must return the same number of elements that has type vars.
Basically, each type var is a model.
Each element in a tuple is the corresponding data for that model.
When this method returns not a tuple and there’s only one type variable,
it also works.
*default* is the default value of the endpoint parameter,
like `parsed_body: Body[Model | None] = None`.
When it is not `EMPTY` and the request
has no data for this component, return *default* as-is.
> [!WARNING]
> Changed in version 0.16.0: Added *default* parameter.
Source: /en/latest/pages/components/files.md
## Uploading files
There are several ways users can send files to a REST API:
1. Via `multipart/form-data` requests.
It supports passing multiple files at once,
it also supports sending other body parameters together with the files.
It is the best option for 95% of cases. This way requires our
`MultiPartParser` to be used
2. Via direct requests with a single file and a concrete content-type metadata
3. Via base64-encoded strings inside a JSON or XML files.
It is only suitable for really small files
Currently we support only the first option.
The second option is not supported yet, but it may be supported in future releases.
Currently users can implement their own `Parser` to do that.
While the third way has no specific support, but it can be implemented
by users directly.
> [!CAUTION]
> Uploading files with Python and Django can be really slow.
> For most cases it would be a better idea to use S3-like system
> to upload user-generated content.
>
> Sync uploads must never be used.
> Even a very small amount of traffic will completely block your app.
### Parsing files
> [!NOTE]
> Parsed `FileMetadata` parameter must be named `parsed_file_metadata`.
> While file objects themselves are available as `self.request.FILES`.
> See [`django.http.HttpRequest.FILES`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.FILES) for more info.
We don’t provide any extra abstractions on top of Django’s file uploads.
> [!NOTE]
> Official Django docs:
> [https://docs.djangoproject.com/en/stable/topics/http/file-uploads/](https://docs.djangoproject.com/en/stable/topics/http/file-uploads/)
All Django features for file uploads work as well for `django-modern-rest`.
Like [FILE_UPLOAD_MAX_MEMORY_SIZE](https://docs.djangoproject.com/en/stable/ref/settings/#file-upload-max-memory-size)
or [FILE_UPLOAD_HANDLERS](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-FILE_UPLOAD_HANDLERS).
So, we do not touch Django’s internal logic for file uploads.
What we do instead is: we provide extra metadata
to be validated / rendered in the schema:
msgspec
pydantic
views.py
```python
from typing import Literal
import msgspec
from dmr import Controller, FileMetadata
from dmr.parsers import MultiPartParser
from dmr.plugins.msgspec import MsgspecSerializer
class _FileModel(msgspec.Struct):
# No other content types will be allowed:
content_type: Literal['text/plain']
# No other filenames will be allowed:
name: Literal['receipt.txt', 'rules.txt']
class _UploadedFiles(msgspec.Struct):
receipt: _FileModel
rules: _FileModel
class FileController(Controller[MsgspecSerializer]):
parsers = (MultiPartParser(),)
def put(
self,
parsed_file_metadata: FileMetadata[_UploadedFiles],
) -> _UploadedFiles:
return parsed_file_metadata
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X PUT -F receipt=@receipt.txt -F rules=@rules.txt -H 'Content-Type: multipart/form-data'
> {"receipt":{"content_type":"text/plain","name":"receipt.txt"},"rules":{"content_type":"text/plain","name":"rules.txt"}}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X PUT -F receipt=@wrong.json -H 'Content-Type: multipart/form-data'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:51 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 118
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Invalid enum value 'wrong.json' - at `$.parsed_file_metadata.receipt.name`","type":"value_error"}]}
> ```
views.py
```python
from typing import Literal
import pydantic
from dmr import Controller, FileMetadata
from dmr.parsers import MultiPartParser
from dmr.plugins.pydantic import PydanticSerializer
class _FileModel(pydantic.BaseModel):
# No other content types will be allowed:
content_type: Literal['text/plain']
# No other filenames will be allowed:
name: Literal['receipt.txt', 'rules.txt']
class _UploadedFiles(pydantic.BaseModel):
receipt: _FileModel
rules: _FileModel
class FileController(Controller[PydanticSerializer]):
parsers = (MultiPartParser(),)
def put(
self,
parsed_file_metadata: FileMetadata[_UploadedFiles],
) -> _UploadedFiles:
return parsed_file_metadata
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X PUT -F receipt=@receipt.txt -F rules=@rules.txt -H 'Content-Type: multipart/form-data'
> {"receipt":{"content_type":"text/plain","name":"receipt.txt"},"rules":{"content_type":"text/plain","name":"rules.txt"}}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X PUT -F receipt=@wrong.json -H 'Content-Type: multipart/form-data'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:16:52 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 337
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Input should be 'text/plain'","loc":["parsed_file_metadata","receipt","content_type"],"type":"value_error"},{"msg":"Input should be 'receipt.txt' or 'rules.txt'","loc":["parsed_file_metadata","receipt","name"],"type":"value_error"},{"msg":"Field required","loc":["parsed_file_metadata","rules"],"type":"value_error"}]}
> ```
What happens in this example?
1. We define a `FileMetadata` model using [`msgspec.Struct`](https://msgspec.dev/api.md#msgspec.Struct)
or [`pydantic.BaseModel`](https://pydantic.dev/docs/validation/latest/api/pydantic/base_model/#pydantic.BaseModel). Other types are also supported:
[`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict), [`dataclasses.dataclass()`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass), etc
2. Next, we use `FileMetadata` component,
provide the model as a type parameter,
and subclass it when defining `Controller` type
3. Then we use `self.parsed_file_metadata`
that will have the correct model type
> [!NOTE]
> Unlike raw Django, `django-modern-rest` allows file uploads
> for all HTTP methods with defined bodies.
Here’s the list of fields that we support as a metadata:
```python
[
'size',
'name',
'content_type',
'charset',
'content_type_extra',
]
```
We don’t copy content for the validation, only metadata.
### Customizing OpenAPI metadata for FileMetadata
See Customizing media types.
### Sending files with extra body parameters
It might be required to send some files as `multipart/form-data`
together with some extra information, like `user_id`.
This is also supported:
views.py
```python
from typing import Literal
import pydantic
from django.core.files.uploadedfile import UploadedFile
from dmr import Body, Controller, FileMetadata
from dmr.parsers import MultiPartParser
from dmr.plugins.pydantic import PydanticSerializer
class _FileModel(pydantic.BaseModel):
content_type: Literal['text/plain']
size: int
class _UploadedFiles(pydantic.BaseModel):
receipt: _FileModel
rules: _FileModel
class _BodyPayload(pydantic.BaseModel):
user_id: int
user_email: str
class _OutputPayload(pydantic.BaseModel):
receipt: _FileModel
rules: _FileModel
user_id: int
user_email: str
class FileAndBodyController(Controller[PydanticSerializer]):
parsers = (MultiPartParser(),)
def post(
self,
parsed_body: Body[_BodyPayload],
parsed_file_metadata: FileMetadata[_UploadedFiles],
) -> _OutputPayload:
for content_key in parsed_file_metadata.model_fields_set:
assert isinstance(self.request.FILES[content_key], UploadedFile)
return _OutputPayload(
user_id=parsed_body.user_id,
user_email=parsed_body.user_email,
receipt=parsed_file_metadata.receipt,
rules=parsed_file_metadata.rules,
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X POST -F user_id=1 -F user_email=example@mail.com -F receipt=@receipt.txt -F rules=@rules.txt -H 'Content-Type: multipart/form-data'
> {"receipt":{"content_type":"text/plain","size":16},"rules":{"content_type":"text/plain","size":14},"user_id":1,"user_email":"example@mail.com"}
> ```
To do that also define `Body` component
in the same controller.
### Sending files with json as a body parameter
You can send complex data together with files as `multipart/form-data`.
To do so, you would need to encode json as a string
and attach it to a form data field.
views.py
```python
from typing import Literal
import pydantic
from dmr import Body, Controller, FileMetadata
from dmr.parsers import MultiPartParser
from dmr.plugins.pydantic import PydanticSerializer
class _FileModel(pydantic.BaseModel):
content_type: Literal['text/plain']
size: int
class _UploadedFiles(pydantic.BaseModel):
receipt: _FileModel
rules: _FileModel
class _BodyPayload(pydantic.BaseModel):
user_id: int
user_email: str
class _JsonBodyPayload(pydantic.BaseModel):
user: pydantic.Json[_BodyPayload]
class _JsonOutputPayload(pydantic.BaseModel):
receipt: _FileModel
rules: _FileModel
user: _BodyPayload
class FileAndJsonController(Controller[PydanticSerializer]):
parsers = (MultiPartParser(),)
def put(
self,
parsed_body: Body[_JsonBodyPayload],
parsed_file_metadata: FileMetadata[_UploadedFiles],
) -> _JsonOutputPayload:
return _JsonOutputPayload(
user=parsed_body.user,
receipt=parsed_file_metadata.receipt,
rules=parsed_file_metadata.rules,
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X PUT -F 'user={"user_id": 1, "user_email": "example@mail.com"}' -F receipt=@receipt.txt -F rules=@rules.txt -H 'Content-Type: multipart/form-data'
> {"receipt":{"content_type":"text/plain","size":16},"rules":{"content_type":"text/plain","size":14},"user":{"user_id":1,"user_email":"example@mail.com"}}
> ```
The easiest way to do this would be to declare
a field as `pydantic.Json`.
However, this can be done with `msgspec` as well.
### Conditional file types
> [!WARNING]
> Added in version 0.15.0.
Files can also contain conditional schemas, for example different ones
for `application/octet-stream`, which takes a single file,
and `multipart/form-data`, which takes multiple files.
See Using different schemes for different content types to learn more.
> [!WARNING]
> This is not something we recommend doing, because uploading files
> with Django is slow and can cause performance / network issues.
### API Reference
#### dmr.components.FileMetadata
Annotated alias for parsing file metadata.
alias of `Annotated`[`_FileMetadataT`, ]
#### *class* dmr.components.FileMetadataComponent
Bases: `ComponentParser`
Parses files metadata from [`django.http.HttpRequest.FILES`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.FILES).
Django handles files itself natively, we don’t need to do anything
in `django-modern-rest`. Everything just works, including all
Django’s advanced file features like customizing the storage backends.
But, we need a way to represent and validate the metadata.
This class is designed to do just that: validate files’ metadata.
```python
>>> from typing import Literal
>>> import pydantic
>>> from dmr import Controller, FileMetadata
>>> from dmr.plugins.pydantic import PydanticSerializer
>>> from dmr.parsers import MultiPartParser
>>> class TextFile(pydantic.BaseModel):
... # Will validate that all files are text files
... # and are less than 1000 bytes in size:
... name: str
... content_type: Literal['text/plain']
... size: int = pydantic.Field(lt=1000)
>>> class ContractPayload(pydantic.BaseModel):
... receipt: TextFile
... contract: TextFile
>>> class ContractController(Controller[PydanticSerializer]):
... parsers = (MultiPartParser(),)
...
... def post(
... self,
... parsed_file_metadata: FileMetadata[ContractPayload],
... ) -> str:
... return 'Valid files!'
```
What attributes are available to be validated?
See [`django.core.files.uploadedfile.UploadedFile`](https://docs.djangoproject.com/en/6.1/ref/files/uploads/#django.core.files.uploadedfile.UploadedFile)
for the full list of metadata attributes.
Parameter for `FileMetadata` component
must be named `parsed_file_metadata`.
Users can customize how they want their file metadata values:
as single values or as lists of values.
To do so, use `__dmr_force_list__` optional attribute.
Set it to [`frozenset`](https://docs.python.org/3/builtins/stdtypes.html#frozenset) of file keys that need to be lists.
All other values will be regular single values:
```python
>>> class ContractPayload(pydantic.BaseModel):
... __dmr_force_list__: ClassVar[frozenset[str]] = frozenset((
... 'receipts',
... ))
...
... receipts: list[TextFile]
... contract: TextFile
```
This will parse a `multipart/form-data` request with potentially multiple
receipts and a single contract files.
See Conditional file types to learn more about conditional bodies.
> [!WARNING]
> Changed in version 0.15.0: Added support for conditional types.
> Removed `self.schema_metadata` attribute.
> [!NOTE]
> ### See also
>
> [https://docs.djangoproject.com/en/stable/topics/http/file-uploads/](https://docs.djangoproject.com/en/stable/topics/http/file-uploads/)
##### conditional_types(model: TypeForm[Any], model_meta: tuple[Any, ...]) → Mapping[str, Any]
Provide conditional parsing types based on a content type.
FileMetadata model can be conditional based on the content_type.
If [`typing.Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated) is passed together
with `dmr.negotiation.conditional_type()`
we treat the model as conditional. Otherwise, returns an empty dict.
##### context_name *: ClassVar[str]* *= 'parsed_file_metadata'*
All subtypes must provide a unique name that will be used to parse context.
We use a single context for all parsing, this component
will live under a dict field with this name.
##### get_schema(model: TypeForm[Any], model_meta: tuple[Any, ...], metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], context: OpenAPIContext) → list[Parameter | Reference] | RequestBody
Generate OpenAPI spec for component.
> [!WARNING]
> Changed in version 0.16.0: *serializer* parameter was changed to be *controller_cls*.
##### provide_context_data(endpoint: Endpoint, controller: Controller[BaseSerializer], \*, field_model: TypeForm[Any], default: Any = EMPTY) → Mapping[str, Any]
Return unstructured raw values for `serializer.from_python()`.
It must return the same number of elements that has type vars.
Basically, each type var is a model.
Each element in a tuple is the corresponding data for that model.
When this method returns not a tuple and there’s only one type variable,
it also works.
*default* is the default value of the endpoint parameter,
like `parsed_body: Body[Model | None] = None`.
When it is not `EMPTY` and the request
has no data for this component, return *default* as-is.
> [!WARNING]
> Changed in version 0.16.0: Added *default* parameter.
##### validate(controller_cls: type[Controller[BaseSerializer]], metadata: EndpointMetadata) → None
Validates that the component is correctly defined.
This component requires at least one
`dmr.parsers.SupportsFileParsing` instance
to be present in parsers.
Runs in import time.
Source: /en/latest/pages/configuration.md
## Configuration
We use `DMR_SETTINGS` dictionary object to store all the configuration.
All keys are typed with `Settings` enum keys
which can be used to both set and get settings.
> [!NOTE]
> Remember, that `django-modern-rest` settings
> are cached after the first access.
> If you need to modify settings dynamically
> in runtime use `clear_settings_cache()`.
> You can modify the size of cache with adjusting
> `DMR_MAX_CACHE_SIZE` value.
Here are all keys and values that can be set.
As usual, all settings go to `settings.py` file in your Django project.
> [!NOTE]
> ### See also
> - Official Django settings docs:
> [https://docs.djangoproject.com/en/5.2/topics/settings](https://docs.djangoproject.com/en/5.2/topics/settings)
> - `django-split-settings` configuration helper
> [https://github.com/wemake-services/django-split-settings](https://github.com/wemake-services/django-split-settings)
We also validate defined settings in import time.
See Settings validation for more details.
### Configuration levels
Most of the settings can be also configured on the controller level
with `Controller` attributes
and on the endpoint level with `modify`
and `validate` parameters.
The most specific level always wins:
1. Endpoint values override controller values
2. Controller values override settings values
Values are never merged, they always redefine each other.
Every value on every level defaults to `EMPTY`,
which means “not set on this level, use the next one”.
This is true for sequences, sets, booleans, and all other values.
`None` is never a default: it is a real value that disables something,
like `auth=None` or `tags=None`, and it is only allowed
where disabling makes sense.
Explicit empty values are real values as well, they are taken literally:
`auth=[]` means “no auth on this level”, it does not fall through
to the next level. So, `auth=[]` and `auth=()` work exactly
like `auth=None` does. The same is true for `throttling`,
`security`, `responses`, `tags`, and all other sequences and sets.
Only `EMPTY` uses the next level.
Here’s how one can use this system to achieve different strategies.
Let’s use authentication as the example.
Override
Merge
Disable
Overrides values from previous levels.
views.py
```python
from dmr import Controller, modify
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.security.jwt import CookieJWTSyncAuth, HeaderJWTSyncAuth
class APIController(Controller[PydanticFastSerializer]):
auth = (HeaderJWTSyncAuth(),)
@modify(auth=[CookieJWTSyncAuth()])
def get(self) -> str: # has custom auth
assert self.request.user.is_authenticated
return 'get'
def post(self) -> str: # has auth from the controller level
assert self.request.user.is_authenticated
return 'post'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X GET --cookie access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzkxMjg1NDI4LCJpYXQiOjE3OTExOTkwMjgsImp0aSI6ImYwNTJiYWFkMzQwYTQ3ZDI5NWIyMzk2MzA0N2NlODM0IiwiZXh0cmFzIjp7InR5cGUiOiJhY2Nlc3MifX0.Xnkk5cg1vvPTRT9zowxrsjhy7jPDngnV-kIBj0evl2s
> "get"
>
> $ curl http://127.0.0.1:8000/api/users/ -X GET -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzkxMjg1NDI5LCJpYXQiOjE3OTExOTkwMjksImp0aSI6IjBiYmIxNTVhMDAzMjQ4MmY5ODA2OGNlMWNkOTFiODg3IiwiZXh0cmFzIjp7InR5cGUiOiJhY2Nlc3MifX0.lRXdMkBXchKoX7fywkS565uAA9EOme3nVe1QgEhklXE'
> {"detail":[{"msg":"Not authenticated","type":"security"}]}
>
> $ curl http://127.0.0.1:8000/api/users/ -X POST -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzkxMjg1NDI5LCJpYXQiOjE3OTExOTkwMjksImp0aSI6IjA2ODE0ZDU3ZTVmZjQ0ZTc4NDkwOGUwZmJjZDNhNGE3IiwiZXh0cmFzIjp7InR5cGUiOiJhY2Nlc3MifX0.FUubMRqWZq-8Vaji3pfDnWSHY9tE5nVtg0td7jlio8s'
> "post"
>
> $ curl http://127.0.0.1:8000/api/users/ -X POST --cookie access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzkxMjg1NDMwLCJpYXQiOjE3OTExOTkwMzAsImp0aSI6ImNjODM1MzhmMTZhNDQ2NjJiY2Q0MzYyZWNlMWM3ZTg1IiwiZXh0cmFzIjp7InR5cGUiOiJhY2Nlc3MifX0.mFtybBnF9l8ZBWb4bmbpXOTLoWj6t8LknLugcmkGZLE
> {"detail":[{"msg":"Not authenticated","type":"security"}]}
> ```
Merge values from previous levels.
views.py
```python
from dmr import Controller, modify
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.security.jwt import CookieJWTSyncAuth, HeaderJWTSyncAuth
class APIController(Controller[PydanticFastSerializer]):
auth = (HeaderJWTSyncAuth(),)
@modify(auth=[*auth, CookieJWTSyncAuth()])
def get(self) -> str: # has merged auth
assert self.request.user.is_authenticated
return 'get'
def post(self) -> str: # has auth from the controller level
assert self.request.user.is_authenticated
return 'post'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X GET --cookie access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzkxMjg1NDMxLCJpYXQiOjE3OTExOTkwMzEsImp0aSI6IjNhNDM0MDdhZTEwYzQzMDU4ZTQyZjgwNGUxNzljNDI1IiwiZXh0cmFzIjp7InR5cGUiOiJhY2Nlc3MifX0.WU4_FcgK857CWAEXu0MsDyxSLdBYTV9n4cRRUYhc2YQ
> "get"
>
> $ curl http://127.0.0.1:8000/api/users/ -X GET -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzkxMjg1NDMxLCJpYXQiOjE3OTExOTkwMzEsImp0aSI6Ijg3NDFlOTU5ZTExZTQxM2ZiZjRiYTJmYzdlNTAwYTYxIiwiZXh0cmFzIjp7InR5cGUiOiJhY2Nlc3MifX0.hUyKBVwj7aZvSwgCcRmABKtCyMnt8quG3ol87xCMyXk'
> "get"
>
> $ curl http://127.0.0.1:8000/api/users/ -X POST -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzkxMjg1NDMyLCJpYXQiOjE3OTExOTkwMzIsImp0aSI6IjgwNTE4NDM1Yzg1YTRhYTNiYWRlZTdjMzhhZmIyOWUzIiwiZXh0cmFzIjp7InR5cGUiOiJhY2Nlc3MifX0.WdVp2Ef9ckhn4V2dcLZ-6OHPqdq_M-BEIWTn8x-ZFDE'
> "post"
>
> $ curl http://127.0.0.1:8000/api/users/ -X POST --cookie access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzkxMjg1NDMyLCJpYXQiOjE3OTExOTkwMzIsImp0aSI6Ijg2N2I1ZGU1N2UzNTRmZTI5MWYxYzYzMDJhOWEyOTM0IiwiZXh0cmFzIjp7InR5cGUiOiJhY2Nlc3MifX0.rb0OIeGpfAB7QHO_UFBZ8qvNrVsKBAewWTP-l9rx8GQ
> {"detail":[{"msg":"Not authenticated","type":"security"}]}
> ```
Disable values from previous levels.
views.py
```python
from dmr import Controller, modify
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.security.jwt import HeaderJWTSyncAuth
class APIController(Controller[PydanticFastSerializer]):
auth = (HeaderJWTSyncAuth(),)
@modify(auth=None)
def get(self) -> str: # has no auth
assert not self.request.user.is_authenticated
return 'public'
def post(self) -> str: # has auth from the controller level
assert self.request.user.is_authenticated
return 'authed'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X GET
> "public"
>
> $ curl http://127.0.0.1:8000/api/users/ -X POST -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzkxMjg1NDMzLCJpYXQiOjE3OTExOTkwMzMsImp0aSI6ImYwMWRlZjg0MDlmODQ4MjZiYzhjMmNiYjA1M2U0YWIyIiwiZXh0cmFzIjp7InR5cGUiOiJhY2Nlc3MifX0.V1UKtD8I4zdc9B1S_U0zU_Vd1L4hirhllcfi_z2mP8k'
> "authed"
>
> $ curl http://127.0.0.1:8000/api/users/ -X POST
> {"detail":[{"msg":"Not authenticated","type":"security"}]}
> ```
One can customize `metadata_merger_cls`
from the default `MetadataMerger`
to change how values are merged into the final metadata.
For example, it is possible to restore `0.15.0` behavior
and merge all sequences in a custom subclass, if it is needed.
> [!WARNING]
> Changed in version 0.16.0: Values from different levels used to be merged.
### Settings
Class with all possible setting keys as enum:
#### *final class* dmr.settings.Settings(\*values)
Bases: [`StrEnum`](https://docs.python.org/3/library/enum.html#enum.StrEnum)
Keys for all settings.
To get settings use
`resolve_setting()` function
together with `Settings` keys:
```python
>>> from dmr.settings import Settings, resolve_setting
>>> resolve_setting(Settings.responses)
[]
```
To set settings use:
```python
>>> DMR_SETTINGS = {Settings.responses: []}
```
Class that can be used to properly type settings in user’s code:
`dmr.settings.SettingsDict`.
### Content negotiation
> [!NOTE]
> It is recommended to always install `msgspec`
> with `'django-modern-rest[msgspec]'` extra for better performance.
##### dmr.settings.Settings.parsers
Default: `dmr.parsers.JsonParser` or
`dmr.plugins.msgspec.MsgspecJsonParser` if installed.
A list of instances of subtypes
of `Parser`
to serialize data from the requested text format,
like json or xml, into python object.
Custom configuration example, let’s say you want to always use `orjson`:
settings.py
```python
import orjson
from dmr.parsers import JsonParser
DMR_SETTINGS = {Settings.parsers: [JsonParser(json_module=orjson)]}
```
See Configuration levels on how
to merge / override / disable this setting on multiple levels.
> [!WARNING]
> Changed in version 0.16.0: Controller and endpoint values are not merged anymore.
##### dmr.settings.Settings.renderers
Default: `dmr.renderers.JsonRenderer` or
`dmr.plugins.msgspec.MsgspecJsonRenderer` if installed.
A list of instances of subtypes
of `Renderer`
to serialize python objects to the requested text format, like json or xml.
Custom configuration example, let’s say you want to always use `orjson`:
settings.py
```python
import orjson
from dmr.renderers import JsonRenderer
DMR_SETTINGS = {Settings.renderers: [JsonRenderer(json_module=orjson)]}
```
See Configuration levels on how
to merge / override / disable this setting on multiple levels.
> [!WARNING]
> Changed in version 0.16.0: Controller and endpoint values are not merged anymore.
##### dmr.settings.Settings.validate_negotiation
Default: `EMPTY`
Should we validate content negotiation?
Meaning: `django-modern-rest` finds which parser and which renderer
to use based on `Content-Type` and `Accept` headers.
However, by mistake people can return responses with wrong `Content-Type`
if they construct responses manually.
See Content negotiation for more info.
Defaults to the value set in `validate_responses`
for convenience if this value is `EMPTY`.
To disable the content negotiation validation globally, use:
settings.py
```python
>>> DMR_SETTINGS = {
... Settings.validate_negotiation: False,
... }
```
### Response handling
##### dmr.settings.Settings.responses
Default: `[]`
The list of global `ResponseSpec`
object that will be added to endpoints’ metadata
as a possible response schema.
Use it to set global responses’ status codes like `500`:
settings.py
```python
>>> from http import HTTPStatus
>>> from typing_extensions import TypedDict
>>> from dmr import ResponseSpec
>>> class Error(TypedDict):
... detail: str
>>> # If our API can always return a 500 response with `{"detail": str}`
>>> # error message:
>>> DMR_SETTINGS = {
... Settings.responses: [
... ResponseSpec(
... Error,
... status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
... ),
... ],
... }
```
See Configuration levels on how
to merge / override / disable this setting on multiple levels.
> [!WARNING]
> Changed in version 0.16.0: Controller and endpoint values are not merged anymore.
##### dmr.settings.Settings.validate_responses
Default: `True`
When some endpoint returns any data, we by default validate
that this data matches the endpoint schema.
So, this code will produce not only static typing error,
but also a runtime error:
```python
>>> from dmr import Controller
>>> from dmr.plugins.pydantic import PydanticFastSerializer
>>> class MyController(Controller[PydanticFastSerializer]):
... def get(self) -> list[str]:
... return [1, 2] # <- both static typing and runtime error
```
But, there’s a runtime cost to this. It is recommended to switch
this validation off for production:
settings.py
```python
>>> DMR_SETTINGS = {Settings.validate_responses: False}
```
> [!NOTE]
> You can also switch off this validation per-controller
> with `validate_responses`
> and per-endpoint with `validate_responses` argument
> to `modify`
> and `validate`.
We recommend setting this value to `not DEBUG`.
##### dmr.settings.Settings.exclude_validate_responses
Default: `frozenset()`
Pass any status code that you don’t want to be validated,
even when `validate_responses` is enabled.
Unlike describing a response, this does not add anything
to the OpenAPI schema: such responses are simply
returned to the client as-is.
See Validating error responses for the details.
settings.py
```python
>>> from http import HTTPStatus
>>> DMR_SETTINGS = {
... Settings.exclude_validate_responses: {
... HTTPStatus.INTERNAL_SERVER_ERROR,
... },
... }
```
See Configuration levels on how
to merge / override / disable this setting on multiple levels.
> [!WARNING]
> Added in version 0.15.0.
> [!WARNING]
> Changed in version 0.16.0: Controller and endpoint values are not merged anymore.
### Semantic schema generation
##### dmr.settings.Settings.semantic_schema
Default: `True`
When `True`, parsers, renderers, and authentication classes
automatically inject their semantic schema like response specs,
security schemes, and security requirements into every endpoint’s metadata.
This data is then visible in the generated OpenAPI spec.
Set to `False` to disable this auto-injection globally.
User-defined schema (via `@modify`, `@validate`,
controller `responses`, `Settings.responses`, or `security`)
are not affected by this flag.
Runtime response validation still works as configured
by `Settings.validate_responses`.
settings.py
```python
>>> DMR_SETTINGS = {Settings.semantic_schema: False}
```
> [!WARNING]
> Added in version 0.16.0.
##### dmr.settings.Settings.semantic_responses
Default: `EMPTY`
When `True`, parsers, renderers, and authentication classes
automatically inject their semantic responses spec
(e.g. `422 Unprocessable Entity`, `406 Not Acceptable`)
into every endpoint’s metadata.
These responses are then visible in the generated OpenAPI spec.
Set to `False` to disable this auto-injection globally.
User-defined responses (via `@modify`, `@validate`,
controller `responses`, or `Settings.responses`)
are not affected by this flag.
Runtime response validation still works as configured
by `Settings.validate_responses`.
Defaults to the value set in `semantic_schema`
for convenience if this value is `EMPTY`.
settings.py
```python
>>> DMR_SETTINGS = {Settings.semantic_responses: False}
```
##### dmr.settings.Settings.exclude_semantic_responses
Default: `frozenset()`
Pass any status code, that you wanna exclude from semantic responses.
settings.py
```python
>>> from http import HTTPStatus
>> DMR_SETTINGS = {
... Settings.exclude_semantic_responses: {
... HTTPStatus.CONFLICT,
... },
... }
```
See Configuration levels on how
to merge / override / disable this setting on multiple levels.
> [!WARNING]
> Changed in version 0.16.0: Controller and endpoint values are not merged anymore.
##### dmr.settings.Settings.semantic_auth
Default: `EMPTY`
When `True`, authentication classes
automatically inject their semantic security requirements and security schemes
into the final OpenAPI spec.
Set to `False` to disable this auto-injection globally.
User-defined schemes and `security` field
(via `@modify`, `@validate`, etc) are not affected by this flag.
Defaults to the value set in `semantic_schema`
for convenience if this value is `EMPTY`.
settings.py
```python
>>> DMR_SETTINGS = {Settings.semantic_auth: False}
```
> [!WARNING]
> Added in version 0.16.0.
##### dmr.settings.Settings.exclude_semantic_auth
Default: `frozenset()`
Exclude specific semantic security schemes and security requirements
that auth instances produce from the spec.
settings.py
```python
>>> DMR_SETTINGS = {Settings.exclude_semantic_auth: {'jwt'}}
```
See Configuration levels on how
to merge / override / disable this setting on multiple levels.
> [!WARNING]
> Added in version 0.16.0.
##### dmr.settings.Settings.semantic_schema_providers
Default: `[ResponseValidationSpecProvider(), CSRFSemanticSchemaProvider()]`
Sequence of default system-wide semantic schema providers.
By default we use `ResponseValidationSpecProvider`
and `CSRFSemanticSchemaProvider` to provide common
responses and auth specs that can happen with all endpoints.
Each endpoint will have these parts of the semantic schema,
if their conditions are met.
For example, `CSRFSemanticSchemaProvider` will inject
for controller that have `csrf_exempt=False`:
- `403` status code response spec
- `csrf` security requirement to all auth instances
- Global `csrf` security scheme component
`exclude_semantic_responses`
and `exclude_semantic_auth`
are also respected.
settings.py
```python
>>> from http import HTTPStatus
>>> from dmr.security.csrf import CSRFSemanticSchemaProvider
>> DMR_SETTINGS = {
... Settings.semantic_schema_providers: [
... CSRFSemanticSchemaProvider(description='CSRF error'),
... ],
... }
```
You can customize instances of the response spec providers or add new ones.
> [!WARNING]
> Added in version 0.16.0.
### Error handling
##### dmr.settings.Settings.global_error_handler
Default: `'dmr.errors.global_error_handler'`
Globally handle all errors in the application.
You can use real object or string path for the object to be imported.
Here’s our error handling hierarchy:
1. Per-endpoint with
`handle_error()`
and `handle_async_error()`
2. Per-controller with
`handle_error()`
or `handle_async_error()`
3. If nothing helped, `'global_error_handler'` is called
See `global_error_handler()`
for the callback type.
settings.py
```python
>>> DMR_SETTINGS = {Settings.global_error_handler: 'path.to.your.handler'}
```
### Authentication
##### dmr.settings.Settings.auth
Default: `[]`
Configure authentication rules for the whole API.
To enable auth for all endpoints you can use:
settings.py
```python
>>> from dmr.security.django_session import DjangoSessionSyncAuth
>>> DMR_SETTINGS = {
... Settings.auth: [
... DjangoSessionSyncAuth(),
... ],
... }
```
If you use both sync and async controllers in your app,
consider using `SyncOrAsyncAuth` for settings.
All auth types must be importable in settings.
See Configuration levels on how
to merge / override / disable this setting on multiple levels.
> [!WARNING]
> Changed in version 0.16.0: Controller and endpoint values are not merged anymore.
### Throttling
##### dmr.settings.Settings.throttling
Default: `[]`
Configure throttling rules for the whole API.
To enable throttling for all endpoints you can use:
settings.py
```python
>>> from dmr.settings import Settings
>>> from dmr.throttling import SyncThrottle, Rate
>>> DMR_SETTINGS = {
... Settings.throttling: [SyncThrottle(10, Rate.second)],
... }
```
If you use both sync and async controllers in your app,
consider using `SyncOrAsyncThrottle` for settings.
All throttle types must be importable in settings.
See Configuration levels on how
to merge / override / disable this setting on multiple levels.
> [!WARNING]
> Changed in version 0.16.0: Controller and endpoint values are not merged anymore.
### HTTP Spec validation
##### dmr.settings.Settings.no_validate_http_spec
Default: `frozenset()`
A set of unique `HttpSpec` codes
to be globally disabled.
We don’t recommend disabling any of these checks globally.
settings.py
```python
>>> from dmr.settings import HttpSpec
>>> DMR_SETTINGS = {
... Settings.no_validate_http_spec: {
... HttpSpec.empty_request_body,
... },
... }
```
See Configuration levels on how
to merge / override / disable this setting on multiple levels.
> [!WARNING]
> Changed in version 0.16.0: Controller and endpoint values are not merged anymore.
#### *final class* dmr.settings.HttpSpec(\*values)
Bases: [`StrEnum`](https://docs.python.org/3/library/enum.html#enum.StrEnum)
Keys for our HTTP spec validation.
All rules can be disabled per endpoint and per controller.
You can disable any of the validation rules we have here globally by:
```python
>>> DMR_SETTINGS = {
... Settings.no_validate_http_spec: {
... HttpSpec.empty_response_body,
... },
... }
```
##### empty_request_body
Disables validation that methods
like `GET` and `HEAD` can’t have request bodies.
##### empty_response_body
Disables validation that some status codes
like `204` must not have response bodies.
##### header_name_server_managed
Disables validation that headers
like `Server`, `Keep-Alive`, etc. can’t be
included in response headers.
##### header_name_syntax
Disables validations that check
name syntax to avoid headers like X Custom Name or @@@.
##### cookie_name_syntax
Disables validation that check
name syntax for cookie names to avoid disallowed
characters.
##### cookie_semantics
Disables validation that check if cookies
semantix container proper values, like max_age must not be
negative, or samesite=’none’ requires secure=True, etc
### Streaming
##### dmr.settings.Settings.validate_events
Default: `EMPTY`
Should we validate the events in all streams?
Defaults to the value set in `validate_responses`
for convenience if this value is `EMPTY`.
To disable the event validation globally, use:
settings.py
```python
>>> DMR_SETTINGS = {
... Settings.validate_events: False,
... }
```
### OpenAPI
##### dmr.settings.Settings.openapi_config
Default: `OpenAPIConfig(title='Your Awesome Project', version='0.1.0')`
Metadata to be used in the OpenAPI schema.
See `OpenAPIConfig`
for the available fields and their description.
It can also be used to change the default OpenAPI spec version.
Its `security` field is also the settings level of `security`
for all controllers and endpoints,
see Customizing security.
settings.py
```python
>>> from dmr.openapi.config import OpenAPIConfig
>>> DMR_SETTINGS = {
... Settings.openapi_config: OpenAPIConfig(
... title='My Amazing App',
... version='13.22.3',
... openapi_version='3.2.0',
... ),
... }
```
##### dmr.settings.Settings.openapi_examples_seed
Default: `EMPTY`
Random seed to use when generating missing examples in the OpenAPI spec.
If set to `EMPTY`, no examples are generated.
Existing examples won’t be overridden.
It only works if `'django-modern-rest[openapi]'` extra is installed.
If [polyfactory](https://github.com/litestar-org/polyfactory)
package is missing, no examples are generated.
settings.py
```python
>>> DMR_SETTINGS = {
... Settings.openapi_examples_seed: 10,
... }
```
> [!NOTE]
> This is a global setting, it cannot be changed
> per controller or per endpoint.
> Generated examples are stored on shared `components/schemas` entries,
> which several endpoints and controllers can reference at once,
> so there’s no single endpoint to read a local seed from.
> See Examples generation.
##### dmr.settings.Settings.openapi_static_cdn
Default: `{}`
Optional mapping to switch OpenAPI renderers to CDN resources.
Only renderers explicitly listed in this mapping will use CDN,
the rest will use local bundled static files served by Django.
Supported keys are:
- `swagger`: base URL to `swagger-ui-dist` (without file name)
- `redoc`: full URL to `redoc.standalone.js`
- `scalar`: full URL to `@scalar/api-reference` standalone bundle
- `stoplight`: base URL to `@stoplight/elements` (without file name)
You can also modify the exact versions that we use for each tool this way.
settings.py
```python
>>> DMR_SETTINGS = {
... Settings.openapi_static_cdn: {
... 'swagger': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.1',
... 'redoc': 'https://cdn.redoc.ly/redoc/2.5.2/bundles/redoc.standalone.js',
... 'scalar': 'https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.49.2/dist/browser/standalone.js',
... 'stoplight': 'https://unpkg.com/@stoplight/elements@9.0.16',
... },
... }
```
### Hacks
##### dmr.settings.Settings.django_treat_as_post
Default: `frozenset({'PUT', 'PATCH'})`
By default Django only populates
[`django.http.HttpRequest.POST`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.POST)
and [`django.http.HttpRequest.FILES`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.FILES)
for `POST` requests.
Some parsers like `MultiPartParser`
require `.POST` and `.FILES` to be set to work with
`Body`
and `FileMetadata`.
However, we build REST APIs where more methods are in use, not just `POST`.
So, we use this setting to populate `.POST` and `.FILES`
when parser is a subtype of either
`SupportsFileParsing`
or `SupportsDjangoDefaultParsing`
and we work with components that require request body.
> [!NOTE]
> This is a really advanced setting. If you are not creating
> your own components or your own parsers - do not change it.
### Environment variables
#### DMR_MAX_CACHE_SIZE
Default: `1024`
We use [`functools.lru_cache()`](https://docs.python.org/3/library/functools.html#functools.lru_cache) in many places internally.
For example:
- To create json encoders and decoders only once
- To create type validation objects
in `BaseEndpointOptimizer`
- To remember which parser and renderer were negotiated
for a given `Content-Type` / `Accept` header value
in `RequestNegotiator`
and `ResponseNegotiator`
You can control the size / memory usage with this setting.
Increase if you have a lot of different return types
or if your clients send a lot of distinct `Accept` headers.
#### DMR_USE_COMPILED
Default: `1`
We compile some modules to C-extensions with mypyc compilation.
If you want to disable the extensions and fallback to pure Python
implementation, set this variable to `0`.
It is only recommended for debugging.
It should be set to `1` in production for maximum speed.
### API Reference
#### dmr.settings.resolve_setting(setting_name: Settings, \*, import_string: bool = False) → Any
Resolves setting by *setting_name*.
The result is cached using `@lru_cache` for performance.
When testing with custom settings, you *must* call
`clear_settings_cache()` before and after modifying
Django settings to ensure the cache is invalidated properly.
#### dmr.settings.clear_settings_cache() → None
Clears settings cache for all functions in this module.
Useful for tests, when you modify the global settings object.
#### *class* dmr.settings.SettingsDict
Settings type that can be used for typing.
Source: /en/latest/pages/plugins.md
## Plugins
To be able to support multiple serializer models
like `pydantic` and `msgspec`, we have a concept of a plugin.
There are several bundled ones, but you can write your own as well.
To do that see our advanced Serialization guide.
As a user you are only interested in choosing the right plugin
for the controller definition.
msgspec
pydantic
```python
from dmr.plugins.msgspec import MsgspecSerializer
```
> [!TIP]
> If you only use `json` parsers and renderers,
> it would be faster to use
> `PydanticFastSerializer` instead.
```python
from dmr.plugins.pydantic import PydanticSerializer
```
### Customizing serializers
There are several things why you can possibly want
to customize an existing serializer.
#### Support more data types
By default,
`serialize_hook()`
and
`deserialize_hook()`
support not that many types.
You can customize the serializer to know how to serializer / deserialize
more types by extending it and customizing the method you need.
### Customizing the serializer context
We use `dmr.endpoint.SerializerContext` type
to deserialize all components from a single model, so it would be much faster
than parsing each component separately.
The model itself is built by the serializer
in `build_context_model()`,
each serializer picks the fastest model type it can validate:
`PydanticSerializer`
uses a [`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) or a [`dataclasses.dataclass()`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass)
when there are default values,
`MsgspecSerializer`
always uses a [`msgspec.Struct`](https://msgspec.dev/api.md#msgspec.Struct) with `gc=False`.
This class can be customized for several reasons.
#### Change the default strictness
Tools like `pydantic` offer several useful type conversions in non-strict mode.
For example, `'1'` can be parsed as `1` if strict mode is not enabled.
It is kinda useful for request bodies, where you don’t control the clients.
Here’s how we determine the default strictness for `pydantic` models:
1. If `strict_validation`
is not `None`, we return the serializer-level strictness
2. Then `pydantic` looks at `strict` attribute
in [`ConfigDict`](https://pydantic.dev/docs/validation/latest/api/pydantic/config/#pydantic.config.ConfigDict)
3. Then `pydantic` looks at `strict` attribute
for individual [`Field()`](https://pydantic.dev/docs/validation/latest/api/pydantic/fields/#pydantic.fields.Field) items
We recommend to change the strictness on a per-model basis, but if you want to,
you can subclass the `SerializerContext` to be strict / non-strict
and use it for all controllers.
### Endpoint optimizers
Before actually serving any requests, during import-time,
we try to optimize the future validation.
For example, [`pydantic.TypeAdapter`](https://pydantic.dev/docs/validation/latest/api/pydantic/type_adapter/#pydantic.type_adapter.TypeAdapter) takes time to be created.
Why doing it on the first request, when we can do that during the import time?
Each serializer must provide a type, which must be a subclass
of `BaseEndpointOptimizer`
to optimize / pre-compile / create / cache things that it can.
### Writing a custom plugin
Our API is flexible enough to potentially support any custom
third-party serializers of your choice, like:
- [https://github.com/python-attrs/cattrs](https://github.com/python-attrs/cattrs)
- [https://github.com/reagento/adaptix](https://github.com/reagento/adaptix)
- etc
Follow the API of `PydanticSerializer`
and `MsgspecSerializer`.
You would need to:
- Provide a way to serializer and deserialize your models
- Provide serializer error converter by overriding
`serialize_validation_error()` method
- Optionally, validate how the serializer is used by overriding
`validate()` method,
which receives the complete endpoint metadata during the import time,
for example: to only allow some parsers and renderers
- Provide a way to get the OpenAPI / JsonSchema schema from your models,
see `dmr.serializer.BaseSchemaGenerator`. Example implementations:
`PydanticSchemaGenerator`
and `MsgspecSchemaGenerator`
### Pydantic plugin
#### PydanticFastSerializer
`pydantic` plugin contains one extra serializer optimized for `json` usage.
Our regular API requires parsers and renderers
to format the final response,
so you can negotiate the request and response formats.
However, for cases when you only have `json` requests
and responses (which is quite common), use
`PydanticFastSerializer`.
> [!WARNING]
> It will ignore all parsers and serializers and use the `pydantic`
> own way to serialize and deserialize objects to `json` bytestring.
It will work from **3 up 10 times** faster depending on the data
then the common serializer.
views.py
```python
import pydantic
from dmr import Body, Controller
from dmr.plugins.pydantic import PydanticFastSerializer
class _User(pydantic.BaseModel):
username: str
age: int
class UserController(Controller[PydanticFastSerializer]):
def put(self, parsed_body: Body[_User]) -> _User:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X PUT -d '{"username": "sobolevn", "age": 27}' -H 'Content-Type: application/json'
> {"username":"sobolevn","age":27}
>
> $ curl http://127.0.0.1:8000/api/users/ -D - -X PUT -d '{"username": "sobolevn"}' -H 'Content-Type: application/json'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:18:05 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 86
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Field required","loc":["parsed_body","age"],"type":"value_error"}]}
> ```
No API changes are required to use it
if you don’t use other request / response formats.
#### Serialization / deserialization flags
We have to special attributes to change how `pydantic` serializes data:
1. `to_json_kwargs`
for serialization purposes
2. `to_model_kwargs`
for deserialization purposes
By default these flags only pass `{'by_alias': True}`
to support field aliases, when they are defined.
For example, when working with [`pydantic.types.Json`](https://pydantic.dev/docs/validation/latest/api/pydantic/types/#pydantic.types.Json),
one can set `round_trip` to `True`
(which is not passed by default,
because it disables [`computed fields`](https://pydantic.dev/docs/validation/latest/api/pydantic/fields/#pydantic.fields.computed_field)):
views.py
```python
from typing import ClassVar
import pydantic
from dmr import Body, Controller
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.plugins.pydantic.serializer import ToJsonKwargs
class _User(pydantic.BaseModel):
username: str
settings: pydantic.Json[dict[str, str]]
class _RoundTripPydantic(PydanticFastSerializer):
to_json_kwargs: ClassVar[ToJsonKwargs] = {
**PydanticFastSerializer.to_json_kwargs,
'round_trip': True,
}
class UserController(Controller[_RoundTripPydantic]):
def post(self, parsed_body: Body[_User]) -> _User:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X POST -d '{"username": "sobolevn", "settings": "{\"status\": \"active\"}"}' -H 'Content-Type: application/json'
> {"username":"sobolevn","settings":"{\"status\":\"active\"}"}
> ```
> [!NOTE]
> ### See also
>
> Docs: [https://docs.pydantic.dev/2.3/usage/types/json/](https://docs.pydantic.dev/2.3/usage/types/json/)
### Msgspec plugin
#### attrs support
We support [`attrs.define()`](https://www.attrs.org/en/stable/api.html#attrs.define) via `msgspec` compatibility layer.
It has its own limitations.
See [msgspec docs](https://msgspec.dev/supported-types#attrs).
Native support of `attrs` can be implemented in the future
with its own serializer.
Source: /en/latest/pages/queryset.md
## Serializing models and Querysets
#### Quote of the day
> There are things that are easy.
> There are things that seem easy.
> Usually they are different things.
Django is built around its [`QuerySet`](https://docs.djangoproject.com/en/6.1/ref/models/querysets/#django.db.models.query.QuerySet) type.
Of course, we make sure that it is supported.
Prepare yourself for a wild ride!
This section will not only be about queryset,
but also about the architecture of Django applications.
> [!IMPORTANT]
> We offer a brand new way of working with
> [`QuerySet`](https://docs.djangoproject.com/en/6.1/ref/models/querysets/#django.db.models.query.QuerySet).
>
> It is the best thing that ever happened to Django’s serialization.
>
> We built our serialization patterns on:
>
> 1. Simplicity
> 2. Database table independence from the serialization schema
> 3. Customizability: you can change anything at any time
> 4. Performance: no extra fields, no extra queries, fast serialization
> 5. Everything must be typed at all times
### Oversimplied example
Let’s start with the very simple definition of a regular model
with no foreign keys or many-to-many fields.
Our approach is to always move all the business logic away from the view.
Even in examples, because they form the habit
of developers and LLMs who are reading it.
> [!NOTE]
> ### See also
>
> Full-featured example with the proper layers can be found here:
>
> [https://github.com/wemake-services/wemake-django-template/blob/master/%7B%7Bcookiecutter.project_name%7D%7D/server/apps/main/api/views.py](https://github.com/wemake-services/wemake-django-template/blob/master/%7B%7Bcookiecutter.project_name%7D%7D/server/apps/main/api/views.py)
#### Model
Our regular [`Model`](https://docs.djangoproject.com/en/6.1/ref/models/instances/#django.db.models.Model) definition:
models.py
```python
from typing import final
from django.db import models
@final
class User(models.Model):
# This is sent to us from another service:
email = models.EmailField(unique=True)
customer_service_uid = models.UUIDField(unique=True)
# Our own fields:
is_active = models.BooleanField(db_index=True, default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
```
For this example, the model won’t have any FK or M2M fields.
In the next examples we will show how to work with them as well.
#### Serializer schemas
Next, let’s define serializer schemas
to get the incoming data and return the response.
You can use any schema type, such as [`msgspec.Struct`](https://msgspec.dev/api.md#msgspec.Struct), [`pydantic.BaseModel`](https://pydantic.dev/docs/validation/latest/api/pydantic/base_model/#pydantic.BaseModel),
[`attrs.define()`](https://www.attrs.org/en/stable/api.html#attrs.define), [`typing.TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict), etc.
We will use `pydantic` for this example, because
it is the most familiar concept among them for most developers:
serializers.py
```python
import datetime as dt
import uuid
from typing import Annotated, TypeAlias, final
import pydantic
DatabaseId: TypeAlias = Annotated[int, pydantic.Field(gt=0)]
class SimpleUserCreateSchema(pydantic.BaseModel):
email: str
customer_service_uid: uuid.UUID
@final
class SimpleUserSchema(SimpleUserCreateSchema):
id: DatabaseId
created_at: dt.datetime
```
> [!IMPORTANT]
> This step is really important! We **have to** separate database
> models and serializer schemas from each other. Why?
>
> 1. Because it gives us more control over the serialization process both ways
> 2. Because new database fields will not automatically appear in your API spec
> 3. Because removing a database field will not break the API contract
> 4. Because versioning the API becomes much easier
> 5. Because explicit typing will let you catch more errors earlier
#### Services
Now, let’s define our business logic.
> [!NOTE]
> We don’t give any architectural advice here, you can use any approach
> that you like for your projects: DDD, Clean or Hexagonal Architecture,
> Functional Core and Imperative Shell, whatever you like.
>
> However, we **strongly** recommend that you separate your business logic
> from views for better testing and better composition.
For this example, we will use the simplest `services.py`
layer for our business logic:
services.py
```python
from typing import final
from django.db import IntegrityError
from django.db.models import QuerySet
from server.apps.model_simple.models import User
from server.apps.model_simple.serializers import SimpleUserCreateSchema
@final
class UniqueConstraintError(Exception):
"""Fields ``email`` and ``customer_service_uid`` must be unique."""
def user_create_service(user_schema: SimpleUserCreateSchema) -> User:
"""This is a function just for the demo's purpose, it is usually a class."""
try:
return User.objects.create(
email=user_schema.email,
customer_service_uid=user_schema.customer_service_uid,
)
except IntegrityError:
# We don't raise `IntegrityError` here, because we prefer domain
# exceptions over Django ones. It is much easier to manage.
raise UniqueConstraintError from None
def user_list_service() -> QuerySet[User]:
"""Return all users."""
# Only can exclude fields that we won't use by passing as a param
# `UserSchema.__struct_fields__` tuple and calling `.only(*fields)` here:
return User.objects.all()
```
There’s nothing fancy about it. Just creating a model from the typed input data.
#### Views
Now, we can define views that will use everything defined above.
Minimalistic
Detailed
Just convert models from attributes,
using the builtin `.model_validate()` converter.
For `msgspec` one can use [`msgspec.convert()`](https://msgspec.dev/api.md#msgspec.convert).
- The main reason to use this approach is that it is short and easy
- The main reason not to use this approach is that errors will only show up
during tests. Not during type-checking. For example,
removing `customer_service_uid` from `models.py`
(for some business reason)
will not trigger any type checking errors.
If this line is not covered with tests, your API will not work:
```
Traceback (most recent call last):
File "server/apps/model_simple/views/minimalistic.py", line 42
return UserSchema.model_validate(
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic.ValidationError: Object missing required field `customer_service_uid`
```
views.py
```python
from http import HTTPStatus
from typing import Final, final
import pydantic
from django.http import HttpResponse
from typing_extensions import override
from dmr import Body, Controller, modify
from dmr.endpoint import Endpoint
from dmr.errors import ErrorType
from dmr.metadata import ResponseSpec
from dmr.plugins.pydantic import PydanticSerializer
from server.apps.model_simple.serializers import (
SimpleUserCreateSchema,
SimpleUserSchema,
)
from server.apps.model_simple.services import (
UniqueConstraintError,
user_create_service,
user_list_service,
)
_UserList: Final = pydantic.TypeAdapter(list[SimpleUserSchema])
@final
class UserController(Controller[PydanticSerializer]):
def get(self) -> list[SimpleUserSchema]:
"""List existing users."""
return _UserList.validate_python(
user_list_service(),
from_attributes=True,
)
@modify(
extra_responses=[
ResponseSpec(
Controller.error_model,
status_code=HTTPStatus.CONFLICT,
),
],
)
def post(
self,
parsed_body: Body[SimpleUserCreateSchema],
) -> SimpleUserSchema:
"""Create new user."""
return SimpleUserSchema.model_validate(
user_create_service(parsed_body),
from_attributes=True,
)
@override
def handle_error(
self,
endpoint: Endpoint,
controller: Controller[PydanticSerializer],
exc: Exception,
) -> HttpResponse:
# Handle custom errors that can happen in this controller:
if isinstance(exc, UniqueConstraintError):
return self.to_error(
self.format_error(
'User `email` and `customer_service_uid` must be unique',
error_type=ErrorType.value_error,
),
status_code=HTTPStatus.CONFLICT,
)
# Handle default errors:
return super().handle_error(endpoint, controller, exc)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X POST -d '{"email": "minimalistic@example.com", "customer_service_uid": "e87035e1-27a6-4e6b-a61a-d395bd4e221a"}' -H 'Content-Type: application/json'
> {"email":"minimalistic@example.com","customer_service_uid":"e87035e1-27a6-4e6b-a61a-d395bd4e221a","id":1,"created_at":"2026-10-05T11:18:06.969336Z"}
>
> $ curl http://127.0.0.1:8000/api/users/ -X GET
> [{"email":"minimalistic@example.com","customer_service_uid":"e87035e1-27a6-4e6b-a61a-d395bd4e221a","id":1,"created_at":"2026-10-05T11:18:06.969336Z"}]
> ```
Convert models to schemas manually. It might seem like a lot of code,
but actually, it is pretty simple to do
(especially with the help of an LLM).
- The main reason to use this approach is correctness.
For example, removing the `customer_service_uid` model field
will now trigger an early type-checking error,
unlike the “Minimalistic” version, which will only fail at runtime:
```
server/apps/model_simple/views/detailed.py:28: error: "User" has no attribute "customer_service_uid" [attr-defined]
server/apps/model_simple/views/detailed.py:48: error: "User" has no attribute "customer_service_uid" [attr-defined]
```
- The main reason not to use this approach is its verbosity
views.py
```python
from http import HTTPStatus
from typing import final
from django.http import HttpResponse
from typing_extensions import override
from dmr import Body, Controller, modify
from dmr.endpoint import Endpoint
from dmr.errors import ErrorType
from dmr.metadata import ResponseSpec
from dmr.plugins.pydantic import PydanticSerializer
from server.apps.model_simple.serializers import (
SimpleUserCreateSchema,
SimpleUserSchema,
)
from server.apps.model_simple.services import (
UniqueConstraintError,
user_create_service,
user_list_service,
)
@final
class UserController(Controller[PydanticSerializer]):
def get(self) -> list[SimpleUserSchema]:
"""List existing users."""
return [
SimpleUserSchema(
id=user.pk,
email=user.email,
customer_service_uid=user.customer_service_uid,
created_at=user.created_at,
)
for user in user_list_service()
]
@modify(
extra_responses=[
ResponseSpec(
Controller.error_model,
status_code=HTTPStatus.CONFLICT,
),
],
)
def post(
self,
parsed_body: Body[SimpleUserCreateSchema],
) -> SimpleUserSchema:
"""Create new user."""
user = user_create_service(parsed_body)
return SimpleUserSchema(
id=user.pk,
email=user.email,
customer_service_uid=user.customer_service_uid,
created_at=user.created_at,
)
@override
def handle_error(
self,
endpoint: Endpoint,
controller: Controller[PydanticSerializer],
exc: Exception,
) -> HttpResponse:
# Handle custom errors that can happen in this controller:
if isinstance(exc, UniqueConstraintError):
return self.to_error(
self.format_error(
'User `email` and `customer_service_uid` must be unique',
error_type=ErrorType.value_error,
),
status_code=HTTPStatus.CONFLICT,
)
# Handle default errors:
return super().handle_error(endpoint, controller, exc)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X POST -d '{"email": "detailed@example.com", "customer_service_uid": "616d51f2-2c31-4b04-8034-2f59eae042db"}' -H 'Content-Type: application/json'
> {"email":"detailed@example.com","customer_service_uid":"616d51f2-2c31-4b04-8034-2f59eae042db","id":2,"created_at":"2026-10-05T11:18:08.001687Z"}
>
> $ curl http://127.0.0.1:8000/api/users/ -X GET
> [{"email":"minimalistic@example.com","customer_service_uid":"e87035e1-27a6-4e6b-a61a-d395bd4e221a","id":1,"created_at":"2026-10-05T11:18:06.969336Z"},{"email":"detailed@example.com","customer_service_uid":"616d51f2-2c31-4b04-8034-2f59eae042db","id":2,"created_at":"2026-10-05T11:18:08.001687Z"}]
> ```
Now you have your REST API that returns fully typed model responses
and can work with [`QuerySet`](https://docs.djangoproject.com/en/6.1/ref/models/querysets/#django.db.models.query.QuerySet)
and [`Model`](https://docs.djangoproject.com/en/6.1/ref/models/instances/#django.db.models.Model) instances.
### Realistic example
Now, let’s see how a more realistic layout may look like. It will include:
- `ForeignKey` relationship
- `ManyToMany` relationship
- DI for realistic expectations
- Mappers and services separate layers
#### Models
We start with model definitions.
models.py
```python
from typing import final
from django.db import models
@final
class Tag(models.Model):
name = models.CharField(max_length=100, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@final
class Role(models.Model):
name = models.CharField(max_length=100, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@final
class User(models.Model):
# This is sent to us from another service:
email = models.EmailField(unique=True)
# Linking:
role = models.ForeignKey(
Role,
on_delete=models.CASCADE,
related_name='users',
)
tags = models.ManyToManyField(Tag, related_name='users')
# Our own fields:
is_active = models.BooleanField(db_index=True, default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
```
We added two models:
- `Role` for a foreign key relation
- `Tag` for a many-to-many relation
#### Serializer schemas
Next, let’s see how serializer schemas are defined.
serializers.py
```python
import datetime as dt
from typing import Annotated, TypeAlias, final
import pydantic
DatabaseId: TypeAlias = Annotated[int, pydantic.Field(gt=0)]
@final
class PageQuery(pydantic.BaseModel):
page_size: int = pydantic.Field(default=10, ge=1, le=100)
page: int = pydantic.Field(default=1, ge=1)
model_config = pydantic.ConfigDict(extra='forbid')
@final
class TagSchema(pydantic.BaseModel):
name: str = pydantic.Field(max_length=100)
@final
class RoleSchema(pydantic.BaseModel):
name: str = pydantic.Field(max_length=100)
class UserCreateSchema(pydantic.BaseModel):
email: str
role: RoleSchema
tags: list[TagSchema]
@final
class UserSchema(UserCreateSchema):
id: DatabaseId
created_at: dt.datetime
```
Note that we model foreign key and many-to-many relations
here as nested schemas or list of nested schemas.
However, you can also model the same thing
as `role_id: int` and `tags: list[int]` to support IDs for linking.
That’s the beauty of this extremely simple approach:
it is customizable to the core.
#### Views
In this example, it is easier to start with `views.py`:
views.py
```python
from http import HTTPStatus
from typing import final
from django.http import HttpResponse
from typing_extensions import override
from dmr import Body, Controller, Query, modify
from dmr.endpoint import Endpoint
from dmr.errors import ErrorType
from dmr.metadata import ResponseSpec
from dmr.pagination import Paginated
from dmr.plugins.pydantic import PydanticSerializer
from server.apps.model_fk.implemented import HasContainer
from server.apps.model_fk.serializers import (
PageQuery,
UserCreateSchema,
UserSchema,
)
from server.apps.model_fk.services import (
UniqueConstraintError,
UserCreate,
UserList,
)
@final
class UserController(HasContainer, Controller[PydanticSerializer]):
def get(self, parsed_query: Query[PageQuery]) -> Paginated[UserSchema]:
"""List existing users."""
return self.resolve(UserList)(parsed_query)
@modify(
extra_responses=[
ResponseSpec(
Controller.error_model,
status_code=HTTPStatus.CONFLICT,
),
],
)
def post(self, parsed_body: Body[UserCreateSchema]) -> UserSchema:
"""Create new user."""
return self.resolve(UserCreate)(parsed_body)
@override
def handle_error(
self,
endpoint: Endpoint,
controller: Controller[PydanticSerializer],
exc: Exception,
) -> HttpResponse:
# Handle custom errors that can happen in this controller:
if isinstance(exc, UniqueConstraintError):
return self.to_error(
self.format_error(
'User `email` must be unique',
error_type=ErrorType.value_error,
),
status_code=HTTPStatus.CONFLICT,
)
# Handle default errors:
return super().handle_error(endpoint, controller, exc)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X POST -d '{"email": "test@example.com", "role": {"name": "admin"}, "tags": [{"name": "paid"}]}' -H 'Content-Type: application/json'
> {"email":"test@example.com","role":{"name":"admin"},"tags":[{"name":"paid"}],"id":1,"created_at":"2026-10-05T11:18:09.051247Z"}
>
> $ curl http://127.0.0.1:8000/api/users/ -X GET
> {"count":1,"num_pages":1,"per_page":10,"page":{"number":1,"object_list":[{"email":"test@example.com","role":{"name":"admin"},"tags":[{"name":"paid"}],"id":1,"created_at":"2026-10-05T11:18:09.051247Z"}]}}
> ```
What happens here?
1. We refactored our views to only run an instance of a specific service,
which we get using `HasContainer.resolve` call, which is our DI
2. We now don’t construct any serializer schemas inside our views,
we move to its independent infra layer
3. We now use Pagination to list all users
Our views here are reduced to a single line of code, which do everything
inside the business logic. Exactly the way it should
be for scalable and reliable applications.
#### DI
In this example we use [punq](https://github.com/bobthemighty/punq)
as a simplistic DI container to show how big projects really handle such cases.
> [!NOTE]
> You are not forced to use `punq`, we don’t enforce any DI framework.
> Our principle is to Bring your own DI.
The DI part looks like this:
views.py
```python
from typing import Any, TypeVar
import punq
from server.apps.model_fk.mappers import RoleMap, TagMap, UserMap
from server.apps.model_fk.services import (
RoleCreate,
TagsCreate,
UserCreate,
UserList,
)
_ItemT = TypeVar('_ItemT')
class HasContainer:
__slots__ = ('_container',)
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
# Will be created in import-time:
self._container = self._create_container()
def resolve(self, thing: type[_ItemT]) -> _ItemT:
return self._container.resolve(thing)
def _create_container(self) -> punq.Container:
container = punq.Container()
container.register(TagMap)
container.register(RoleMap)
container.register(UserMap)
container.register(TagsCreate)
container.register(RoleCreate)
container.register(UserCreate)
container.register(UserList)
return container
```
What happens here?
1. We define a class that will be used as a mixin for all future controllers
2. This class provides a pre-built container with all the dependencies
3. Users can call `self.resolve` inside controllers
to resolve specific dependencies
Now, let’s see how we create objects in the database.
#### Services
Again, this is just an example. You are not forced
to create this specific service-based architecture.
Use whatever layer separation practice you want.
Our big example uses [usecases](https://github.com/wemake-services/wemake-django-template/tree/master/%7B%7Bcookiecutter.project_name%7D%7D/server/apps/main/logic/usecases)
as the main logic entities and entry points.
However, our `services.py` is the simplest and the shortest way.
That’s why we are using them as an example:
services.py
```python
from collections.abc import Iterable
from typing import final
import attrs
from django.db import IntegrityError, transaction
from dmr.pagination import Paginated
from server.apps.model_fk.mappers import UserMap
from server.apps.model_fk.models import Role, Tag, User
from server.apps.model_fk.serializers import (
PageQuery,
RoleSchema,
TagSchema,
UserCreateSchema,
UserSchema,
)
@final
class UniqueConstraintError(Exception):
"""Field ``email`` must be unique."""
@final
@attrs.define(frozen=True)
class TagsCreate:
def __call__(self, tags: list[TagSchema]) -> Iterable[Tag]:
"""Service for creating new tags with unique names."""
tags_names = {tag.name for tag in tags}
# Create new tags and ignore existing ones in 1 query:
Tag.objects.bulk_create(
[Tag(name=tag) for tag in tags_names],
ignore_conflicts=True,
)
# Since `bulk_create` does not return `ids`, we need to query them:
return Tag.objects.in_bulk(
tags_names,
field_name='name',
).values()
@final
@attrs.define(frozen=True)
class RoleCreate:
def __call__(self, role_schema: RoleSchema) -> Role:
"""Service for getting an existing role or creating a new one."""
role, _ = Role.objects.get_or_create(name=role_schema.name)
return role
@final
@attrs.define(frozen=True)
class UserCreate:
_role_create: RoleCreate
_tags_create: TagsCreate
_mapper: UserMap
def __call__(self, user_schema: UserCreateSchema) -> UserSchema:
"""Service to create new users."""
return self._mapper.single(self._create_user(user_schema))
def _create_user(self, user_schema: UserCreateSchema) -> User:
with transaction.atomic():
role = self._role_create(user_schema.role)
try:
user = User.objects.create(
email=user_schema.email,
role=role,
)
except IntegrityError:
# We don't raise `IntegrityError` here, because we prefer domain
# exceptions over Django ones. It is much easier to manage.
raise UniqueConstraintError from None
# Handle m2m:
user.tags.set(self._tags_create(user_schema.tags))
return user
@final
@attrs.define(frozen=True)
class UserList:
_mapper: UserMap
def __call__(self, parsed_query: PageQuery) -> Paginated[UserSchema]:
"""Return all users."""
return self._mapper.multiple(
(
User.objects
.select_related('role')
.prefetch_related('tags')
.order_by('id')
.all() # it is still lazy
),
parsed_query,
)
```
What happens here?
1. We define three services: one per create operation
2. Some of them have dependencies defined as dataclass fields,
like `_mapper: UserMap`. These fields will be resolved by our DI.
3. Each service does just a single thing, it would be easy to compose them
Now we are ready for the final layer: mapping of the created database models.
#### Mappers
Mappers just map database models into serialization schemas.
> [!TIP]
> In real projects, it is better to have a separate layer that does mapping
> of database models into
> [serialization schemas](https://github.com/wemake-services/wemake-django-template/blob/master/{{cookiecutter.project_name}}/server/apps/main/infra/mappers.py)
>
> It will allow you to compose mappers freely.
> For example, it allows you to create compatibility layers,
> when some model has some database fields removed,
> but you still need a way to send users the same API schema.
>
> Or it can do some small representation logic, like combining
> `first_name` and `last_name` of users into `full_name`.
>
> Or handle Pagination, as we do in this example.
mappers.py
```python
from typing import final
import attrs
from django.core.paginator import EmptyPage, Paginator
from django.db.models import QuerySet
from dmr.pagination import Page, Paginated
from server.apps.model_fk.models import Role, Tag, User
from server.apps.model_fk.serializers import (
PageQuery,
RoleSchema,
TagSchema,
UserSchema,
)
@final
@attrs.define(frozen=True)
class TagMap:
def single(self, tag: Tag) -> TagSchema:
return TagSchema(name=tag.name)
def multiple(self, tags: QuerySet[Tag]) -> list[TagSchema]:
return [self.single(tag) for tag in tags]
@final
@attrs.define(frozen=True)
class RoleMap:
def single(self, role: Role) -> RoleSchema:
return RoleSchema(name=role.name)
@final
@attrs.define(frozen=True)
class UserMap:
_role: RoleMap
_tag: TagMap
def single(self, user: User) -> UserSchema:
return UserSchema(
id=user.pk,
email=user.email,
created_at=user.created_at,
role=self._role.single(user.role),
tags=self._tag.multiple(user.tags.all()),
)
def multiple(
self,
users: QuerySet[User],
parsed_query: PageQuery,
) -> Paginated[UserSchema]:
# Logic to paginate users:
paginator = Paginator(users, parsed_query.page_size)
try:
object_list = [
self.single(user)
for user in paginator.page(parsed_query.page).object_list
]
except EmptyPage:
object_list = []
return Paginated(
count=paginator.count,
num_pages=paginator.num_pages,
per_page=paginator.per_page,
page=Page(
number=parsed_query.page,
object_list=object_list,
),
)
```
Now we have a fully working application.
With composable and flexible schemas, which will:
1. Report any type errors early
2. Be customizable to the core
3. Can be used in a good architecture in big real business apps
4. Change independently from models
#### Conclusions
Here’s the final table to help you decide what to use:
| Your App | What to Use |
|------------------|----------------------|
| Todo App | Minimalistic Example |
| Real Application | Mappers |
### django-mantle
If you want to automate the mapping part and automagically
convert `QuerySet` into typed models, you can use
[django-mantle](https://noumenal.es/mantle/).
- Allows you to move your business logic into type-safe Python classes,
decoupled from the Django ORM.
- Provides automatic generation (with declarative overrides)
of efficient ORM queries, including limited field fetches
with `only()` and `defer()` and prefetching related objects,
avoiding N+1 query problems.
- Uses a modern and performant approach to serialisation and validation.
- Provides a progressive API, with a minimal surface
area by default, and depth when needed.
It’s your type-safe layer around Django’s liquid core.
views.py
```python
import attrs
from dmr import Controller
from dmr.plugins.msgspec import MsgspecSerializer
@attrs.define
class _UserModel:
username: str
email: str
is_active: bool
class UsersController(Controller[MsgspecSerializer]):
def get(self) -> list[_UserModel]:
# We have to do import here due to how our docs build systems works,
# but in real apps they must be on the module level:
from django.contrib.auth.models import User # noqa: PLC0415
from mantle import Query # noqa: PLC0415
return Query(User.objects.all(), _UserModel).all()
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X GET
> [{"email":"test@example.com","is_active":true,"username":"test_user"}]
> ```
### django-modern-schemas
If you want to automate the schema definition part and derive your
serializer schemas from the models you already have, you can use
[django-modern-schemas](https://github.com/open-byte/django-modern-schemas).
- Generates [`pydantic.BaseModel`](https://pydantic.dev/docs/validation/latest/api/pydantic/base_model/#pydantic.BaseModel) subclasses
from your [`Model`](https://docs.djangoproject.com/en/6.1/ref/models/instances/#django.db.models.Model) definitions,
reusing field types, constraints, defaults, choices and relationships,
so things like `max_length` are declared once.
- Keeps the API contract explicit: `fields`, `exclude` and `optional`
define what a schema exposes, so read, create and `PATCH` schemas
are built from the same model without repeating it.
- Maps model methods and dotted ORM paths into schema fields with
`MethodSource` and `Source`, including reverse relations,
nested schemas and aggregates.
- `ModelSchema` is generic in its model, so `.create()`, `.update()`
and `.save()` are typed as returning that exact model.
- Needs nothing in `INSTALLED_APPS`: schemas are regular Python classes.
From the controller’s point of view they are just `pydantic` schemas:
[`QuerySet`](https://docs.djangoproject.com/en/6.1/ref/models/querysets/#django.db.models.query.QuerySet)
and [`Model`](https://docs.djangoproject.com/en/6.1/ref/models/instances/#django.db.models.Model) instances are valid inputs,
and `.save()` writes a parsed body back to the database.
The very same schema serves a collection and a single object.
views.py
```python
from typing import Annotated, Final, final
import pydantic
from django.contrib.auth.models import User
from django.shortcuts import get_object_or_404
from django_modern_schemas import MethodSource, ModelSchema
from typing_extensions import TypedDict
from dmr import Body, Controller, Path
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.routing import Router, path
class UserSchema(ModelSchema[User]):
# Extra fields can be computed by the model itself:
full_name: Annotated[str, MethodSource('get_full_name')]
class Config:
model = User
fields = ('id', 'username', 'email')
class UserCreateSchema(ModelSchema[User]):
class Config:
model = User
fields = ('username', 'email', 'first_name', 'last_name')
class _UserPath(TypedDict):
user_id: int
_UserList: Final = pydantic.TypeAdapter(list[UserSchema])
@final
class UsersController(Controller[PydanticFastSerializer]):
def get(self) -> list[UserSchema]:
return _UserList.validate_python(User.objects.all())
def post(self, parsed_body: Body[UserCreateSchema]) -> UserSchema:
# Schemas know how to persist themselves:
return UserSchema.model_validate(parsed_body.save())
@final
class UserDetailController(Controller[PydanticFastSerializer]):
def get(self, parsed_path: Path[_UserPath]) -> UserSchema:
return UserSchema.model_validate(
get_object_or_404(User, pk=parsed_path['user_id']),
)
router = Router(
'api/',
[
path('users/', UsersController.as_view(), name='users'),
path(
'users//',
UserDetailController.as_view(),
name='user',
),
],
)
urlpatterns = [router.to_urlpatterns(namespace='api')]
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X POST -d '{"username": "ada", "email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace"}' -H 'Content-Type: application/json'
> {"id":2,"username":"ada","email":"ada@example.com","full_name":"Ada Lovelace"}
>
> $ curl http://127.0.0.1:8000/api/users/ -X GET
> [{"id":1,"username":"test_user","email":"test@example.com","full_name":""},{"id":2,"username":"ada","email":"ada@example.com","full_name":"Ada Lovelace"}]
>
> $ curl http://127.0.0.1:8000/api/users/1/ -X GET
> {"id":1,"username":"test_user","email":"test@example.com","full_name":""}
> ```
> [!NOTE]
> Schemas generated this way follow your database tables by default.
> Keep `fields` and `exclude` explicit
> to decide what your API really exposes,
> as described in the sections above.
> [!NOTE]
> ### See also
>
> A complete application built with `django-modern-rest` and
> `django-modern-schemas`, with nested schemas, reverse relations
> and partial updates, can be found here:
>
> [https://github.com/open-byte/dmr-with-django-modern-schemas](https://github.com/open-byte/dmr-with-django-modern-schemas)
Source: /en/latest/pages/pagination.md
## Pagination
`django-modern-rest` supports two main pagination patterns:
- **Limit/offset** pagination,
based on the built-in [`django.core.paginator.Paginator`](https://docs.djangoproject.com/en/6.1/ref/paginator/#django.core.paginator.Paginator)
- **Cursor** (also known as keyset or seek) pagination,
based on the first-party `CursorPaginator`
Use limit/offset for simple and relatively small collections.
Use cursor pagination for large tables and for feeds
that keep receiving new rows
while a client is paging through them.
### Limit offset
We support the built-in [`django.core.paginator.Paginator`](https://docs.djangoproject.com/en/6.1/ref/paginator/#django.core.paginator.Paginator).
To do so, we only provide metadata for the default pagination:
views.py
```python
from typing import Final, NotRequired
import pydantic
from django.core.paginator import Paginator
from typing_extensions import TypedDict
from dmr import Controller, Query
from dmr.pagination import Page, Paginated
from dmr.plugins.pydantic import PydanticFastSerializer
class _User(pydantic.BaseModel):
email: str
class _PageQuery(TypedDict):
page_size: NotRequired[int]
page: NotRequired[int]
_USERS: Final = (
_User(email='one@example.com'),
_User(email='two@example.com'),
_User(email='three@example.com'),
)
class UsersController(Controller[PydanticFastSerializer]):
def get(self, parsed_query: Query[_PageQuery]) -> Paginated[_User]:
page = parsed_query.get('page', 1)
page_size = parsed_query.get('page_size', 2)
paginator = Paginator(_USERS, page_size)
return Paginated(
count=paginator.count,
num_pages=paginator.num_pages,
per_page=paginator.per_page,
page=Page(
number=page,
object_list=list(paginator.page(page).object_list),
),
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/users/ -X GET
> {"count":3,"num_pages":2,"per_page":2,"page":{"number":1,"object_list":[{"email":"one@example.com"},{"email":"two@example.com"}]}}
>
> $ curl 'http://127.0.0.1:8000/api/users/?page=2' -X GET
> {"count":3,"num_pages":2,"per_page":2,"page":{"number":2,"object_list":[{"email":"three@example.com"}]}}
> ```
If you are using a different pagination system, you can define
your own metadata / models and use them with our framework.
### Cursor
Cursor pagination works by remembering the position of the last seen row
and asking the database for the next batch of rows after that position.
Unlike limit/offset, it does not use `OFFSET`,
so it stays fast even on very large tables,
and it never skips or duplicates rows
when new data is inserted in the middle of a collection.
The built-in `CursorPaginator`
works directly with [`QuerySet`](https://docs.djangoproject.com/en/6.1/ref/models/querysets/#django.db.models.query.QuerySet)
and has both sync and async APIs.
> [!IMPORTANT]
> The paginator requires a stable ordering.
> Set `.order_by()` on your queryset
> before creating the paginator.
> The primary key fields are appended automatically as tiebreakers,
> so your `order_by` fields do not have to be unique.
#### Model
It works with any model:
models.py
```python
from typing import final
from django.db import models
@final
class Entry(models.Model):
rank = models.IntegerField(db_index=True)
name = models.CharField(max_length=100, default='')
created_at = models.DateTimeField(auto_now_add=True)
```
#### Views
Here is an example of a list endpoint with cursor pagination:
> [!IMPORTANT]
> We can only paginate stably ordered querysets.
sync
async
views.py
```python
from http import HTTPStatus
from typing import final
from django.http import HttpResponse
from typing_extensions import override
from dmr import Controller, Query
from dmr.endpoint import Endpoint
from dmr.pagination import CursorPaginated, CursorPaginator
from dmr.pagination.cursor import InvalidCursorError
from dmr.plugins.pydantic.serializer import PydanticFastSerializer
from dmr.serializer import BaseSerializer
from server.apps.model_cursor.models import Entry
from server.apps.model_cursor.schema import (
CursorQuery,
EntryModel,
)
@final
class EntryController(Controller[PydanticFastSerializer]):
"""List entries using cursor-based pagination."""
def get(
self,
parsed_query: Query[CursorQuery],
) -> CursorPaginated[EntryModel]:
paginator = CursorPaginator(
Entry.objects.all().order_by(*parsed_query.order_by),
per_page=parsed_query.limit,
)
page = paginator.page(parsed_query.cursor)
return CursorPaginated(
next_cursor=page.next_cursor,
per_page=paginator.per_page,
page=[
EntryModel.model_validate(entry) for entry in page.objects_list
],
)
@override
def handle_error(
self,
endpoint: Endpoint,
controller: Controller[BaseSerializer],
exc: Exception,
) -> HttpResponse:
if isinstance(exc, InvalidCursorError):
return controller.to_error(
controller.format_error(str(exc)),
status_code=HTTPStatus.BAD_REQUEST,
)
raise exc from None
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/entries/ -X GET
> {"next_cursor":"MnwtfGF8LXwy","per_page":2,"page":[{"rank":1,"name":"c"},{"rank":2,"name":"a"}]}
>
> $ curl 'http://127.0.0.1:8000/api/entries/?cursor=MnwtfGF8LXwy' -X GET
> {"next_cursor":"NHwtfGJ8LXw0","per_page":2,"page":[{"rank":3,"name":"e"},{"rank":4,"name":"b"}]}
>
> $ curl 'http://127.0.0.1:8000/api/entries/?cursor=INVALID' -D - -X GET
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:18:01 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 52
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"The cursor cannot be decoded!"}]}
> ```
views.py
```python
from http import HTTPStatus
from typing import final
from django.http import HttpResponse
from typing_extensions import override
from dmr import Controller, Query
from dmr.endpoint import Endpoint
from dmr.pagination import CursorPaginated, CursorPaginator
from dmr.pagination.cursor import InvalidCursorError
from dmr.plugins.pydantic.serializer import PydanticFastSerializer
from dmr.serializer import BaseSerializer
from server.apps.model_cursor.models import Entry
from server.apps.model_cursor.schema import (
CursorQuery,
EntryModel,
)
@final
class EntryAsyncController(Controller[PydanticFastSerializer]):
"""List entries using cursor-based pagination (async)."""
async def get(
self,
parsed_query: Query[CursorQuery],
) -> CursorPaginated[EntryModel]:
paginator = CursorPaginator(
Entry.objects.all().order_by(*parsed_query.order_by),
per_page=parsed_query.limit,
)
page = await paginator.apage(parsed_query.cursor)
return CursorPaginated(
next_cursor=page.next_cursor,
per_page=paginator.per_page,
page=[
EntryModel.model_validate(entry) for entry in page.objects_list
],
)
@override
async def handle_async_error(
self,
endpoint: Endpoint,
controller: Controller[BaseSerializer],
exc: Exception,
) -> HttpResponse:
if isinstance(exc, InvalidCursorError):
return controller.to_error(
controller.format_error(str(exc)),
status_code=HTTPStatus.BAD_REQUEST,
)
raise exc from None
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/entries-async/ -X GET
> {"next_cursor":"MnwtfGF8LXwy","per_page":2,"page":[{"rank":1,"name":"c"},{"rank":2,"name":"a"}]}
>
> $ curl 'http://127.0.0.1:8000/api/entries-async/?cursor=MnwtfGF8LXwy' -X GET
> {"next_cursor":"NHwtfGJ8LXw0","per_page":2,"page":[{"rank":3,"name":"e"},{"rank":4,"name":"b"}]}
>
> $ curl 'http://127.0.0.1:8000/api/entries-async/?cursor=INVALID' -D - -X GET
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:18:03 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 52
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"The cursor cannot be decoded!"}]}
> ```
The async variant is identical,
except that it uses `await paginator.apage(cursor)`
instead of `paginator.page(cursor)`.
What happens here:
1. The query accepts `cursor` and `page_size`
2. The paginator is created over the queryset,
ordered by `rank` and `name`
3. `.page()` (or `await .apage()`)
returns a `CursorPage`
4. The page is mapped to the response model
`CursorPaginated`
#### Client protocol
1. The first request is made without the `cursor` parameter
2. The response contains `next_cursor`
3. To fetch the next page,
pass `next_cursor` back as the `cursor` parameter
4. Repeat until `next_cursor` becomes `None`
The response shape looks like this:
response.json
```json
{
"next_cursor": "MnwtfGF8LXwy",
"per_page": 2,
"page": [
{"rank": 1, "name": "c"},
{"rank": 2, "name": "a"}
]
}
```
> [!NOTE]
> `next_cursor` is `null` only when the page has fewer items
> than `per_page`.
> If the total number of items is an exact multiple of `per_page`,
> one extra request will return an empty page
> with `next_cursor` set to `null`.
> [!WARNING]
> A cursor is bound to the ordering it was created with.
> If you expose a dynamic `order_by` or filters,
> the client must not change them while following a cursor:
> the cursor values will be compared against the new ordering,
> and the client will silently receive wrong rows.
> The request fails with `400 Bad Request`
> only when the number of ordered fields changes.
#### How it works
- The cursor is an encoded tuple of the ordered field values
of the last row of the previous page
- By default it is encoded with `base64.urlsafe_b64encode`,
so the result only contains URL-safe characters;
the values are joined with the `|-|` separator
- The next page is fetched with a single range query
over the ordered fields,
a lexicographic comparison of the whole tuple
- Because the primary key fields are always appended as tiebreakers,
two rows can never share the same cursor position
Unlike many other cursor pagination implementations,
the fields in your `order_by` do not have to be unique:
it is enough to order by a plain non-unique field
like `created_at` or `rank`,
because the primary key fields are appended to the ordering
as tiebreakers automatically.
This works because the range query is not a single comparison,
but a disjunction over the whole ordering tuple.
For a queryset ordered by `rank`
(with the primary key appended as a tiebreaker),
the paginator builds:
```sql
(rank > cursor.rank)
OR (rank = cursor.rank AND pk > cursor.pk)
```
The first branch matches every row strictly after the cursor position.
The second branch matches rows that share the same `rank` value,
ordered by the primary key.
Every extra ordering field extends the chain in the same way.
#### Customizing the cursor format
You can pass your own `cursor_encoder` and `cursor_decoder`
functions, as well as a custom `separator`.
For example, you can encrypt your cursor values to not expose any details and
prevent people from forging cursor values:
views.py
```python
from typing import ClassVar, Final, final
import pydantic
from cryptography.fernet import Fernet
from dmr import Controller, Query
from dmr.pagination import CursorPaginated, CursorPaginator
from dmr.plugins.pydantic import PydanticFastSerializer
from server.apps.model_cursor.models import ( # type: ignore[import-not-found, unused-ignore]
Entry,
)
FERNET_ENCODER: Final = Fernet(b'rrCDOaFu3QjLiRa8fwykCVp7nuPcKIbxnhZSiI_thBE=')
def custom_encoder(to_encode: str) -> str:
"""Encode a string using Fernet symmetric encryption."""
return FERNET_ENCODER.encrypt(to_encode.encode()).decode(
'utf-8',
)
def custom_decoder(to_decode: str) -> str:
"""Decode a string using Fernet symmetric encryption."""
return FERNET_ENCODER.decrypt(to_decode.encode()).decode(
'utf-8',
)
@final
class _PageQuery(pydantic.BaseModel):
__dmr_cast_null__: ClassVar[frozenset[str]] = frozenset(('cursor',))
cursor: str | None = None
class _EntrySchema(pydantic.BaseModel):
rank: int
name: str
class EntryController(Controller[PydanticFastSerializer]):
"""List entries using cursor-based pagination."""
def get(
self,
parsed_query: Query[_PageQuery],
) -> CursorPaginated[_EntrySchema]:
paginator = CursorPaginator(
Entry.objects.all().order_by('rank', 'name'),
per_page=2,
cursor_encoder=custom_encoder,
cursor_decoder=custom_decoder,
)
page = paginator.page(parsed_query.cursor)
return CursorPaginated(
next_cursor=page.next_cursor,
per_page=paginator.per_page,
page=[
_EntrySchema.model_validate(entry, from_attributes=True)
for entry in page.objects_list
],
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/entries/ -X GET
> {"next_cursor":"gAAAAABqw4dsunCBVP7qAoFzs-x0qGdDFt5FDZsdNwu2VuO8ZGOT-hPDZl10XyvOlQFT7ruYaLdHIpYP9d7bO1Ake6P1QD2oBw==","per_page":2,"page":[{"rank":1,"name":"c"},{"rank":2,"name":"a"}]}
> ```
> [!NOTE]
> By default cursors are encoded with `base64.urlsafe_b64encode`,
> so they only contain URL-safe characters
> and can be passed in query strings as-is.
> If you switch to a different alphabet,
> like plain `base64.b64encode` with its `+` and `/` characters,
> they must be percent-encoded by the client.
#### Errors
A cursor is a client-provided value.
When it cannot be decoded,
or when it contains a different number of fields than expected,
the paginator raises `InvalidCursorError`.
Unlike other framework errors,
it is not handled by the default
`global_error_handler()` on purpose.
You should catch it in your view
and convert it to an error response yourself.
Alternatively, you can handle the error
in `handle_error()`
or in a custom global error handler,
see Error handling for details.
If your endpoint can return this error,
remember to document the `400` status code
in its `ResponseSpec` definitions.
#### Other libraries
Any other Django-compatible pagination tool should work out of the box.
Like [django-cursor-pagination](https://github.com/photocrowd/django-cursor-pagination)
or even your custom implementation.
### API Reference
#### Limit offset
#### *class* dmr.pagination.Paginated(\*, count: int, num_pages: int, per_page: int, page: Page[\_ModelT])
Helper type to serialize the default `Paginator` object.
Django already ships a pagination system, we don’t want to replicate it.
So, we only provide metadata.
See [`django.core.paginator.Paginator`](https://docs.djangoproject.com/en/6.1/ref/paginator/#django.core.paginator.Paginator) for the exact API.
##### count
Total count of all objects that we are paginating.
* **Type:**
[int](https://docs.python.org/3/builtins/functions.html#int)
##### num_pages
Total number of pages that we got after pagination.
* **Type:**
[int](https://docs.python.org/3/builtins/functions.html#int)
##### per_page
Number of objects per one page.
* **Type:**
[int](https://docs.python.org/3/builtins/functions.html#int)
##### page
`Page` object that contains paginated objects on this page.
* **Type:**
dmr.pagination.base.Page[dmr.pagination.base._ModelT]
#### *class* dmr.pagination.Page(\*, number: int, object_list: Sequence[\_ModelT])
Default page model for serialization.
Can be used when using pagination with `django-modern-rest`.
##### number
Page number. For example: `5` page out of `10` total pages.
* **Type:**
[int](https://docs.python.org/3/builtins/functions.html#int)
##### object_list
Generic sequence of objects on this page.
* **Type:**
[collections.abc.Sequence](https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence)[dmr.pagination.base._ModelT]
#### Cursor
#### *class* dmr.pagination.CursorPaginator(queryset: QuerySet, per_page: int, cursor_encoder: Callable[[str], str] | None = None, cursor_decoder: Callable[[str], str] | None = None, separator: str = '|-|')
Cursor paginator.
##### \_\_init_\_(queryset: QuerySet, per_page: int, cursor_encoder: Callable[[str], str] | None = None, cursor_decoder: Callable[[str], str] | None = None, separator: str = '|-|') → None
Create a paginator over `queryset`.
The queryset must be ordered: its `order_by` defines
the cursor ordering, and the primary key fields are
appended automatically as tiebreakers to keep it stable.
##### *async* apage(cursor: str | None = None) → CursorPage[\_DjangoModel]
Async version of `CursorPaginator.page()`.
Returns the page that starts after `cursor`.
When `cursor` is `None`, the first page is returned.
##### apply_cursor_filter(cursor: str) → None
Filter the queryset to rows that follow the given *cursor*.
Implements a lexicographic comparison over the `order_by` fields,
so the ordering must end with a unique field (usually the primary key)
to avoid skipping or duplicating rows.
##### encode_cursor(cursor_attrs: dict[str, Any]) → str
Build an encoded cursor from the `cursor_attrs` values.
##### page(cursor: str | None = None) → CursorPage[\_DjangoModel]
Return the page that starts after `cursor`.
When `cursor` is `None`, the first page is returned.
##### page_cls
alias of `CursorPage`
#### *class* dmr.pagination.CursorPage(\*, next_cursor: str | None, objects_list: Sequence[\_DjangoModel])
Helper type for cursor pagination responses.
`next_cursor` is `None` when there are no more objects,
`objects_list` holds the objects of the current page.
##### next_cursor
Encoded cursor to request the next page.
`None` when there are no more items after this page.
* **Type:**
[str](https://docs.python.org/3/builtins/stdtypes.html#str) | None
##### objects_list
Sequence of objects on this page.
* **Type:**
[collections.abc.Sequence](https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence)[dmr.pagination.cursor._DjangoModel]
#### *class* dmr.pagination.CursorPaginated(\*, next_cursor: str | None, per_page: int, page: Sequence[\_ModelT])
Helper for returning cursor paginated response.
##### next_cursor
Encoded cursor to request the next page.
`None` when there are no more pages.
* **Type:**
[str](https://docs.python.org/3/builtins/stdtypes.html#str) | None
##### per_page
Number of objects per one page.
* **Type:**
[int](https://docs.python.org/3/builtins/functions.html#int)
##### page
Generic sequence of objects on this page.
* **Type:**
[collections.abc.Sequence](https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence)[dmr.pagination.cursor._ModelT]
#### *final class* dmr.pagination.cursor.InvalidCursorError(msg: str | Promise | None = None)
Raised when a pagination cursor cannot be decoded or validated.
The cursor is a client-provided value,
so a malformed one is a client error.
Source: /en/latest/pages/negotiation.md
## Content negotiation
`django-modern-rest` supports content negotiation.
We have two abstractions to do that:
- Parsers: instances of subtypes
of `Parser` type
that parses request body based on
[Content-Type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Type)
header into python primitives
- Renderers: instances of subtypes
of `Renderer` type
that renders python primitives into a requested format
based on the
[Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept)
header
By default `json` parser and renderer are configured
to use `msgspec` if it is installed (recommended).
We fallback to pure-python implementation if `msgspec` is not installed.
### Supported content types
We ship several pre-defined parsers and renderers.
Parsers:
- `application/json` with `MsgspecJsonParser`
and `JsonParser`
- `application/msgpack` with `MsgpackParser`
- `multipart/form-data` with `MultiPartParser`
- `application/x-www-form-urlencoded`
with `FormUrlEncodedParser`
Renderers:
- `application/json` with `MsgspecJsonRenderer`
and `JsonRenderer`
- `application/msgpack` with `MsgpackRenderer`
- `*/*` with `FileRenderer`
You can write your own (see Writing custom parsers and renderers)!
### How parser and renderer are selected
We select a `Parser` instance
if there’s a `Body`
or `FileMetadata` components to parse.
Otherwise, for performance reasons, no parser is selected at all.
Nothing to parse - no parser is selected.
Here’s how we select a parser, when it is needed:
1. We look at the `Content-Type` header
2. If it is not provided, we take the default parser,
which is the first specified parser for the endpoint,
aka the most specific one
3. If there’s a `Content-Type` header,
we try to exactly match known parsers based on their
`content_type` attribute.
This is a positive path optimization
4. If there’s no direct match, we now include parsers
that have `*` pattern in supported content types.
We match them in order based on `'specificity', 'quality'`,
the first match wins
5. If no parser fits the request’s content type, we raise
`RequestSerializationError`
We select `Renderer` instance
for all responses (including error responses), before performing any logic.
If the selection fails, we don’t even try to run the endpoint.
Here’s how we select a renderer:
1. We look at `Accept` header
2. If it is not provided, we take the default renderer,
which is the first specified renderer for the endpoint,
aka the most specific one
3. If there’s an `Accept` header,
we use all renderers specified for this endpoint
to match the best accepted type, based on `quality, specificity`,
the first match wins
4. If no renderer fits for the accepted content types, we raise
`ResponseSchemaError`
> [!NOTE]
> When constructing responses manually, like:
>
> ```python
> >>> from django.http import HttpResponse
> >>> response = HttpResponse(b'[]')
> ```
>
> The renderer is selected as usual, but no actual rendering is done.
> However, all other validation works as expected. Which means that even though
> renderer is not actually used, its metadata is still required
> to validate the response content type.
>
> But, when using `to_response()`
> method, renderer will be executed.
> So, it is a preferred method for regular responses.
> [!IMPORTANT]
> Settings always must have one parser
> and one renderer defined at all times,
> because utils like `dmr.response.build_response()`
> fallbacks to settings-defined renderers in some error cases.
> [!NOTE]
> Both selections only depend on the header value,
> because parsers and renderers are fixed for an endpoint in import time.
> That’s why every endpoint remembers what it has already selected
> for a given `Content-Type` and `Accept` header value:
> a client that keeps sending `Accept: application/json`
> only pays for the negotiation once.
>
> The number of remembered header values is limited
> by `DMR_MAX_CACHE_SIZE`.
> If you modify parsers or renderers of an endpoint in place,
> call `clear_cache()`
> or `clear_cache()`
> to make the negotiators forget what they have already decided.
### Alternative JSON backends
By default, we use `msgspec` if it installed.
When it is not, we fallback to `JsonParser`
and `JsonRenderer` types, which use native pure Python
[`json`](https://docs.python.org/3/library/json.html#module-json) with limited features support and very low performance.
For users, who does not want to use `msgspec`, but prefer
[orjson](https://github.com/ijl/orjson) for some reason,
we provide the following API:
views.py
```python
import orjson # type: ignore[import-not-found, unused-ignore]
import pydantic
from dmr import Body, Controller
from dmr.parsers import JsonParser
from dmr.plugins.pydantic import PydanticSerializer
from dmr.renderers import JsonRenderer
class _UserInputData(pydantic.BaseModel):
email: pydantic.EmailStr
class UserController(Controller[PydanticSerializer]):
parsers = (JsonParser(json_module=orjson),)
renderers = (JsonRenderer(json_module=orjson),)
def post(
self,
parsed_body: Body[_UserInputData],
) -> _UserInputData:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "user@example.com"}' -H 'Content-Type: application/json'
> {"email":"user@example.com"}
> ```
Any module that exposes API that fits `JsonModule`
protocol is supported. If some API does not fit exactly, you can create
a small wrapper that would fit, like `NativeJson`.
`orjson` is the recommended alternative because
it is really fast, returns `bytes` directly,
avoiding an extra encode step, and is significantly
faster than the standard library `json`.
The very same backend selection is also used to encode and decode
JWT payloads, see JSON backend. Note that the `json_module`
argument above only affects parsers and renderers, not JWT.
### Customizing negotiation process
> [!NOTE]
> If you only use `json` API - there’s no need to change anything.
However, if you want to support other formats like `xml` or custom ones,
you can write and configure your own parsers and renderers.
Parsers and renderers might be defined on different levels.
Here are all the possible ways starting with the most specific one,
going back to the less specific:
per endpoint
per controller
per settings
views.py
```python
import uuid
from typing import Generic, TypeVar
import pydantic
from dmr import Body, Controller, modify
from dmr.plugins.pydantic import PydanticSerializer
from examples.negotiation.negotiation import XmlParser, XmlRenderer
class _UserProfile(pydantic.BaseModel):
age: int
class _UserInputData(pydantic.BaseModel):
email: str
profile: _UserProfile
class _UserOutputData(_UserInputData):
uid: uuid.UUID
_ModelT = TypeVar('_ModelT')
class _UserDocument(pydantic.BaseModel, Generic[_ModelT]):
user: _ModelT
class UserController(Controller[PydanticSerializer]):
@modify(parsers=[XmlParser()], renderers=[XmlRenderer()])
def post(
self,
parsed_body: Body[_UserDocument[_UserInputData]],
) -> _UserDocument[_UserOutputData]:
return _UserDocument(
user=_UserOutputData(
uid=uuid.uuid4(),
email=parsed_body.user.email,
profile=parsed_body.user.profile,
),
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d 'user@example.com28' -H 'Content-Type: application/xml' -H 'Accept: application/xml'
>
> <_UserDocument>user@example.com281cb42d79-71aa-4727-8214-0e0aea02e370
> ```
views.py
```python
import uuid
from typing import Generic, TypeVar
import pydantic
from dmr import Body, Controller
from dmr.plugins.msgspec import MsgspecJsonParser, MsgspecJsonRenderer
from dmr.plugins.pydantic import PydanticSerializer
from examples.negotiation.negotiation import XmlParser, XmlRenderer
class _UserProfile(pydantic.BaseModel):
age: int
class _UserInputData(pydantic.BaseModel):
email: str
profile: _UserProfile
class _UserOutputData(_UserInputData):
uid: uuid.UUID
_ModelT = TypeVar('_ModelT')
class _UserDocument(pydantic.BaseModel, Generic[_ModelT]):
user: _ModelT
class UserController(Controller[PydanticSerializer]):
parsers = (MsgspecJsonParser(), XmlParser())
renderers = (MsgspecJsonRenderer(), XmlRenderer())
def post(
self,
parsed_body: Body[_UserDocument[_UserInputData]],
) -> _UserDocument[_UserOutputData]:
return _UserDocument(
user=_UserOutputData(
uid=uuid.uuid4(),
email=parsed_body.user.email,
profile=parsed_body.user.profile,
),
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d 'user@example.com28' -H 'Content-Type: application/xml' -H 'Accept: application/json'
> {"user":{"email":"user@example.com","profile":{"age":28},"uid":"8fdf0da5-340d-43f9-b247-0ec1284e30c4"}}
> ```
Set `parsers`
and `renderers`
to control the negotiation process.
settings.py
```python
from dmr.settings import Settings
from examples.negotiation.negotiation import XmlParser, XmlRenderer
DMR_SETTINGS = { # noqa: WPS407
Settings.parsers: [XmlParser()],
Settings.renderers: [XmlRenderer()],
}
```
First parsers / renderers definition found, starting from the top,
will win and be used for the endpoint.
You can also modify
`dmr.endpoint.Endpoint.request_negotiator_cls`
and `dmr.endpoint.Endpoint.response_negotiator_cls`
to completely change the negotiation logic to fit your needs.
This is possible on per-controller level.
### Writing custom parsers and renderers
And here’s how our test `xml` parser and renderer are defined:
negotiation.py
```python
from collections.abc import Callable
from typing import Any, final
from xml.parsers import expat
import xmltodict_rs as xmltodict
from django.http import HttpRequest
from typing_extensions import override
from dmr.exceptions import DataRenderingError, RequestSerializationError
from dmr.negotiation import ContentType
from dmr.parsers import DeserializeFunc, Parser, Raw
from dmr.renderers import Renderer
@final
class XmlParser(Parser):
__slots__ = ()
content_type = ContentType.xml
@override
def parse(
self,
to_deserialize: Raw,
deserializer_hook: DeserializeFunc | None = None,
*,
request: HttpRequest,
model: Any,
) -> Any:
try:
parsed = xmltodict.parse(
to_deserialize,
process_namespaces=True,
postprocessor=self._postprocessor,
# TODO: this is really bad, but I have no idea what to do.
force_list={'detail', 'loc'},
)
except expat.ExpatError as exc:
raise RequestSerializationError(str(exc)) from None
return parsed[next(iter(parsed.keys()))]
def _postprocessor(
self,
path: Any,
key: str,
xml_value: Any,
) -> tuple[str, Any]:
# xmltodict converts empty tags to `None`; for leaf fields in payloads
# we normalize them to empty strings to match OpenAPI string semantics.
if xml_value is None:
return key, ''
return key, xml_value
@final
class XmlRenderer(Renderer):
__slots__ = ()
content_type = ContentType.xml
@override
def render(
self,
to_serialize: Any,
serializer_hook: Callable[[Any], Any],
) -> bytes:
preprocessor = self._wrap_serializer(serializer_hook)
raw_data = xmltodict.unparse(
{type(to_serialize).__qualname__: to_serialize},
preprocessor=preprocessor,
)
assert isinstance(raw_data, str)
return raw_data.encode('utf8')
@property
@override
def validation_parser(self) -> XmlParser:
return XmlParser()
def _wrap_serializer(
self,
serializer_hook: Callable[[Any], Any],
) -> Callable[[str, Any], tuple[str, Any]]:
def factory(xml_key: str, xml_value: Any) -> tuple[str, Any]:
try: # noqa: SIM105
xml_value = serializer_hook(xml_value)
except DataRenderingError:
pass # noqa: WPS420
return xml_key, xml_value
return factory
```
> [!WARNING]
> This parser is only used as a demo, do not use it in production,
> prefer more tested and battle-proven solutions.
### Using different schemes for different content types
Sometimes we have to accept different schemas based on the content type.
[According to the OpenAPI spec](https://swagger.io/docs/specification/v3_0/describing-request-body/describing-request-body/#requestbody-content-and-media-types),
`Body`
should support different content types.
We utilize [`typing.Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated)
and `dmr.negotiation.conditional_type()`:
views.py
```python
from typing import Annotated
import pydantic
from dmr import Body, Controller
from dmr.negotiation import ContentType, conditional_type
from dmr.plugins.msgspec import MsgspecJsonParser, MsgspecJsonRenderer
from dmr.plugins.pydantic import PydanticSerializer
from examples.negotiation.negotiation import XmlParser, XmlRenderer
class _XMLRequestModel(pydantic.BaseModel):
root: dict[str, str]
class ExampleController(Controller[PydanticSerializer]):
parsers = (MsgspecJsonParser(), XmlParser())
renderers = (MsgspecJsonRenderer(), XmlRenderer())
def post(
self,
parsed_body: Body[
Annotated[
# The body will be a union of these two types:
_XMLRequestModel | dict[str, str],
conditional_type({
# But, for json it will always be:
ContentType.json: dict[str, str],
# And for xml it will always be:
ContentType.xml: _XMLRequestModel,
}),
],
],
) -> dict[str, str]:
if isinstance(parsed_body, _XMLRequestModel):
return parsed_body.root
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d 'first' -H 'Content-Type: application/xml' -H 'Accept: application/xml'
>
> first
>
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d '{"one": "first"}' -H 'Content-Type: application/json' -H 'Accept: application/json'
> {"one":"first"}
>
> $ curl http://127.0.0.1:8000/api/example/ -D - -X POST -d '{"root": {"mixin-json-content-type": "with-xml-format"}}' -H 'Content-Type: application/json' -H 'Accept: application/json'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:17:43 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 103
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Input should be a valid string","loc":["parsed_body","root"],"type":"value_error"}]}
> ```
We strictly validate that each content type will have its own unique model.
As the last example shows, it is impossible to send `_XMLRequestModel`
with `Content-Type: application/json` header.
The same works for return types as well:
views.py
```python
from typing import Annotated
import pydantic
from dmr import Body, Controller
from dmr.negotiation import ContentType, conditional_type
from dmr.plugins.msgspec import MsgspecJsonParser, MsgspecJsonRenderer
from dmr.plugins.pydantic import PydanticSerializer
from examples.negotiation.negotiation import XmlParser, XmlRenderer
class _RequestModel(pydantic.BaseModel):
root: dict[str, str]
class ExampleController(Controller[PydanticSerializer]):
parsers = (MsgspecJsonParser(), XmlParser())
renderers = (MsgspecJsonRenderer(), XmlRenderer())
def post(
self,
parsed_body: Body[_RequestModel],
) -> Annotated[
dict[str, str] | list[str],
conditional_type({
ContentType.json: list[str],
ContentType.xml: dict[str, str],
}),
]:
if self.request.accepts(ContentType.json):
return list(parsed_body.root.values())
return parsed_body.root
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d 'first' -H 'Content-Type: application/xml' -H 'Accept: application/xml'
>
> first
>
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d '{"root": {"one": "first"}}' -H 'Content-Type: application/json' -H 'Accept: application/json'
> ["first"]
> ```
Depending on the content type - your return schema
will be fully validated as well.
In the example above, it would be an error to return something other
than `list[str]` for `json` content type, and it would also
be an error to return anything other than `dict[str, str]`
for `xml` content type.
You can combine conditional bodies and conditional return types
in a type-safe and fully OpenAPI-compatible way.
### Using different error models for different content types
The same can be done with error models.
Let’s say you want to present JSON and XML error models differently.
We utilize the same technique [`typing.Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated)
and `dmr.negotiation.conditional_type()`:
views.py
```python
from typing import Annotated
import pydantic
from typing_extensions import TypedDict, override
from dmr import Body, Controller
from dmr.errors import ErrorModel, ErrorType
from dmr.negotiation import ContentType, conditional_type
from dmr.plugins.msgspec import MsgspecJsonRenderer
from dmr.plugins.pydantic import PydanticSerializer
from examples.negotiation.negotiation import XmlRenderer
class _RequestModel(pydantic.BaseModel):
root: dict[str, str]
class _CustomXmlErrorModel(TypedDict):
xml_errors: dict[str, str]
class ExampleController(Controller[PydanticSerializer]):
renderers = (MsgspecJsonRenderer(), XmlRenderer())
error_model = Annotated[
ErrorModel | _CustomXmlErrorModel,
conditional_type({
ContentType.json: ErrorModel,
ContentType.xml: _CustomXmlErrorModel,
}),
]
def post(self, parsed_body: Body[_RequestModel]) -> str:
# Will not be called in this example, because we fail to parse body:
raise NotImplementedError
@override
def format_error(
self,
error: str | Exception,
*,
loc: str | list[str | int] | None = None,
error_type: str | ErrorType | None = None,
) -> ErrorModel | _CustomXmlErrorModel:
original: ErrorModel = super().format_error(
error,
loc=loc,
error_type=error_type,
)
if self.request.accepts(ContentType.json):
return original
return {
'xml_errors': {
'.'.join(str(location) for location in detail['loc']): detail[
'msg'
]
for detail in original['detail']
},
}
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d '{}' -H 'Content-Type: application/json' -H 'Accept: application/json'
> {"detail":[{"msg":"Field required","loc":["parsed_body","root"],"type":"value_error"}]}
>
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d '{}' -H 'Content-Type: application/json' -H 'Accept: application/xml'
>
> Field required
> ```
Note that you would also have to customize
`format_error()`
accordingly.
### Limiting response specs to content types
Sometimes some responses can only be returned for some content types.
We need a way to describe it: both for our validation and OpenAPI spec.
To do so, we utilize `limit_to_content_types`
attribute:
views.py
```python
from http import HTTPStatus
import pydantic
from dmr import APIError, Controller, Query, ResponseSpec
from dmr.negotiation import ContentType
from dmr.plugins.msgspec import MsgspecJsonParser, MsgspecJsonRenderer
from dmr.plugins.pydantic import PydanticSerializer
from examples.negotiation.negotiation import XmlParser, XmlRenderer
class _QueryModel(pydantic.BaseModel):
show_error: bool = False
class ExampleController(Controller[PydanticSerializer]):
parsers = (MsgspecJsonParser(), XmlParser())
renderers = (MsgspecJsonRenderer(), XmlRenderer())
responses = (
ResponseSpec(
list[str],
status_code=HTTPStatus.CONFLICT,
limit_to_content_types={ContentType.json},
),
ResponseSpec(
dict[str, str],
status_code=HTTPStatus.PAYMENT_REQUIRED,
limit_to_content_types={ContentType.xml},
),
)
def get(self, parsed_query: Query[_QueryModel]) -> str:
if self.request.accepts(ContentType.json):
if parsed_query.show_error:
# This is explicitly wrong:
# `PAYMENT_REQUIRED` cannot happen with `json`,
# response validation will catch this:
raise APIError([], status_code=HTTPStatus.PAYMENT_REQUIRED)
raise APIError(['wrong', 'items'], status_code=HTTPStatus.CONFLICT)
raise APIError(
{'wrong': 'items'},
status_code=HTTPStatus.PAYMENT_REQUIRED,
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X GET -H 'Accept: application/json'
> ["wrong","items"]
>
> $ curl http://127.0.0.1:8000/api/example/ -X GET -H 'Accept: application/xml'
>
> items
>
> $ curl 'http://127.0.0.1:8000/api/example/?show_error=1' -D - -X GET -H 'Accept: application/json'
> HTTP/1.1 422 Unprocessable Entity
> date: Mon, 05 Oct 2026 11:17:46 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 124
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Response 402 is not allowed for 'application/json', only for ['application/xml']","type":"value_error"}]}
> ```
### Disabling content negotiation validation
When `validate_responses` is active,
we also validate that the returned `Content-Type` header matches
the negotiated content type.
To disable this validation, if you for some reason,
want to break the content negotiation protocol,
you can set `validate_negotiation`
to `False`.
We support several layers of configuration:
per endpoint
per controller
per settings
views.py
```python
from http import HTTPStatus
from django.http import JsonResponse
from typing_extensions import TypedDict
from dmr import Body, Controller, ResponseSpec, validate
from dmr.plugins.msgspec import MsgspecSerializer
from dmr.settings import default_parser, default_renderer
from examples.negotiation.negotiation import XmlParser, XmlRenderer
class _UserInputData(TypedDict):
email: str
class UserController(Controller[MsgspecSerializer]):
parsers = (XmlParser(), default_parser)
renderers = (XmlRenderer(), default_renderer)
@validate(
ResponseSpec(_UserInputData, status_code=HTTPStatus.OK),
validate_negotiation=False,
)
def post(self, parsed_body: Body[_UserInputData]) -> JsonResponse:
# NOTE: do not do this!
return JsonResponse(parsed_body)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d 'user@example.com' -H 'Content-Type: application/xml' -H 'Accept: application/xml'
> {"email": "user@example.com"}
> ```
views.py
```python
from http import HTTPStatus
from django.http import JsonResponse
from typing_extensions import TypedDict
from dmr import Body, Controller, ResponseSpec, validate
from dmr.plugins.msgspec import MsgspecSerializer
from dmr.settings import default_parser, default_renderer
from examples.negotiation.negotiation import XmlParser, XmlRenderer
class _UserInputData(TypedDict):
email: str
class UserController(Controller[MsgspecSerializer]):
parsers = (XmlParser(), default_parser)
renderers = (XmlRenderer(), default_renderer)
validate_negotiation = False
@validate(ResponseSpec(_UserInputData, status_code=HTTPStatus.OK))
def post(self, parsed_body: Body[_UserInputData]) -> JsonResponse:
# NOTE: do not do this!
return JsonResponse(parsed_body)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d 'user@example.com' -H 'Content-Type: application/xml' -H 'Accept: application/xml'
> {"email": "user@example.com"}
> ```
Set `validate_negotiation` setting
to disable the content negotiation validation.
settings.py
```python
from dmr.settings import Settings, default_parser, default_renderer
from examples.negotiation.negotiation import XmlParser, XmlRenderer
DMR_SETTINGS = { # noqa: WPS407
Settings.parsers: [XmlParser(), default_parser],
Settings.renderers: [XmlRenderer(), default_renderer],
Settings.validate_negotiation: False,
}
```
Despite the fact that `Content-Type` is not validated,
we still validate the response schema. So, you must still provide correct
renderers and parsers to do validation. Otherwise, you would have to also
disable Response validation.
> [!WARNING]
> We do not ever recommend to do this in any sane setups.
> This only makes sense for legacy API contracts that you want to migrate
> to `django-modern-rest`.
### Negotiation API
#### *class* dmr.negotiation.RequestNegotiator(metadata: EndpointMetadata, serializer: type[BaseSerializer])
Selects a correct parser type for a request.
Which parser fits only depends on the `Content-Type` header value,
because parsers are fixed for an endpoint in import time.
That’s why we memoize the decision per header value: almost every
client keeps sending the very same `Content-Type: application/json`
and there’s no point in negotiating it over and over again.
The cache is not shared between endpoints, it holds at most
`DMR_MAX_CACHE_SIZE` header values and can be dropped with
`clear_cache()`.
Only the decision is memoized, never the error:
unsupported headers still raise for every single request.
> [!WARNING]
> Changed in version 0.16.0: Parser selection is now memoized per `Content-Type` header value.
##### \_\_call_\_(request: HttpRequest) → Parser
Negotiates which parser to use for parsing this request.
Based on `Content-Type` header.
Called in runtime.
Must work for O(1) for the best case scenario because of that.
Must set `__dmr_parser__` request attribute
if the negotiation is successful.
* **Returns:**
Parser class for this request.
* **Raises:**
**RequestSerializationError** – when `Content-Type` request
header is not supported.
##### clear_cache() → None
Drop everything this negotiator has memoized so far.
Parsers are fixed for an endpoint in import time,
so this is only needed when they are modified in place:
in tests or in some very dynamic setups.
> [!WARNING]
> Added in version 0.16.0.
#### *class* dmr.negotiation.ResponseNegotiator(metadata: EndpointMetadata, serializer: type[BaseSerializer], \*, streaming: bool)
Selects a correct renderer for a response body.
Which renderer fits only depends on the `Accept` header value,
because renderers are fixed for an endpoint in import time.
That’s why we memoize the decision per header value: almost every
client keeps sending the very same `Accept: application/json`
and there’s no point in negotiating it over and over again.
The cache is not shared between endpoints, it holds at most
`DMR_MAX_CACHE_SIZE` header values and can be dropped with
`clear_cache()`.
Only the decision is memoized, never the error:
unsupported headers still raise for every single request.
> [!WARNING]
> Changed in version 0.5.0: Now it uses a custom algorithm that is x30 times faster
> (when compiled with mypyc compilation) then the original
> [`django.http.HttpRequest.get_preferred_type()`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.get_preferred_type) way we used before.
> [!WARNING]
> Changed in version 0.16.0: Renderer selection is now memoized per `Accept` header value.
##### \_\_call_\_(request: HttpRequest) → Renderer
Negotiates which renderer to use for rendering this response.
Based on `Accept` header.
Called in runtime.
Must work for O(1) because of that.
We use [`django.http.HttpRequest.get_preferred_type()`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.get_preferred_type) inside.
So, we have exactly the same negotiation rules as django has.
Must set `__dmr_renderer__` request attribute
if the negotiation is successful.
Can set `__dmr_nonstreaming_renderer__` if working
with streaming responses.
* **Returns:**
Renderer class for this response.
* **Raises:**
**NotAcceptableError** – when `Accept` request header is not supported.
##### clear_cache() → None
Drop everything this negotiator has memoized so far.
Renderers are fixed for an endpoint in import time,
so this is only needed when they are modified in place:
in tests or in some very dynamic setups.
> [!WARNING]
> Added in version 0.16.0.
#### *final class* dmr.negotiation.ContentType(\*values)
Enumeration of frequently used content types.
##### json
`'application/json'` format.
##### xml
`'application/xml'` format.
##### x_www_form_urlencoded
`'application/x-www-form-urlencoded'` format.
##### multipart_form_data
`'multipart/form-data'` format.
##### msgpack
`'application/msgpack'` format.
##### event_stream
`'text/event-stream'` format for SSE streaming.
##### jsonl
`'application/jsonl'` format for JSON Lines streaming.
##### json_problem_details
`'application/problem+json'` format
for RFC 9457.
#### dmr.negotiation.conditional_type(mapping: Mapping[str | ContentType, Any]) → ConditionalType
Create conditional validation for different content types.
It is rather usual to see a requirement like:
- If this method returns `json` then we should follow schema1
- If this methods returns `xml` then we should follow schema2
#### dmr.negotiation.request_parser(request: HttpRequest, \*, strict: Literal[True]) → Parser
#### dmr.negotiation.request_parser(request: HttpRequest, \*, strict: bool = False) → Parser | None
Get parser used to parse this request.
When *strict* is passed and *request* has no parser,
we raise [`AttributeError`](https://docs.python.org/3/builtins/exceptions.html#AttributeError).
> [!NOTE]
> Since request parsing is only used when there’s
> a `dmr.components.Body` or similar component,
> there might be no parser at all.
#### dmr.negotiation.request_renderer(request: HttpRequest, \*, strict: Literal[True], use_nonstreaming_renderer: bool = False) → Renderer
#### dmr.negotiation.request_renderer(request: HttpRequest, \*, strict: bool = False, use_nonstreaming_renderer: bool = False) → Renderer | None
Get pre-negotiated renderer.
First, tries a special `__dmr_nonstreaming_renderer__` case,
which will be different for `streaming` responses.
For example: for SSE controllers `__dmr_nonstreaming_renderer__`
will be just `json` or `xml`.
It is not used for REST endpoints.
While `__dmr_renderer__` will be whatever `Accept` header
contains as the first value.
When *strict* is passed and *request* has no renderer,
we raise [`AttributeError`](https://docs.python.org/3/builtins/exceptions.html#AttributeError).
> [!NOTE]
> There might not be a response renderer that fits what client has asked.
> So, it can return `None`.
#### dmr.negotiation.get_conditional_types(model: TypeForm[Any], model_meta: tuple[Any, ...]) → Mapping[str, Any] | None
Returns possible conditional types.
Conditional types are defined with [`typing.Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated)
and `dmr.negotiation.conditional_type()` helper.
### Parser API
#### *class* dmr.parsers.Parser
Base class for all parsers.
Subclass it to implement your own parsers.
##### content_type *: str*
Content-Type that this parser works with.
Must be defined for all subclasses.
##### *abstractmethod* parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, \*, request: HttpRequest, model: TypeForm[Any]) → Any
Deserialize a raw string/bytes/bytearray into an object.
* **Parameters:**
* **to_deserialize** – Value to deserialize.
* **deserializer_hook** – Hook to convert types
that are not natively supported.
* **request** – Django’s original request with all the details.
* **model** – Model that represents the final result’s structure.
* **Returns:**
Simple python object with primitive parts.
* **Raises:**
**DataParsingError** – If error decoding `obj`.
##### provide_response_specs(metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], existing_responses: Mapping[HTTPStatus, ResponseSpec]) → list[ResponseSpec]
Provides responses that can happen when data can’t be parsed.
##### validate(controller_cls: type[Controller[BaseSerializer]], metadata: EndpointMetadata) → None
Validate parser configuration at import time.
Override this method to enforce parser-specific constraints.
Raise `dmr.exceptions.EndpointMetadataError`
if the parser is used incorrectly.
### Renderer API
#### *class* dmr.renderers.Renderer
Base class for all renderer types.
Subclass it to implement your own renderers.
##### content_type *: str*
Content-Type that this renderer works with.
Must be defined for all subclasses.
##### provide_response_specs(metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], existing_responses: Mapping[HTTPStatus, ResponseSpec]) → list[ResponseSpec]
Provides responses that can happen when data can’t be rendered.
##### *abstractmethod* render(to_serialize: Any, serializer_hook: Callable[[Any], Any]) → bytes
Function to be called on object serialization.
##### streaming *: ClassVar[bool]* *= False*
Whether or not this renderer is used for streaming responses.
##### validate(controller_cls: type[Controller[BaseSerializer]], metadata: EndpointMetadata) → None
Validate renderer configuration at import time.
Override this method to enforce renderer-specific constraints.
Raise `dmr.exceptions.EndpointMetadataError`
if the renderer is used incorrectly.
##### *abstract property* validation_parser *: Parser*
Returns a parser that can parse what this renderer rendered.
Why? Because when `validate_responses` is `True`,
we parse the response body once again to see if it fits the schema.
That’s why all renderers must know how to unparse its results.
### Existing parsers and renderers
#### Parsers
#### *class* dmr.plugins.msgspec.MsgspecJsonParser
Parsers json bodies using `msgspec`.
##### content_type *: str* *= 'application/json'*
Content-Type that this parser works with.
Must be defined for all subclasses.
##### parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, \*, request: HttpRequest, model: TypeForm[Any]) → Any
Deserialize a raw JSON string/bytes/bytearray into an object.
* **Parameters:**
* **to_deserialize** – Value to deserialize.
* **deserializer_hook** – Hook to convert types
that are not natively supported.
* **request** – Django’s original request with all the details.
* **model** – Model that represents the final result’s structure.
* **Returns:**
Simple python object with primitive parts.
* **Raises:**
* **DataParsingError** – If error decoding *to_deserialize*.
* [**msgspec.ValidationError**](https://msgspec.dev/api.md#msgspec.ValidationError) – When *model* is not `Any`
and validation fails, like with
`BodyMsgspec`.
##### strict *: ClassVar[bool]* *= False*
Default strictness of the parser.
#### *class* dmr.plugins.msgspec.MsgpackParser
Parsers `msgpack` bodies using `msgspec`.
##### content_type *: str* *= 'application/msgpack'*
Content-Type that this parser works with.
Must be defined for all subclasses.
##### parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, \*, request: HttpRequest, model: TypeForm[Any]) → Any
Deserialize a raw msgpack string/bytes/bytearray into an object.
* **Parameters:**
* **to_deserialize** – Value to deserialize.
* **deserializer_hook** – Hook to convert types
that are not natively supported.
* **request** – Django’s original request with all the details.
* **model** – Model that represents the final result’s structure.
* **Returns:**
Simple python object with primitive parts.
* **Raises:**
* **DataParsingError** – If error decoding `obj`.
* [**msgspec.ValidationError**](https://msgspec.dev/api.md#msgspec.ValidationError) – When *model* is not `Any`
and validation fails, like with
`BodyMsgspec`.
##### strict *: ClassVar[bool]* *= False*
Default strictness of the parser.
#### *class* dmr.parsers.JsonParser(content_type: str = 'application/json', \*, json_module: JsonModule = )
Fallback implementation of a json parser.
Only is used when `msgspec` is not installed.
> [!WARNING]
> It is not recommended to be used directly
> when using default `json` module.
> It is slow and has less features.
> We won’t add any complex objects support to this parser.
Alternative `json` implementations can be provided.
See Alternative JSON backends for more info.
##### content_type *: str*
Content-Type that this parser works with.
Must be defined for all subclasses.
##### parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, \*, request: HttpRequest, model: TypeForm[Any]) → Any
Decode a JSON string/bytes/bytearray into an object.
* **Parameters:**
* **to_deserialize** – Value to decode.
* **deserializer_hook** – Hook to convert types
that are not natively supported.
* **request** – Django’s original request with all the details.
* **model** – Model that represents the final result’s structure.
* **Returns:**
Decoded object.
* **Raises:**
**DataParsingError** – If error decoding `obj`.
#### *class* dmr.parsers.MultiPartParser
Parses multipart form data.
In reality this is a quite tricky parser.
Since, Django already parses `multipart/form-data` content natively,
there’s no reason to duplicate its work.
So, we return original Django’s content.
##### content_type *: str* *= 'multipart/form-data'*
Works with multipart data.
##### parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, \*, request: HttpRequest, model: TypeForm[Any]) → None
Returns parsed multipart form data.
##### schema_metadata(model: TypeForm[Any], model_meta: tuple[Any, ...], metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], context: OpenAPIContext) → type[FileBodyLike]
Provide schema for the file request spec.
> [!WARNING]
> Added in version 0.15.0.
> [!WARNING]
> Changed in version 0.16.0: *serializer* parameter was changed to be *controller_cls*.
#### *class* dmr.parsers.FormUrlEncodedParser
Parses `x-www-form-urlencoded` forms into schemas.
In reality this is a quite tricky parser.
Since, Django already parses `application/x-www-form-urlencoded`
content natively, there’s no reason to duplicate its work.
So, we return original Django’s content.
##### content_type *: str* *= 'application/x-www-form-urlencoded'*
Works with urlencoded forms.
##### parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, \*, request: HttpRequest, model: TypeForm[Any]) → None
Returns parsed form data.
Also parses `dmr.settings.Settings.django_treat_as_post`
as regular `POST` data. Unlike regular Django,
which only parses `POST` methods.
#### Renderers
#### *class* dmr.plugins.msgspec.MsgspecJsonRenderer(content_type: str = 'application/json')
Renders json bodies using `msgspec`.
##### content_type *: str*
Content-Type that this renderer works with.
Must be defined for all subclasses.
##### render(to_serialize: Any, serializer_hook: Callable[[Any], Any] | None = None) → bytes
Encode a value into JSON bytestring.
* **Parameters:**
* **to_serialize** – Value to encode.
* **serializer_hook** – Callable to support non-natively supported types.
* **Returns:**
JSON as bytes.
##### *property* validation_parser *: MsgspecJsonParser*
Msgspec can parse this.
#### *class* dmr.plugins.msgspec.MsgpackRenderer
Renders `msgpack` bodies using `msgspec`.
##### content_type *: str* *= 'application/msgpack'*
Content-Type that this renderer works with.
Must be defined for all subclasses.
##### render(to_serialize: Any, serializer_hook: Callable[[Any], Any] | None = None) → bytes
Encode a value into `msgpack` bytestring.
* **Parameters:**
* **to_serialize** – Value to encode.
* **serializer_hook** – Callable to support non-natively supported types.
* **Returns:**
`msgpack` as bytes.
##### *property* validation_parser *: MsgpackParser*
Msgspec can parse this.
#### *class* dmr.renderers.JsonRenderer(content_type: str = 'application/json', \*, json_module: JsonModule = )
Fallback implementation of a json renderer.
Only is used when `msgspec` is not installed.
> [!WARNING]
> It is not recommended to be used directly.
> It is slow and has less features.
> We won’t add any complex objects support to this renderer.
Alternative `json` implementations can be provided.
See Alternative JSON backends for more info.
##### content_type *: str*
Content-Type that this renderer works with.
Must be defined for all subclasses.
##### render(to_serialize: Any, serializer_hook: Callable[[Any], Any]) → bytes
Encode a value into JSON bytestring.
* **Parameters:**
* **to_serialize** – Value to encode.
* **serializer_hook** – Callable to support non-natively supported types.
* **Returns:**
JSON as bytes.
##### *property* validation_parser *: JsonParser*
Regular json parser can parse this.
#### *class* dmr.renderers.FileRenderer(content_type: str = '\*/\*')
Renders any file.
Works with any files and any content types.
> [!WARNING]
> Works with any content type by default,
> so it must be an only renderer for the endpoint.
##### content_type *: str*
Content-Type that this renderer works with.
Must be defined for all subclasses.
##### render(to_serialize: Any, serializer_hook: Callable[[Any], Any]) → bytes
Render a file.
##### *property* validation_parser *: \_NoOpParser*
Since there’s nothing to parse, we return a no-op.
### Advanced API
#### *class* dmr.parsers.SupportsFileParsing
Mixin class for parsers that can parse files.
We require parsers that can parse files to populate
[`django.http.HttpRequest.FILES`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.FILES) and to not return anything.
##### *abstractmethod* parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, \*, request: HttpRequest, model: TypeForm[Any]) → None
Populate `request.FILES` if possible.
##### *abstractmethod* schema_metadata(model: TypeForm[Any], model_meta: tuple[Any, ...], metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], context: OpenAPIContext) → type[FileBodyLike]
Provide schema for the file request spec.
> [!WARNING]
> Added in version 0.15.0.
> [!WARNING]
> Changed in version 0.16.0: *serializer* parameter was changed to be *controller_cls*.
#### *class* dmr.parsers.SupportsDjangoDefaultParsing
Mark for parsers that support default Django’s parsing.
By default Django can parse multipart/form-data
and application/x-www-form-urlencoded in a very specific way.
Django only parses [`django.http.HttpRequest.POST`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.POST)
and [`django.http.HttpRequest.FILES`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.FILES)
when it receives a real `POST` request.
Which does not really work for us.
We need more methods to be able to send the same content.
So, parsers that extends this type must:
1. Return default parsed objects when method is `POST`
2. Parse similar HTTP methods the same way Django does for `POST`
Contract: `parse()` method must return `None`, but populate
[`django.http.HttpRequest.POST`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.POST)
and [`django.http.HttpRequest.FILES`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.FILES) if they were missing.
##### *abstractmethod* parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, \*, request: HttpRequest, model: TypeForm[Any]) → None
Populate `request.POST` and `request.FILES` if possible.
Source: /en/latest/pages/error-handling.md
## Error handling
`django-modern-rest` has 3 layers where errors might be handled.
It provides flexible error handling logic
on `Endpoint`,
`Controller`,
and `global` levels.
All error handling functions always accept 3 arguments:
1. `Endpoint` where error happened
2. `Controller` where error happened
3. Exception that happened
Here’s how it works:
1. We first try to call `error_handler` that was passed into the endpoint
definition via `modify`
or `validate`
2. If it returns [`django.http.HttpResponse`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpResponse), return it to the user
3. If it raises an error, call
`handle_error()` for sync
controllers
and `handle_async_error()`
for async controllers with this raised error
4. If controller’s handler returns [`HttpResponse`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpResponse),
return it to the user
5. If it raises an error, call configured global error handler
with this raised error, by default
it is `global_error_handler()` (it is always sync)
> [!WARNING]
> There are two things to keep in mind:
>
> 1. Async endpoints will require async `error_handler` parameter,
> Sync endpoints will require sync `error_handler` parameter.
> This is validated on endpoint creation
> 2. We don’t allow to define sync `handle_error` handlers
> for async controllers.
> We also don’t allow async `handle_async_error` handlers
> for sync controllers.
> [!NOTE]
> `APIError` does not follow any of these
> rules and has a default handler, which will convert an instance
> of `APIError` to [`HttpResponse`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpResponse) via
> `to_error()` call.
>
> You don’t need to catch `APIError` in any way,
> unless you know what you are doing.
You can change the error instance that is handled during
the error handling pipeline. For example:
- Some `YourCustomError` happens in your endpoint
- It is handled in a custom endpoint-level handler,
which raises `YourIntermediateError` instead
- Custom controller-level handler catches it and raises
`ValidationError` instead
- `ValidationError` is handled by the default
`global_error_handler()` and returns an expected response
### Customizing endpoint error handler
Let’s pass custom error handling to a single endpoint:
views.py
```python
from http import HTTPStatus
import pydantic
from django.http import HttpResponse
from dmr import Body, Controller, modify
from dmr.endpoint import Endpoint
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.serializer import BaseSerializer
class TwoNumbers(pydantic.BaseModel):
left: int
right: int
def division_error( # <- we define an error handler
endpoint: Endpoint,
controller: Controller[BaseSerializer],
exc: Exception,
) -> HttpResponse:
if isinstance(exc, ZeroDivisionError):
# This response's schema was automatically added by `Body`:
return controller.to_error(
controller.format_error(str(exc)),
status_code=HTTPStatus.BAD_REQUEST,
)
# Reraise unfamiliar errors to let someone
# else to handle them further.
raise exc from None
class MathController(Controller[PydanticFastSerializer]):
@modify(error_handler=division_error) # <- and we pass the handler
def patch(
self,
parsed_body: Body[TwoNumbers],
) -> float: # <- has custom error handling
return parsed_body.left / parsed_body.right
def post(
self,
parsed_body: Body[TwoNumbers],
) -> float: # <- has only default error handling
return parsed_body.left / parsed_body.right
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/math/ -D - -X PATCH -d '{"left": 1, "right": 0}' -H 'Content-Type: application/json'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:17:20 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 39
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"division by zero"}]}
>
> $ curl http://127.0.0.1:8000/api/math/ -D - -X POST -d '{"left": 1, "right": 0}' -H 'Content-Type: application/json'
> HTTP/1.1 500 Internal Server Error
> date: Mon, 05 Oct 2026 11:17:20 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 68
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Internal server error","type":"internal_error"}]}
> ```
In this example we add error handling defined as `division_error`
to `patch` endpoint, while keeping `post` endpoint
without a custom error handler.
So, the same request to `post` results in a default `500` response.
Per-endpoint’s error handling has a priority
over per-controller and global handlers.
You can also define endpoint error handlers as controller methods
and pass them wrapped with `wrap_handler()`
as handlers. Like so:
views.py
```python
from http import HTTPStatus
import pydantic
from django.http import HttpResponse
from dmr import Body, Controller, modify
from dmr.endpoint import Endpoint
from dmr.errors import wrap_handler
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.serializer import BaseSerializer
class TwoNumbers(pydantic.BaseModel):
left: int
right: int
class MathController(Controller[PydanticFastSerializer]):
def division_error( # <- we define an error handler
self,
endpoint: Endpoint,
controller: Controller[BaseSerializer],
exc: Exception,
) -> HttpResponse:
if isinstance(exc, ZeroDivisionError):
# This response's schema was automatically added by `Body`:
return controller.to_error(
controller.format_error(str(exc)),
status_code=HTTPStatus.BAD_REQUEST,
)
# Reraise unfamiliar errors to let someone
# else to handle them further.
raise exc from None
@modify(
error_handler=wrap_handler( # <- and we pass the handler
division_error,
),
)
def patch(
self,
parsed_body: Body[TwoNumbers],
) -> float: # <- has custom error handling
return parsed_body.left / parsed_body.right
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/math/ -D - -X PATCH -d '{"left": 1, "right": 0}' -H 'Content-Type: application/json'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:17:20 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 39
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"division by zero"}]}
> ```
### Customizing controller error handler
Let’s create custom error handling for the whole controller:
views.py
```python
from http import HTTPStatus
import zapros
from django.http import HttpResponse
from typing_extensions import override
from dmr import Controller, ResponseSpec
from dmr.endpoint import Endpoint
from dmr.plugins.pydantic import PydanticFastSerializer
class ProxyController(Controller[PydanticFastSerializer]):
responses = (
# Custom schema that we can return when `HTTPError` happens:
ResponseSpec(str, status_code=HTTPStatus.FAILED_DEPENDENCY),
)
async def get(self) -> None:
async with self._client() as client:
# Simulate some real work:
await client.get('https://example.com')
async def post(self) -> None:
async with self._client() as client:
# Simulate some real work:
await client.post('https://example.com', json={})
@override
async def handle_async_error(
self,
endpoint: Endpoint,
controller: Controller[PydanticFastSerializer],
exc: Exception,
) -> HttpResponse:
# Will handle errors in all endpoints.
if isinstance(exc, zapros.ZaprosError):
return self.to_error(
'Request to example.com failed',
status_code=HTTPStatus.FAILED_DEPENDENCY,
)
# Handle errors from super:
return await super().handle_async_error(
endpoint,
controller,
exc,
)
def _client(self) -> zapros.AsyncClient:
return zapros.AsyncClient()
```
In this example we are using [zapros](https://github.com/kap-sh/zapros)
HTTP client to proxy an HTTP `GET` and `POST`
requests to some other API service.
If we fail to send a request and raise a specific HTTP client error,
we return an error with `424` error code.
### Going further
Now you can understand how you can create:
- Endpoints with custom error handlers
- Controllers with custom error handlers
- `ResponseSpec` objects
for new error response schemas
You can dive even deeper and:
- Subclass `Controller`
and provide default error handling for this specific subclass
- Redefine `endpoint_cls`
and change how one specific endpoint behaves on a deep level,
see `handle_error()`
and `handle_async_error()`
### Error handling diagram
The same error handling logic can be represented as a diagram:
```mermaid
---
config:
theme: forest
---
graph TB
Start[Request] --> Error{Error?};
Error -->|Yes| Endpoint[Endpoint-level handler];
Endpoint --> EndpointHandler{Raises or returns response?};
EndpointHandler -->|response| Failure[Error response];
EndpointHandler -->|raises| Controller[Controller-level handler];
Controller --> ControllerHandler{Raises or returns response?};
ControllerHandler -->|response| Failure[Error response];
ControllerHandler -->|raises| Global[Global handler];
Global --> GlobalHandler{Raises or returns response?};
GlobalHandler -->|response| Failure[Error response];
GlobalHandler -->|raises| Reraises[Reraises error];
Error ---->|No| Success[Successful response];
```
Error handling logic> [!NOTE]
> If Handling 500 errors is configured, it will catch all unhandled errors
> in the provided scope and return `500` errors with the correct payload.
### Validating error responses
Error responses are validated like any other response.
With `validate_responses` enabled,
only the status codes that you describe are allowed to be returned.
This matters the most for `500`: we raise
`InternalServerError` ourselves in several places,
and your own code can raise it as well.
An undescribed `500` will not reach the client, response validation
will replace it with `422 Returned status code 500 is not specified`.
Pick the way that matches how much you promise to your clients:
Describe it
Test it, disable validation
Exclude it
The strictest option: `500` becomes a part of your public contract.
It is listed in the OpenAPI schema, so clients can generate code
for it and handle it in a typed way. Its body is still validated,
so you cannot accidentally return something
that does not match the schema.
views.py
```python
from http import HTTPStatus
import msgspec
from dmr import Body, Controller, ResponseSpec
from dmr.errors import ErrorModel
from dmr.exceptions import InternalServerError
from dmr.plugins.msgspec import MsgspecSerializer
class UserModel(msgspec.Struct):
email: str
class UserController(Controller[MsgspecSerializer]):
# Now `500` is a part of our public contract:
# it is in the OpenAPI schema and its body is validated.
responses = (
ResponseSpec(ErrorModel, status_code=HTTPStatus.INTERNAL_SERVER_ERROR),
)
def post(self, parsed_body: Body[UserModel]) -> UserModel:
if parsed_body.email.endswith('@example.com'):
# Imagine that our storage is down for this user:
raise InternalServerError('Cannot reach the database')
# This response will be documented by default:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json'
> {"email":"user@wms.org"}
>
> $ curl http://127.0.0.1:8000/api/user/ -D - -X POST -d '{"email": "user@example.com"}' -H 'Content-Type: application/json'
> HTTP/1.1 500 Internal Server Error
> date: Mon, 05 Oct 2026 11:17:21 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 44
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Internal server error"}]}
> ```
Use `responses`
to do the same for the whole API at once:
settings.py
```python
>>> from http import HTTPStatus
>>> from dmr import ResponseSpec
>>> from dmr.errors import ErrorModel
>>> from dmr.settings import Settings
>>> DMR_SETTINGS = {
... Settings.responses: [
... ResponseSpec(
... ErrorModel,
... status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
... ),
... ],
... }
```
Here `500` is a bug and not a feature, so we don’t promise it
to anyone. Instead, we cover the endpoint with tests to be sure
that regular requests never produce it:
views.py
```python
import msgspec
from dmr import Body, Controller
from dmr.exceptions import InternalServerError
from dmr.plugins.msgspec import MsgspecSerializer
class UserModel(msgspec.Struct):
email: str
class UserController(Controller[MsgspecSerializer]):
# `500` is not documented here, we treat it as a bug and not as a feature.
# In development an undocumented `500` is reported as a `422`
# with `Returned status code 500 is not specified` message,
# which is a hint that something is wrong with this endpoint.
def post(self, parsed_body: Body[UserModel]) -> UserModel:
if parsed_body.email.endswith('@example.com'):
# Imagine that our storage is down for this user:
raise InternalServerError('Cannot reach the database')
# This response will be documented by default:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json'
> {"email":"user@wms.org"}
> ```
tests.py
```python
from http import HTTPStatus
from django.http import HttpResponse
from dmr.test import DMRRequestFactory
from examples.error_handling.server_error_undocumented import UserController
def test_no_server_errors() -> None:
"""Ensures that regular requests never return a `500`."""
request = DMRRequestFactory().post(
'/api/user/',
data={'email': 'user@wms.org'},
content_type='application/json',
)
response = UserController.as_view()(request)
assert isinstance(response, HttpResponse)
assert response.status_code == HTTPStatus.CREATED, response.content
```
And then we turn the validation off in production,
where it costs performance anyway, see Response validation:
settings.py
```python
>>> DMR_SETTINGS = {Settings.validate_responses: False}
```
In development the validation is still on, so an unexpected `500`
shows up as a `422` telling you that this status code
is not described. Which is exactly what it is: an undescribed response.
The simplest option: keep the validation on,
but tell us not to validate this status code
with `exclude_validate_responses`.
`500` does not get into the OpenAPI schema
and reaches the client as-is:
views.py
```python
from http import HTTPStatus
import msgspec
from dmr import Body, Controller
from dmr.exceptions import InternalServerError
from dmr.plugins.msgspec import MsgspecSerializer
class UserModel(msgspec.Struct):
email: str
class UserController(Controller[MsgspecSerializer]):
# We still validate everything else, but `500` is not in our schema:
exclude_validate_responses = frozenset((HTTPStatus.INTERNAL_SERVER_ERROR,))
def post(self, parsed_body: Body[UserModel]) -> UserModel:
if parsed_body.email.endswith('@example.com'):
# Imagine that our storage is down for this user.
# This response is never documented in the spec, but it won't be
# replaced with a `422`, because `500` is excluded above.
raise InternalServerError('Cannot reach the database')
# This response will be documented by default:
return parsed_body
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/user/ -X POST -d '{"email": "user@wms.org"}' -H 'Content-Type: application/json'
> {"email":"user@wms.org"}
>
> $ curl http://127.0.0.1:8000/api/user/ -D - -X POST -d '{"email": "user@example.com"}' -H 'Content-Type: application/json'
> HTTP/1.1 500 Internal Server Error
> date: Mon, 05 Oct 2026 11:17:22 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 44
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Internal server error"}]}
> ```
All other status codes are still validated.
The same can be set globally
and per-endpoint, see Excluding some status codes.
> [!WARNING]
> Added in version 0.15.0: `exclude_validate_responses`
### Customizing error messages
All error messages, including pre-defined ones, can be easily customized
on a per-controller basis.
To do so, you would need to change:
1. `error_model` attribute for
all controllers that will be using this error message schema
2. `format_error()` method
to provide custom runtime error formatting
views.py
```python
from http import HTTPStatus
from typing_extensions import TypedDict, override
from dmr import APIError, Body, Controller, ResponseSpec, modify
from dmr.errors import ErrorType, format_error
from dmr.plugins.pydantic import PydanticFastSerializer
class CustomErrorDetail(TypedDict):
message: str
class CustomErrorModel(TypedDict):
errors: list[CustomErrorDetail]
class ApiController(Controller[PydanticFastSerializer]):
error_model = CustomErrorModel
@override
def format_error(
self,
error: str | Exception,
*,
loc: str | list[str | int] | None = None,
error_type: str | ErrorType | None = None,
) -> CustomErrorModel:
default = format_error(
error,
loc=loc,
error_type=error_type,
)
return {
'errors': [
{'message': detail['msg']} for detail in default['detail']
],
}
@modify(
extra_responses=[
ResponseSpec(
return_type=CustomErrorModel,
status_code=HTTPStatus.PAYMENT_REQUIRED,
),
],
)
def post(self, parsed_body: Body[dict[str, str]]) -> str:
raise APIError(
self.format_error('test msg'),
status_code=HTTPStatus.PAYMENT_REQUIRED,
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d '{}' -H 'Content-Type: application/json'
> {"errors":[{"message":"test msg"}]}
>
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d '[]' -H 'Content-Type: application/json'
> {"errors":[{"message":"Input should be a valid dictionary"}]}
> ```
This will also change the OpenAPI schema for the affected controller.
See `ErrorModel`
for the default error model schema.
And `format_error()`
for the default error formatting.
See content negotiation (see Using different error models for different content types)
docs about how to use different error models
for different content types.
But, there are more places that can have their own error schemas:
1. `build_csrf_handler()`
and `CSRFSemanticSchemaProvider`
if you are using CSRF controllers
2. `build_404_handler()` for default `404` responses
3. `build_500_handler()` for default `500` responses
#### Customizing error headers and cookies
Let’s say you want to customize how all errors responses behave
and add a header, for example, `X-Error-Id` from your error tracking system.
How this can be done?
views.py
```python
from collections.abc import Mapping
from http import HTTPStatus
from typing import Annotated, Any
from django.http import HttpResponse
from typing_extensions import override
from dmr import Body, Controller, HeaderSpec, NewCookie
from dmr.errors import ErrorModel
from dmr.metadata import ResponseSpecMetadata
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.renderers import Renderer
class ApiController(Controller[PydanticFastSerializer]):
error_model = Annotated[
ErrorModel,
ResponseSpecMetadata(headers={'X-Error-Id': HeaderSpec()}),
]
def post(self, parsed_body: Body[dict[str, str]]) -> dict[str, str]:
return parsed_body
@override
def to_error(
self,
raw_data: Any,
*,
status_code: HTTPStatus,
headers: Mapping[str, str] | None = None,
cookies: Mapping[str, NewCookie] | None = None,
renderer: Renderer | None = None,
) -> HttpResponse:
headers = dict(headers or {})
headers.setdefault('X-Error-Id', 'Error-Id from your provider')
return super().to_error(
raw_data,
status_code=status_code,
headers=headers,
cookies=cookies,
renderer=renderer,
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d '{"key": "value"}' -H 'Content-Type: application/json'
> {"key":"value"}
>
> $ curl http://127.0.0.1:8000/api/example/ -D - -X POST -d '[]' -H 'Content-Type: application/json'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:17:23 GMT
> server: uvicorn
> X-Error-Id: Error-Id from your provider
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 100
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Input should be a valid dictionary","loc":["parsed_body"],"type":"value_error"}]}
> ```
To attach response headers or cookies to the error model
we use `ResponseSpecMetadata`
inside [`typing.Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated) type.
We also have to redefine `to_error()`
to add missing `X-Error-Id` headers for your error responses.
You can do the same for all responses, not just failing ones.
For this, override `to_response()`.
This can also be used to attach `RateLimit` headers
and other Throttling information.
#### Headers and cookies of union responses
A single response can be a union of several models,
and `ResponseSpecMetadata` can be placed
on the union or on any of its members. Where you put it changes
what you promise:
views.py
```python
from collections.abc import Mapping
from http import HTTPStatus
from typing import Annotated, Any
import pydantic
from django.http import HttpResponse
from typing_extensions import override
from dmr import Body, Controller, HeaderSpec, NewCookie
from dmr.metadata import ResponseSpecMetadata
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.renderers import Renderer
class User(pydantic.BaseModel):
username: str
class ApiController(Controller[PydanticFastSerializer]):
def post(
self,
parsed_body: Body[dict[str, str]],
) -> (
Annotated[
User,
ResponseSpecMetadata(headers={'X-User-Id': HeaderSpec()}),
]
| str
):
username = parsed_body.get('username')
if username is None:
return 'anonymous'
return User(username=username)
@override
def to_response(
self,
raw_data: Any,
*,
status_code: HTTPStatus | None = None,
headers: Mapping[str, str] | None = None,
cookies: Mapping[str, NewCookie] | None = None,
renderer: Renderer | None = None,
) -> HttpResponse:
headers = dict(headers or {})
if isinstance(raw_data, User):
# Only `User` responses declare this header, so we only set it
# here. A `str` response is documented without it.
headers.setdefault('X-User-Id', 'User-Id from your database')
return super().to_response(
raw_data,
status_code=status_code,
headers=headers,
cookies=cookies,
renderer=renderer,
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -D - -X POST -d '{"username": "sobolevn"}' -H 'Content-Type: application/json'
> HTTP/1.1 201 Created
> date: Mon, 05 Oct 2026 11:17:24 GMT
> server: uvicorn
> X-User-Id: User-Id from your database
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 23
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"username":"sobolevn"}
>
> $ curl http://127.0.0.1:8000/api/example/ -D - -X POST -d '{}' -H 'Content-Type: application/json'
> HTTP/1.1 201 Created
> date: Mon, 05 Oct 2026 11:17:24 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 11
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> "anonymous"
> ```
Here only `User` responses carry `X-User-Id`,
a `str` response is returned without it. Since one response
has one set of specs, we document `X-User-Id` as
`required=False`: it can be missing, so we don’t validate
that it is always there.
Annotate the whole union instead when every response has the header:
```python
>>> from typing import Annotated
>>> from dmr import HeaderSpec
>>> from dmr.metadata import ResponseSpecMetadata
>>> AlwaysIdentified = Annotated[
... str | int,
... ResponseSpecMetadata(headers={'X-User-Id': HeaderSpec()}),
... ]
```
Now `X-User-Id` is required for both `str` and `int` responses,
and a response without it fails validation.
The same rule applies when several members are annotated:
a header is required only when every member of the union declares it
as required. Specs of all members end up in the documentation either way.
> [!WARNING]
> Do not design new APIs this way.
>
> One response with one status code should always have the same set of
> required headers and cookies. When it does not, every client has to
> inspect the body first to learn which headers it is allowed to read,
> and the OpenAPI schema cannot express that dependency at all: it only
> says the header is optional.
>
> We support per-member metadata for legacy code and migrations, where
> a response already behaves like this and the behaviour cannot be changed
> yet. For new endpoints, annotate the whole union, or split the response
> into separate status codes.
> [!WARNING]
> Changed in version 0.16.0: Metadata of union members used to be ignored completely.
### Problem Details
> [!NOTE]
> ### See also
>
> RFC: [https://datatracker.ietf.org/doc/html/rfc9457](https://datatracker.ietf.org/doc/html/rfc9457)
`django-modern-rest` supports customizing of all error message
inside the framework, including builtin ones.
`ProblemDetailsError`
is a great example of how it can be done.
It is a regular subclass of `APIError`,
which does not have any special handling inside our framework.
This is done on purpose, so we can be sure that users also can
to customize their exceptions any way they need.
We support two main use-cases for Problem Details.
#### Always raising Problem Details
To always use `ProblemDetailsError`
inside your controller you would need to:
1. Define `error_model` attribute
as `ProblemDetailsModel`
2. Raise an exception itself, pass all the required fields
3. Convert other message to the Problem Details format
using `format_error()` method
views.py
```python
from http import HTTPStatus
from typing import Any
import pydantic
from typing_extensions import override
from dmr import Controller, Query, ResponseSpec
from dmr.errors import ErrorType
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.problem_details import ProblemDetailsError, ProblemDetailsModel
class _QueryModel(pydantic.BaseModel):
number: int = 0
class ProblemDetailsController(Controller[PydanticFastSerializer]):
error_model = ProblemDetailsModel
responses = (
ResponseSpec(error_model, status_code=HTTPStatus.PAYMENT_REQUIRED),
)
async def get(self, parsed_query: Query[_QueryModel]) -> str:
raise ProblemDetailsError(
(
f'Your current balance is {parsed_query.number}, '
'but the price is 15'
),
status_code=HTTPStatus.PAYMENT_REQUIRED,
type='https://example.com/probs/out-of-credit',
title='Not enough funds',
instance='/account/users/1/',
extra={'balance': parsed_query.number, 'price': 15},
)
@override
def format_error(
self,
error: str | Exception,
*,
loc: str | list[str | int] | None = None,
error_type: str | ErrorType | None = None,
) -> Any:
return ProblemDetailsError.format_error(
error,
loc=loc,
error_type=error_type,
title='From format_error',
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/balance/ -X GET
> {"detail":"Your current balance is 0, but the price is 15","status":402,"type":"https://example.com/probs/out-of-credit","title":"Not enough funds","instance":"/account/users/1/","balance":0,"price":15}
>
> $ curl 'http://127.0.0.1:8000/api/balance/?number=a' -X GET
> {"detail":"Input should be a valid integer, unable to parse string as an integer","status":400,"type":"value_error","title":"From format_error"}
> ```
#### Conditionally raising Problem Details
Another way is to negotiate the error response format.
How does it work?
1. When user sends a request with `Accept` header
with `application/problem+json` content type,
we will return Problem Details errors
2. When `application/json` or any other content type is sent,
we return regular `ErrorModel` error payloads
To do so, you would need a slightly more difficult setup:
1. Define `error_model` attribute
as the result of `error_model()`
method call. It will add conditional schema types (see Using different schemes for different content types)
to your error responses
2. Define several `Renderer` types,
including the one which will handle `application/problem+json`
3. Raise a conditional exception:
use `conditional_error()`
to only raise Problem Details when the correct accepted type is passed
4. Convert other message to the Problem Details format
using `format_error()`
method when the correct accepted type is passed
views.py
```python
from http import HTTPStatus
from typing import Any
from typing_extensions import override
from dmr import Controller, ResponseSpec
from dmr.errors import ErrorModel, ErrorType
from dmr.negotiation import ContentType, accepts
from dmr.plugins.pydantic import PydanticSerializer
from dmr.problem_details import ProblemDetailsError
from dmr.renderers import JsonRenderer
class ProblemDetailsController(Controller[PydanticSerializer]):
error_model = ProblemDetailsError.error_model({
ContentType.json: ErrorModel,
})
renderers = (
JsonRenderer(ContentType.json),
JsonRenderer(ContentType.json_problem_details),
)
responses = (
ResponseSpec(error_model, status_code=HTTPStatus.PAYMENT_REQUIRED),
)
async def get(self) -> str:
raise ProblemDetailsError.conditional_error(
'Your current balance is 0, but the price is 15',
status_code=HTTPStatus.PAYMENT_REQUIRED,
type='https://example.com/probs/out-of-credit',
title='Not enough funds',
instance='/account/users/1/',
extra={'balance': 0, 'price': 15},
controller=self,
)
@override
def format_error(
self,
error: str | Exception,
*,
loc: str | list[str | int] | None = None,
error_type: str | ErrorType | None = None,
) -> Any:
if accepts(self.request, ContentType.json_problem_details):
return ProblemDetailsError.format_error(
error,
loc=loc,
error_type=error_type,
title='From format_error',
)
return super().format_error(error, loc=loc, error_type=error_type)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/balance/ -X GET
> {"detail":[{"msg":"Your current balance is 0, but the price is 15","type":"https://example.com/probs/out-of-credit"}]}
>
> $ curl http://127.0.0.1:8000/api/balance/ -X GET -H 'Accept: application/problem+json'
> {"detail":"Your current balance is 0, but the price is 15","status":402,"type":"https://example.com/probs/out-of-credit","title":"Not enough funds","instance":"/account/users/1/","balance":0,"price":15}
> ```
> [!TIP]
> You can still make `application/problem+json` the default
> and when `application/json` (or any other type) is explicitly requested
> return the `ErrorModel` errors.
### Handling validation errors from models
When creating models with, for example, [`pydantic.BaseModel`](https://pydantic.dev/docs/validation/latest/api/pydantic/base_model/#pydantic.BaseModel),
your validation can fail. This error will not be handled by design.
Why? Because catching all specific validation errors for a specific serializer
that can happen in your application will do more harm than good.
This is the default behavior:
views.py
```python
from typing import Literal
import pydantic
from dmr import Controller
from dmr.plugins.pydantic import PydanticFastSerializer
class Pong(pydantic.BaseModel):
message: Literal['pong']
class PongController(Controller[PydanticFastSerializer]):
def get(self) -> Pong:
# This will trigger `pydantic.ValidationError`,
# because `message` must be `'pong'`, not `'wrong'`:
return Pong(message='wrong')
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/ping/ -D - -X GET
> HTTP/1.1 500 Internal Server Error
> date: Mon, 05 Oct 2026 11:17:26 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 68
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Internal server error","type":"internal_error"}]}
> ```
If you want to catch this error in a specific place
and attach a specific behavior, use an error handler at a proper level.
For example, here we would handle it on a controller level:
views.py
```python
from http import HTTPStatus
from typing import Literal
import pydantic
from django.http import HttpResponse
from typing_extensions import override
from dmr import Controller, ResponseSpec
from dmr.endpoint import Endpoint
from dmr.plugins.pydantic import PydanticFastSerializer
class Pong(pydantic.BaseModel):
message: Literal['pong']
class PongController(Controller[PydanticFastSerializer]):
responses = (
ResponseSpec(
Controller.error_model,
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
),
)
def get(self) -> Pong:
# This will trigger `pydantic.ValidationError`,
# because `message` must be `'pong'`, not `'wrong'`:
return Pong(message='wrong')
@override
def handle_error(
self,
endpoint: Endpoint,
controller: Controller[PydanticFastSerializer],
exc: Exception,
) -> HttpResponse:
if isinstance(exc, pydantic.ValidationError):
# Now, handle the error, but do not show what actually happened
# to the outside world, it might contain sensitive data:
return self.to_response(
self.format_error('Validation error'),
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
)
return super().handle_error(endpoint, controller, exc)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/ping/ -D - -X GET
> HTTP/1.1 500 Internal Server Error
> date: Mon, 05 Oct 2026 11:17:27 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 39
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Validation error"}]}
> ```
Now, the error is handled: we modified its error text and status code.
Remember not to dump all the error information out to users,
since they might contain sensitive data.
> [!NOTE]
> ### See also
>
> See Handling 500 errors if you want to change the `500` error rendering.
### API Reference
#### dmr.errors.global_error_handler(endpoint: Endpoint, controller: Controller[BaseSerializer], exc: Exception) → HttpResponse
Global error handler for all cases.
It is the last item in the chain that we try:
1. Per endpoint configuration via
`handle_error()`
and `handle_async_error()`
methods
2. Per controller handlers
3. This global handler, specified via the configuration
If some exception cannot be handled, it is just reraised.
* **Parameters:**
* **endpoint** – Endpoint where error happened.
* **controller** – Controller instance that *endpoint* belongs to.
* **exc** – Exception instance that happened.
* **Returns:**
[`HttpResponse`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpResponse) with proper response for this error.
Or raise *exc* back.
Here’s an example that will produce
`{'detail': [{'msg': 'inf', 'type': 'user_msg'}]}`
for any [`ZeroDivisionError`](https://docs.python.org/3/builtins/exceptions.html#ZeroDivisionError) in your application:
```python
>>> from http import HTTPStatus
>>> from django.http import HttpResponse
>>> from dmr.controller import Controller
>>> from dmr.endpoint import Endpoint
>>> from dmr.errors import global_error_handler, ErrorType
>>> def custom_error_handler(
... controller: Controller,
... endpoint: Endpoint,
... exc: Exception,
... ) -> HttpResponse:
... if isinstance(exc, ZeroDivisionError):
... return controller.to_error(
... controller.format_error(
... 'inf',
... error_type=ErrorType.user_msg,
... ),
... status_code=HTTPStatus.NOT_IMPLEMENTED,
... )
... # Call the original handler to handle default errors:
... return global_error_handler(controller, endpoint, exc)
>>> # And then in your settings file:
>>> DMR_SETTINGS = {
... # Object `custom_error_handler` will also work:
... 'global_error_handler': 'path.to.custom_error_handler',
... }
```
> [!WARNING]
> Make sure you always call original `global_error_handler`
> in the very end. Unless, you want to disable original error handling.
#### dmr.errors.wrap_handler(method: \_MethodSyncHandler) → SyncErrorHandler
#### dmr.errors.wrap_handler(method: \_MethodAsyncHandler) → AsyncErrorHandler
Utility function to wrap controller methods.
It is used to wrap an existing controller method
and pass it as `error_handler=` argument to an endpoint.
#### *final class* dmr.errors.ErrorType(\*values)
Collection of all possible error types that we use in DMR.
##### value_error
Raised when we can’t parse something.
##### internal_error
Raised when internal error happens.
##### not_allowed
Raised when using unsupported http method. 405 alias.
##### security
Raised when security related error happens.
##### ratelimit
Raised when ratelimit related error happens.
##### user_msg
Raised for custom errors from users.
##### not_found
Raised when we can’t find controller.
##### streaming
Happens when we stream events.
#### *class* dmr.errors.ErrorModel
Bases: [`TypedDict`](https://typing-extensions.readthedocs.io/en/stable/index.html#typing_extensions.TypedDict)
Default error response schema.
Can be customized.
See Customizing error messages for more details.
#### *class* dmr.errors.ErrorDetail
Bases: [`TypedDict`](https://typing-extensions.readthedocs.io/en/stable/index.html#typing_extensions.TypedDict)
Base schema for error details description.
##### msg *: str*
Optional error type identifier.
##### type *: NotRequired[str]*
Optional location where this error happened.
#### dmr.errors.format_error(error: str | Exception, \*, loc: str | list[str | int] | None = None, error_type: str | ErrorType | None = None) → ErrorModel
Convert error to the common format.
Default implementation.
* **Parameters:**
* **error** – A serialization exception like a validation error.
* **loc** – Location where this error happened.
Like `"headers"`, or `"field_name"`,
or `["parsed_headers", "header_name"]`.
* **error_type** – Optional type of the error for extra metadata.
* **Returns:**
Simple python object - exception converted to a common format.
#### Problem Details API
#### *class* dmr.problem_details.ProblemDetailsError(detail: str, \*, status_code: HTTPStatus, type: str | None = None, title: str | None = None, instance: str | None = None, extra: Mapping[str, Any] | None = None, headers: Mapping[str, str] | None = None, cookies: Mapping[str, NewCookie] | None = None, show_status: bool = True, show_detail: bool = True)
Bases: `APIError`[`ProblemDetailsModel`]
Problem Details exception.
It is a subclass of `dmr.response.APIError`,
so you can raise it anywhere in the REST part of your app.
There are two major use-cases that we support for this class:
1. Direct usage: you raise an error and get what you raise, no changes
2. Conditional usage: you call `conditional_error` method
and if the client accepts `application/problem+json`, we will return
the proper Problem Details description. But, if it is not requested
directly, we will return our regular `dmr.errors.ErrorModel`
Both use-cases are independent. You can decide what to use per controller.
##### *classmethod* conditional_error(detail: str, \*, status_code: HTTPStatus, controller: Controller[BaseSerializer], type: str | None = None, title: str | None = None, instance: str | None = None, extra: Mapping[str, Any] | None = None, headers: Mapping[str, str] | None = None, cookies: Mapping[str, NewCookie] | None = None, show_status: bool = True, show_detail: bool = True) → APIError[Any]
Create conditional error.
If request accepts `application/problem+json` then return
a Problem Details exception. If not, return regular
`dmr.response.APIError` instance.
Otherwise, returns regular error from *controller* using its
`format_error()` method for formatting.
##### *classmethod* error_model(existing_errors: Mapping[str, Any], content_type: str | None = None) → Any
Builds an error model for conditional responses.
Only use this method when you use `conditional_error`.
If you are using regular exceptions,
use `ProblemDetailsModel` directly.
##### *classmethod* format_error(error: str | Exception, \*, loc: str | list[str | int] | None = None, error_type: str | ErrorType | None = None, status_code: HTTPStatus | None = None, title: str | None = None, instance: str | None = None, extra: Mapping[str, Any] | None = None) → ErrorModel | ProblemDetailsModel
Format other errors to be in format of Problem Details.
##### problem_details_model
alias of `ProblemDetailsModel`
#### *class* dmr.problem_details.ProblemDetailsModel
Error payload model for Problem Details.
See [https://datatracker.ietf.org/doc/html/rfc9457](https://datatracker.ietf.org/doc/html/rfc9457)
for the detailed description of each field.
Source: /en/latest/pages/throttling.md
## Throttling
`django-modern-rest` ships its own throttling
(also known as “rate limiting”) mechanism.
Here’s how everything works.
> [!IMPORTANT]
> If you have an option not to use ratelimiting in Django,
> but to use it on the HTTP Proxy side, you should prefer the proxy.
> It is significantly faster and more secure.
### Defining throttling
We have two classes to define throttling:
- `dmr.throttling.SyncThrottle` for sync endpoints
- `dmr.throttling.AsyncThrottle` for async endpoints
We can define throttling on three different levels:
per endpoint
per controller
per settings
views.py
```python
from dmr import Controller, modify
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.throttling import Rate, SyncThrottle
class SyncController(Controller[PydanticFastSerializer]):
@modify(throttling=[SyncThrottle(1, Rate.minute)])
def get(self) -> str:
return 'inside'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/sync/ -X GET
> "inside"
>
> $ curl http://127.0.0.1:8000/api/sync/ -D - -X GET
> HTTP/1.1 429 Too Many Requests
> date: Mon, 05 Oct 2026 11:18:42 GMT
> server: uvicorn
> X-RateLimit-Limit: 1
> X-RateLimit-Remaining: 0
> X-RateLimit-Reset: 60
> Retry-After: 60
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 59
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Too many requests","type":"ratelimit"}]}
> ```
views.py
```python
from dmr import Controller
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.throttling import AsyncThrottle, Rate
class AsyncController(Controller[PydanticFastSerializer]):
throttling = (AsyncThrottle(1, Rate.minute),)
async def get(self) -> str:
return 'inside'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/async/ -X GET
> "inside"
>
> $ curl http://127.0.0.1:8000/api/async/ -D - -X GET
> HTTP/1.1 429 Too Many Requests
> date: Mon, 05 Oct 2026 11:18:43 GMT
> server: uvicorn
> X-RateLimit-Limit: 1
> X-RateLimit-Remaining: 0
> X-RateLimit-Reset: 60
> Retry-After: 60
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 59
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Too many requests","type":"ratelimit"}]}
> ```
Set `throttling` setting
to enable throttling for all controllers.
settings.py
```python
>>> from dmr.settings import Settings
>>> from dmr.throttling import SyncThrottle, Rate
>>> DMR_SETTINGS = {Settings.throttling: [SyncThrottle(5, Rate.minute)]}
```
When your project mixes sync and async endpoints,
use `SyncOrAsyncThrottle`
in settings:
settings.py
```python
>>> from dmr.settings import Settings
>>> from dmr.throttling import (
... AsyncThrottle,
... Rate,
... SyncOrAsyncThrottle,
... SyncThrottle,
... )
>>> DMR_SETTINGS = {
... Settings.throttling: [
... SyncOrAsyncThrottle(
... SyncThrottle(5, Rate.minute),
... AsyncThrottle(5, Rate.minute),
... ),
... ],
... }
```
> [!NOTE]
> `SyncOrAsyncThrottle` is only allowed
> in `DMR_SETTINGS`. Using it on a controller or endpoint
> raises `EndpointMetadataError`.
Providing several throttling instances means that all of them must succeed.
Throttling rules from different levels are not merged:
endpoint `throttling` overrides controller `throttling`,
controller `throttling` overrides `throttling`.
To combine them, do it explicitly:
views.py
```python
from dmr import Controller, modify
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.throttling import Rate, SyncThrottle
class SyncController(Controller[PydanticFastSerializer]):
throttling = (SyncThrottle(5, Rate.hour),)
@modify(throttling=[SyncThrottle(1, Rate.minute), *throttling])
def get(self) -> str:
return 'inside'
def post(self) -> str:
return 'inside'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/sync/ -X GET
> "inside"
>
> $ curl http://127.0.0.1:8000/api/sync/ -D - -X GET
> HTTP/1.1 429 Too Many Requests
> date: Mon, 05 Oct 2026 11:18:44 GMT
> server: uvicorn
> X-RateLimit-Limit: 1
> X-RateLimit-Remaining: 0
> X-RateLimit-Reset: 59
> Retry-After: 59
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 59
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Too many requests","type":"ratelimit"}]}
> ```
Will guard `GET` method with 2 throttling checks:
1. Not more `<=` than 1 request per minute
2. And not more `<=` than 5 requests per hour
While `POST` method will only have the controller-level check.
> [!WARNING]
> Changed in version 0.16.0: Throttling rules from different levels used to be merged.
### Customizing throttling
#### Rates
`Rate` is passed as the second required
parameter to throttle classes. However, all values that you pass
are just numbers of seconds. So, you can fully customize throttling
timings by passing any amount of seconds that you wish:
settings.py
```python
>>> from dmr.settings import Settings, DMR_SETTINGS
>>> from dmr.throttling import SyncThrottle
>>> DMR_SETTINGS = {
... Settings.throttling: [
... SyncThrottle(
... max_requests=5,
... duration_in_seconds=10,
... ),
... ],
... }
```
This will set a throttling rule: no more than 5 requests in 10 seconds.
#### Backends
Backends are used to define where we store throttling data.
By default we use:
- `dmr.throttling.backends.SyncDjangoCache` for sync endpoints
- `dmr.throttling.backends.AsyncDjangoCache` for async endpoints
All backends that we support can be further customized.
DjangoCache
Redis
By default we store all the data in the `'default'` Django cache.
You can customize which Django cache name is used. For example:
views.py
```python
from dmr import Controller
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.throttling import Rate, SyncThrottle
from dmr.throttling.backends import SyncDjangoCache
class SyncController(Controller[PydanticFastSerializer]):
throttling = (
SyncThrottle(
max_requests=1,
duration_in_seconds=Rate.minute,
backend=SyncDjangoCache(cache_name='throttling'),
),
)
def get(self) -> str:
return 'inside'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/sync/ -X GET
> "inside"
>
> $ curl http://127.0.0.1:8000/api/sync/ -D - -X GET
> HTTP/1.1 429 Too Many Requests
> date: Mon, 05 Oct 2026 11:18:44 GMT
> server: uvicorn
> X-RateLimit-Limit: 1
> X-RateLimit-Remaining: 0
> X-RateLimit-Reset: 59
> Retry-After: 59
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 59
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Too many requests","type":"ratelimit"}]}
> ```
Any Redis-compliant tool is supported, including: Valkey, KeyDB, etc.
You can fully customize the client:
views.py
```python
from typing import Any
import redis
from django.core.cache import caches
from dmr import Controller
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.throttling import Rate, SyncThrottle
from dmr.throttling.backends.redis import SyncRedis
# If `CACHES['redis']` with `django.core.cache.backends.redis.RedisCache`
# backend is defined in `settings.py`:
redis_client: 'redis.Redis[Any]' = caches['redis']._cache.get_client() # type: ignore[attr-defined] # noqa: SLF001
class SyncController(Controller[PydanticFastSerializer]):
throttling = (
SyncThrottle(
max_requests=1,
duration_in_seconds=Rate.minute,
backend=SyncRedis(redis_client),
),
)
def get(self) -> str:
return 'inside'
```
> [!NOTE]
> Make sure that [redis](https://pypi.org/project/redis/) client
> library is installed, we don’t ship
> it together with `django-modern-rest`.
You can also write your own backends, for example,
to store throttling information in memory, filesystem, or somewhere else.
To do so, you would need to subclass
`dmr.throttling.backends.BaseThrottleSyncBackend`
or `dmr.throttling.backends.BaseThrottleAsyncBackend`
and override 2 methods.
Full list of backends that we ship in `django-modern-rest`:
- `SyncDjangoCache`
and `AsyncDjangoCache`, default
- `SyncRedis`
and `AsyncRedis`
> [!WARNING]
> When using `SyncDjangoCache`
> or `AsyncDjangoCache`
> the final behavior will depend on the cache that you use.
>
> Some Django cache backends like
> `django.core.cache.backends.locmem.LocMemCache`
> store cache in memory per-process. So, any multiprocess environments
> with `N` processes will allow to use `N * max_request` requests.
> Using such cache backends is not safe.
>
> Some like `django.core.cache.backends.dummy.DummyCache` do nothing at all.
##### Choosing a backend
| Backend | Atomicity | Overhead | Supported algorithms | Best suited for |
|---------------|---------------------------------------------------------|--------------------------------------|--------------------------------------------------------|-----------------------------------------------------------------|
| `DjangoCache` | Per-process: multiprocess deployments may face problems | Very low (depends on the cache type) | All | Non-critical IP based checks with not-strict windows and limits |
| `Redis` | Full | Low | All builtin ones, but requires `lua` scripting support | Strict distributed limits |
##### Unsafe backend warning
By default, `django-modern-rest` emits
a `UnsafeCacheBackendWarning`
warning when detecting an unsafe cache backend for throttling
and `settings.DEBUG` is `False`.
Unsafe backends are fine for local development,
so no warning is emitted when `settings.DEBUG` is `True`.
You can configure this check with the `allow_unsafe_cache`
parameter of `SyncDjangoCache`
and `AsyncDjangoCache`:
```python
>>> from dmr.throttling import Rate, SyncThrottle
>>> from dmr.throttling.backends import SyncDjangoCache
>>> throttle = SyncThrottle(
... 10,
... Rate.minute,
... backend=SyncDjangoCache(allow_unsafe_cache=False),
... )
```
When `allow_unsafe_cache` is set to `False`,
we raise a `dmr.exceptions.EndpointMetadataError`
exception instead of a warning, regardless of `settings.DEBUG`.
This will ensure the maximum safety.
To suppress this check completely and run throttling at your own risk,
set `allow_unsafe_cache` to `True`.
#### Algorithms
Algorithms are used to define the logic of how requests are counted.
By default we use `dmr.throttling.algorithms.SimpleRate`
as the algorithm.
It defines a fixed window with a fixed amount of requests possible.
When window is expired, it resets the count of requests.
Here’s how you can customize the algorithm for a throttling:
views.py
```python
from dmr import Controller
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.throttling import Rate, SyncThrottle
from dmr.throttling.algorithms import LeakyBucket
class SyncController(Controller[PydanticFastSerializer]):
throttling = (
SyncThrottle(
max_requests=1,
duration_in_seconds=Rate.minute,
algorithm=LeakyBucket(),
),
)
def get(self) -> str:
return 'inside'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/sync/ -X GET
> "inside"
> ```
You can also write your own algorithms.
To do so, you would need to subclass
`dmr.throttling.algorithms.BaseThrottleAlgorithm`
and override 2 required methods.
Full list of algorithms that we ship in `django-modern-rest`:
- `SimpleRate`, default
- `LeakyBucket` where requests fill
the bucket; tokens leak at a steady rate. Unlike `SimpleRate`,
drains continuously providing smoother rate-limiting
without allowing bursts at window boundaries.
> [!WARNING]
> `SimpleRate` uses a **fixed window**
> that resets when the window expires. This allows a boundary burst pattern:
> a client can send `N` requests right before the window resets and `N`
> more right after, effectively firing `2N` requests in a short interval
> while remaining within the configured per-window limit.
>
> For abuse-sensitive endpoints (login, OTP, password reset) prefer
> `LeakyBucket`, which drains
> continuously and eliminates this burst window.
##### Choosing an algorithm
| Algorithm | Window type | Overhead | Boundary burst risk | Best suited for |
|---------------|---------------------------------|------------|-----------------------------------------------|----------------------------------------------------------|
| `SimpleRate` | Fixed — resets after `duration` | Very low | Yes — up to `2N` requests in a short burst | General-purpose, internal, and admin endpoints |
| `LeakyBucket` | Continuous drain | Low | No — traffic is smoothed regardless of timing | Auth endpoints (login, OTP, password reset), public APIs |
For auth and abuse-sensitive endpoints, use
`LeakyBucket`,
see the algorithm customization example above.
#### Cache keys
Cache keys is what defines how requests are identified.
By default we use `dmr.throttling.cache_keys.RemoteAddr()` cache key,
which identifies requests by IP taken from
[REMOTE_ADDR](https://docs.djangoproject.com/en/stable/ref/request-response/#django.http.HttpRequest.META)
value from `request.META`.
> [!WARNING]
> If you are using reverse proxies, make sure to correctly configure
> how they pass request headers, to `REMOTE_ADDR` would be correct.
You can write your own cache keys, they are subclasses
of `BaseThrottleCacheKey`
and must return a string or `None`.
If cache key returns `None`, it means that this request
will be skipped from this exact throttling check.
However, other keys may still be applied.
It is useful to skip some requests from throttling checks,
for example, from paid or stuff users.
Full list of cache keys that we ship in `django-modern-rest`:
- `RemoteAddr`, default
- `UserPk`, based on `request.user`,
by default we use `request.user.pk` if it exists.
You can pass `exclude_stuff` argument as `False`
to also limit `is_stuff` users,
or you can pass `exclude_superuser` argument as `False`
to also limit super users
- `JwtToken`, based on
`request.__dmr_jwt__`.
Uses `jti` claim when present and falls back to `sub` claim.
Raw value is hashed before being used as a cache key.
Returns `None` when `request.__dmr_jwt__` is not set.
#### When throttling is executed
Throttling is executed in two stages: before auth and after auth.
Why? Because we need to:
1. Protect auth from abusive requests and brute forcing
2. Make sure we can base throttling rules on the auth info
The same can be said about content negotiation,
it also must be protected by throttling.
Otherwise, people can abuse content negotiation without any request limits.
```mermaid
---
config:
theme: forest
---
graph
Start[New request] --> BeforeThrottle[Throttling based on IP or 429];
BeforeThrottle --> RendererNegotiation[Renderer is negotiated or 406];
RendererNegotiation --> Auth[Auth or 401];
Auth --> AfterThrottle[Throttling based on auth or 429];
```
Throttling execution
All cache keys know when to execute by default, however you can customize this.
For example, you can run some IP based throttling checks after the auth itself:
views.py
```python
from dmr import Controller, modify
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.security.django_session import DjangoSessionSyncAuth
from dmr.throttling import Rate, SyncThrottle
from dmr.throttling.cache_keys import RemoteAddr
class SyncController(Controller[PydanticFastSerializer]):
@modify(
auth=[DjangoSessionSyncAuth()],
throttling=[
SyncThrottle(
1,
Rate.minute,
cache_key=RemoteAddr(runs_before_auth=False),
),
],
)
def get(self) -> str:
return 'inside'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/sync/ -X GET
> {"detail":[{"msg":"Not authenticated","type":"security"}]}
>
> $ curl http://127.0.0.1:8000/api/sync/ -X GET
> {"detail":[{"msg":"Not authenticated","type":"security"}]}
> ```
> [!WARNING]
> It is **strongly** not recommended to have
> auth without any throttling before it.
>
> Auth must be protected from brute force and denial of service attacks!
> For example, one can also use
> [django-axes](https://github.com/jazzband/django-axes) for this.
>
> [wemake-django-template](https://github.com/wemake-services/wemake-django-template)
> has this configured properly.
Note that it won’t make any sense to run auth-based throttling before auth.
So, customize it with care.
#### Headers
By default on
[429 Too Many Requests](https://developer.mozilla.org/de/docs/Web/HTTP/Reference/Status/429)
error we return four headers:
- `X-RateLimit-Limit` - The maximum number of requests permitted
in the current time window
- `X-RateLimit-Remaining` - The number of requests remaining
in the current time window
- `X-RateLimit-Reset` - The number of seconds until the current
rate limit window resets
- `Retry-After` - The number of seconds until the current
rate limit window resets, see
[RFC-6585](https://datatracker.ietf.org/doc/html/rfc6585#section-4)
and [RFC-7231](https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3)
> [!NOTE]
> Headers with `X-` prefix means that they are custom ones,
> there’s no spec behind them.
> However, this convention is the most popular one as of right now.
OpenAPI support is built in for this feature.
All headers classes will provide the proper
`HeaderSpec` for the `429` response.
You might want to customize the returned headers. To do so,
you can pass `response_headers` argument to throttling classes
with header classes that you actually want to support.
For example, you can disable
[Retry-After](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After)
header with:
views.py
```python
from dmr import Controller
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.throttling import AsyncThrottle, Rate
from dmr.throttling.headers import XRateLimit
class AsyncController(Controller[PydanticFastSerializer]):
throttling = (
AsyncThrottle(1, Rate.minute, response_headers=[XRateLimit()]),
)
async def get(self) -> str:
return 'inside'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/async/ -X GET
> "inside"
>
> $ curl http://127.0.0.1:8000/api/async/ -D - -X GET
> HTTP/1.1 429 Too Many Requests
> date: Mon, 05 Oct 2026 11:18:47 GMT
> server: uvicorn
> X-RateLimit-Limit: 1
> X-RateLimit-Remaining: 0
> X-RateLimit-Reset: 60
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 59
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Too many requests","type":"ratelimit"}]}
> ```
Or if you want to support
the [latest draft](https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-10.html)
about `RateLimit` and `RateLimit-Policy` headers, you can use:
views.py
```python
from dmr import Controller
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.throttling import Rate, SyncThrottle
from dmr.throttling.headers import RateLimitIETFDraft
class SyncController(Controller[PydanticFastSerializer]):
throttling = (
SyncThrottle(1, Rate.minute, response_headers=[RateLimitIETFDraft()]),
)
def get(self) -> str:
return 'inside'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/sync/ -X GET
> "inside"
>
> $ curl http://127.0.0.1:8000/api/sync/ -D - -X GET
> HTTP/1.1 429 Too Many Requests
> date: Mon, 05 Oct 2026 11:18:48 GMT
> server: uvicorn
> RateLimit-Policy: 1;w=60;name="RemoteAddr"
> RateLimit: "RemoteAddr";r=0;t=59
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 59
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Too many requests","type":"ratelimit"}]}
> ```
You can also combine these headers with each other in any combinations.
You can write your own classes with custom headers support.
To do so, subclass `dmr.throttling.headers.BaseResponseHeadersProvider`.
You can completely disable any extra response headers by passing an empty list.
Full list of header providers that we ship in `django-modern-rest`:
- `XRateLimit`, default
- `RetryAfter`, default
- `RateLimitIETFDraft`
### Security
Key considerations:
- Be sure to correctly setup your HTTP Proxy server to send correct IP headers
- Be especially careful with `X-Forwarded-For` header,
because it can contain several layers of proxies
- Never rate limit on user supplied data such as `User-Agent`,
because this data can easily be changed
- Denial of service: be careful not to limit other users when limiting just one
- Do not store sensitive or personal users’ data in your cache keys,
because it is stored with no protection / encryption
> [!NOTE]
> ### See also
>
> [https://django-ratelimit.readthedocs.io/en/latest/security.html](https://django-ratelimit.readthedocs.io/en/latest/security.html)
### Throttling reports
If you need to attach any throttling headers to successful responses,
you can do it as well.
For this we offer two APIs:
- `dmr.throttling.ThrottlingReport.report()` for sync APIs
- `dmr.throttling.ThrottlingReport.areport()` for async APIs
All our regular rules apply:
- All new headers must be added to the corresponding
`ResponseSpec` definitions
- When settings headers, you would need to use `validate`
views.py
```python
from http import HTTPStatus
from typing import Final
from django.http import HttpResponse
from dmr import Controller, ResponseSpec, validate
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.throttling import AsyncThrottle, Rate, ThrottlingReport
from dmr.throttling.cache_keys import RemoteAddr
from dmr.throttling.headers import RateLimitIETFDraft
_draft_headers: Final = RateLimitIETFDraft()
class AsyncController(Controller[PydanticFastSerializer]):
@validate(
ResponseSpec(
str,
status_code=HTTPStatus.OK,
headers=_draft_headers.provide_headers_specs(),
),
throttling=[
AsyncThrottle(
1,
Rate.second,
response_headers=[_draft_headers],
cache_key=RemoteAddr(name='per-second'),
),
AsyncThrottle(
5,
Rate.minute,
response_headers=[_draft_headers],
cache_key=RemoteAddr(name='per-minute'),
),
],
)
async def get(self) -> HttpResponse:
return self.to_response(
'inside',
headers=await ThrottlingReport(self).areport(),
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/async/ -D - -X GET
> HTTP/1.1 200 OK
> date: Mon, 05 Oct 2026 11:18:48 GMT
> server: uvicorn
> RateLimit-Policy: 1;w=1;name="per-second", 5;w=60;name="per-minute"
> RateLimit: "per-second";r=0;t=1, "per-minute";r=4;t=60
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 8
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> "inside"
> ```
Use `headers` argument to `to_response()`
to add needed headers.
> [!WARNING]
> `ThrottlingReport` will make `N` cache requests
> when building header reports (where `N` is the number of throttle
> instances used for this endpoint).
>
> It might be slow, depending on the number of throttles and your cache.
>
> It might also fail, we don’t handle any errors
> in the reports building process.
>
> Use this feature only when there’s a serious need for it.
You can also provide the same headers not just for successful responses,
but for any errors that consume ratelimit quota as well.
To so, you would need
to customize headers spec (see Customizing error messages)
of your error model:
views.py
```python
from collections.abc import Mapping
from http import HTTPStatus
from typing import Annotated, Any, Final
import pydantic
from django.http import HttpResponse
from typing_extensions import override
from dmr import Controller, NewCookie, Query, ResponseSpec, validate
from dmr.errors import ErrorModel
from dmr.metadata import ResponseSpecMetadata
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.renderers import Renderer
from dmr.throttling import Rate, SyncThrottle, ThrottlingReport
from dmr.throttling.cache_keys import RemoteAddr
from dmr.throttling.headers import RateLimitIETFDraft
_draft_headers: Final = RateLimitIETFDraft()
class _QueryModel(pydantic.BaseModel):
number: int
class SyncController(Controller[PydanticFastSerializer]):
error_model = Annotated[
ErrorModel,
ResponseSpecMetadata(headers=_draft_headers.provide_headers_specs()),
]
@validate(
ResponseSpec(
str,
status_code=HTTPStatus.OK,
headers=_draft_headers.provide_headers_specs(),
),
throttling=[
SyncThrottle(
2,
Rate.second,
response_headers=[_draft_headers],
cache_key=RemoteAddr(name='per-second'),
),
SyncThrottle(
5,
Rate.minute,
response_headers=[_draft_headers],
cache_key=RemoteAddr(name='per-minute'),
),
],
)
def get(self, parsed_query: Query[_QueryModel]) -> HttpResponse:
return self.to_response('inside')
@override
def to_response(
self,
raw_data: Any,
*,
status_code: HTTPStatus | None = None,
headers: Mapping[str, str] | None = None,
cookies: Mapping[str, NewCookie] | None = None,
renderer: Renderer | None = None,
) -> HttpResponse:
response_headers = ThrottlingReport(self).report()
response_headers.update(headers or {})
return super().to_response(
raw_data,
status_code=status_code,
headers=response_headers,
cookies=cookies,
renderer=renderer,
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl 'http://127.0.0.1:8000/api/sync/?number=1' -D - -X GET
> HTTP/1.1 200 OK
> date: Mon, 05 Oct 2026 11:18:49 GMT
> server: uvicorn
> RateLimit-Policy: 2;w=1;name="per-second", 5;w=60;name="per-minute"
> RateLimit: "per-second";r=1;t=1, "per-minute";r=4;t=60
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 8
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> "inside"
>
> $ curl 'http://127.0.0.1:8000/api/sync/?number=a' -D - -X GET
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:18:49 GMT
> server: uvicorn
> RateLimit-Policy: 2;w=1;name="per-second", 5;w=60;name="per-minute"
> RateLimit: "per-second";r=0;t=1, "per-minute";r=3;t=60
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 145
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Input should be a valid integer, unable to parse string as an integer","loc":["parsed_query","number"],"type":"value_error"}]}
> ```
Method `to_response` is used for both successful and error responses.
This way both your successful responses
and error responses will have the needed ratelimiting headers.
### Testing
> [!NOTE]
> `dmr.test` and our `pytest` plugin ship helpers to drive an endpoint
> to its limit and assert the `429` – `assert_throttling`,
> `reduced_throttling` and `assert_throttled`.
> See Testing throttling for details.
### API Reference
#### Base
#### *class* dmr.throttling.SyncThrottle(max_requests: int, duration_in_seconds: Rate | int, \*, cache_key: BaseThrottleCacheKey | None = None, backend: \_BackendT | None = None, algorithm: BaseThrottleAlgorithm | None = None, response_headers: Iterable[BaseResponseHeadersProvider] | None = None)
Sync throttle type for sync endpoints.
> [!WARNING]
> Added in version 0.7.0.
##### \_\_call_\_(endpoint: Endpoint, controller: Controller[BaseSerializer], lock: AbstractContextManager[Any, Any]) → None
Put your throttle business logic here.
Return `None` if throttle check passed.
Raise `dmr.exceptions.TooManyRequestsError`
if throttle check failed.
Raise `dmr.response.APIError`
if you want to change the return code, for example,
when some data is missing or has wrong format.
##### \_\_replace_\_(\*, max_requests: int | None = None, duration_in_seconds: Rate | int | None = None, cache_key: BaseThrottleCacheKey | None = None, backend: \_BackendT | None = None, algorithm: BaseThrottleAlgorithm | None = None, response_headers: Iterable[BaseResponseHeadersProvider] | None = None) → Self
Return a copy of this throttle, overriding only the given fields.
Any argument left as `None` keeps this throttle’s current value, so
`replace(max_requests=2)` changes just the limit. Also backs
`copy.replace` (Python 3.13+) through `__replace__`.
> [!WARNING]
> Added in version 0.12.0.
##### collect_response_headers(endpoint: Endpoint, controller: Controller[BaseSerializer], remaining: int, reset: int, \*, report_all: bool = False) → dict[str, str]
Collects response headers for all `response_headers` classes.
##### full_cache_key(endpoint: Endpoint, controller: Controller[BaseSerializer]) → str | None
Get the full cache key value.
##### provide_response_specs(metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], existing_responses: Mapping[HTTPStatus, ResponseSpec]) → list[ResponseSpec]
Provides responses that can happen when throttle triggers.
##### replace(\*, max_requests: int | None = None, duration_in_seconds: Rate | int | None = None, cache_key: BaseThrottleCacheKey | None = None, backend: \_BackendT | None = None, algorithm: BaseThrottleAlgorithm | None = None, response_headers: Iterable[BaseResponseHeadersProvider] | None = None) → Self
Return a copy of this throttle, overriding only the given fields.
Any argument left as `None` keeps this throttle’s current value, so
`replace(max_requests=2)` changes just the limit. Also backs
`copy.replace` (Python 3.13+) through `__replace__`.
> [!WARNING]
> Added in version 0.12.0.
##### report_usage(endpoint: Endpoint, controller: Controller[BaseSerializer]) → dict[str, str]
Report throttle usage stats.
##### validate(controller_cls: type[Controller[BaseSerializer]], metadata: EndpointMetadata) → None
Validate throttling configuration at import time.
> [!WARNING]
> Added in version 0.16.0.
#### *class* dmr.throttling.AsyncThrottle(max_requests: int, duration_in_seconds: Rate | int, \*, cache_key: BaseThrottleCacheKey | None = None, backend: \_BackendT | None = None, algorithm: BaseThrottleAlgorithm | None = None, response_headers: Iterable[BaseResponseHeadersProvider] | None = None)
Async throttle type for async endpoints.
> [!WARNING]
> Added in version 0.7.0.
##### *async* \_\_call_\_(endpoint: Endpoint, controller: Controller[BaseSerializer], lock: AbstractAsyncContextManager[Any, Any]) → None
Put your throttle business logic here.
Return `None` if throttle check passed.
Raise `dmr.exceptions.TooManyRequestsError`
if throttle check failed.
Raise `dmr.response.APIError`
if you want to change the return code, for example,
when some data is missing or has wrong format.
##### \_\_replace_\_(\*, max_requests: int | None = None, duration_in_seconds: Rate | int | None = None, cache_key: BaseThrottleCacheKey | None = None, backend: \_BackendT | None = None, algorithm: BaseThrottleAlgorithm | None = None, response_headers: Iterable[BaseResponseHeadersProvider] | None = None) → Self
Return a copy of this throttle, overriding only the given fields.
Any argument left as `None` keeps this throttle’s current value, so
`replace(max_requests=2)` changes just the limit. Also backs
`copy.replace` (Python 3.13+) through `__replace__`.
> [!WARNING]
> Added in version 0.12.0.
##### collect_response_headers(endpoint: Endpoint, controller: Controller[BaseSerializer], remaining: int, reset: int, \*, report_all: bool = False) → dict[str, str]
Collects response headers for all `response_headers` classes.
##### full_cache_key(endpoint: Endpoint, controller: Controller[BaseSerializer]) → str | None
Get the full cache key value.
##### provide_response_specs(metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], existing_responses: Mapping[HTTPStatus, ResponseSpec]) → list[ResponseSpec]
Provides responses that can happen when throttle triggers.
##### replace(\*, max_requests: int | None = None, duration_in_seconds: Rate | int | None = None, cache_key: BaseThrottleCacheKey | None = None, backend: \_BackendT | None = None, algorithm: BaseThrottleAlgorithm | None = None, response_headers: Iterable[BaseResponseHeadersProvider] | None = None) → Self
Return a copy of this throttle, overriding only the given fields.
Any argument left as `None` keeps this throttle’s current value, so
`replace(max_requests=2)` changes just the limit. Also backs
`copy.replace` (Python 3.13+) through `__replace__`.
> [!WARNING]
> Added in version 0.12.0.
##### *async* report_usage(endpoint: Endpoint, controller: Controller[BaseSerializer]) → dict[str, str]
Async report throttle usage stats.
##### validate(controller_cls: type[Controller[BaseSerializer]], metadata: EndpointMetadata) → None
Validate throttling configuration at import time.
> [!WARNING]
> Added in version 0.16.0.
#### *final class* dmr.throttling.SyncOrAsyncThrottle(\_sync_throttle: \_SyncThrottleT, \_async_throttle: \_AsyncThrottleT)
Throttle that selects between a sync and async instance.
Use in global settings to apply a single throttling rule to both
sync and async endpoints. Not allowed on controller or endpoint level.
> [!WARNING]
> Added in version 0.11.0.
> [!WARNING]
> Changed in version 0.16.0: Now it is generic.
##### resolve(\*, is_async: Literal[True]) → \_AsyncThrottleT
##### resolve(\*, is_async: Literal[False]) → \_SyncThrottleT
##### resolve(\*, is_async: bool) → \_AsyncThrottleT | \_SyncThrottleT
Return the throttle instance matching *is_async* requirement.
> [!WARNING]
> Changed in version 0.16.0: Replaced *throttle_cls* parameter with simpler *is_async*.
#### *final class* dmr.throttling.Rate(\*values)
Throttling rates in seconds.
##### second
1 second.
##### minute
60 seconds.
##### hour
60 \* 60 seconds.
##### day
24 \* 60 \* 60 seconds.
#### *class* dmr.throttling.ThrottlingReport(controller: Controller[BaseSerializer])
Get throttling data to be reported in response headers.
Unlike `dmr.exceptions.TooManyRequestsError`,
which reports the first stat for throttle that is failing,
it reports all stats for all throttles.
It will make N (which is the number of throttles) requests to cache.
> [!WARNING]
> This class does not handle exceptions at all.
> Because we don’t know how to report headers
> for failing cache connections.
> If you want to handle errors, catch them explicitly.
> [!WARNING]
> Added in version 0.7.0.
##### *async* areport() → dict[str, str]
Report throttling all headers for a async controller and endpoint.
Unlike sync version it makes parallel requests to cache
by utilizing [`asyncio.gather()`](https://docs.python.org/3/library/asyncio-task.html#asyncio.gather) function inside.
##### report() → dict[str, str]
Report throttling all headers for a sync controller and endpoint.
Note that it will make N consecutive requests to cache.
It might be rather long. Use the sync version with care.
#### Backends
#### *class* dmr.throttling.backends.CachedRateLimit
Bases: [`TypedDict`](https://typing-extensions.readthedocs.io/en/stable/index.html#typing_extensions.TypedDict)
Representation of a cached object’s metadata.
#### *class* dmr.throttling.backends.BaseThrottleSyncBackend
Base class for all throttling backends.
It must provide sync and async API for sync and async throttling classes.
##### *abstractmethod* get(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: SyncThrottle, \*, cache_key: str) → CachedRateLimit | None
Sync get the state with no increments.
##### *abstractmethod* incr(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: SyncThrottle, \*, cache_key: str, algorithm: BaseThrottleAlgorithm) → CachedRateLimit
Sync increment cached rate limit state.
Can be atomic, can be non atomic. Atomicity needs to be documented.
##### lock(lock: AbstractContextManager[Any, Any]) → AbstractContextManager[Any, Any]
Return the context manager to acquire for increment.
#### *class* dmr.throttling.backends.BaseThrottleAsyncBackend
Base class for all throttling backends.
It must provide sync and async API for sync and async throttling classes.
##### *abstractmethod async* get(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: AsyncThrottle, \*, cache_key: str) → CachedRateLimit | None
Sync get the state with no increments.
##### *abstractmethod async* incr(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: AsyncThrottle, \*, cache_key: str, algorithm: BaseThrottleAlgorithm) → CachedRateLimit
Async increment cached rate limit state.
##### lock(lock: AbstractAsyncContextManager[Any, Any]) → AbstractAsyncContextManager[Any, Any]
Return the context manager to acquire for increment.
#### *class* dmr.throttling.backends.SyncDjangoCache(cache_name: str = 'default', allow_unsafe_cache: bool | None = None)
Uses Django sync cache framework for storing the rate limiting state.
Unsafe cache backends (`LocMemCache`, `DummyCache`) do not share
the throttling state between processes. Use `allow_unsafe_cache`
to control what happens when such a backend is detected:
`None` (default) emits
`UnsafeCacheBackendWarning`
when `settings.DEBUG` is `False` and stays silent otherwise,
`False` always raises `EndpointMetadataError`,
and `True` disables the check.
> [!NOTE]
> ### See also
>
> [https://docs.djangoproject.com/en/stable/topics/cache/](https://docs.djangoproject.com/en/stable/topics/cache/)
##### get(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: SyncThrottle, \*, cache_key: str) → CachedRateLimit | None
Sync get the cached rate limit state.
##### incr(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: SyncThrottle, \*, cache_key: str, algorithm: BaseThrottleAlgorithm) → CachedRateLimit
Sync increment cached rate limit state.
Can be atomic, can be non atomic. Atomicity needs to be documented.
#### *class* dmr.throttling.backends.AsyncDjangoCache(cache_name: str = 'default', allow_unsafe_cache: bool | None = None)
Uses Django async cache framework for storing the rate limiting state.
Unsafe cache backends (`LocMemCache`, `DummyCache`) do not share
the throttling state between processes. Use `allow_unsafe_cache`
to control what happens when such a backend is detected:
`None` (default) emits
`UnsafeCacheBackendWarning`
when `settings.DEBUG` is `False` and stays silent otherwise,
`False` always raises `EndpointMetadataError`,
and `True` disables the check.
> [!NOTE]
> ### See also
>
> [https://docs.djangoproject.com/en/stable/topics/cache/](https://docs.djangoproject.com/en/stable/topics/cache/)
##### *async* get(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: AsyncThrottle, \*, cache_key: str) → CachedRateLimit | None
Async get the cached rate limit state.
##### *async* incr(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: AsyncThrottle, \*, cache_key: str, algorithm: BaseThrottleAlgorithm) → CachedRateLimit
Async increment cached rate limit state.
#### *final class* dmr.throttling.backends.django_cache.UnsafeCacheBackendWarning
Warning emitted when an unsafe cache backend is used for throttling.
#### *class* dmr.throttling.backends.redis.SyncRedis(client: redis.Redis[Any])
Uses sync Redis client for multiproccess safe rate-limiting.
This backend requires `redis` client library to be installed.
We don’t ship it with the `django-modern-rest`.
You would have to install it separately:
```bash
pip install redis
```
> [!NOTE]
> ### See also
>
> [https://redis.readthedocs.io](https://redis.readthedocs.io)
##### get(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: SyncThrottle, \*, cache_key: str) → CachedRateLimit | None
Sync get the cached rate limit state.
##### incr(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: SyncThrottle, \*, cache_key: str, algorithm: BaseThrottleAlgorithm) → CachedRateLimit
Atomic sync increment via Lua script.
##### initialize_algorithm(algorithm: BaseThrottleAlgorithm) → None
Initialize and prepare backend for the algorithm.
##### lock(lock: AbstractContextManager[Any, Any]) → AbstractContextManager[Any, Any]
Skip the in-process lock: the incr is atomic.
##### needs_transaction_script *: ClassVar[str]* *= 'lua'*
Format name that the backend needs:
#### *class* dmr.throttling.backends.redis.AsyncRedis(client: aioredis.Redis[Any])
Uses async Redis client for multiproccess safe rate-limiting.
This backend requires `redis` client library to be installed.
We don’t ship it with the `django-modern-rest`.
You would have to install it separately:
```bash
pip install redis
```
> [!NOTE]
> ### See also
>
> [https://redis.readthedocs.io](https://redis.readthedocs.io)
##### *async* get(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: AsyncThrottle, \*, cache_key: str) → CachedRateLimit | None
Async get the cached rate limit state.
##### *async* incr(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: AsyncThrottle, \*, cache_key: str, algorithm: BaseThrottleAlgorithm) → CachedRateLimit
Atomic async increment via Lua script.
##### initialize_algorithm(algorithm: BaseThrottleAlgorithm) → None
Initialize and prepare backend for the algorithm.
##### lock(lock: AbstractAsyncContextManager[Any, Any]) → AbstractAsyncContextManager[Any, Any]
Skip the in-process lock: the incr is atomic.
##### needs_transaction_script *: ClassVar[str]* *= 'lua'*
Format name that the backend needs:
#### Algorithms
#### *class* dmr.throttling.algorithms.BaseThrottleAlgorithm
Base class for all throttling algorithms.
##### *abstractmethod* access(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: SyncThrottle | AsyncThrottle, cache_object: CachedRateLimit | None) → CachedRateLimit
Called when new access attempt is made.
* **Returns:**
Cached rate limiting state.
* **Raises:**
**dmr.exceptions.TooManyRequestsError** – when the limit is overused.
##### *abstractmethod* report_usage(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: SyncThrottle | AsyncThrottle, cache_object: CachedRateLimit | None) → dict[str, str]
Reports the throttling usage, but does not additionally increment.
##### transaction_script(script_format: str) → str | None
Optionally dump the transaction script for backends that support it.
It can be `.lua` or `.sql`.
#### *class* dmr.throttling.algorithms.SimpleRate
Simple rate algorithm.
Defines a fixed window with a fixed amount of requests possible.
When window is expired, resets the count of requests.
##### access(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: SyncThrottle | AsyncThrottle, cache_object: CachedRateLimit | None) → CachedRateLimit
Check access.
##### report_usage(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: SyncThrottle | AsyncThrottle, cache_object: CachedRateLimit | None) → dict[str, str]
Reports the throttling usage, but does not additionally increment.
##### transaction_script(script_format: str) → str | None
Optionally dump the transaction script for backends that support it.
It can be `.lua` or `.sql`.
#### *class* dmr.throttling.algorithms.LeakyBucket
Leaky bucket algorithm.
Requests fill the bucket; tokens leak at a steady rate.
Unlike `dmr.throttling.algorithms.SimpleRate`,
which resets after a fixed window, `LeakyBucket` drains
continuously providing smoother rate-limiting
without allowing bursts at window boundaries.
Internally, the bucket request level is stored in scaled units
as `level * duration` so all arithmetic stays integer-only.
Each request adds `duration` scaled units to the level.
Every elapsed second `max_requests` scaled units leak out.
> [!NOTE]
> ### See also
>
> [https://en.wikipedia.org/wiki/Leaky_bucket](https://en.wikipedia.org/wiki/Leaky_bucket)
##### access(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: SyncThrottle | AsyncThrottle, cache_object: CachedRateLimit | None) → CachedRateLimit
Check access; raise when the bucket is full.
##### report_usage(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: SyncThrottle | AsyncThrottle, cache_object: CachedRateLimit | None) → dict[str, str]
Report throttling usage without incrementing.
##### transaction_script(script_format: str) → str | None
Optionally dump the transaction script for backends that support it.
It can be `.lua` or `.sql`.
#### Cache keys
#### *class* dmr.throttling.cache_keys.BaseThrottleCacheKey(\*, runs_before_auth: bool, name: str)
Base class for all cache keys.
##### *abstractmethod* \_\_call_\_(endpoint: Endpoint, controller: Controller[BaseSerializer]) → str | None
Returns the cache key.
If string is returned, we use this as a cache key.
If `None` is returned, this request
will be skipped from this exact throttling check.
However, other keys may still be applied.
#### *class* dmr.throttling.cache_keys.RemoteAddr(\*, runs_before_auth: bool = True, name: str = 'RemoteAddr')
Uses `REMOTE_ADDR` from `request.META` as a cache key.
> [!WARNING]
> Be sure to correctly configure your HTTP Proxy!
> Otherwise, `REMOTE_ADDR` might be set incorrectly.
> [!NOTE]
> ### See also
>
> See [`django.http.HttpRequest.META`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.META) docs.
##### \_\_call_\_(endpoint: Endpoint, controller: Controller[BaseSerializer]) → str | None
Return `REMOTE_ADDR` which is a user’s IP address, if it exists.
#### *class* dmr.throttling.cache_keys.UserPk(\*, runs_before_auth: Literal[False] = False, name: str = 'UserPk', exclude_superuser: bool = True, exclude_stuff: bool = True)
Uses `request.user.pk` as a cache key.
Returns `None` for users that should be excluded
from throttling checks or when `pk` is not set.
##### \_\_call_\_(endpoint: Endpoint, controller: Controller[BaseSerializer]) → str | None
Return `request.user.pk` when user should be throttled.
#### *class* dmr.throttling.cache_keys.JwtToken(\*, runs_before_auth: Literal[False] = False, name: str = 'JwtToken')
Uses a hash of JWT claims from `request.__dmr_jwt__` as a cache key.
Requires JWT extra to be installed with:
```bash
pip install 'django-modern-rest[jwt]'
```
1. Never use a full token string for cache key generation.
2. Prefer `jti` claim, fallback to `sub` claim.
3. Store only SHA-256 hash, never raw claim values.
Returns `None` when jwt token is not set,
or when both `jti` and `sub` are missing.
##### \_\_call_\_(endpoint: Endpoint, controller: Controller[BaseSerializer]) → str | None
Return a hash of JWT `jti` / `sub` claims as a cache key.
#### Headers
#### *class* dmr.throttling.headers.BaseResponseHeadersProvider
Base class for all header providers.
##### *abstractmethod* provide_headers_specs() → dict[str, HeaderSpec]
Provide a spec for headers for the OpenAPI.
##### *abstractmethod* response_headers(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: \_BaseThrottle[Any], remaining: int, reset: int, \*, report_all: bool = True) → dict[str, str]
Return a dict of rendered headers to be added to the response.
#### *class* dmr.throttling.headers.XRateLimit
Provides `X-RateLimit` headers.
There headers inside:
- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`
- `X-RateLimit-Reset`
It is based on popular convention and does not have a formal spec.
> [!NOTE]
> ### See also
>
> [https://http.dev/x-ratelimit-limit](https://http.dev/x-ratelimit-limit)
##### provide_headers_specs() → dict[str, HeaderSpec]
Provides headers specification.
##### response_headers(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: \_BaseThrottle[Any], remaining: int, reset: int, \*, report_all: bool = True) → dict[str, str]
Returns the formatted response headers.
#### *class* dmr.throttling.headers.RetryAfter
Provides `Retry-After` header.
It is based on the existing spec.
> [!NOTE]
> ### See also
>
> [RFC-6585](https://datatracker.ietf.org/doc/html/rfc6585#section-4)
> and
> [RFC-7231](https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3)
##### provide_headers_specs() → dict[str, HeaderSpec]
Provides headers specification.
##### response_headers(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: \_BaseThrottle[Any], remaining: int, reset: int, \*, report_all: bool = True) → dict[str, str]
Returns the formatted response headers.
#### *class* dmr.throttling.headers.RateLimitIETFDraft
Provides `RateLimit` and `RateLimit-Policy` headers.
It is based on the latest draft of the ratelmiting headers spec.
> [!NOTE]
> ### See also
>
> [https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-10.html](https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-10.html)
##### provide_headers_specs() → dict[str, HeaderSpec]
Provides headers specification.
##### response_headers(endpoint: Endpoint, controller: Controller[BaseSerializer], throttle: \_BaseThrottle[Any], remaining: int, reset: int, \*, report_all: bool = True) → dict[str, str]
Returns the formatted response headers.
Source: /en/latest/pages/middleware.md
## Middleware
As per our main principle, you can use any default
Django middleware with your API. But, it has several minor problems by default:
1. Any middleware responses won’t show up in your schema
2. Responses won’t have the right `'Content-Type'`
3. Responses won’t be validated
That’s why `django-modern-rest` provides a powerful middleware
system that allows you to wrap Django middleware around your controllers
while maintaining proper OpenAPI documentation and response handling.
The main function for this
is `wrap_middleware()`,
which creates reusable decorators that can be applied to controller classes.
### How it works
`wrap_middleware` is a factory function that creates decorators
with pre-configured middleware. It takes:
1. A middleware function or class
2. One or more `ResponseSpec` objects
3. Returns a decorator factory that takes a response converter function
The created decorator:
- Wraps the controller’s dispatch method with the specified middleware
- Handles both sync and async controllers automatically
- Applies response conversion when the middleware returns a specific status code
- Adds the response descriptions to the controller’s OpenAPI schema
### Basic Usage
Let’s create a simple middleware decorator for CSRF protection:
```python
from http import HTTPStatus
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_protect
from dmr import Controller, ResponseSpec
from dmr.decorators import wrap_middleware
from dmr.errors import ErrorModel, format_error
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.response import build_response
@wrap_middleware(
csrf_protect,
ResponseSpec(
return_type=ErrorModel,
status_code=HTTPStatus.FORBIDDEN,
),
)
def csrf_protect_json(response: HttpResponse) -> HttpResponse:
"""Convert CSRF failure responses to JSON."""
return build_response(
PydanticFastSerializer,
raw_data=format_error(
'CSRF verification failed. Request aborted.',
),
status_code=HTTPStatus(response.status_code),
)
@csrf_protect_json
class MyController(Controller[PydanticFastSerializer]):
"""Example controller using CSRF protection middleware."""
responses = csrf_protect_json.responses
def post(self) -> dict[str, str]:
return {'message': 'ok'}
```
In this example:
1. We create a middleware decorator using `wrap_middleware`
2. The decorator wraps `csrf_protect` middleware around the controller
3. When CSRF verification fails, our converter function
transforms the response to JSON
4. The response description is automatically added to the OpenAPI schema
### Custom Middleware
You can also create custom middleware functions.
Here’s an example of a rate limiting middleware:
```python
from collections.abc import Callable
from http import HTTPStatus
from django.http import HttpRequest, HttpResponse
from dmr import Controller, ResponseSpec
from dmr.decorators import wrap_middleware
from dmr.errors import ErrorModel, format_error
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.response import build_response
def rate_limit_middleware(
get_response: Callable[[HttpRequest], HttpResponse],
) -> Callable[[HttpRequest], HttpResponse]:
"""Middleware that simulates rate limiting."""
def decorator(request: HttpRequest) -> HttpResponse:
if request.headers.get('X-Rate-Limited') == 'true':
return build_response(
PydanticFastSerializer,
raw_data=format_error('Rate limit exceeded'),
status_code=HTTPStatus.TOO_MANY_REQUESTS,
)
return get_response(request)
return decorator
@wrap_middleware(
rate_limit_middleware,
ResponseSpec(
return_type=ErrorModel,
status_code=HTTPStatus.TOO_MANY_REQUESTS,
),
)
def rate_limit_json(response: HttpResponse) -> HttpResponse:
"""Pass through the rate limit response."""
return response
@rate_limit_json
class RateLimitedController(Controller[PydanticFastSerializer]):
"""Example controller with custom rate limit middleware."""
responses = rate_limit_json.responses
def post(self) -> dict[str, str]:
return {'message': 'Request processed'}
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/ratelimit/ -X POST
> {"message":"Request processed"}
>
> $ curl http://127.0.0.1:8000/api/ratelimit/ -D - -X POST -H 'X-Rate-Limited: true'
> HTTP/1.1 429 Too Many Requests
> date: Mon, 05 Oct 2026 11:17:40 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 42
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Rate limit exceeded"}]}
> ```
### Multiple Response Descriptions
You can specify multiple response descriptions for different status codes:
```python
from collections.abc import Callable
from http import HTTPStatus
from django.http import HttpRequest, HttpResponse
from dmr import ResponseSpec
from dmr.decorators import wrap_middleware
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.response import build_response
def custom_middleware(
get_response: Callable[[HttpRequest], HttpResponse],
) -> Callable[[HttpRequest], HttpResponse]:
"""Dummy middleware for demonstration purposes."""
def decorator(request: HttpRequest) -> HttpResponse:
"""Just pass the request through unchanged."""
return get_response(request)
return decorator
@wrap_middleware(
custom_middleware,
ResponseSpec(
return_type=dict[str, str],
status_code=HTTPStatus.BAD_REQUEST,
),
ResponseSpec(
return_type=dict[str, str],
status_code=HTTPStatus.UNAUTHORIZED,
),
)
def multi_status_middleware(response: HttpResponse) -> HttpResponse:
"""Handle multiple status codes."""
if response.status_code == HTTPStatus.BAD_REQUEST:
return build_response(
PydanticFastSerializer,
raw_data={'error': 'Bad request'},
status_code=HTTPStatus(response.status_code),
)
if response.status_code == HTTPStatus.UNAUTHORIZED:
return build_response(
PydanticFastSerializer,
raw_data={'error': 'Unauthorized'},
status_code=HTTPStatus(response.status_code),
)
return response
```
### Async Controllers
`wrap_middleware` works seamlessly with both sync and async controllers:
```python
from dmr import Controller
from dmr.plugins.pydantic import PydanticFastSerializer
from examples.middleware.csrf_protect_json import csrf_protect_json
@csrf_protect_json
class AsyncController(Controller[PydanticFastSerializer]):
"""Example async controller using CSRF protection middleware."""
responses = csrf_protect_json.responses
async def post(self) -> dict[str, str]:
# Your async logic here
return {'message': 'async response'}
```
The middleware will automatically detect whether the controller is async
and handle it appropriately.
### Response Converter Function
The response converter function is called when the middleware returns
a response with a status code that matches one
of the provided response descriptions. This allows you to:
- Transform error responses to JSON format
- Add custom headers
- Modify response content
- Apply consistent error formatting across your API
The converter function receives the original response and should
return a modified [`django.http.HttpResponse`](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpResponse).
### Understanding the Two-Phase Middleware Pattern
Django middleware operates in two distinct phases around the view execution.
Understanding this pattern is crucial for effectively using middleware
with `django-modern-rest`.
#### get_response callback
Every Django middleware receives a `get_response` callable parameter.
This is **not** the actual response - it’s a callback that represents
the next middleware in the chain or the final view function.
```python
from collections.abc import Callable
from django.http import HttpRequest, HttpResponse
def my_middleware(
get_response: Callable[[HttpRequest], HttpResponse],
) -> Callable[[HttpRequest], HttpResponse]:
"""
get_response is a callback.
It will:
1. Call the next middleware (if any)
2. Eventually call your controller/view
3. Return the response
"""
def factory(request: HttpRequest) -> HttpResponse:
# Your middleware logic here:
return get_response(request) # Call the view
return factory
```
#### Phase 1: Process Request (before get_response)
Before calling `get_response`, you can:
- Read and validate request data
- Add attributes to the request object
- Perform authentication/authorization
- Short-circuit and return early (without calling the view)
```python
import uuid
from collections.abc import Callable
from typing import Any, TypeAlias
from django.http import HttpRequest, HttpResponse
_CallableAny: TypeAlias = Callable[..., Any]
def add_request_id_middleware(
get_response: Callable[[HttpRequest], HttpResponse],
) -> _CallableAny:
"""Middleware that adds request_id to both request and response.
This demonstrates the two-phase middleware pattern:
1. Process request BEFORE calling get_response (adds request.request_id)
2. Process response AFTER calling get_response (adds X-Request-ID header)
"""
def decorator(request: HttpRequest) -> Any:
request_id = str(uuid.uuid4())
request.request_id = request_id # type: ignore[attr-defined]
response = get_response(request)
response['X-Request-ID'] = request_id
return response
return decorator
```
Wrap your middleware:
```python
from http import HTTPStatus
from django.http import HttpResponse
from dmr import ResponseSpec
from dmr.decorators import wrap_middleware
from examples.middleware.add_request_id import add_request_id_middleware
@wrap_middleware(
add_request_id_middleware,
ResponseSpec(
return_type=dict[str, str],
status_code=HTTPStatus.OK,
),
)
def add_request_id_json(response: HttpResponse) -> HttpResponse:
"""Pass through response - ``request_id`` is added automatically."""
return response
```
Now your controller can access `self.request.request_id`:
```python
from django.http import HttpRequest
from dmr import Controller
from dmr.plugins.pydantic import PydanticFastSerializer
from examples.middleware.wrap_add_request_id import add_request_id_json
class _RequestWithID(HttpRequest):
request_id: str
@add_request_id_json
class RequestIdController(Controller[PydanticFastSerializer]):
"""Controller that uses request_id added by middleware."""
responses = add_request_id_json.responses
# Use request with request_id field
request: _RequestWithID
def get(self) -> dict[str, str]:
"""GET endpoint that returns request_id from modified request."""
return {
'request_id': self.request.request_id,
'message': 'Request ID tracked',
}
```
#### Phase 2: Process Response (after get_response)
After calling `get_response`, you can:
- Modify the response object
- Add headers
- Log response details
- Transform response content
```python
from collections.abc import Callable
from typing import Any, TypeAlias
from django.http import HttpRequest, HttpResponse
_CallableAny: TypeAlias = Callable[..., Any]
def custom_header_middleware(
get_response: Callable[[HttpRequest], HttpResponse],
) -> _CallableAny:
"""Simple middleware that adds a custom header to response."""
def decorator(request: HttpRequest) -> Any:
response = get_response(request)
response['X-Custom-Header'] = 'CustomValue'
return response
return decorator
```
#### Short-Circuiting: Returning Without Calling get_response
Middleware can return a response **without** calling `get_response`.
This is called “short-circuiting” - the view is never executed.
Common use cases:
- Rate limiting (return 429)
- Request validation failures (return 400)
- Cache hits (return cached response)
- Custom authentication/authorization checks
See the rate limiting middleware in Custom Middleware
for an example.
#### Wrapping Django’s Built-in Decorators
You can wrap Django’s built-in authentication decorators like `login_required`
to make them REST API friendly. By default, `login_required` returns a 302
redirect, but you can convert it to a JSON 401 response:
```python
from http import HTTPStatus
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from dmr import Controller, ResponseSpec
from dmr.decorators import wrap_middleware
from dmr.errors import ErrorModel, format_error
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.response import build_response
@wrap_middleware(
login_required,
ResponseSpec(
return_type=ErrorModel,
status_code=HTTPStatus.UNAUTHORIZED,
),
)
def login_required_json(response: HttpResponse) -> HttpResponse:
"""Convert Django's login_required redirect to JSON 401 response."""
if response.status_code == HTTPStatus.FOUND:
return build_response(
PydanticFastSerializer,
raw_data=format_error(
'Authentication credentials were not provided',
),
status_code=HTTPStatus.UNAUTHORIZED,
)
return response
@login_required_json
class LoginRequiredController(Controller[PydanticFastSerializer]):
"""Controller that uses Django's login_required decorator.
Demonstrates wrapping Django's built-in authentication decorators.
Converts 302 redirect to JSON 401 response for REST API compatibility.
"""
responses = login_required_json.responses
def get(self) -> dict[str, str]:
"""GET endpoint that requires Django authentication."""
# Access Django's authenticated user
user = self.request.user
username = user.username if user.is_authenticated else 'anonymous' # type: ignore[attr-defined]
return {
'username': username,
'message': 'Successfully accessed protected resource',
}
```
#### Visual Flow
Here’s how a request flows through middleware:
```mermaid
---
config:
theme: forest
---
graph TB
A[HTTP Request] --> B1[Middleware 1 Phase 1: process request]
B1 --> B2[Middleware 2 Phase 1: process request]
B2 --> C[Controller/View executes]
C --> D2[Middleware 2 Phase 2: process response]
D2 --> D1[Middleware 1 Phase 2: process response]
D1 --> E[HTTP Response]
```
Middleware execution flow
### Best Practices
1. **Always include response descriptions**: This ensures your OpenAPI
documentation is complete and accurate.
2. **Use consistent error formatting**: Create reusable converter functions
that format errors consistently across your API.
3. **Handle both sync and async**: The same middleware decorator works
with both sync and async controllers.
4. **Test your middleware**: Make sure to test both the success
and error cases for your middleware.
5. **Document your middleware**: Add docstrings to explain what
your middleware does and when it’s triggered.
### Example: Complete CSRF Protection Setup
Here’s a complete example showing how to set up CSRF protection for a REST API:
```python
from http import HTTPStatus
from django.http import HttpResponse
from django.views.decorators.csrf import ensure_csrf_cookie
from dmr import Controller, ResponseSpec
from dmr.decorators import wrap_middleware
from dmr.plugins.pydantic import PydanticFastSerializer
from examples.middleware.csrf_protect_json import csrf_protect_json
# CSRF cookie for GET requests
@wrap_middleware(
ensure_csrf_cookie,
ResponseSpec(
return_type=dict[str, str],
status_code=HTTPStatus.OK,
),
)
def ensure_csrf_cookie_json(response: HttpResponse) -> HttpResponse:
"""Return response ensuring CSRF cookie is set."""
return response
@csrf_protect_json
class ProtectedController(Controller[PydanticFastSerializer]):
"""Protected API controller requiring CSRF token."""
responses = csrf_protect_json.responses
def get(self) -> dict[str, str]:
"""Get CSRF token."""
return {'message': 'Use this endpoint to get CSRF token'}
def post(self) -> dict[str, str]:
"""Protected endpoint requiring CSRF token."""
return {'message': 'Successfully created resource'}
@ensure_csrf_cookie_json
class PublicController(Controller[PydanticFastSerializer]):
responses = ensure_csrf_cookie_json.responses
def get(self) -> dict[str, str]:
"""Public endpoint that sets CSRF cookie."""
return {'message': 'CSRF cookie set'}
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/publiccontroller/ -D - -X GET
> HTTP/1.1 200 OK
> date: Mon, 05 Oct 2026 11:17:41 GMT
> server: uvicorn
> Content-Type: application/json
> Vary: Cookie, Accept-Language
> X-Frame-Options: DENY
> Content-Language: en
> Content-Length: 29
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
> Set-Cookie: csrftoken=oEJbKHICvyEZPNli0HglDxmDgr4LmFiK; expires=Mon, 04 Oct 2027 11:17:41 GMT; Max-Age=31449600; Path=/; SameSite=Lax
>
> {"message":"CSRF cookie set"}
> ```
Source: /en/latest/pages/validation.md
## Validation
`django-modern-rest` has several layers of import-time and runtime validation.
We try to do everything that we can during import-time,
so it won’t affect your requests and responses.
However, we have to validate requests and responses during the runtime.
Responses validation can be turned off
in production for speed.
What do we validate and how?
### Settings validation
We start with settings validation.
We only validate settings once per application,
we do it when the first `Controller` is created.
We also validate our own default values to be correct.
See `SettingsValidator` for the API.
### Endpoint validation
Next, when controller is being created,
we run `Endpoint` validation.
Here we can detect all kinds of problems with how endpoints are defined:
- Invalid `modify`
or `validate` usage
- Invalid component annotations, like `parsed_body: Body[Model] | None`,
see Default values
- Or invalid `HttpSpec` usage
See `EndpointMetadataBuilder`
and `EndpointMetadataBuilder`
for the API.
#### HttpSpec validation
You can customize the strictness of HTTP Spec validation with overriding
disabled `HttpSpec` options per-endpoint,
per-controller, and globally.
> [!WARNING]
> We don’t recommend overriding any of these settings by default.
> It only makes sense to change, when implementing some
> old legacy API the “same” way as it used to be.
>
> And only when you need this for a very specific reason.
The right way
Per endpoint
Per controller
views.py
```python
from http import HTTPStatus
from dmr import Controller, modify
from dmr.plugins.pydantic import PydanticFastSerializer
class JobController(Controller[PydanticFastSerializer]):
@modify(status_code=HTTPStatus.NO_CONTENT)
def post(self) -> None:
print('Job created') # noqa: WPS421
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/job/ -D - -X POST
> HTTP/1.1 204 No Content
> date: Mon, 05 Oct 2026 11:19:03 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 0
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
> ```
views.py
```python
from http import HTTPStatus
from dmr import Controller, modify
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.settings import HttpSpec
class JobController(Controller[PydanticFastSerializer]):
@modify(
status_code=HTTPStatus.NO_CONTENT,
no_validate_http_spec={HttpSpec.empty_response_body},
)
def post(self) -> int:
job_id = 4 # very random number :)
return job_id # noqa: RET504
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/job/ -D - -X POST
> HTTP/1.1 204 No Content
> date: Mon, 05 Oct 2026 11:19:03 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 1
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
> ```
views.py
```python
from http import HTTPStatus
from dmr import Controller, modify
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.settings import HttpSpec
class JobController(Controller[PydanticFastSerializer]):
no_validate_http_spec = frozenset((HttpSpec.empty_response_body,))
@modify(status_code=HTTPStatus.NO_CONTENT)
def post(self) -> int:
job_id = 4 # very random number :)
return job_id # noqa: RET504
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/job/ -D - -X POST
> HTTP/1.1 204 No Content
> date: Mon, 05 Oct 2026 11:19:04 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 1
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
> ```
### Controller validation
The last step is the final `Controller`
validation which has everything ready:
- `api_endpoints`
Here we validate:
- That all `Controller` classes have unique methods
- That all endpoints are either sync or async
- All per-controller and per-endpoint error handling
See `ControllerValidator` for the API.
### Response validation
The last step is to validate the response when returning
it from the endpoint in runtime.
We need this to make sure that API responses always match response schemas.
It can be turned off.
See `ResponseValidator` for the API.
### API Reference
#### Settings
#### *class* dmr.validation.settings.SettingsValidator(\*, serializer: type[BaseSerializer])
Validates defined settings once.
##### \_\_call_\_() → None
Collect and validate settings.
#### Endpoint
#### *class* dmr.validation.endpoint_metadata.EndpointMetadataBuilder(\*, payload: ValidateEndpointPayload | ModifyEndpointPayload | None, controller_cls: type[Controller[BaseSerializer]], func: Callable[[...], Any], metadata_cls: type[EndpointMetadata], response_modification_cls: type[ResponseModification], metadata_merger_cls: type[MetadataMerger], component_parsers: list[ComponentParserSpec], type_annotations: dict[str, Any])
Validate the metadata definition.
It is done during import-time only once, so it can be not blazing fast.
It is better to be precise here than to be fast.
Here we only do structure and required validation.
All semantic validation will be performed later on.
Metadata will NOT be considered ready after running this process.
##### \_\_call_\_() → EndpointMetadata
Do the validation.
##### build_semantic_schema() → bool
Resolve the `semantic_schema` flag for this endpoint.
It is public, because other flags can default to this one.
> [!WARNING]
> Added in version 0.16.0.
##### build_validate_responses() → bool
Resolve the `validate_responses` flag for this endpoint.
It is public, because other flags can default to this one.
> [!WARNING]
> Added in version 0.16.0.
##### merger(field_name: str) → MetadataMerger
Create a merger for the given metadata field.
Use it to resolve endpoint, controller, and settings layers
the same way built-in fields are resolved.
> [!WARNING]
> Added in version 0.16.0.
#### *class* dmr.validation.endpoint_metadata.EndpointMetadataValidator(\*, metadata: EndpointMetadata, metadata_merger_cls: type[MetadataMerger])
Builds responses for the endpoint metadata.
Runs semantic validation.
Metadata will be considered ready after running this process.
##### \_\_call_\_(func: Callable[[...], Any], payload: ValidateEndpointPayload | ModifyEndpointPayload | None, \*, controller_cls: type[Controller[BaseSerializer]]) → None
Collect and validate all responses.
##### response_list_validator_cls
alias of `_ResponseListValidator`
#### *class* dmr.validation.metadata_merger.MetadataMerger(\*, field_name: str)
Define how endpoint, controller, and settings values are resolved.
Every configurable field of the endpoint metadata is resolved
with a single call to one of these methods.
All layers are passed at once, from the most specific one (endpoint)
to the least specific one (settings), so *field_name* can be used
to change how a specific field is resolved.
By default, nothing is merged: the first layer with an explicit value
wins. Only `EMPTY` is not explicit: `None`, empty collections,
and all other values are used as-is. Override this class and set it as
`metadata_merger_cls`
to change this. For example, to merge `auth` from all layers:
> [!WARNING]
> Added in version 0.16.0.
##### empty_to_none(layer: \_LayerT | EMPTY) → \_LayerT | None
Convert `EMPTY` to `None` for the resolved metadata.
Resolved metadata does not have the “not set” state anymore,
so `None` is used there for missing optional values.
It is used for endpoint-only fields, which have a single layer.
##### first_defined(\*layers: \_LayerT | EMPTY | None) → \_LayerT | EMPTY | None
Return the first explicitly defined configuration layer.
It is used for collections. Only `EMPTY` is not explicit,
the next layer is used instead of it. It returns `EMPTY`
if no layer has an explicit value.
`None` is an explicit value, it disables all less specific layers.
Empty collections like `[]` and `()` are explicit values too,
they are taken literally: “nothing on this layer”,
they also disable all less specific layers.
##### first_set(\*layers: \_LayerT | EMPTY) → \_LayerT | EMPTY
Return the first configuration layer that is not `EMPTY`.
Unlike `first_defined()`, it is used for scalar values,
where `False`, `0`, and `None` are real values.
It returns `EMPTY` if all layers are `EMPTY`.
##### not_empty(to_check: \_LayerT | EMPTY) → \_LayerT
Check that a value is not empty.
#### Controller
#### *class* dmr.validation.controller.ControllerValidator
Validates that controller is created correctly.
##### \_\_call_\_(controller: type[Controller[BaseSerializer]]) → bool | None
Run the validation.
#### Response
#### *class* dmr.validation.response.ResponseValidator(metadata: EndpointMetadata, serializer: type[BaseSerializer])
Response validator.
Can validate responses that return raw data as well as real `HttpResponse`
that are returned from endpoints.
##### validate_modification(endpoint: Endpoint, controller: Controller[BaseSerializer], structured: Any) → HttpResponseBase
Validate *structured* data before dumping it to json.
> [!WARNING]
> Changed in version 0.16.0: Now returns `HttpResponseBase` object directly.
> Removed `ValidatedModification` creation.
##### validate_response(endpoint: Endpoint, controller: Controller[BaseSerializer], response: \_ResponseT) → \_ResponseT
Validate response based on provided schema.
> [!WARNING]
> Changed in version 0.15.0: Respects
> `exclude_validate_responses`.
Source: /en/latest/pages/reusable-code.md
## Reusable code
One of the worst things about the current generation of
Python REST frameworks is code re-usability.
- `django-rest-framework` is very flexible, but all the flexibility
comes from importing fully qualified object’s path
strings taken from app’s settings.
It is very hard to properly type a code base like this.
Using it is also really hard, because you can’t easily
navigate in your source code.
- `fastapi` does not even offer a way to write reusable code,
because it is based on functions, which are really hard to reuse and modify.
That’s why you have to copy paste lots of code just to,
for example, use the most common things such as JWT auth.
What does `django-modern-rest` offer instead?
### Explicitly abstract controllers
A controller can have an exact serializer and endpoints,
but you might still not want to route it: you only want to reuse it.
Declare `is_abstract = True` explicitly for this:
views.py
```python
from dmr import Controller
from dmr.plugins.pydantic import PydanticFastSerializer
class MyBaseController(Controller[PydanticFastSerializer]):
# It has an exact serializer and an endpoint,
# but we only want to reuse it, we don't want to route it:
is_abstract = True
def get(self) -> str:
return 'hello from the base controller'
class MyController(MyBaseController):
"""It does not declare `is_abstract`, so it is concrete again."""
# It serves the same `GET` request as `MyBaseController`,
# but this one builds its endpoints and can be routed.
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X GET
> "hello from the base controller"
> ```
Such a controller does not build any endpoints,
they are only created in a concrete context.
Subclasses that don’t declare `is_abstract` themselves
are concrete again, even when their base controller is explicitly abstract.
So, `MyController` from the example above does the same
`GET` request as its base, but it can be routed.
> [!WARNING]
> Added in version 0.16.0.
### Providing extras for `@modify` and `@validate`
Your custom controllers might require custom parameters that users can provide
to `validate` and `modify`.
To do so, we utilize `extras=` parameter. By default it is always typed
as empty sentinel, because default controller do not allow any extra parameters.
Three steps are needed:
1. Define a subclass of `Extras`.
Its fields can default to `EMPTY` if some arguments can be missing
2. Define `build` with how to build your value
from several configuration layers, you can use global settings there as well
3. Create typed decorators by passing this class
to `ModifyEndpoint`
and `ValidateEndpoint`
4. Assign an instance of this class to `extras` attribute
of your controller. It enables `extras=` for all endpoints
of this controller and provides controller-level defaults
`build()` receives the endpoint layer,
which is `EMPTY` when `extras=` is not passed,
and the controller layer as instances of your class,
and returns the resolved value.
It is stored inside `extras`
and can be read with `of()` in a typed way.
First, define the extras model itself:
views.py
```python
import dataclasses
from typing import Self
from typing_extensions import override
from dmr.controller import Controller
from dmr.endpoint import Extras
from dmr.serializer import BaseSerializer
from dmr.types import EMPTY
from dmr.validation import EndpointMetadataBuilder
@dataclasses.dataclass(slots=True, frozen=True, kw_only=True)
class SmartResponse(Extras[str]):
response_text: str | EMPTY = EMPTY
@classmethod
@override
def build(
cls,
from_endpoint: Self | EMPTY,
from_controller: Self,
controller_cls: type[Controller[BaseSerializer]],
builder: EndpointMetadataBuilder,
) -> str:
merger = builder.merger('response_text')
return merger.not_empty(
merger.first_set(
(
EMPTY
if from_endpoint is EMPTY
else from_endpoint.response_text
),
from_controller.response_text,
'default response',
),
)
```
Then define and use new endpoint decorators:
modify
validate
Real world example: `dmr.streaming.modify`
views.py
```python
from typing import Final
from dmr.controller import Controller
from dmr.endpoint import ModifyEndpoint
from dmr.plugins.pydantic import PydanticFastSerializer
from examples.reusable_code.extras_model import SmartResponse
#: Same as :data:`dmr.modify`, but supports ``extras=SmartResponse(...)``.
modify: Final = ModifyEndpoint(SmartResponse)
class APIController(Controller[PydanticFastSerializer]):
extras = SmartResponse()
def get(self) -> str:
return SmartResponse.of(self)
@modify(extras=SmartResponse(response_text='from endpoint'))
def post(self) -> str:
return SmartResponse.of(self)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X GET
> "default response"
>
> $ curl http://127.0.0.1:8000/api/example/ -X POST
> "from endpoint"
> ```
Real world example: `dmr.streaming.validate`
views.py
```python
from http import HTTPStatus
from typing import Final
from django.http import HttpResponse
from dmr import ResponseSpec
from dmr.controller import Controller
from dmr.endpoint import ValidateEndpoint
from dmr.plugins.pydantic import PydanticFastSerializer
from examples.reusable_code.extras_model import SmartResponse
#: Same as :data:`dmr.validate`, but supports ``extras=SmartResponse(...)``.
validate: Final = ValidateEndpoint(SmartResponse)
class APIController(Controller[PydanticFastSerializer]):
extras = SmartResponse(response_text='from controller')
@validate(
ResponseSpec(str, status_code=HTTPStatus.OK),
extras=SmartResponse(response_text='from endpoint'),
)
def get(self) -> HttpResponse:
return self.to_response(SmartResponse.of(self))
@validate(ResponseSpec(str, status_code=HTTPStatus.CREATED))
def post(self) -> HttpResponse: # controller level extras
return self.to_response(SmartResponse.of(self))
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X GET
> "from endpoint"
>
> $ curl http://127.0.0.1:8000/api/example/ -X POST
> "from controller"
> ```
This way your controller subtypes can have any extras that you need!
These definitions would only differ in terms of typing.
Everything else would work the same way.
> [!WARNING]
> Added in version 0.16.0.
### Reusable controllers
We offer a concept of a “reusable controllers”.
To make a reusable controller, you need
to provide [`typing.TypeVar`](https://docs.python.org/3/library/typing.html#typing.TypeVar) instead of a
real `BaseSerializer` type.
Here’s an example:
views.py
```python
from typing import TypeVar
from typing_extensions import TypedDict
from dmr import Controller
from dmr.serializer import BaseSerializer
_SerializerT = TypeVar('_SerializerT', bound=BaseSerializer)
class _ResponseBody(TypedDict):
message: str
class ReusableController(Controller[_SerializerT]):
def get(self) -> _ResponseBody:
serializer_name = self.serializer.__name__
return {'message': f'hello from {serializer_name}'}
```
This code can work with both `pydantic` and `msgspec` as serializers.
Let’s try to create two exact controllers with exact serializers:
msgspec
pydantic
views.py
```python
from dmr.plugins.msgspec import MsgspecSerializer
from examples.reusable_code.reusable_controller import ReusableController
class MsgspecController(ReusableController[MsgspecSerializer]):
"""This controller will use msgspec for serialization."""
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X GET
> {"message":"hello from MsgspecSerializer"}
> ```
views.py
```python
from dmr.plugins.pydantic import PydanticFastSerializer
from examples.reusable_code.reusable_controller import ReusableController
class PydanticController(ReusableController[PydanticFastSerializer]):
"""This controller will use pydantic for serialization."""
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X GET
> {"message":"hello from PydanticFastSerializer"}
> ```
Basically - we just specify what kind of serializer to use. And that’s it.
But, this is just the first step. We can do much more!
> [!NOTE]
> Only controllers with an exact serializer and at least one endpoint
> can be routed. Reusable ones have `is_abstract` set to `True`
> and raise `EndpointMetadataError`
> when you call `.as_view()` on them.
> Route their subclasses instead.
>
> > [!WARNING]
> > Added in version 0.16.0.
> [!TIP]
> Annotate class-level options like `responses`, `auth`, `parsers`,
> `renderers`, and `throttling` in controllers that will be subclassed.
>
> Without an annotation type-checkers infer a fixed-size tuple
> from the value you assign, and subclasses won’t be able
> to add or to remove items from it.
>
> views.py
> ```python
> from collections.abc import Sequence
> from http import HTTPStatus
> from typing import ClassVar
>
> from dmr import Controller, ResponseSpec
> from dmr.errors import ErrorModel
> from dmr.plugins.pydantic import PydanticFastSerializer
>
>
> class MyBaseController(Controller[PydanticFastSerializer]):
> responses: ClassVar[Sequence[ResponseSpec]] = (
> ResponseSpec(ErrorModel, status_code=HTTPStatus.NOT_FOUND),
> )
>
> def get(self) -> str:
> return 'reusable'
>
>
> class MyController(MyBaseController):
> # This works only because `responses` is annotated in the parent class.
> # Without that annotation type-checkers infer the type of `responses`
> # from the parent's value, which is a tuple of exactly one item,
> # and this assignment fails with:
> # Incompatible types in assignment (expression has type
> # "tuple[ResponseSpec, ResponseSpec]", base class "MyBaseController"
> # defined the type as "tuple[ResponseSpec]")
> responses = (
> *MyBaseController.responses,
> ResponseSpec(ErrorModel, status_code=HTTPStatus.CONFLICT),
> )
> ```
### Generic parsing and response models
Next, let’s define a reusable controller that will have:
- customizable serializer
- customizable request model
- customizable response body
#### Raw endpoints support
The process will look exactly the same:
views.py
```python
from abc import abstractmethod
from typing import Generic, TypeVar
from dmr import Body, Controller
from dmr.serializer import BaseSerializer
_SerializerT = TypeVar('_SerializerT', bound=BaseSerializer)
_RequestModelT = TypeVar('_RequestModelT')
_ResponseBodyT = TypeVar('_ResponseBodyT')
class ReusableController(
Controller[_SerializerT],
Generic[_SerializerT, _RequestModelT, _ResponseBodyT],
):
def post(self, parsed_body: Body[_RequestModelT]) -> _ResponseBodyT:
return self.convert(parsed_body)
@abstractmethod
def convert(self, parsed_body: _RequestModelT) -> _ResponseBodyT:
raise NotImplementedError
```
Here we use 3 type variables. One of each of the parts we want to customize.
Important part here is that we defined our own abstract `convert` method
to convert unknown request model into an unknown response body.
We would need to implement this method in all of our concrete controllers.
msgspec
pydantic
views.py
```python
from typing_extensions import TypedDict, override
from dmr.plugins.msgspec import MsgspecSerializer
from examples.reusable_code.reusable_parsing import ReusableController
class _RequestModel(TypedDict):
username: str
class _ResponseBody(TypedDict):
message: str
class MsgspecController(
ReusableController[MsgspecSerializer, _RequestModel, _ResponseBody],
):
@override
def convert(self, parsed_body: _RequestModel) -> _ResponseBody:
return {'message': f'Hello, {parsed_body["username"]}'}
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d '{"username": "sobolevn"}' -H 'Content-Type: application/json'
> {"message":"Hello, sobolevn"}
> ```
views.py
```python
from typing_extensions import TypedDict, override
from dmr.plugins.pydantic import PydanticFastSerializer
from examples.reusable_code.reusable_parsing import ReusableController
class _RequestModel(TypedDict):
first_name: str
last_name: str
class _ResponseBody(TypedDict):
full_name: str
class PydanticController(
ReusableController[PydanticFastSerializer, _RequestModel, _ResponseBody],
):
@override
def convert(self, parsed_body: _RequestModel) -> _ResponseBody:
return {
'full_name': (
f'{parsed_body["first_name"]} {parsed_body["last_name"]}'
),
}
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d '{"first_name": "Nikita", "last_name": "Sobolev"}' -H 'Content-Type: application/json'
> {"full_name":"Nikita Sobolev"}
> ```
Note that `msgspec` and `pydantic` controllers in this
case have completely different request and response bodies
and completely different OpenAPI schemas.
We can completely customize each controller and all parsing components
and return type validation.
> [!IMPORTANT]
> All schema generation and validation rules work
> the same way for concrete controllers.
>
> We infer the passed values during import time and use real types.
#### Real endpoints support
> [!WARNING]
> Added in version 0.14.0.
The same would work with endpoints defined
with `validate` decorator.
The logic is the same, but syntax is a bit different.
> [!TIP]
> By default `mypy` and other type-checkers won’t allow to write
> `ResponseSpec(_TypeT, status_code=OK)`, because type vars can’t be used
> in such places according
> to the [typing spec](https://typing.python.org/en/latest/#specification).
>
> So, we provide `dmr.types.safe_typevar()` helper
> to get rid of the type-checking errors.
Here’s how we can do the same example, but with `@validate`.
The reusable part:
views.py
```python
from abc import abstractmethod
from http import HTTPStatus
from typing import Generic, TypeVar
from django.http import HttpResponse
from dmr import Body, Controller, ResponseSpec, validate
from dmr.serializer import BaseSerializer
from dmr.types import safe_typevar
_SerializerT = TypeVar('_SerializerT', bound=BaseSerializer)
_RequestModelT = TypeVar('_RequestModelT')
_ResponseBodyT = TypeVar('_ResponseBodyT')
class ReusableController(
Controller[_SerializerT],
Generic[_SerializerT, _RequestModelT, _ResponseBodyT],
):
@validate(
ResponseSpec(
safe_typevar('_ResponseBodyT'),
status_code=HTTPStatus.CREATED,
),
)
def post(self, parsed_body: Body[_RequestModelT]) -> HttpResponse:
return self.to_response(self.convert(parsed_body))
@abstractmethod
def convert(self, parsed_body: _RequestModelT) -> _ResponseBodyT:
raise NotImplementedError
```
And then - implementations:
msgspec
pydantic
views.py
```python
import msgspec
from typing_extensions import override
from dmr.plugins.msgspec import MsgspecSerializer
from examples.reusable_code.validate_reusable import ReusableController
class _RequestModel(msgspec.Struct):
username: str
class _ResponseBody(msgspec.Struct):
message: str
class MsgspecController(
ReusableController[MsgspecSerializer, _RequestModel, _ResponseBody],
):
@override
def convert(self, parsed_body: _RequestModel) -> _ResponseBody:
return _ResponseBody(message=f'Hello, {parsed_body.username}')
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d '{"username": "sobolevn"}' -H 'Content-Type: application/json'
> {"message":"Hello, sobolevn"}
> ```
views.py
```python
import pydantic
from typing_extensions import override
from dmr.plugins.pydantic import PydanticFastSerializer
from examples.reusable_code.validate_reusable import ReusableController
class _RequestModel(pydantic.BaseModel):
first_name: str
last_name: str
class _ResponseBody(pydantic.BaseModel):
full_name: str
class PydanticController(
ReusableController[PydanticFastSerializer, _RequestModel, _ResponseBody],
):
@override
def convert(self, parsed_body: _RequestModel) -> _ResponseBody:
return _ResponseBody(
full_name=(f'{parsed_body.first_name} {parsed_body.last_name}'),
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d '{"first_name": "Nikita", "last_name": "Sobolev"}' -H 'Content-Type: application/json'
> {"full_name":"Nikita Sobolev"}
> ```
This way offers you more control over the response headers, cookies, etc.
Choose the one that fits best of the job.
#### Type variable defaults
> [!WARNING]
> Added in version 0.16.0.
Type variables can have defaults, as described in [**PEP 696**](https://peps.python.org/pep-0696/).
We use them when a subclass does not provide some of the type args.
There are three ways to define them:
- `typing_extensions.TypeVar('_ModelT', default=MyModel)` on any version
- [`typing.TypeVar`](https://docs.python.org/3/library/typing.html#typing.TypeVar) with the same `default=` argument on 3.13 and above
- the native `class Reusable[_ModelT = MyModel]` syntax on 3.13 and above
views.py
```python
from typing import Generic
from typing_extensions import TypedDict, TypeVar
from dmr import Body, Controller
from dmr.serializer import BaseSerializer
class DefaultRequestModel(TypedDict):
first_name: str
last_name: str
_SerializerT = TypeVar('_SerializerT', bound=BaseSerializer)
_RequestModelT = TypeVar('_RequestModelT', default=DefaultRequestModel)
class ReusableController(
Controller[_SerializerT],
Generic[_SerializerT, _RequestModelT],
):
def post(self, parsed_body: Body[_RequestModelT]) -> _RequestModelT:
return parsed_body
```
The request model is now optional for the subclasses:
with default
substituting default
views.py
```python
from dmr.plugins.pydantic import PydanticFastSerializer
from examples.reusable_code.reusable_defaults import ReusableController
class PydanticController(ReusableController[PydanticFastSerializer]):
"""The request model is not given, so it is `DefaultRequestModel`."""
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d '{"first_name": "Nikita", "last_name": "Sobolev"}' -H 'Content-Type: application/json'
> {"first_name":"Nikita","last_name":"Sobolev"}
> ```
views.py
```python
from typing_extensions import TypedDict
from dmr.plugins.pydantic import PydanticFastSerializer
from examples.reusable_code.reusable_defaults import ReusableController
class _RequestModel(TypedDict):
email: str
class PydanticController(
ReusableController[PydanticFastSerializer, _RequestModel],
):
"""
Defaults are only used when type args are missing.
``DefaultRequestModel`` is fully replaced here,
so its payload does not validate anymore.
"""
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d '{"email": "example@example.com"}' -H 'Content-Type: application/json'
> {"email":"example@example.com"}
>
> $ curl http://127.0.0.1:8000/api/example/ -D - -X POST -d '{"first_name": "Nikita", "last_name": "Sobolev"}' -H 'Content-Type: application/json'
> HTTP/1.1 400 Bad Request
> date: Mon, 05 Oct 2026 11:18:19 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 88
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Field required","loc":["parsed_body","email"],"type":"value_error"}]}
> ```
Defaults can also point to other type variables:
`_ResponseBodyT = TypeVar('_ResponseBodyT', default=_RequestModelT)`
means “the response body is the request model, unless told otherwise”.
Serializers can have defaults as well. Then a subclass
that passes no type args at all is a concrete controller,
because it has an exact serializer:
views.py
```python
from typing import Generic
from typing_extensions import TypedDict, TypeVar
from dmr import Body, Controller
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.serializer import BaseSerializer
class _RequestModel(TypedDict):
first_name: str
last_name: str
_SerializerT = TypeVar(
'_SerializerT',
bound=BaseSerializer,
default=PydanticFastSerializer,
)
_RequestModelT = TypeVar('_RequestModelT', default=_RequestModel)
class ReusableController(
Controller[_SerializerT],
Generic[_SerializerT, _RequestModelT],
):
"""Still abstract: its own type variables are not exact types."""
def post(self, parsed_body: Body[_RequestModelT]) -> _RequestModelT:
return parsed_body
class PydanticController(ReusableController):
"""Concrete: both type variables fall back to their defaults."""
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -X POST -d '{"first_name": "Nikita", "last_name": "Sobolev"}' -H 'Content-Type: application/json'
> {"first_name":"Nikita","last_name":"Sobolev"}
> ```
> [!IMPORTANT]
> A default only applies to the subclasses, never to the reusable
> controller that declares it. `ReusableController` above still has
> `is_abstract` set to `True` and cannot be routed,
> even though every one of its type variables has a default.
>
> This is the same rule as everywhere else: we only build endpoints
> for concrete controllers, and a type variable is not an exact type.
> [!NOTE]
> A type variable without a default is still required.
> Controllers that don’t have an exact serializer
> stay abstract, as always.
Write the subclass when you have anything else to say: a setting
to change, a hook to redefine, or a name to route several times.
### Lazy reusable endpoints
In some cases you need to do even more
than request / response payload modifications.
Sometimes, you need to change the status code, header and cookie specs,
maybe even auth or throttling definitions.
> [!IMPORTANT]
> We only create endpoints when **concrete** controller is built.
> We never create endpoins definitions for reusable controllers.
> So, all endpoints are always created in the correct - final - context.
To use the full customization, we provide:
- `dmr.endpoint.ModifyEndpoint.lazy()` method to work
with `modify`. It accepts
a function or a `classmethod` to lazily provide a spec in the future
- `dmr.endpoint.ValidateEndpoint.lazy()` method to work
with `validate`. It accepts
a function or a `classmethod` to lazily provide a spec in the future
Here’s how it works:
modify
validate
views.py
```python
from http import HTTPStatus
from typing import ClassVar, TypeVar
from dmr import Controller, modify
from dmr.endpoint import ModifyAnyCallable
from dmr.serializer import BaseSerializer
_SerializerT = TypeVar('_SerializerT', bound=BaseSerializer)
class LoginController(Controller[_SerializerT]):
status_code: ClassVar[HTTPStatus] = HTTPStatus.OK
@classmethod
def lazy_spec(cls) -> ModifyAnyCallable:
return modify(status_code=cls.status_code)
@modify.lazy(lazy_spec)
def get(self) -> str:
return 'login'
```
views.py
```python
from http import HTTPStatus
from typing import ClassVar, TypeVar
from django.http import HttpResponse
from dmr import Controller, ResponseSpec, validate
from dmr.endpoint import ValidateAnyCallable
from dmr.serializer import BaseSerializer
_SerializerT = TypeVar('_SerializerT', bound=BaseSerializer)
class LoginController(Controller[_SerializerT]):
status_code: ClassVar[HTTPStatus] = HTTPStatus.IM_USED
@classmethod
def lazy_spec(cls) -> ValidateAnyCallable:
return validate(ResponseSpec(str, status_code=cls.status_code))
@validate.lazy(lazy_spec)
def get(self) -> HttpResponse:
return self.to_response('login', status_code=self.status_code)
```
What happens here?
1. We define a reusable controller with lazy endpoint specification
2. We define `lazy_spec` classmethod that will provide the actual decorator
during the child - final - controller build time
3. We use class-level API to define constants that people
can modify in their child - final - controllers if needed.
But, the default implementation would work the way we described it
Notice that we use special types to define
the return type from the `lazy_spec` classmethod.
Here are all of them, choose the one for your task:
| Type name | Original decorator | What it does |
|-------------------------|----------------------|------------------------------------------------------------------|
| `ModifyAnyCallable` | `modify` | Creates a decorator for endpoints without sync / async specifics |
| `ModifySyncCallable` | `modify` | Creates a decorator for sync endpoints |
| `ModifyAsyncCallable` | `modify` | Creates a decorator for async endpoints |
| `ValidateAnyCallable` | `validate` | Creates a decorator for endpoints without sync / async specifics |
| `ValidateSyncCallable` | `validate` | Creates a decorator for sync endpoints |
| `ValidateAsyncCallable` | `validate` | Creates a decorator for async endpoints |
Basically, there are several major rules:
1. Prefer defining decorators that do not care about sync / async code, use
`ModifyAnyCallable`
and `ValidateAnyCallable` by default
2. If you need sync / async details like `error_handler`, `auth`,
or `throttling` - then use exact type
for sync / async decorator of your choice
3. Do not mix specs for `@validate` and `@modify`, it will be a type error
#### Customizing definitions
Now, let’s use the reusable controller we defined above,
but we would customize the response status code,
just as an example of power that we have:
modify
validate
views.py
```python
from http import HTTPStatus
from typing import ClassVar
from dmr.plugins.pydantic import PydanticFastSerializer
from examples.reusable_code.lazy_modify import LoginController
class OurLoginController(LoginController[PydanticFastSerializer]):
status_code: ClassVar[HTTPStatus] = HTTPStatus.IM_USED
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -D - -X GET
> HTTP/1.1 226 IM Used
> date: Mon, 05 Oct 2026 11:18:20 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 7
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> "login"
> ```
views.py
```python
from http import HTTPStatus
from typing import ClassVar
from dmr.plugins.pydantic import PydanticFastSerializer
from examples.reusable_code.lazy_validate import LoginController
class OurLoginController(LoginController[PydanticFastSerializer]):
status_code: ClassVar[HTTPStatus] = HTTPStatus.IM_USED
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -D - -X GET
> HTTP/1.1 226 IM Used
> date: Mon, 05 Oct 2026 11:18:20 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 7
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> "login"
> ```
Notice that the response code would be changed in both the spec and runtime.
And, of course, you can combine this approach
with generic serializer, or request and response payloads.
Giving the full control over code reuse.
#### Overriding definitions
To finish this example off, we would completely override the spec
in a child controller. Sometimes users might want to do that,
for example: to provide auth or custom OpenAPI spec.
But, user is free to modify any parts of the spec, if needed.
modify
validate
views.py
```python
from http import HTTPStatus
from typing import ClassVar
from typing_extensions import override
from dmr import modify
from dmr.endpoint import ModifyAnyCallable
from dmr.plugins.pydantic import PydanticFastSerializer
from examples.reusable_code.lazy_modify import LoginController
class OurLoginController(LoginController[PydanticFastSerializer]):
status_code: ClassVar[HTTPStatus] = HTTPStatus.IM_USED
@classmethod
@override
def lazy_spec(cls) -> ModifyAnyCallable:
return modify(status_code=cls.status_code, tags=['custom_openapi_meta'])
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -D - -X GET
> HTTP/1.1 226 IM Used
> date: Mon, 05 Oct 2026 11:18:21 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 7
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> "login"
> ```
views.py
```python
from http import HTTPStatus
from typing import ClassVar
from typing_extensions import override
from dmr import ResponseSpec, validate
from dmr.endpoint import ValidateAnyCallable
from dmr.plugins.pydantic import PydanticFastSerializer
from examples.reusable_code.lazy_validate import LoginController
class OurLoginController(LoginController[PydanticFastSerializer]):
status_code: ClassVar[HTTPStatus] = HTTPStatus.IM_USED
@classmethod
@override
def lazy_spec(cls) -> ValidateAnyCallable:
return validate(
ResponseSpec(str, status_code=cls.status_code),
tags=['custom_openapi_meta'],
)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -D - -X GET
> HTTP/1.1 226 IM Used
> date: Mon, 05 Oct 2026 11:18:21 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 7
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> "login"
> ```
> [!NOTE]
> Notice that `lazy_spec` classmethod is resolve from the final controller,
> not the one that was used during the decoration time.
>
> `@classmethod` is preferable over `lambda` functions,
> because they provide easier override API and they are fully typed.
> The `controller` argument of a `lambda` is typed as `type[Any]`,
> so attribute access on it is not checked by type checkers.
### Where is it actually helpful in practice?
We use this feature a lot in the pre-defined views
we provide with the framework.
For example, we use this in JWT Auth obtain views:
1. `ObtainTokensSyncController`
for sync controllers
2. `ObtainTokensAsyncController`
for async controllers
Usage example:
views.py
```python
import datetime as dt
from typing_extensions import override
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.security.jwt.views import (
ObtainTokensPayload,
ObtainTokensResponse,
ObtainTokensSyncController,
)
# You can also use `ObtainTokensAsyncController` if needed:
class ObtainAccessAndRefreshSyncController(
ObtainTokensSyncController[
PydanticFastSerializer,
ObtainTokensPayload,
ObtainTokensResponse,
],
):
@override
def convert_auth_payload(
self,
payload: ObtainTokensPayload,
) -> ObtainTokensPayload:
return payload
@override
def make_api_response(self) -> ObtainTokensResponse:
now = dt.datetime.now(dt.UTC)
return {
'access_token': self.create_jwt_token(
expiration=now + self.jwt_expiration,
token_type='access',
),
'refresh_token': self.create_jwt_token(
expiration=now + self.jwt_refresh_expiration,
token_type='refresh',
),
}
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/auth/ -X POST -d '{"username": "test_user", "password": "password"}' -H 'Content-Type: application/json'
> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzkxMjg1NTAzLCJpYXQiOjE3OTExOTkxMDMsImp0aSI6IjFhN2E1NTFmNWE4ZDQ0MDBhNTE0ZTcyNGQyY2RiNGIxIiwiZXh0cmFzIjp7InR5cGUiOiJhY2Nlc3MifX0.hJ1tND7iKiyilspFkzGTjnxMezlDYySjcknz3abLC-w","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzkyMDYzMTAzLCJpYXQiOjE3OTExOTkxMDMsImp0aSI6IjEzMmRhNjNjN2EwZjRiZjBiMmNjMWQ5ODQyNjM3YWViIiwiZXh0cmFzIjp7InR5cGUiOiJyZWZyZXNoIn19.Hj-SQ1_X-udxvydJjLW6-Lbefn9n_aw3n0bJKoMTWgU"}
> ```
Why is it useful?
1. We can work with any serializer
2. We can change our request payload to be whatever we need,
it would be correctly rendered in the final OpenAPI schema
3. We can change the response schema,
which would also be correctly rendered in the OpenAPI
This feature allows us to have type-safe
and OpenAPI-first approach to code reusability,
great DX, and Python-native abstractions.
Users / plugin developers can do the same
to provide universal customizable controllers.
Source: /en/latest/pages/external-views.md
## External views
> [!WARNING]
> Added in version 0.13.0.
`django-modern-rest` is build around several pure-Django concepts:
- `Controller` which is a subclass
of [`View`](https://docs.djangoproject.com/en/6.1/ref/class-based-views/base/#django.views.generic.base.View) to define API views
- `Router` to manipulate `URLPattern` objects and URLs
- `OpenAPI` dataclass
to store OpenAPI spec near the view
So, *any* Django-compatible [`View`](https://docs.djangoproject.com/en/6.1/ref/class-based-views/base/#django.views.generic.base.View)
objects can be used with `django-modern-rest`
with the user-provided OpenAPI schema.
> [!IMPORTANT]
> `'django-modern-rest[pydantic]'` must be installed
> to use external views feature.
> `load_schema()` requires it to deserialize OpenAPI objects.
### How it works?
Just like good old Django!
We **don’t touch the existing view logic / OpenAPI metadata in any way**.
The view itself can do any validation or logic,
parse / serialize objects in any way.
We also just include the existing OpenAPI metadata,
without any logic or modifications.
> [!NOTE]
> However, if you define top-level error handlers with
> `build_404_handler()`
> and `build_500_handler()`,
> it would still affect the view, when these errors happen.
#### OpenAPI
Imagine that you already have an OpenAPI spec,
it might be from another library, old project, legacy service, etc.
But, we can continue to use it.
Let’s say you have an existing service that returns you random numbers.
Here’s its spec:
openapi.yml
```yaml
openapi: 3.0.3
info:
title: Number API
version: 1.0.0
paths:
/api/number:
get:
operationId: getNumber
responses:
'200':
description: A number
content:
application/json:
schema:
type: number
```
Next, let’s show how we can adapt existing
pure-Django API views, attach this to existing schema as:
- both functional,
- and class-based.
> [!TIP]
> If external OpenAPI has validation issues,
> you might want to disable the OpenAPI validation process for the whole schema.
> Pass `skip_validation=False` to the converter methods.
> See `dmr.openapi.openapi.OpenAPI.convert()`
> and `dmr.openapi.views.base.OpenAPIView.as_view()`.
The main feature that allows us to do this are two functions:
- `dmr.routing.external_path()` for regular routes with converters,
like [`django.urls.path()`](https://docs.djangoproject.com/en/6.1/ref/urls/#django.urls.path)
- `dmr.routing.external_re_path()` for `regex` based routes,
like [`django.urls.re_path()`](https://docs.djangoproject.com/en/6.1/ref/urls/#django.urls.re_path)
They follow the same API design as regular `path` functions,
but also require `openapi` kw-only parameter
to be passed together with other regular parameters.
#### Functional example
Let’s start with functions.
Our previously described random number API service can be a function:
views.py
```python
import random
from django.http import HttpRequest, JsonResponse
from django.views.decorators.http import require_GET
from dmr.openapi import build_schema, load_schema
from dmr.openapi.objects import PathItem
from dmr.openapi.views import OpenAPIJsonView
from dmr.routing import Router, external_path, path
from examples.external_views.read_openapi import read_openapi_yaml
@require_GET
def number(request: HttpRequest) -> JsonResponse:
return JsonResponse(random.randint(1, 10), safe=False)
# Now, load the schema:
raw_schema = read_openapi_yaml('openapi.yml')
# Create a router and URL patterns:
router = Router(
'api/',
urls=[
external_path(
'number/',
number,
name='number',
openapi=load_schema(raw_schema['paths']['/api/number'], PathItem),
),
],
)
schema = build_schema(router)
urlpatterns = [
# Register our router in the final url patterns:
router.to_urlpatterns(namespace='api'),
# Add swagger:
path('docs/openapi.json/', OpenAPIJsonView.as_view(schema), name='openapi'),
]
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/number/ -X GET
> 2
> ```
Notice that `/api/numbers` path item definition from `openapi.yml`
was inserted into our final schema as-is.
#### Class example
Now, the same, but with a class:
views.py
```python
import random
from django.http import HttpRequest, JsonResponse
from django.views import View
from dmr.openapi import build_schema, load_schema
from dmr.openapi.objects import PathItem
from dmr.openapi.views import OpenAPIJsonView
from dmr.routing import Router, external_path, path
from examples.external_views.read_openapi import read_openapi_yaml
class NumberView(View):
def get(self, request: HttpRequest) -> JsonResponse:
return JsonResponse(random.randint(1, 10), safe=False)
# Now, load the schema:
raw_schema = read_openapi_yaml('openapi.yml')
# Create a router and URL patterns:
router = Router(
'api/',
urls=[
external_path(
'number/',
NumberView.as_view(),
name='number',
openapi=load_schema(raw_schema['paths']['/api/number'], PathItem),
),
],
)
schema = build_schema(router)
urlpatterns = [
# Register our router in the final url patterns:
router.to_urlpatterns(namespace='api'),
# Add swagger:
path('docs/openapi.json/', OpenAPIJsonView.as_view(schema), name='openapi'),
]
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/number/ -X GET
> 10
> ```
It can be any [`View`](https://docs.djangoproject.com/en/6.1/ref/class-based-views/base/#django.views.generic.base.View) compatible class!
### Mixing URLs
Since we just work with regular Django URLs, you can mix
[`django.urls.path()`](https://docs.djangoproject.com/en/6.1/ref/urls/#django.urls.path) and `dmr.routing.external_path()` items.
> [!NOTE]
> Both `dmr.routing.external_path()`
> and `dmr.routing.external_re_path()`
> return regular `URLPattern` objects.
> They can be used like any regular Django `path()` objects.
Order of passed urls is preserved:
views.py
```python
import random
from django.http import HttpRequest, JsonResponse
from django.views.decorators.http import require_GET
from dmr import Controller
from dmr.openapi import build_schema, load_schema
from dmr.openapi.objects import PathItem
from dmr.openapi.views import OpenAPIJsonView
from dmr.plugins.pydantic import PydanticFastSerializer
from dmr.routing import Router, external_path, path
from examples.external_views.read_openapi import read_openapi_yaml
class NumberController(Controller[PydanticFastSerializer]):
def get(self) -> int:
return random.randint(1, 10)
@require_GET
def number(request: HttpRequest) -> JsonResponse:
return JsonResponse(random.randint(1, 10), safe=False)
# Now, load the schema:
raw_schema = read_openapi_yaml('openapi.yml')
# Create a router and URL patterns:
router = Router(
'api/',
urls=[
path('dmr-number/', NumberController.as_view(), name='dmr_number'),
external_path(
'number/',
number,
name='number',
openapi=load_schema(raw_schema['paths']['/api/number'], PathItem),
),
],
)
schema = build_schema(router)
urlpatterns = [
# Register our router in the final url patterns:
router.to_urlpatterns(namespace='api'),
# Add swagger:
path('docs/openapi.json/', OpenAPIJsonView.as_view(schema), name='openapi'),
]
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/dmr-number/ -X GET
> 1
>
> $ curl http://127.0.0.1:8000/api/number/ -X GET
> 1
> ```
Note that `dmr.controller.Controller` items will generate
its own OpenAPI `PathItem` schema.
While `external_path()` would just insert the existing OpenAPI metadata as-is.
### Registering OpenAPI schemas
Now, we have a new OpenAPI file. It has several changes from the first one:
- It contains [`int`](https://docs.python.org/3/builtins/functions.html#int) path parameters `start` and `end`
- It has a schema component with a `$ref` as a return,
so we would have to register in the final spec
- It has top-level tags definition that we also want to copy to the final spec
It does the same thing,
but has two path parameters and also defines OpenAPI schemas
that we need to re-register, so they would be available in our final spec:
openapi.yml
```yaml
openapi: 3.1.0
info:
title: Random Number API
version: 1.0.0
tags:
- name: random
description: Operations for generating random numbers
paths:
/api/random/{start}/{end}:
get:
operationId: getRandomNumber
tags:
- random
parameters:
- name: start
in: path
required: true
schema:
type: integer
- name: end
in: path
required: true
schema:
type: integer
responses:
'200':
description: A random number between start and end
content:
application/json:
schema:
$ref: '#/components/schemas/RandomNumber'
components:
schemas:
RandomNumber:
type: object
properties:
value:
type: number
required:
- value
```
To register OpenAPI schema components and tags, we can use
`dmr.openapi.OpenAPIConfig` customization:
views.py
```python
import random
from django.http import HttpRequest, JsonResponse
from django.views.decorators.http import require_GET
from dmr.openapi import OpenAPIConfig, build_schema, load_schema
from dmr.openapi.objects import Components, PathItem, Tag
from dmr.openapi.views import OpenAPIJsonView
from dmr.routing import Router, external_path, path
from examples.external_views.read_openapi import read_openapi_yaml
@require_GET
def number(request: HttpRequest, start: int, end: int) -> JsonResponse:
return JsonResponse(random.randint(start, end), safe=False)
# Now, load the schema:
raw_schema = read_openapi_yaml('openapi2.yml')
# Create a router and URL patterns:
router = Router(
'api/',
urls=[
external_path(
'number///',
number,
name='number',
openapi=load_schema(
raw_schema['paths']['/api/random/{start}/{end}'],
PathItem,
),
),
],
)
# Register external components to the config:
config = OpenAPIConfig(
title='New Random Number API',
version='0.0.1',
# Here you can pass any `OpenAPI` class parameters,
# not just components and tags:
components=load_schema(raw_schema['components'], Components),
tags=[load_schema(tag, Tag) for tag in raw_schema['tags']],
)
schema = build_schema(router, config=config)
urlpatterns = [
# Register our router in the final url patterns:
router.to_urlpatterns(namespace='api'),
# Add swagger:
path('docs/openapi.json/', OpenAPIJsonView.as_view(schema), name='openapi'),
]
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/number/1/5/ -X GET
> 1
>
> $ curl http://127.0.0.1:8000/api/number/regular-django/url-error/ -D - -X GET
> HTTP/1.1 404 Not Found
> date: Mon, 05 Oct 2026 11:17:30 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 56
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"Page not found","type":"not_found"}]}
> ```
There are several rules on how we merge the pre-defined config
with the automatically generated one:
- When trying to redefine an existing component by name,
[`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError) is raised
- We merge all components from all possible types
- We can’t detect unused ones, so we merge all,
even if some of them are not used
If you have several `Components` definitions,
you can pass a list of them to the `dmr.openapi.OpenAPIConfig` instance.
All of them will be merged into the final spec correctly.
See OpenAPI for more possible customizations.
### Excluding external views from OpenAPI
It might be important to add a private API endpoint,
without registering it in the final OpenAPI spec.
Some endpoints might not even have OpenAPI in the first place!
To achieve this, pass `None` instead of the `PathItem` schema:
views.py
```python
import random
from django.http import HttpRequest, JsonResponse
from django.views.decorators.http import require_GET
from dmr.openapi import build_schema
from dmr.openapi.views import OpenAPIJsonView
from dmr.routing import Router, external_path, path
@require_GET
def number(request: HttpRequest) -> JsonResponse:
return JsonResponse(random.randint(1, 10), safe=False)
# Create a router and URL patterns:
router = Router(
'api/',
urls=[
# This function will still work, but be ignored from the spec:
external_path('number/', number, name='number', openapi=None),
],
)
schema = build_schema(router)
urlpatterns = [
# Register our router in the final url patterns:
router.to_urlpatterns(namespace='api'),
# Add swagger:
path('docs/openapi.json/', OpenAPIJsonView.as_view(schema), name='openapi'),
]
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/number/ -X GET
> 1
> ```
The view will still work as expected, but won’t be present in the spec.
See Excluding views from OpenAPI for more details
about excluding regular views from the OpenAPI.
### Real world use-cases
For example, one can reuse:
- `django-allauth`
[headless views](https://github.com/pennersr/django-allauth/tree/main/allauth/headless)
that are built to be used by the pure Django framework
- Any `APIView` or `GenericAPIView` objects from `django-rest-framework`
- Any `ControllerBase` objects from `django-ninja-extra`
- And many more!
Source: /en/latest/pages/integrations.md
## Integrations
- Big list of Django integrations: [https://github.com/wsvincent/awesome-django](https://github.com/wsvincent/awesome-django)
- Big list of `django-modern-rest` awesome things: [https://github.com/kondratevdev/awesome-django-modern-rest](https://github.com/kondratevdev/awesome-django-modern-rest)
> [!WARNING]
> In the future - some integrations from this list might be included
> into the core of `django-modern-rest` package. Or ship as plugins.
>
> If you are interested in something:
> [open an issue](https://github.com/wemake-services/django-modern-rest/issues).
`django-modern-rest` must be compatible with the most packages
from the `django` ecosystem. To do so, we support:
- All the regular `View` based APIs like mixins,
since `Controller` is a subclass of `View`
- Middlewares to wrap any existing middleware
- Decorators, thanks to `endpoint_decorator()`
and `dispatch_decorator()`
- External views to integrate any existing views
into `django-modern-rest`, including regular `django`,
`django-rest-framework`, and `django-ninja`
### CSRF
Django supports
[Cross Site Request Forgery](https://docs.djangoproject.com/en/stable/ref/csrf/)
protection.
We support both ways of enforcing CSRF checks:
- Via [`django.middleware.csrf.CsrfViewMiddleware`](https://docs.djangoproject.com/en/6.1/ref/middleware/#django.middleware.csrf.CsrfViewMiddleware) middleware
- Via [`django.views.decorators.csrf.csrf_protect()`](https://docs.djangoproject.com/en/6.1/ref/csrf/#django.views.decorators.csrf.csrf_protect) decorator
By default we exempt all controllers from CSRF checks, unless:
1. `csrf_exempt`
is set to `False` for a specific controller
2. Endpoints protected by
`DjangoSessionSyncAuth`,
`DjangoSessionAsyncAuth`
or `CookieJWTSyncAuth`,
`CookieJWTAsyncAuth`
will require CSRF as well. Because using cookies
for auth with explicit CSRF is not secure
> [!NOTE]
> Detailed CSRF failure reason on response content
> will be exposed only in debug mode for security reasons.
Here’s an example of how you can re-enable CSRF for a specific controller:
views.py
```python
from dmr import Controller
from dmr.plugins.pydantic import PydanticFastSerializer
class ExampleController(Controller[PydanticFastSerializer]):
csrf_exempt = False
def post(self) -> str:
return 'ok'
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl http://127.0.0.1:8000/api/example/ -D - -X POST -H 'X-CSRFToken: dmrDocsExampleCsrfTokenValue0001' --cookie csrftoken=dmrDocsExampleCsrfTokenValue0001
> HTTP/1.1 201 Created
> date: Mon, 05 Oct 2026 11:17:36 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 4
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> "ok"
>
> $ curl http://127.0.0.1:8000/api/example/ -D - -X POST
> HTTP/1.1 403 Forbidden
> date: Mon, 05 Oct 2026 11:17:37 GMT
> server: uvicorn
> Content-Type: application/json
> X-Frame-Options: DENY
> Vary: Accept-Language
> Content-Language: en
> Content-Length: 35
> X-Content-Type-Options: nosniff
> Referrer-Policy: same-origin
> Cross-Origin-Opener-Policy: same-origin
>
> {"detail":[{"msg":"CSRF Failed."}]}
> ```
Next: see [CSRF_FAILURE_VIEW](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-failure-view)
Django setting value to configure how CSRF failure view will work.
To do so, we use `build_csrf_handler()` function:
settings.py
```python
>>> from dmr.security.csrf import build_csrf_handler
>>> from dmr.plugins.pydantic import PydanticSerializer
>>> CSRF_FAILURE_VIEW = build_csrf_handler(
... 'api/',
... serializer=PydanticSerializer,
... )
```
This configuration will work similarly
to `build_404_handler()`:
- It will check the request path, if it starts with `'api/'`,
we will negotiate the renderer and return
the expected error in the default format
- If not, it will return the regular Django HTML page
We automatically inject CSRF response specs and security schemes
in endpoints for controllers with `csrf_exempt = False`
and unsafe HTTP methods like `POST`, etc.
We do so with the help
of `semantic_schema_providers` setting
and `CSRFSemanticSchemaProvider`
response spec provider.
> [!NOTE]
> `csrf` security requirement spec is injected with `AND` logic.
> So, if you have existing auth instances, you will have to satisfy
> both this auth and `csrf` requirements. Just like in the runtime.
#### Customizing error model
Since we support full error customization (see Customizing error messages)
we also support this feature for CSRF responses.
To change the error message format you will need:
- Pass needed *format_error* callback parameter
to `build_csrf_handler()`
- Customize `error_model` parameter
to `CSRFSemanticSchemaProvider`
in `semantic_schema_providers` setting
### Bring your own DI
We don’t have any opinions about any DI that you can potentially use.
Because `django-modern-rest` is compatible with any of the existing ones.
Use any DI that you already have or want to use with `django`.
Try any of these officially recommended tools:
- [https://github.com/maksimzayats/diwire](https://github.com/maksimzayats/diwire)
with the official
[django-modern-rest how-to](https://docs.diwire.dev/howto/web/django-modern-rest.html)
- [https://github.com/reagento/dishka](https://github.com/reagento/dishka) with the help of [https://github.com/arturboyun/dmr-dishka](https://github.com/arturboyun/dmr-dishka) plugin
- [https://github.com/bobthemighty/punq](https://github.com/bobthemighty/punq)
Or any other one that suits your needs :)
### Typing
Django does not have type annotations, by default,
so `mypy` won’t type check Django apps by default.
But, when [django-stubs](https://github.com/typeddjango/django-stubs)
is installed, type checking starts to shine.
So, when you use `mypy`, you will need
to install `django-stubs` together with `django-modern-rest`
to have the best type checking experience.
This package is included in `pyright` by default. No actions are required.
We check `django-modern-rest` code with `mypy` and `pyright`
strict modes in CI, so be sure to have the best typing possible.
See our
[project template](https://github.com/wemake-services/wemake-django-template)
to learn how typing works, how `mypy` is configured,
how `django-stubs` is used.
### Pagination
See Pagination —
we support both limit/offset
and cursor-based pagination out of the box.
### Filters
No special integration with
[django-filter](https://github.com/carltongibson/django-filter)
is required.
Everything just works:
views.py
```python
import django_filters
import pydantic
from django.contrib.auth.models import User
from dmr import Controller, Query
from dmr.plugins.pydantic import PydanticFastSerializer
class UserFilter(django_filters.FilterSet):
class Meta:
model = User
fields = ('is_active',)
# Create query model for validation and docs:
class QueryModel(pydantic.BaseModel):
is_active: bool
class UserModel(pydantic.BaseModel):
username: str
email: str
is_active: bool
_UserList = pydantic.TypeAdapter(list[UserModel])
class UsersController(Controller[PydanticFastSerializer]):
def get(self, parsed_query: Query[QueryModel]) -> list[UserModel]:
# Still pass `.GET` for API compatibility:
user_filter = UserFilter(
self.request.GET,
queryset=User.objects.all(),
)
return _UserList.validate_python(user_filter.qs, from_attributes=True)
```
> [!NOTE]
> #### Run result
>
> ```shell
> $ curl 'http://127.0.0.1:8000/api/users/?is_active=1' -X GET
> [{"username":"test_user","email":"test@example.com","is_active":true}]
> ```
### Health Checks
We recommend using
[django-health-check](https://github.com/codingjoe/django-health-check)
for monitoring your application’s health.
No special integration is required — the package works out-of-the-box with
`django-modern-rest`. Simply install it, include its URLs in your main
urlconf, and add the desired check apps to `INSTALLED_APPS`.
For advanced configuration, please refer to the
[django-health-check documentation](https://codingjoe.dev/django-health-check).
### CORS Headers
No special integration with
[django-cors-headers](https://github.com/adamchainz/django-cors-headers)
is required.
Everything just works.
### Content Security Policy (CSP)
No special integration with
[django-csp](https://github.com/mozilla/django-csp)
is required.
Everything just works, but there is one important nuance:
`django-modern-rest` itself only controls Django templates and local
initialization files. If you use OpenAPI UI renderers, final CSP compatibility
still depends on the upstream frontend bundle you choose.
The OpenAPI UI templates shipped by `django-modern-rest` avoid inline
`