Repository: adamchainz/django-cors-headers

Issue:
Make middleware compatible with async views
https://docs.djangoproject.com/en/dev/topics/http/middleware/#asynchronous-support


Discussion:
I think we can do this by inheriting from Django's `MiddlewareMixin` - fancy trying this?
`MiddlewareMixin` is deprecated now (it relates with `MIDDLEWARE_CLASSES` support for old Django versions).
IMHO we should not use deprecated code for new features if we can avoid it. 

Do you see a good reason to use deprecated `MiddlewareMixin` instead of `@sync_and_async_middleware` decorator, suggested in documentation?
It's deprecated-but-not-deprecated. It lives inside a module called `deprecation` but it doesn't raise a `DeprecationWarning` and it is the only way that Django's internal middleware works with async too. So I think we're safe to use it, until Django core presents a better solution.

That said, it would be more performant to go the other route. I forgot there was a guide in the docs about async middleware. So feel free to open a PR trying that out.
Both middlewares in this package already inherit from `from django.utils.deprecation.MiddlewareMixin` which does all the legwork to be async compatible. The only deprecated thing about that base class is calling `__init__` without a `get_response` callable.
Hi @adamchainz 
Do you plan working on this issue ? 

Or do you accept contributions ? :slightly_smiling_face: (If so, I'd be happy to help here) 

We're using django-cors-headers in a production product but we faced this issue when trying to add a few "async views" since unfortunatelly it seems the corsheaders' middleware is making them non-async. It'd be great if we find a way to solve this

Resolving patch:
diff --git a/src/corsheaders/middleware.py b/src/corsheaders/middleware.py
--- a/src/corsheaders/middleware.py
+++ b/src/corsheaders/middleware.py
@@ -1,13 +1,16 @@
 from __future__ import annotations
 
+import asyncio
 import re
+from typing import Awaitable
+from typing import Callable
 from urllib.parse import SplitResult
 from urllib.parse import urlsplit
 
 from django.http import HttpRequest
 from django.http import HttpResponse
+from django.http.response import HttpResponseBase
 from django.utils.cache import patch_vary_headers
-from django.utils.deprecation import MiddlewareMixin
 
 from corsheaders.conf import conf
 from corsheaders.signals import check_request_enabled
@@ -20,15 +23,52 @@
 ACCESS_CONTROL_MAX_AGE = "Access-Control-Max-Age"
 
 
-class CorsMiddleware(MiddlewareMixin):
-    def process_request(self, request: HttpRequest) -> HttpResponse | None:
-        """
-        If CORS preflight header, then create an
-        empty body response (200 OK) and return it
+class CorsMiddleware:
+    sync_capable = True
+    async_capable = True
+
+    def __init__(
+        self,
+        get_response: (
+            Callable[[HttpRequest], HttpResponseBase]
+            | Callable[[HttpRequest], Awaitable[HttpResponseBase]]
+        ),
+    ) -> None:
+        self.get_response = get_response
+        if asyncio.iscoroutinefunction(self.get_response):
+            # Mark the class as async-capable, but do the actual switch
+            # inside __call__ to avoid swapping out dunder methods
+            self._is_coroutine = (
+                asyncio.coroutines._is_coroutine  # type: ignore [attr-defined]
+            )
+        else:
+            self._is_coroutine = None
+
+    def __call__(
+        self, request: HttpRequest
+    ) -> HttpResponseBase | Awaitable[HttpResponseBase]:
+        if self._is_coroutine:
+            return self.__acall__(request)
+        response: HttpResponseBase | None = self.check_preflight(request)
+        if response is None:
+            result = self.get_response(request)
+            assert isinstance(result, HttpResponseBase)
+            response = result
+        self.add_response_headers(request, response)
+        return response
 
-        Django won't bother calling any other request
-        view/exception middleware along with the requested view;
-        it will call any response middlewares
+    async def __acall__(self, request: HttpRequest) -> HttpResponseBase:
+        response = self.check_preflight(request)
+        if response is None:
+            result = self.get_response(request)
+            assert not isinstance(result, HttpResponseBase)
+            response = await result
+        self.add_response_headers(request, response)
+        return response
+
+    def check_preflight(self, request: HttpRequest) -> HttpResponseBase | None:
+        """
+        Generate a response for CORS preflight requests.
         """
         request._cors_enabled = self.is_enabled(request)  # type: ignore [attr-defined]
         if (
@@ -36,14 +76,12 @@ def process_request(self, request: HttpRequest) -> HttpResponse | None:
             and request.method == "OPTIONS"
             and "access-control-request-method" in request.headers
         ):
-            response = HttpResponse()
-            response["Content-Length"] = "0"
-            return response
+            return HttpResponse(headers={"content-length": "0"})
         return None
 
-    def process_response(
-        self, request: HttpRequest, response: HttpResponse
-    ) -> HttpResponse:
+    def add_response_headers(
+        self, request: HttpRequest, response: HttpResponseBase
+    ) -> HttpResponseBase:
         """
         Add the respective CORS headers
         """


Test patch:
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+from http import HTTPStatus
+
 from django.http import HttpResponse
 from django.test import TestCase
 from django.test.utils import override_settings
@@ -50,6 +52,11 @@ def test_get_in_allowed_origins(self):
         resp = self.client.get("/", HTTP_ORIGIN="http://example.org")
         assert resp[ACCESS_CONTROL_ALLOW_ORIGIN] == "http://example.org"
 
+    @override_settings(CORS_ALLOWED_ORIGINS=["http://example.org"])
+    async def test_async_get_in_allowed_origins(self):
+        resp = await self.async_client.get("/async/", origin="http://example.org")
+        assert resp[ACCESS_CONTROL_ALLOW_ORIGIN] == "http://example.org"
+
     @override_settings(CORS_ALLOWED_ORIGINS=["http://example.com", "null"])
     def test_null_in_allowed_origins(self):
         resp = self.client.get("/", HTTP_ORIGIN="null")
@@ -99,6 +106,24 @@ def test_options_allowed_origin(self):
             HTTP_ORIGIN="http://example.com",
             HTTP_ACCESS_CONTROL_REQUEST_METHOD="GET",
         )
+        assert resp.status_code == HTTPStatus.OK
+        assert resp[ACCESS_CONTROL_ALLOW_HEADERS] == "content-type, origin"
+        assert resp[ACCESS_CONTROL_ALLOW_METHODS] == "GET, OPTIONS"
+        assert resp[ACCESS_CONTROL_MAX_AGE] == "1002"
+
+    @override_settings(
+        CORS_ALLOW_HEADERS=["content-type", "origin"],
+        CORS_ALLOW_METHODS=["GET", "OPTIONS"],
+        CORS_PREFLIGHT_MAX_AGE=1002,
+        CORS_ALLOW_ALL_ORIGINS=True,
+    )
+    async def test_async_options_allowed_origin(self):
+        resp = await self.async_client.options(
+            "/async/",
+            origin="http://example.com",
+            access_control_request_method="GET",
+        )
+        assert resp.status_code == HTTPStatus.OK
         assert resp[ACCESS_CONTROL_ALLOW_HEADERS] == "content-type, origin"
         assert resp[ACCESS_CONTROL_ALLOW_METHODS] == "GET, OPTIONS"
         assert resp[ACCESS_CONTROL_MAX_AGE] == "1002"
diff --git a/tests/urls.py b/tests/urls.py
--- a/tests/urls.py
+++ b/tests/urls.py
@@ -11,6 +11,10 @@ def test_view(request):
     return HttpResponse("Test view")
 
 
+async def async_(request):
+    return HttpResponse("Test view")
+
+
 def test_view_http401(request):
     return HttpResponse("Unauthorized", status=401)
 
@@ -23,6 +27,7 @@ def test_view_that_deletes_is_enabled(request):
 urlpatterns = [
     path("", test_view),
     path("foo/", test_view),
+    path("async/", async_),
     path("test-401/", test_view_http401),
     path("delete-is-enabled/", test_view_that_deletes_is_enabled),
 ]