Integrations¶
Serializing QuerySets into models¶
Django is built around its QuerySet type.
Of course, we have to make sure that it is supported.
Let’s say you have these models that you already work with:
1from django.db import models
2
3
4class Tag(models.Model):
5 name = models.CharField(max_length=100)
6
7 created_at = models.DateTimeField(auto_now_add=True)
8 updated_at = models.DateTimeField(auto_now=True)
9
10
11class Role(models.Model):
12 name = models.CharField(max_length=100)
13
14 created_at = models.DateTimeField(auto_now_add=True)
15 updated_at = models.DateTimeField(auto_now=True)
16
17
18class User(models.Model):
19 email = models.EmailField(unique=True)
20
21 role = models.ForeignKey(
22 Role,
23 on_delete=models.CASCADE,
24 related_name='users',
25 )
26 tags = models.ManyToManyField(Tag, related_name='users')
27
28 created_at = models.DateTimeField(auto_now_add=True)
29 updated_at = models.DateTimeField(auto_now=True)
Now, let’s create an API that will work with your models. To do that the first thing you need to do is to create your API serializers / deserializers.
While it may seems that this is a redundant duplication of code, and that it should be possible to build serialization schemas out of Django models, but that’s actually the opposite.
Because models and serialization schemes must change independenly. Otherwise, your API would be a mess and will change unexpectedly, when you create a new migration. This problem happened to me too many times.
1import datetime as dt
2from typing import final
3
4import pydantic
5
6
7@final
8class TagSchema(pydantic.BaseModel):
9 name: str
10
11
12@final
13class RoleSchema(pydantic.BaseModel):
14 name: str
15
16
17class UserCreateSchema(pydantic.BaseModel):
18 email: str
19 role: RoleSchema
20 tags: list[TagSchema]
21
22
23@final
24class UserSchema(UserCreateSchema):
25 id: int
26 created_at: dt.datetime
Important
Models and QuerySets can’t be serialized to json by default. This is a design choice, this is a feature.
Why?
Because Models and QuerySets are not designed for serialization, they are designed for the database access. Mixing these two layers will complicate, not simplify, your app.
Now, let’s create a service to build your model instances:
1from django.db import IntegrityError, transaction
2
3from server.apps.models_example import serializers
4from server.apps.models_example.models import Role, Tag, User
5
6
7class UniqueEmailError(Exception):
8 """Email must be unique."""
9
10
11def user_create_service(user_schema: serializers.UserCreateSchema) -> User:
12 """This is a function just for the demo purpose, it is usually a class."""
13 with transaction.atomic():
14 role = Role.objects.create(name=user_schema.role.name)
15
16 try:
17 user = User.objects.create(
18 email=user_schema.email,
19 role_id=role.pk,
20 )
21 except IntegrityError:
22 raise UniqueEmailError from None
23
24 # Handle m2m:
25 tags = Tag.objects.bulk_create([
26 Tag(name=tag.name) for tag in user_schema.tags
27 ])
28 user.tags.set(tags)
29 return user
Here’s how the final Controller
would look like:
1from http import HTTPStatus
2from typing import final
3
4from django.http import HttpResponse
5from typing_extensions import override
6
7from dmr import Body, Controller, modify
8from dmr.endpoint import Endpoint
9from dmr.errors import ErrorModel, ErrorType
10from dmr.metadata import ResponseSpec
11from dmr.plugins.pydantic import PydanticSerializer
12from server.apps.models_example.serializers import (
13 UserCreateSchema,
14 UserSchema,
15)
16from server.apps.models_example.services import (
17 UniqueEmailError,
18 user_create_service,
19)
20
21
22@final
23class UserCreateController(Controller[PydanticSerializer]):
24 @modify(
25 extra_responses=[
26 ResponseSpec(
27 ErrorModel,
28 status_code=HTTPStatus.CONFLICT,
29 ),
30 ],
31 )
32 def post(self, parsed_body: Body[UserCreateSchema]) -> UserSchema:
33 user = user_create_service(parsed_body)
34 return UserSchema(
35 id=user.pk,
36 created_at=user.created_at,
37 email=user.email,
38 role=parsed_body.role,
39 tags=parsed_body.tags,
40 )
41
42 @override
43 def handle_error(
44 self,
45 endpoint: Endpoint,
46 controller: Controller[PydanticSerializer],
47 exc: Exception,
48 ) -> HttpResponse:
49 # Handle custom errors that can happen in this controller:
50 if isinstance(exc, UniqueEmailError):
51 return self.to_error(
52 self.format_error(
53 'User email must be unique',
54 error_type=ErrorType.value_error,
55 ),
56 status_code=HTTPStatus.CONFLICT,
57 )
58 # Handle default errors:
59 return super().handle_error(endpoint, controller, exc)
Now you have your REST API that returns fully typed model responses
and can work with QuerySet
and Model instances.
django-mantle¶
If you want to automate this part and automatically
convert QuerySet into typed models, you can use
django-mantle
which is built just for this purpose:
1import attrs
2
3from dmr import Controller
4from dmr.plugins.msgspec import MsgspecSerializer
5
6
7@attrs.define
8class _UserModel:
9 username: str
10 email: str
11 is_active: bool
12
13
14class UsersController(Controller[MsgspecSerializer]):
15 def get(self) -> list[_UserModel]:
16 # We have to do import here due to how our docs build systems works,
17 # but in real apps they must be on the module level:
18 from django.contrib.auth.models import User # noqa: PLC0415
19 from mantle import Query # noqa: PLC0415
20
21 return Query(User.objects.all(), _UserModel).all()
22
Run result
$ curl http://127.0.0.1:8000/api/users/ -X GET
[{"email":"test@example.com","is_active":true,"username":"test_user"}]
OpenAPI Schema
Preview openapi.json
{
"components": {
"schemas": {
"ErrorDetail": {
"description": "Base schema for error details description.",
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
},
"type": "array"
},
"msg": {
"type": "string"
},
"type": {
"type": "string"
}
},
"required": [
"msg"
],
"title": "ErrorDetail",
"type": "object"
},
"ErrorModel": {
"description": "Default error response schema.\n\nCan be customized.\nSee :ref:`customizing-error-messages` for more details.",
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ErrorDetail"
},
"type": "array"
}
},
"required": [
"detail"
],
"title": "ErrorModel",
"type": "object"
},
"_UserModel": {
"properties": {
"email": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
"username": {
"type": "string"
}
},
"required": [
"username",
"email",
"is_active"
],
"title": "_UserModel",
"type": "object"
}
},
"securitySchemes": {}
},
"info": {
"title": "Django Modern Rest",
"version": "0.1.0"
},
"openapi": "3.2.0",
"paths": {
"/api/userscontroller/": {
"get": {
"deprecated": false,
"operationId": "getUserscontrollerApiUserscontroller",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/_UserModel"
},
"type": "array"
}
}
},
"description": "OK"
},
"406": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when provided `Accept` header cannot be satisfied"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when returned response does not match the response schema"
}
}
}
}
}
}
CSRF¶
Django supports Cross Site Request Forgery protection.
By default we exempt all controllers from CSRF checks, unless:
csrf_exemptis set toFalsefor a specific controllerEndpoints protected by
DjangoSessionSyncAuthorDjangoSessionAsyncAuthwill require CSRF as well. Because using Django sessions without CSRF is not secure
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 with the official django-modern-rest how-to
https://github.com/reagento/dishka with the help of https://github.com/arturboyun/dmr-dishka plugin
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
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
to learn how typing works, how mypy is configured,
how django-stubs is used.
Pagination¶
We don’t ship our own pagination.
We (as our main design goal suggests) provide support
for any existing pagination plugin for Django.
Including the built-in django.core.paginator.Paginator.
To do so, we only provide metadata for the default pagination:
1from typing import Final, NotRequired, TypedDict
2
3import pydantic
4from django.core.paginator import Paginator
5
6from dmr import Controller, Query
7from dmr.pagination import Page, Paginated
8from dmr.plugins.pydantic import PydanticSerializer
9
10
11class _User(pydantic.BaseModel):
12 email: str
13
14
15class _PageQuery(TypedDict):
16 page_size: NotRequired[int]
17 page: NotRequired[int]
18
19
20_USERS: Final = (
21 _User(email='one@example.com'),
22 _User(email='two@example.com'),
23 _User(email='three@example.com'),
24)
25
26
27class UsersController(
28 Controller[PydanticSerializer],
29):
30 def get(self, parsed_query: Query[_PageQuery]) -> Paginated[_User]:
31 page = parsed_query.get('page', 1)
32 page_size = parsed_query.get('page_size', 2)
33
34 paginator = Paginator(_USERS, page_size)
35 return Paginated(
36 count=paginator.count,
37 num_pages=paginator.num_pages,
38 per_page=paginator.per_page,
39 page=Page(
40 number=page,
41 object_list=list(paginator.page(page).object_list),
42 ),
43 )
44
Run result
$ 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"}]}}
OpenAPI Schema
Preview openapi.json
{
"components": {
"schemas": {
"ErrorDetail": {
"description": "Base schema for error details description.",
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
},
"title": "Loc",
"type": "array"
},
"msg": {
"title": "Msg",
"type": "string"
},
"type": {
"title": "Type",
"type": "string"
}
},
"required": [
"msg"
],
"title": "ErrorDetail",
"type": "object"
},
"ErrorModel": {
"description": "Default error response schema.\n\nCan be customized.\nSee :ref:`customizing-error-messages` for more details.",
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ErrorDetail"
},
"title": "Detail",
"type": "array"
}
},
"required": [
"detail"
],
"title": "ErrorModel",
"type": "object"
},
"Page__User_": {
"properties": {
"number": {
"title": "Number",
"type": "integer"
},
"object_list": {
"items": {
"$ref": "#/components/schemas/_User"
},
"title": "Object List",
"type": "array"
}
},
"required": [
"number",
"object_list"
],
"title": "Page",
"type": "object"
},
"Paginated": {
"properties": {
"count": {
"title": "Count",
"type": "integer"
},
"num_pages": {
"title": "Num Pages",
"type": "integer"
},
"page": {
"$ref": "#/components/schemas/Page__User_"
},
"per_page": {
"title": "Per Page",
"type": "integer"
}
},
"required": [
"count",
"num_pages",
"per_page",
"page"
],
"title": "Paginated",
"type": "object"
},
"_User": {
"properties": {
"email": {
"title": "Email",
"type": "string"
}
},
"required": [
"email"
],
"title": "_User",
"type": "object"
}
},
"securitySchemes": {}
},
"info": {
"title": "Django Modern Rest",
"version": "0.1.0"
},
"openapi": "3.2.0",
"paths": {
"/api/userscontroller/": {
"get": {
"deprecated": false,
"operationId": "getUserscontrollerApiUserscontroller",
"parameters": [
{
"deprecated": false,
"in": "query",
"name": "page_size",
"schema": {
"title": "Page Size",
"type": "integer"
}
},
{
"deprecated": false,
"in": "query",
"name": "page",
"schema": {
"title": "Page",
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Paginated"
}
}
},
"description": "OK"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when request components cannot be parsed"
},
"406": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when provided `Accept` header cannot be satisfied"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when returned response does not match the response schema"
}
}
}
}
}
}
If you are using a different pagination system, you can define your own metadata / models and use them with our framework.
- class dmr.pagination.Paginated(*, count: int, num_pages: int, per_page: int, page: Page[_ModelT])[source]¶
Helper type to serialize the default
Paginatorobject.Django already ships a pagination system, we don’t want to replicate it. So, we only provide metadata. See
django.core.paginator.Paginatorfor the exact API.
django-filters¶
No special integration with django-filter is required.
Everything just works.
import django_filters
import pydantic
from dmr import Controller, Query
from dmr.plugins.pydantic import PydanticSerializer
from your_app.models import User
class UserFilter(django_filters.FilterSet):
class Meta:
model = User
fields = ('is_active',)
# Create query model for better docs:
class QueryModel(pydantic.BaseModel):
is_active: bool
class UserModel(pydantic.BaseModel):
username: str
email: str
is_active: bool
class UserListController(
Controller[PydanticSerializer],
Query[QueryModel],
):
def get(self) -> list[UserModel]:
# Still pass `.GET` for API compatibility:
user_filter = UserFilter(
self.request.GET,
queryset=User.objects.all(),
)
return [
UserModel.model_validate(user, from_attributes=True)
for user in user_filter.qs
]
CORS Headers¶
No special integration with django-cors-headers is required.
Everything just works.
Conditional requests (ETag)¶
Django has built-in support for conditional request processing
(If-None-Match, If-Modified-Since, 304 Not Modified):
With django-modern-rest you can integrate it via
wrap_middleware()
and django.views.decorators.http.condition().
1from datetime import UTC, datetime
2from http import HTTPStatus
3from types import MappingProxyType
4from typing import Any, Final, final
5
6import pydantic
7from django.http import HttpRequest, HttpResponse
8from django.views.decorators.http import condition
9
10from dmr import Controller, HeaderSpec, Path, ResponseSpec
11from dmr.decorators import wrap_middleware
12from dmr.errors import ErrorType
13from dmr.plugins.pydantic import PydanticSerializer
14from dmr.response import APIError
15
16
17@final
18class _UserModel(pydantic.BaseModel):
19 user_id: int
20 updated_at: datetime
21 message: str
22
23
24@final
25class _PathModel(pydantic.BaseModel):
26 user_id: int
27
28
29@final
30class _ResponseModel(pydantic.BaseModel):
31 message: str
32 updated_at: str
33
34
35# Imitate DB
36_USERS: Final = MappingProxyType({
37 1: _UserModel(
38 user_id=1,
39 updated_at=datetime(2026, 3, 23, 12, 30, tzinfo=UTC), # noqa: WPS432
40 message='Fresh content for user #1',
41 ),
42 2: _UserModel(
43 user_id=2,
44 updated_at=datetime(2026, 3, 24, 9, 15, tzinfo=UTC), # noqa: WPS432
45 message='Fresh content for user #2',
46 ),
47})
48
49
50def _build_etag(user: _UserModel) -> str:
51 updated_at = user.updated_at.isoformat()
52 return f'"user-{user.user_id}-{updated_at}"'
53
54
55def _etag(request: HttpRequest, user_id: int = 0, **kwargs: Any) -> str | None:
56 user = _USERS.get(user_id)
57 return _build_etag(user) if user else None
58
59
60@wrap_middleware(
61 condition(etag_func=_etag),
62 ResponseSpec(return_type=_ResponseModel, status_code=HTTPStatus.OK),
63 ResponseSpec(
64 return_type=None,
65 status_code=HTTPStatus.NOT_MODIFIED,
66 headers={'ETag': HeaderSpec()},
67 ),
68)
69def _condition_middleware(response: HttpResponse) -> HttpResponse:
70 """Adds Content-Type for 304 responses to satisfy strict validation."""
71 if response.status_code == HTTPStatus.NOT_MODIFIED:
72 response.headers['Content-Type'] = 'application/json'
73 return response
74
75
76@final
77@_condition_middleware
78class ConditionalETagController(Controller[PydanticSerializer]):
79 responses = _condition_middleware.responses
80
81 def get(self, parsed_path: Path[_PathModel]) -> HttpResponse:
82 user = _USERS.get(parsed_path.user_id)
83 if user is None:
84 raise APIError(
85 self.format_error(
86 f'User {parsed_path.user_id} not found',
87 error_type=ErrorType.not_found,
88 ),
89 status_code=HTTPStatus.NOT_FOUND,
90 )
91 return self.to_response(
92 _ResponseModel(
93 message=user.message,
94 updated_at=user.updated_at.isoformat(),
95 ),
96 )
97
Run result
$ curl http://127.0.0.1:8000/api/example/1/ -D - -X GET
HTTP/1.1 200 OK
date: Sun, 29 Mar 2026 18:52:52 GMT
server: uvicorn
Content-Type: application/json
ETag: "user-1-2026-03-23T12:30:00+00:00"
X-Frame-Options: DENY
Vary: Accept-Language
Content-Language: en
Content-Length: 80
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
{"message":"Fresh content for user #1","updated_at":"2026-03-23T12:30:00+00:00"}
$ curl http://127.0.0.1:8000/api/example/1/ -D - -X GET -H 'If-None-Match: "user-1-2026-03-23T12:30:00+00:00"'
HTTP/1.1 304 Not Modified
date: Sun, 29 Mar 2026 18:52:52 GMT
server: uvicorn
ETag: "user-1-2026-03-23T12:30:00+00:00"
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/example/2/ -D - -X GET
HTTP/1.1 200 OK
date: Sun, 29 Mar 2026 18:52:52 GMT
server: uvicorn
Content-Type: application/json
ETag: "user-2-2026-03-24T09:15:00+00:00"
X-Frame-Options: DENY
Vary: Accept-Language
Content-Language: en
Content-Length: 80
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
{"message":"Fresh content for user #2","updated_at":"2026-03-24T09:15:00+00:00"}