# django-modern-rest Documentation Source: /en/latest/index.md ![image](_images/logo-light.svg)![image](_images/logo-dark.svg)[![wemake.services](https://img.shields.io/badge/%20-wemake.services-green.svg?label=%20&logo=data%3Aimage%2Fpng%3Bbase64%2CiVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAABGdBTUEAALGPC%2FxhBQAAAAFzUkdCAK7OHOkAAAAbUExURQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP%2F%2F%2F5TvxDIAAAAIdFJOUwAjRA8xXANAL%2Bv0SAAAADNJREFUGNNjYCAIOJjRBdBFWMkVQeGzcHAwksJnAPPZGOGAASzPzAEHEGVsLExQwE7YswCb7AFZSF3bbAAAAABJRU5ErkJggg%3D%3D)](https://github.com/wemake-services)[![Modern REST](https://img.shields.io/badge/Modern%20REST-0C4B33?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTA4MCIgaGVpZ2h0PSIxMDgwIiB2aWV3Qm94PSIwIDAgMTA4MCAxMDgwIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNMiA3MDQuMDJMMTQ1LjQ1OSA0NjYuMTlMMjc3Ljg4MyA3MDQuMDJMMTQ1LjQ1OSA5NDEuODQ5TDIgNzA0LjAyWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTE0NS40NTkgOTQxLjg0OUwyIDcwNC4wMkgyNzcuODgzTDE0NS40NTkgOTQxLjg0OVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik02NzguOTQ4IDcwNC4wMzVMMzQxLjIzIDEzOEwyMjcuMDcxIDMyOC4yNjRMNDM2LjM2MiA3MDQuMDM1TDMwMy4xNzcgOTQxLjg2NEg1MzYuMjVMNjc4Ljk0OCA3MDQuMDM1WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTY3OC45MzcgNzA0LjAyNkg0MzYuMzVMMzAzLjE2NiA5NDEuODU2SDUzNi4yMzlMNjc4LjkzNyA3MDQuMDI2WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTEwNzguMTcgNzA0LjAzNUw3NDAuNDUxIDEzOEw2MjYuMjkzIDMyOC4yNjRMODM1LjU4MyA3MDQuMDM1TDcwMi4zOTkgOTQxLjg2NEg5MzUuNDcyTDEwNzguMTcgNzA0LjAzNVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xMDc4LjE3IDcwNC4wMzVIODM1LjU4M0w3MDIuMzk5IDk0MS44NjRIOTM1LjQ3MkwxMDc4LjE3IDcwNC4wMzVaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K&color=35544A)](https://github.com/wemake-services/django-modern-rest)[![Tests result](https://github.com/wemake-services/django-modern-rest/actions/workflows/test.yml/badge.svg?event=push)](https://github.com/wemake-services/django-modern-rest/actions/workflows/test.yml)[![Codecov](https://codecov.io/gh/wemake-services/django-modern-rest/branch/master/graph/badge.svg)](https://codecov.io/gh/wemake-services/django-modern-rest)[![No AI slop](https://img.shields.io/badge/no-slop-purple.svg)](https://github.com/wemake-services/django-modern-rest/blob/master/.github/AI_POLICY.md)[![PyPI Downloads](https://static.pepy.tech/personalized-badge/django-modern-rest?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=BRIGHTGREEN&left_text=downloads)](https://pepy.tech/projects/django-modern-rest)[![Supported Python Version](https://img.shields.io/pypi/pyversions/django-modern-rest.svg)](https://pypi.org/project/django-modern-rest/)[![Code Style](https://img.shields.io/badge/style-wemake-000000.svg)](https://github.com/wemake-services/wemake-python-styleguide)[![Ask DeepWiki](https://img.shields.io/badge/ask-deepwiki-blue)](https://deepwiki.com/wemake-services/django-modern-rest)[![Telegram Chat](https://img.shields.io/badge/chat-join-blue.svg?logo=telegram)](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. * [![sobolevn](https://avatars.githubusercontent.com/u/4660275?v=4)](https://github.com/sobolevn) [sobolevn](https://github.com/sobolevn) 600 contributions * [![Alexander Kondratev](https://avatars.githubusercontent.com/u/101856129?v=4)](https://github.com/kondratevdev) [Alexander Kondratev](https://github.com/kondratevdev) 99 contributions * [![Olga Pichuzhkina](https://avatars.githubusercontent.com/u/22027864?v=4)](https://github.com/vyhuholl) [Olga Pichuzhkina](https://github.com/vyhuholl) 49 contributions * [![Alexey Potapov](https://avatars.githubusercontent.com/u/548631?v=4)](https://github.com/milssky) [Alexey Potapov](https://github.com/milssky) 44 contributions * [![Eugene Sattarau](https://avatars.githubusercontent.com/u/2903245?v=4)](https://github.com/Satevg) [Eugene Sattarau](https://github.com/Satevg) 12 contributions * [![Marat Khagazheev](https://avatars.githubusercontent.com/u/68775385?v=4)](https://github.com/F0RRZZ) [Marat Khagazheev](https://github.com/F0RRZZ) 11 contributions * [![Uladzislaw](https://avatars.githubusercontent.com/u/79528793?v=4)](https://github.com/Chyrets) [Uladzislaw](https://github.com/Chyrets) 9 contributions * [![Pavel Kirilin](https://avatars.githubusercontent.com/u/18153319?v=4)](https://github.com/s3rius) [Pavel Kirilin](https://github.com/s3rius) 8 contributions * [![Kanishk Pachauri](https://avatars.githubusercontent.com/u/81439109?v=4)](https://github.com/Mr-Sunglasses) [Kanishk Pachauri](https://github.com/Mr-Sunglasses) 7 contributions * [![Maksim Zayats](https://avatars.githubusercontent.com/u/49313691?v=4)](https://github.com/maksimzayats) [Maksim Zayats](https://github.com/maksimzayats) 7 contributions * [![Diemid](https://avatars.githubusercontent.com/u/112730325?v=4)](https://github.com/MasoNord) [Diemid](https://github.com/MasoNord) 6 contributions * [![Maxim Tetyushkin](https://avatars.githubusercontent.com/u/127615110?v=4)](https://github.com/max0nT) [Maxim Tetyushkin](https://github.com/max0nT) 5 contributions * [![azad-mammedov](https://avatars.githubusercontent.com/u/57948114?v=4)](https://github.com/azad-mammedov) [azad-mammedov](https://github.com/azad-mammedov) 5 contributions * [![Гимаев Наиль](https://avatars.githubusercontent.com/u/1645084?v=4)](https://github.com/gimntut) [Гимаев Наиль](https://github.com/gimntut) 4 contributions * [![Sergey Silaev](https://avatars.githubusercontent.com/u/1636488?v=4)](https://github.com/sesav) [Sergey Silaev](https://github.com/sesav) 4 contributions * [![Samar Nathani](https://avatars.githubusercontent.com/u/231691305?v=4)](https://github.com/SammySN-car) [Samar Nathani](https://github.com/SammySN-car) 4 contributions * [![Roman Kitaev](https://avatars.githubusercontent.com/u/5829154?v=4)](https://github.com/deliro) [Roman Kitaev](https://github.com/deliro) 4 contributions * [![Matthias Schoettle](https://avatars.githubusercontent.com/u/9328433?v=4)](https://github.com/mschoettle) [Matthias Schoettle](https://github.com/mschoettle) 4 contributions * [![Ageev Maxim](https://avatars.githubusercontent.com/u/90645107?v=4)](https://github.com/ApostolFet) [Ageev Maxim](https://github.com/ApostolFet) 3 contributions * [![Emil Humbatov](https://avatars.githubusercontent.com/u/61885642?v=4)](https://github.com/cmdtorch) [Emil Humbatov](https://github.com/cmdtorch) 3 contributions * [![Evgeny Demchenko](https://avatars.githubusercontent.com/u/165988121?v=4)](https://github.com/RenameMe1) [Evgeny Demchenko](https://github.com/RenameMe1) 3 contributions * [![Sanghun](https://avatars.githubusercontent.com/u/267685376?v=4)](https://github.com/soil0119) [Sanghun](https://github.com/soil0119) 3 contributions * [![Sayfulla Mirkhalikov](https://avatars.githubusercontent.com/u/61648680?v=4)](https://github.com/thesayfulla) [Sayfulla Mirkhalikov](https://github.com/thesayfulla) 3 contributions * [![Sehat1137](https://avatars.githubusercontent.com/u/29227141?v=4)](https://github.com/Sehat1137) [Sehat1137](https://github.com/Sehat1137) 3 contributions * [![Viktor Merkurev](https://avatars.githubusercontent.com/u/6966509?v=4)](https://github.com/fennr) [Viktor Merkurev](https://github.com/fennr) 3 contributions * [![Dmitrii Anfimov](https://avatars.githubusercontent.com/u/32870032?v=4)](https://github.com/danfimov) [Dmitrii Anfimov](https://github.com/danfimov) 3 contributions * [![Mikhail Popov](https://avatars.githubusercontent.com/u/36836047?v=4)](https://github.com/TakiMoysha) [Mikhail Popov](https://github.com/TakiMoysha) 2 contributions * [![Pablo Aguilar](https://avatars.githubusercontent.com/u/25423500?v=4)](https://github.com/thepabloaguilar) [Pablo Aguilar](https://github.com/thepabloaguilar) 2 contributions * [![Reilly](https://avatars.githubusercontent.com/u/53958043?v=4)](https://github.com/Ryota-Di) [Reilly](https://github.com/Ryota-Di) 2 contributions * [![Sergey Miryanov](https://avatars.githubusercontent.com/u/67315?v=4)](https://github.com/sergey-miryanov) [Sergey Miryanov](https://github.com/sergey-miryanov) 2 contributions * [![Ilya Solin](https://avatars.githubusercontent.com/u/88054906?v=4)](https://github.com/Tapeline) [Ilya Solin](https://github.com/Tapeline) 2 contributions * [![chiri](https://avatars.githubusercontent.com/u/109767616?v=4)](https://github.com/chirizxc) [chiri](https://github.com/chirizxc) 2 contributions * [![jarik2014](https://avatars.githubusercontent.com/u/46443045?v=4)](https://github.com/jarik2014) [jarik2014](https://github.com/jarik2014) 2 contributions * [![kodsurfer](https://avatars.githubusercontent.com/u/86408225?v=4)](https://github.com/kodsurfer) [kodsurfer](https://github.com/kodsurfer) 2 contributions * [![Alexey Grishin](https://avatars.githubusercontent.com/u/95340036?v=4)](https://github.com/Nifacy) [Alexey Grishin](https://github.com/Nifacy) 2 contributions * [![amirbek_azimov](https://avatars.githubusercontent.com/u/98589891?v=4)](https://github.com/aazmv) [amirbek_azimov](https://github.com/aazmv) 2 contributions * [![Maxim Golovin](https://avatars.githubusercontent.com/u/48629696?v=4)](https://github.com/golovindev) [Maxim Golovin](https://github.com/golovindev) 2 contributions * [![Max Freedom Pollard](https://avatars.githubusercontent.com/u/272618364?v=4)](https://github.com/MaxFreedomPollard) [Max Freedom Pollard](https://github.com/MaxFreedomPollard) 2 contributions * [![MaierYan](https://avatars.githubusercontent.com/u/98267981?v=4)](https://github.com/Axiya3749) [MaierYan](https://github.com/Axiya3749) 2 contributions * [![Kirill Statsenko](https://avatars.githubusercontent.com/u/23508103?v=4)](https://github.com/kittywaresz) [Kirill Statsenko](https://github.com/kittywaresz) 2 contributions * [![Henry Yates](https://avatars.githubusercontent.com/u/61974157?v=4)](https://github.com/hpy1968) [Henry Yates](https://github.com/hpy1968) 2 contributions * [![AryanSinha](https://avatars.githubusercontent.com/u/90175339?v=4)](https://github.com/aryansinha1908) [AryanSinha](https://github.com/aryansinha1908) 2 contributions * [![AndreyGatsuk](https://avatars.githubusercontent.com/u/148007543?v=4)](https://github.com/AndreyGatsuk) [AndreyGatsuk](https://github.com/AndreyGatsuk) 2 contributions * [![Abduaziz](https://avatars.githubusercontent.com/u/68025869?v=4)](https://github.com/AbduazizZiyodov) [Abduaziz](https://github.com/AbduazizZiyodov) 2 contributions * [![Albert Eduardovich N.](https://avatars.githubusercontent.com/u/90478897?v=4)](https://github.com/albertedwardson) [Albert Eduardovich N.](https://github.com/albertedwardson) 2 contributions * [![Shai](https://avatars.githubusercontent.com/u/66793913?v=4)](https://github.com/Shaisolaris) [Shai](https://github.com/Shaisolaris) 1 contribution * [![Shlok Sharma](https://avatars.githubusercontent.com/u/84820012?v=4)](https://github.com/ShlokShar) [Shlok Sharma](https://github.com/ShlokShar) 1 contribution * [![Shraddha Meduri Kishore](https://avatars.githubusercontent.com/u/182374923?v=4)](https://github.com/Shraddhameduri) [Shraddha Meduri Kishore](https://github.com/Shraddhameduri) 1 contribution * [![Sidorenkov Anton](https://avatars.githubusercontent.com/u/22550109?v=4)](https://github.com/asidowner) [Sidorenkov Anton](https://github.com/asidowner) 1 contribution * [![Stefano Fusai](https://avatars.githubusercontent.com/u/46556949?v=4)](https://github.com/stefanofusai) [Stefano Fusai](https://github.com/stefanofusai) 1 contribution * [![Tosinibikunle](https://avatars.githubusercontent.com/u/89074158?v=4)](https://github.com/Tosinibikunle) [Tosinibikunle](https://github.com/Tosinibikunle) 1 contribution * [![Trần Hoàng Tú](https://avatars.githubusercontent.com/u/175955013?v=4)](https://github.com/tranhoangtu-it) [Trần Hoàng Tú](https://github.com/tranhoangtu-it) 1 contribution * [![Vladislav Andriyanov](https://avatars.githubusercontent.com/u/138744140?v=4)](https://github.com/Nalsher) [Vladislav Andriyanov](https://github.com/Nalsher) 1 contribution * [![Zeel Desai](https://avatars.githubusercontent.com/u/72783325?v=4)](https://github.com/zeel2104) [Zeel Desai](https://github.com/zeel2104) 1 contribution * [![Zeeyawnn](https://avatars.githubusercontent.com/u/127404623?v=4)](https://github.com/Syed-Zeeyan) [Zeeyawnn](https://github.com/Syed-Zeeyan) 1 contribution * [![aayushkc](https://avatars.githubusercontent.com/u/57221458?v=4)](https://github.com/aayushkc) [aayushkc](https://github.com/aayushkc) 1 contribution * [![abhinav](https://avatars.githubusercontent.com/u/244986440?v=4)](https://github.com/erensh27) [abhinav](https://github.com/erensh27) 1 contribution * [![Sergey Shirokov](https://avatars.githubusercontent.com/u/43002246?v=4)](https://github.com/sh1rokovs) [Sergey Shirokov](https://github.com/sh1rokovs) 1 contribution * [![Sergey Avdeev](https://avatars.githubusercontent.com/u/101973024?v=4)](https://github.com/wimble3) [Sergey Avdeev](https://github.com/wimble3) 1 contribution * [![Sanjeev](https://avatars.githubusercontent.com/u/230678117?v=4)](https://github.com/sanjeevafk) [Sanjeev](https://github.com/sanjeevafk) 1 contribution * [![Sadra Aghajanlo](https://avatars.githubusercontent.com/u/113305332?v=4)](https://github.com/SaDR4M) [Sadra Aghajanlo](https://github.com/SaDR4M) 1 contribution * [![Sabfo](https://avatars.githubusercontent.com/u/20401570?v=4)](https://github.com/Sabfo) [Sabfo](https://github.com/Sabfo) 1 contribution * [![Ruslan Alimov](https://avatars.githubusercontent.com/u/73937722?v=4)](https://github.com/Rusich90) [Ruslan Alimov](https://github.com/Rusich90) 1 contribution * [![Emre](https://avatars.githubusercontent.com/u/126229485?v=4)](https://github.com/Posterfo) [Emre](https://github.com/Posterfo) 1 contribution * [![Pavel Shishmarev](https://avatars.githubusercontent.com/u/17099021?v=4)](https://github.com/PashaWNN) [Pavel Shishmarev](https://github.com/PashaWNN) 1 contribution * [![Pavel Noryk](https://avatars.githubusercontent.com/u/24247833?v=4)](https://github.com/PNoryk) [Pavel Noryk](https://github.com/PNoryk) 1 contribution * [![Pastukhov Nikita](https://avatars.githubusercontent.com/u/44573917?v=4)](https://github.com/Lancetnik) [Pastukhov Nikita](https://github.com/Lancetnik) 1 contribution * [![Nikolay S.](https://avatars.githubusercontent.com/u/3074155?v=4)](https://github.com/nikolaysm) [Nikolay S.](https://github.com/nikolaysm) 1 contribution * [![Nikolay Kovalenko](https://avatars.githubusercontent.com/u/20641713?v=4)](https://github.com/nikolay-kovalenko91) [Nikolay Kovalenko](https://github.com/nikolay-kovalenko91) 1 contribution * [![Nikolay Chillev](https://avatars.githubusercontent.com/u/173775974?v=4)](https://github.com/Nikolay-Chillev) [Nikolay Chillev](https://github.com/Nikolay-Chillev) 1 contribution * [![Yuki Furukawa](https://avatars.githubusercontent.com/u/51281148?v=4)](https://github.com/K-dash) [Yuki Furukawa](https://github.com/K-dash) 1 contribution * [![사재혁](https://avatars.githubusercontent.com/u/104830931?v=4)](https://github.com/JaeHyuckSa) [사재혁](https://github.com/JaeHyuckSa) 1 contribution * [![藍-Ai](https://avatars.githubusercontent.com/u/275152799?v=4)](https://github.com/Ai-chan-0411) [藍-Ai](https://github.com/Ai-chan-0411) 1 contribution * [![Максим Мельников](https://avatars.githubusercontent.com/u/28685107?v=4)](https://github.com/Peopl3s) [Максим Мельников](https://github.com/Peopl3s) 1 contribution * [![Денис](https://avatars.githubusercontent.com/u/113355417?v=4)](https://github.com/panguss) [Денис](https://github.com/panguss) 1 contribution * [![vgnatyuk](https://avatars.githubusercontent.com/u/70257309?v=4)](https://github.com/vgnatyuk) [vgnatyuk](https://github.com/vgnatyuk) 1 contribution * [![vansh](https://avatars.githubusercontent.com/u/189650812?v=4)](https://github.com/vansh-nagar) [vansh](https://github.com/vansh-nagar) 1 contribution * [![subnix](https://avatars.githubusercontent.com/u/11701236?v=4)](https://github.com/subnix) [subnix](https://github.com/subnix) 1 contribution * [![qwist1233-cpu](https://avatars.githubusercontent.com/u/267938209?v=4)](https://github.com/qwist1233-cpu) [qwist1233-cpu](https://github.com/qwist1233-cpu) 1 contribution * [![pomponchik](https://avatars.githubusercontent.com/u/34486915?v=4)](https://github.com/pomponchik) [pomponchik](https://github.com/pomponchik) 1 contribution * [![Parth-Vasave](https://avatars.githubusercontent.com/u/218812702?v=4)](https://github.com/Parth-Vasave) [Parth-Vasave](https://github.com/Parth-Vasave) 1 contribution * [![neocim](https://avatars.githubusercontent.com/u/155900752?v=4)](https://github.com/neocim) [neocim](https://github.com/neocim) 1 contribution * [![nanookclaw](https://avatars.githubusercontent.com/u/258741235?v=4)](https://github.com/nanookclaw) [nanookclaw](https://github.com/nanookclaw) 1 contribution * [![kanagarajSCK](https://avatars.githubusercontent.com/u/159970347?v=4)](https://github.com/kanagarajSCK) [kanagarajSCK](https://github.com/kanagarajSCK) 1 contribution * [![johnmakarov](https://avatars.githubusercontent.com/u/177346423?v=4)](https://github.com/johnmakarov) [johnmakarov](https://github.com/johnmakarov) 1 contribution * [![iccccccccccccc](https://avatars.githubusercontent.com/u/286839273?v=4)](https://github.com/iccccccccccccc) [iccccccccccccc](https://github.com/iccccccccccccc) 1 contribution * [![hydrauluu](https://avatars.githubusercontent.com/u/238933032?v=4)](https://github.com/hydrauluu) [hydrauluu](https://github.com/hydrauluu) 1 contribution * [![helo060228](https://avatars.githubusercontent.com/u/321095093?v=4)](https://github.com/helo060228) [helo060228](https://github.com/helo060228) 1 contribution * [![gas12241](https://avatars.githubusercontent.com/u/80094031?v=4)](https://github.com/gas12241) [gas12241](https://github.com/gas12241) 1 contribution * [![fwh888](https://avatars.githubusercontent.com/u/304902023?v=4)](https://github.com/fwh888) [fwh888](https://github.com/fwh888) 1 contribution * [![dghnts](https://avatars.githubusercontent.com/u/45346649?v=4)](https://github.com/dghnts) [dghnts](https://github.com/dghnts) 1 contribution * [![arimu1](https://avatars.githubusercontent.com/u/19286898?v=4)](https://github.com/arimu1) [arimu1](https://github.com/arimu1) 1 contribution * [![Aditya-myst](https://avatars.githubusercontent.com/u/134023488?v=4)](https://github.com/Aditya-myst) [Aditya-myst](https://github.com/Aditya-myst) 1 contribution * [![asm-1](https://avatars.githubusercontent.com/u/85699189?v=4)](https://github.com/asm-1) [asm-1](https://github.com/asm-1) 1 contribution * [![tomatotomata](https://avatars.githubusercontent.com/u/170848582?v=4)](https://github.com/tomatotomata) [tomatotomata](https://github.com/tomatotomata) 1 contribution * [![sr-dotcom](https://avatars.githubusercontent.com/u/32037550?v=4)](https://github.com/sr-dotcom) [sr-dotcom](https://github.com/sr-dotcom) 1 contribution * [![dgolov](https://avatars.githubusercontent.com/u/72383588?v=4)](https://github.com/dgolov) [dgolov](https://github.com/dgolov) 1 contribution * [![gokul-debugger](https://avatars.githubusercontent.com/u/250397141?v=4)](https://github.com/gokul-debugger) [gokul-debugger](https://github.com/gokul-debugger) 1 contribution * [![Girdharilal-aiml](https://avatars.githubusercontent.com/u/254125960?v=4)](https://github.com/Girdharilal-aiml) [Girdharilal-aiml](https://github.com/Girdharilal-aiml) 1 contribution * [![Evgenmater](https://avatars.githubusercontent.com/u/127532690?v=4)](https://github.com/Evgenmater) [Evgenmater](https://github.com/Evgenmater) 1 contribution * [![Shecspi](https://avatars.githubusercontent.com/u/59818410?v=4)](https://github.com/Shecspi) [Shecspi](https://github.com/Shecspi) 1 contribution * [![Stranger6667](https://avatars.githubusercontent.com/u/1236561?v=4)](https://github.com/Stranger6667) [Stranger6667](https://github.com/Stranger6667) 1 contribution * [![dimk00z](https://avatars.githubusercontent.com/u/4112821?v=4)](https://github.com/dimk00z) [dimk00z](https://github.com/dimk00z) 1 contribution * [![dzhalaevd](https://avatars.githubusercontent.com/u/72649244?v=4)](https://github.com/dzhalaevd) [dzhalaevd](https://github.com/dzhalaevd) 1 contribution * [![eoan-ermine](https://avatars.githubusercontent.com/u/47888628?v=4)](https://github.com/eoan-ermine) [eoan-ermine](https://github.com/eoan-ermine) 1 contribution * [![SemTiOne](https://avatars.githubusercontent.com/u/217018617?v=4)](https://github.com/SemTiOne) [SemTiOne](https://github.com/SemTiOne) 1 contribution * [![wrongnull](https://avatars.githubusercontent.com/u/65823030?v=4)](https://github.com/wrongnull) [wrongnull](https://github.com/wrongnull) 1 contribution * [![bagowix](https://avatars.githubusercontent.com/u/84540747?v=4)](https://github.com/bagowix) [bagowix](https://github.com/bagowix) 1 contribution * [![drlinggg](https://avatars.githubusercontent.com/u/124909828?v=4)](https://github.com/drlinggg) [drlinggg](https://github.com/drlinggg) 1 contribution * [![AsilbekKha](https://avatars.githubusercontent.com/u/139436725?v=4)](https://github.com/AsilbekKha) [AsilbekKha](https://github.com/AsilbekKha) 1 contribution * [![aryansk](https://avatars.githubusercontent.com/u/70511529?v=4)](https://github.com/aryansk) [aryansk](https://github.com/aryansk) 1 contribution * [![arturboyun](https://avatars.githubusercontent.com/u/30303225?v=4)](https://github.com/arturboyun) [arturboyun](https://github.com/arturboyun) 1 contribution * [![larryxs](https://avatars.githubusercontent.com/u/108982955?v=4)](https://github.com/larryxs) [larryxs](https://github.com/larryxs) 1 contribution * [![insspb](https://avatars.githubusercontent.com/u/1916759?v=4)](https://github.com/insspb) [insspb](https://github.com/insspb) 1 contribution * [![amasen02](https://avatars.githubusercontent.com/u/97525823?v=4)](https://github.com/amasen02) [amasen02](https://github.com/amasen02) 1 contribution * [![bernalalexis-try](https://avatars.githubusercontent.com/u/292251307?v=4)](https://github.com/bernalalexis-try) [bernalalexis-try](https://github.com/bernalalexis-try) 1 contribution * [![oek1ng](https://avatars.githubusercontent.com/u/193062679?v=4)](https://github.com/oek1ng) [oek1ng](https://github.com/oek1ng) 1 contribution * [![AlexanderZharyuk](https://avatars.githubusercontent.com/u/103115934?v=4)](https://github.com/AlexanderZharyuk) [AlexanderZharyuk](https://github.com/AlexanderZharyuk) 1 contribution * [![TurtleOld](https://avatars.githubusercontent.com/u/25572145?v=4)](https://github.com/TurtleOld) [TurtleOld](https://github.com/TurtleOld) 1 contribution * [![adityabagla7](https://avatars.githubusercontent.com/u/133275400?v=4)](https://github.com/adityabagla7) [adityabagla7](https://github.com/adityabagla7) 1 contribution * [![Act0r1](https://avatars.githubusercontent.com/u/59477383?v=4)](https://github.com/Act0r1) [Act0r1](https://github.com/Act0r1) 1 contribution * [![n05tr0m0](https://avatars.githubusercontent.com/u/32748711?v=4)](https://github.com/n05tr0m0) [n05tr0m0](https://github.com/n05tr0m0) 1 contribution * [![muratclk](https://avatars.githubusercontent.com/u/10229969?v=4)](https://github.com/muratclk) [muratclk](https://github.com/muratclk) 1 contribution * [![MukievMukhammad](https://avatars.githubusercontent.com/u/42972450?v=4)](https://github.com/MukievMukhammad) [MukievMukhammad](https://github.com/MukievMukhammad) 1 contribution * [![wized2](https://avatars.githubusercontent.com/u/245696177?v=4)](https://github.com/wized2) [wized2](https://github.com/wized2) 1 contribution * [![mikhailofff](https://avatars.githubusercontent.com/u/81404675?v=4)](https://github.com/mikhailofff) [mikhailofff](https://github.com/mikhailofff) 1 contribution * [![MikeVL](https://avatars.githubusercontent.com/u/118285?v=4)](https://github.com/MikeVL) [MikeVL](https://github.com/MikeVL) 1 contribution * [![Dresdn](https://avatars.githubusercontent.com/u/22396211?v=4)](https://github.com/Dresdn) [Dresdn](https://github.com/Dresdn) 1 contribution * [![acostyle](https://avatars.githubusercontent.com/u/56687178?v=4)](https://github.com/acostyle) [acostyle](https://github.com/acostyle) 1 contribution * [![mvanhorn](https://avatars.githubusercontent.com/u/455140?v=4)](https://github.com/mvanhorn) [mvanhorn](https://github.com/mvanhorn) 1 contribution * [![Matvey1109](https://avatars.githubusercontent.com/u/83714334?v=4)](https://github.com/Matvey1109) [Matvey1109](https://github.com/Matvey1109) 1 contribution * [![markparonyan](https://avatars.githubusercontent.com/u/84400463?v=4)](https://github.com/markparonyan) [markparonyan](https://github.com/markparonyan) 1 contribution * [![hxxxi-malog](https://avatars.githubusercontent.com/u/181807366?v=4)](https://github.com/hxxxi-malog) [hxxxi-malog](https://github.com/hxxxi-malog) 1 contribution * [![MaximGit1](https://avatars.githubusercontent.com/u/130899353?v=4)](https://github.com/MaximGit1) [MaximGit1](https://github.com/MaximGit1) 1 contribution * [![Laksopan23](https://avatars.githubusercontent.com/u/165243013?v=4)](https://github.com/Laksopan23) [Laksopan23](https://github.com/Laksopan23) 1 contribution * [![KardeniaPoyu](https://avatars.githubusercontent.com/u/201376233?v=4)](https://github.com/KardeniaPoyu) [KardeniaPoyu](https://github.com/KardeniaPoyu) 1 contribution * [![krisrajaryan27](https://avatars.githubusercontent.com/u/19838017?v=4)](https://github.com/krisrajaryan27) [krisrajaryan27](https://github.com/krisrajaryan27) 1 contribution * [![cicwak](https://avatars.githubusercontent.com/u/63852198?v=4)](https://github.com/cicwak) [cicwak](https://github.com/cicwak) 1 contribution * [![vedmetskii](https://avatars.githubusercontent.com/u/99505648?v=4)](https://github.com/vedmetskii) [vedmetskii](https://github.com/vedmetskii) 1 contribution * [![Eclips4](https://avatars.githubusercontent.com/u/80244920?v=4)](https://github.com/Eclips4) [Eclips4](https://github.com/Eclips4) 1 contribution * [![karpetrosyan](https://avatars.githubusercontent.com/u/92274156?v=4)](https://github.com/karpetrosyan) [karpetrosyan](https://github.com/karpetrosyan) 1 contribution * [![kstypik](https://avatars.githubusercontent.com/u/53559764?v=4)](https://github.com/kstypik) [kstypik](https://github.com/kstypik) 1 contribution * [![JuliaBars](https://avatars.githubusercontent.com/u/107411145?v=4)](https://github.com/JuliaBars) [JuliaBars](https://github.com/JuliaBars) 1 contribution * [![proofit404](https://avatars.githubusercontent.com/u/1862725?v=4)](https://github.com/proofit404) [proofit404](https://github.com/proofit404) 1 contribution * [![Izcarmt95](https://avatars.githubusercontent.com/u/18058245?v=4)](https://github.com/Izcarmt95) [Izcarmt95](https://github.com/Izcarmt95) 1 contribution * [![HarshRajSinghania](https://avatars.githubusercontent.com/u/40535627?v=4)](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. ![Swagger view](_images/swagger.png) 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 `