Middleware¶
As per our main principle, you can use any default Django middleware with your API. But, it has several minor problems by default:
Any middleware responses won’t show up in your schema
Responses won’t have the right
'Content-Type'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:
A middleware function or class
One or more
ResponseSpecobjectsReturns 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:
1from http import HTTPStatus
2
3from django.http import HttpResponse
4from django.views.decorators.csrf import csrf_protect
5
6from dmr import Controller, ResponseSpec
7from dmr.decorators import wrap_middleware
8from dmr.errors import ErrorModel, format_error
9from dmr.plugins.pydantic import PydanticSerializer
10from dmr.response import build_response
11
12
13@wrap_middleware(
14 csrf_protect,
15 ResponseSpec(
16 return_type=ErrorModel,
17 status_code=HTTPStatus.FORBIDDEN,
18 ),
19)
20def csrf_protect_json(response: HttpResponse) -> HttpResponse:
21 """Convert CSRF failure responses to JSON."""
22 return build_response(
23 PydanticSerializer,
24 raw_data=format_error(
25 'CSRF verification failed. Request aborted.',
26 ),
27 status_code=HTTPStatus(response.status_code),
28 )
29
30
31@csrf_protect_json
32class MyController(Controller[PydanticSerializer]):
33 """Example controller using CSRF protection middleware."""
34
35 responses = csrf_protect_json.responses
36
37 def post(self) -> dict[str, str]:
38 return {'message': 'ok'}
In this example:
We create a middleware decorator using
wrap_middlewareThe decorator wraps
csrf_protectmiddleware around the controllerWhen CSRF verification fails, our converter function transforms the response to JSON
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:
1from collections.abc import Callable
2from http import HTTPStatus
3
4from django.http import HttpRequest, HttpResponse
5
6from dmr import Controller, ResponseSpec
7from dmr.decorators import wrap_middleware
8from dmr.errors import ErrorModel, format_error
9from dmr.plugins.pydantic import PydanticSerializer
10from dmr.response import build_response
11
12
13def rate_limit_middleware(
14 get_response: Callable[[HttpRequest], HttpResponse],
15) -> Callable[[HttpRequest], HttpResponse]:
16 """Middleware that simulates rate limiting."""
17
18 def decorator(request: HttpRequest) -> HttpResponse:
19 if request.headers.get('X-Rate-Limited') == 'true':
20 return build_response(
21 PydanticSerializer,
22 raw_data=format_error('Rate limit exceeded'),
23 status_code=HTTPStatus.TOO_MANY_REQUESTS,
24 )
25 return get_response(request)
26
27 return decorator
28
29
30@wrap_middleware(
31 rate_limit_middleware,
32 ResponseSpec(
33 return_type=ErrorModel,
34 status_code=HTTPStatus.TOO_MANY_REQUESTS,
35 ),
36)
37def rate_limit_json(response: HttpResponse) -> HttpResponse:
38 """Pass through the rate limit response."""
39 return response
40
41
42@rate_limit_json
43class RateLimitedController(Controller[PydanticSerializer]):
44 """Example controller with custom rate limit middleware."""
45
46 responses = rate_limit_json.responses
47
48 def post(self) -> dict[str, str]:
49 return {'message': 'Request processed'}
50
Run result
$ 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: Sun, 05 Apr 2026 17:51:04 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:
1from collections.abc import Callable
2from http import HTTPStatus
3
4from django.http import HttpRequest, HttpResponse
5
6from dmr import ResponseSpec
7from dmr.decorators import wrap_middleware
8from dmr.plugins.pydantic import PydanticSerializer
9from dmr.response import build_response
10
11
12def custom_middleware(
13 get_response: Callable[[HttpRequest], HttpResponse],
14) -> Callable[[HttpRequest], HttpResponse]:
15 """Dummy middleware for demonstration purposes."""
16
17 def decorator(request: HttpRequest) -> HttpResponse:
18 """Just pass the request through unchanged."""
19 return get_response(request)
20
21 return decorator
22
23
24@wrap_middleware(
25 custom_middleware,
26 ResponseSpec(
27 return_type=dict[str, str],
28 status_code=HTTPStatus.BAD_REQUEST,
29 ),
30 ResponseSpec(
31 return_type=dict[str, str],
32 status_code=HTTPStatus.UNAUTHORIZED,
33 ),
34)
35def multi_status_middleware(response: HttpResponse) -> HttpResponse:
36 """Handle multiple status codes."""
37 if response.status_code == HTTPStatus.BAD_REQUEST:
38 return build_response(
39 PydanticSerializer,
40 raw_data={'error': 'Bad request'},
41 status_code=HTTPStatus(response.status_code),
42 )
43 if response.status_code == HTTPStatus.UNAUTHORIZED:
44 return build_response(
45 PydanticSerializer,
46 raw_data={'error': 'Unauthorized'},
47 status_code=HTTPStatus(response.status_code),
48 )
49 return response
Async Controllers¶
wrap_middleware works seamlessly with both sync and async controllers:
1from dmr import Controller
2from dmr.plugins.pydantic import PydanticSerializer
3from examples.middleware.csrf_protect_json import csrf_protect_json
4
5
6@csrf_protect_json
7class AsyncController(Controller[PydanticSerializer]):
8 """Example async controller using CSRF protection middleware."""
9
10 responses = csrf_protect_json.responses
11
12 async def post(self) -> dict[str, str]:
13 # Your async logic here
14 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.
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.
1from collections.abc import Callable
2
3from django.http import HttpRequest, HttpResponse
4
5
6def my_middleware(
7 get_response: Callable[[HttpRequest], HttpResponse],
8) -> Callable[[HttpRequest], HttpResponse]:
9 """
10 get_response is a callback.
11
12 It will:
13 1. Call the next middleware (if any)
14 2. Eventually call your controller/view
15 3. Return the response
16 """
17
18 def factory(request: HttpRequest) -> HttpResponse:
19 # Your middleware logic here:
20 return get_response(request) # Call the view
21
22 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)
1import uuid
2from collections.abc import Callable
3from typing import Any, TypeAlias
4
5from django.http import HttpRequest, HttpResponse
6
7_CallableAny: TypeAlias = Callable[..., Any]
8
9
10def add_request_id_middleware(
11 get_response: Callable[[HttpRequest], HttpResponse],
12) -> _CallableAny:
13 """Middleware that adds request_id to both request and response.
14
15 This demonstrates the two-phase middleware pattern:
16 1. Process request BEFORE calling get_response (adds request.request_id)
17 2. Process response AFTER calling get_response (adds X-Request-ID header)
18 """
19
20 def decorator(request: HttpRequest) -> Any:
21 request_id = str(uuid.uuid4())
22 request.request_id = request_id # type: ignore[attr-defined]
23
24 response = get_response(request)
25 response['X-Request-ID'] = request_id
26
27 return response
28
29 return decorator
Wrap your middleware:
1from http import HTTPStatus
2
3from django.http import HttpResponse
4
5from dmr import ResponseSpec
6from dmr.decorators import wrap_middleware
7from examples.middleware.add_request_id import add_request_id_middleware
8
9
10@wrap_middleware(
11 add_request_id_middleware,
12 ResponseSpec(
13 return_type=dict[str, str],
14 status_code=HTTPStatus.OK,
15 ),
16)
17def add_request_id_json(response: HttpResponse) -> HttpResponse:
18 """Pass through response - ``request_id`` is added automatically."""
19 return response
Now your controller can access self.request.request_id:
1from django.http import HttpRequest
2
3from dmr import Controller
4from dmr.plugins.pydantic import PydanticSerializer
5from examples.middleware.wrap_add_request_id import add_request_id_json
6
7
8class _RequestWithID(HttpRequest):
9 request_id: str
10
11
12@add_request_id_json
13class RequestIdController(Controller[PydanticSerializer]):
14 """Controller that uses request_id added by middleware."""
15
16 responses = add_request_id_json.responses
17
18 # Use request with request_id field
19 request: _RequestWithID
20
21 def get(self) -> dict[str, str]:
22 """GET endpoint that returns request_id from modified request."""
23 return {
24 'request_id': self.request.request_id,
25 'message': 'Request ID tracked',
26 }
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
1from collections.abc import Callable
2from typing import Any, TypeAlias
3
4from django.http import HttpRequest, HttpResponse
5
6_CallableAny: TypeAlias = Callable[..., Any]
7
8
9def custom_header_middleware(
10 get_response: Callable[[HttpRequest], HttpResponse],
11) -> _CallableAny:
12 """Simple middleware that adds a custom header to response."""
13
14 def decorator(request: HttpRequest) -> Any:
15 response = get_response(request)
16 response['X-Custom-Header'] = 'CustomValue'
17 return response
18
19 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
Example with rate limiting:
1from collections.abc import Callable
2from http import HTTPStatus
3
4from django.http import HttpRequest, HttpResponse
5
6from dmr import Controller, ResponseSpec
7from dmr.decorators import wrap_middleware
8from dmr.errors import ErrorModel, format_error
9from dmr.plugins.pydantic import PydanticSerializer
10from dmr.response import build_response
11
12
13def rate_limit_middleware(
14 get_response: Callable[[HttpRequest], HttpResponse],
15) -> Callable[[HttpRequest], HttpResponse]:
16 """Middleware that simulates rate limiting."""
17
18 def decorator(request: HttpRequest) -> HttpResponse:
19 if request.headers.get('X-Rate-Limited') == 'true':
20 return build_response(
21 PydanticSerializer,
22 raw_data=format_error('Rate limit exceeded'),
23 status_code=HTTPStatus.TOO_MANY_REQUESTS,
24 )
25 return get_response(request)
26
27 return decorator
28
29
30@wrap_middleware(
31 rate_limit_middleware,
32 ResponseSpec(
33 return_type=ErrorModel,
34 status_code=HTTPStatus.TOO_MANY_REQUESTS,
35 ),
36)
37def rate_limit_json(response: HttpResponse) -> HttpResponse:
38 """Pass through the rate limit response."""
39 return response
40
41
42@rate_limit_json
43class RateLimitedController(Controller[PydanticSerializer]):
44 """Example controller with custom rate limit middleware."""
45
46 responses = rate_limit_json.responses
47
48 def post(self) -> dict[str, str]:
49 return {'message': 'Request processed'}
50
Run result
$ 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: Sun, 05 Apr 2026 17:51:05 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"}]}
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:
1from http import HTTPStatus
2
3from django.contrib.auth.decorators import login_required
4from django.http import HttpResponse
5
6from dmr import Controller, ResponseSpec
7from dmr.decorators import wrap_middleware
8from dmr.errors import ErrorModel, format_error
9from dmr.plugins.pydantic import PydanticSerializer
10from dmr.response import build_response
11
12
13@wrap_middleware(
14 login_required,
15 ResponseSpec(
16 return_type=ErrorModel,
17 status_code=HTTPStatus.UNAUTHORIZED,
18 ),
19 ResponseSpec( # Uses for proxy authed response with HTTPStatus.OK
20 return_type=dict[str, str],
21 status_code=HTTPStatus.OK,
22 ),
23)
24def login_required_json(response: HttpResponse) -> HttpResponse:
25 """Convert Django's login_required redirect to JSON 401 response."""
26 if response.status_code == HTTPStatus.FOUND:
27 return build_response(
28 PydanticSerializer,
29 raw_data=format_error(
30 'Authentication credentials were not provided',
31 ),
32 status_code=HTTPStatus.UNAUTHORIZED,
33 )
34 return response
35
36
37@login_required_json
38class LoginRequiredController(Controller[PydanticSerializer]):
39 """Controller that uses Django's login_required decorator.
40
41 Demonstrates wrapping Django's built-in authentication decorators.
42 Converts 302 redirect to JSON 401 response for REST API compatibility.
43 """
44
45 responses = login_required_json.responses
46
47 def get(self) -> dict[str, str]:
48 """GET endpoint that requires Django authentication."""
49 # Access Django's authenticated user
50 user = self.request.user
51 username = user.username if user.is_authenticated else 'anonymous' # type: ignore[attr-defined]
52
53 return {
54 'username': username,
55 'message': 'Successfully accessed protected resource',
56 }
Visual Flow¶
Here’s how a request flows through middleware:
---
config:
theme: forest
---
graph TB
A[HTTP Request] --> B1[Middleware 1<br/>Phase 1: process request]
B1 --> B2[Middleware 2<br/>Phase 1: process request]
B2 --> C[Controller/View executes]
C --> D2[Middleware 2<br/>Phase 2: process response]
D2 --> D1[Middleware 1<br/>Phase 2: process response]
D1 --> E[HTTP Response]
Middleware execution flow¶
Best Practices¶
Always include response descriptions: This ensures your OpenAPI documentation is complete and accurate.
Use consistent error formatting: Create reusable converter functions that format errors consistently across your API.
Handle both sync and async: The same middleware decorator works with both sync and async controllers.
Test your middleware: Make sure to test both the success and error cases for your middleware.
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:
1from http import HTTPStatus
2
3from django.http import HttpResponse
4from django.views.decorators.csrf import ensure_csrf_cookie
5
6from dmr import Controller, ResponseSpec
7from dmr.decorators import wrap_middleware
8from dmr.plugins.pydantic import PydanticSerializer
9from examples.middleware.csrf_protect_json import csrf_protect_json
10
11
12# CSRF cookie for GET requests
13@wrap_middleware(
14 ensure_csrf_cookie,
15 ResponseSpec(
16 return_type=dict[str, str],
17 status_code=HTTPStatus.OK,
18 ),
19)
20def ensure_csrf_cookie_json(response: HttpResponse) -> HttpResponse:
21 """Return response ensuring CSRF cookie is set."""
22 return response
23
24
25@csrf_protect_json
26class ProtectedController(Controller[PydanticSerializer]):
27 """Protected API controller requiring CSRF token."""
28
29 responses = csrf_protect_json.responses
30
31 def get(self) -> dict[str, str]:
32 """Get CSRF token."""
33 return {'message': 'Use this endpoint to get CSRF token'}
34
35 def post(self) -> dict[str, str]:
36 """Protected endpoint requiring CSRF token."""
37 return {'message': 'Successfully created resource'}
38
39
40@ensure_csrf_cookie_json
41class PublicController(Controller[PydanticSerializer]):
42 responses = ensure_csrf_cookie_json.responses
43
44 def get(self) -> dict[str, str]:
45 """Public endpoint that sets CSRF cookie."""
46 return {'message': 'CSRF cookie set'}
47
Run result
$ curl http://127.0.0.1:8000/api/publiccontroller/ -D - -X GET
HTTP/1.1 200 OK
date: Sun, 05 Apr 2026 17:51:05 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=RxCKuaEz2p3U8da7XSNf2hEcpDtRAEIs; expires=Sun, 04 Apr 2027 17:51:05 GMT; Max-Age=31449600; Path=/; SameSite=Lax
{"message":"CSRF cookie set"}