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 PydanticFastSerializer
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 PydanticFastSerializer,
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[PydanticFastSerializer]):
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 PydanticFastSerializer
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 PydanticFastSerializer,
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[PydanticFastSerializer]):
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: Thu, 01 Oct 2026 11:36:44 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 PydanticFastSerializer
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 PydanticFastSerializer,
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 PydanticFastSerializer,
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 PydanticFastSerializer
3from examples.middleware.csrf_protect_json import csrf_protect_json
4
5
6@csrf_protect_json
7class AsyncController(Controller[PydanticFastSerializer]):
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 PydanticFastSerializer
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[PydanticFastSerializer]):
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
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:
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 PydanticFastSerializer
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)
20def login_required_json(response: HttpResponse) -> HttpResponse:
21 """Convert Django's login_required redirect to JSON 401 response."""
22 if response.status_code == HTTPStatus.FOUND:
23 return build_response(
24 PydanticFastSerializer,
25 raw_data=format_error(
26 'Authentication credentials were not provided',
27 ),
28 status_code=HTTPStatus.UNAUTHORIZED,
29 )
30 return response
31
32
33@login_required_json
34class LoginRequiredController(Controller[PydanticFastSerializer]):
35 """Controller that uses Django's login_required decorator.
36
37 Demonstrates wrapping Django's built-in authentication decorators.
38 Converts 302 redirect to JSON 401 response for REST API compatibility.
39 """
40
41 responses = login_required_json.responses
42
43 def get(self) -> dict[str, str]:
44 """GET endpoint that requires Django authentication."""
45 # Access Django's authenticated user
46 user = self.request.user
47 username = user.username if user.is_authenticated else 'anonymous' # type: ignore[attr-defined]
48
49 return {
50 'username': username,
51 'message': 'Successfully accessed protected resource',
52 }
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 PydanticFastSerializer
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[PydanticFastSerializer]):
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[PydanticFastSerializer]):
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: Thu, 01 Oct 2026 11:36:44 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=IWb3Rrf1rn0n8058UJcGxKSAF9uTNa7K; expires=Thu, 30 Sep 2027 11:36:45 GMT; Max-Age=31449600; Path=/; SameSite=Lax
{"message":"CSRF cookie set"}