Integrations¶
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(Controller[PydanticSerializer]):
28 def get(self, parsed_query: Query[_PageQuery]) -> Paginated[_User]:
29 page = parsed_query.get('page', 1)
30 page_size = parsed_query.get('page_size', 2)
31
32 paginator = Paginator(_USERS, page_size)
33 return Paginated(
34 count=paginator.count,
35 num_pages=paginator.num_pages,
36 per_page=paginator.per_page,
37 page=Page(
38 number=page,
39 object_list=list(paginator.page(page).object_list),
40 ),
41 )
42
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, 05 Apr 2026 17:51:03 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, 05 Apr 2026 17:51:03 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, 05 Apr 2026 17:51:03 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"}