Repository: litestar-org/litestar

Issue:
refactor: deprecate CORSMiddleware from public interface
We require a `CORSConfig` passed to the application object in order for pre-flight responses to be generated.

https://github.com/litestar-org/litestar/blob/a0d11092dae300af4025d619d8ac818790dede74/litestar/routes/http.py#L258-L262

There is no way to only supply the `CORSMiddleware` to a subset of routes via a `Router.middleware` or similar, because of the fact that the pre-flight response generation is conditional upon the application having access to the `CORSConfig` object. In other words, if a user didn't supply the `CORSConfig` object to the application, but did supply `DefineMiddleware(CORSMiddleware, config=CORSConfig(...))` to a router, then the pre-flight responses for that router wouldn't have worked anyway because there's no `CORSConfig` on the app. And if the `CORSConfig` is added to the app, then there is no point adding the middleware to a sub-router, because we'd then apply it to the whole application anyway.

Also, the `CORSMiddleware` class is hard coded as a wrapper around the `ASGIRouter`:

https://github.com/litestar-org/litestar/blob/a0d11092dae300af4025d619d8ac818790dede74/litestar/app.py#L842-L844

There is no way for a user to sub-class the middleware to alter its behavior, as there is no way to pass that sub-classed middleware to us.

So IMO, `CORSMiddleware` is implementation detail - the whole thing should be undocumented and we should probably deprecate it from public use.

_Originally posted by @peterschutt in https://github.com/litestar-org/litestar/pull/3395#discussion_r1567964790_
            

<!-- POLAR PLEDGE BADGE START -->
---
> [!NOTE]  
> While we are open for sponsoring on [GitHub Sponsors](https://github.com/sponsors/litestar-org/) and 
> [OpenCollective](https://opencollective.com/litestar), we also utilize [Polar.sh](https://polar.sh/) to engage in pledge-based sponsorship.
>
> Check out all issues funded or available for funding [on our Polar.sh dashboard](https://polar.sh/litestar-org)
> * If you would like to see an issue prioritized, make a pledge towards it!
> * We receive the pledge once the issue is completed & verified
> * This, along with engagement in the community, helps us know which features are a priority to our users.

<a href="https://polar.sh/litestar-org/litestar/issues/3403">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/3403/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/3403/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:

Resolving patch:
diff --git a/litestar/app.py b/litestar/app.py
--- a/litestar/app.py
+++ b/litestar/app.py
@@ -32,7 +32,7 @@
     NoRouteMatchFoundException,
 )
 from litestar.logging.config import LoggingConfig, get_logger_placeholder
-from litestar.middleware.cors import CORSMiddleware
+from litestar.middleware._internal import CORSMiddleware
 from litestar.openapi.config import OpenAPIConfig
 from litestar.plugins import (
     CLIPluginProtocol,
@@ -245,7 +245,7 @@ def __init__(
                 this app. Can be overridden by route handlers.
             compression_config: Configures compression behaviour of the application, this enabled a builtin or user
                 defined Compression middleware.
-            cors_config: If set, configures :class:`CORSMiddleware <.middleware.cors.CORSMiddleware>`.
+            cors_config: If set, configures CORS handling for the application.
             csrf_config: If set, configures :class:`CSRFMiddleware <.middleware.csrf.CSRFMiddleware>`.
             debug: If ``True``, app errors rendered as HTML with a stack trace.
             dependencies: A string keyed mapping of dependency :class:`Providers <.di.Provide>`.
diff --git a/litestar/middleware/_internal.py b/litestar/middleware/_internal.py
new file mode 100644
--- /dev/null
+++ b/litestar/middleware/_internal.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from litestar.datastructures import Headers, MutableScopeHeaders
+from litestar.enums import ScopeType
+from litestar.middleware.base import AbstractMiddleware
+
+if TYPE_CHECKING:
+    from litestar.config.cors import CORSConfig
+    from litestar.types import ASGIApp, Message, Receive, Scope, Send
+
+
+class CORSMiddleware(AbstractMiddleware):
+    """CORS Middleware."""
+
+    def __init__(self, app: ASGIApp, config: CORSConfig) -> None:
+        """Middleware that adds CORS validation to the application.
+
+        Args:
+            app: The ``next`` ASGI app to call.
+            config: An instance of :class:`CORSConfig <litestar.config.cors.CORSConfig>`
+        """
+        super().__init__(app=app, scopes={ScopeType.HTTP})
+        self.config = config
+
+    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+        """ASGI callable.
+
+        Args:
+            scope: The ASGI connection scope.
+            receive: The ASGI receive function.
+            send: The ASGI send function.
+
+        Returns:
+            None
+        """
+        headers = Headers.from_scope(scope=scope)
+        if origin := headers.get("origin"):
+            await self.app(scope, receive, self.send_wrapper(send=send, origin=origin, has_cookie="cookie" in headers))
+        else:
+            await self.app(scope, receive, send)
+
+    def send_wrapper(self, send: Send, origin: str, has_cookie: bool) -> Send:
+        """Wrap ``send`` to ensure that state is not disconnected.
+
+        Args:
+            has_cookie: Boolean flag dictating if the connection has a cookie set.
+            origin: The value of the ``Origin`` header.
+            send: The ASGI send function.
+
+        Returns:
+            An ASGI send function.
+        """
+
+        async def wrapped_send(message: Message) -> None:
+            if message["type"] == "http.response.start":
+                message.setdefault("headers", [])
+                headers = MutableScopeHeaders.from_message(message=message)
+                headers.update(self.config.simple_headers)
+
+                if (self.config.is_allow_all_origins and has_cookie) or (
+                    not self.config.is_allow_all_origins and self.config.is_origin_allowed(origin=origin)
+                ):
+                    headers["Access-Control-Allow-Origin"] = origin
+                    headers["Vary"] = "Origin"
+
+                # We don't want to overwrite this for preflight requests.
+                allow_headers = headers.get("Access-Control-Allow-Headers")
+                if not allow_headers and self.config.allow_headers:
+                    headers["Access-Control-Allow-Headers"] = ", ".join(sorted(set(self.config.allow_headers)))
+
+                allow_methods = headers.get("Access-Control-Allow-Methods")
+                if not allow_methods and self.config.allow_methods:
+                    headers["Access-Control-Allow-Methods"] = ", ".join(sorted(set(self.config.allow_methods)))
+
+            await send(message)
+
+        return wrapped_send
diff --git a/litestar/middleware/cors.py b/litestar/middleware/cors.py
--- a/litestar/middleware/cors.py
+++ b/litestar/middleware/cors.py
@@ -1,82 +1,19 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
-
-from litestar.datastructures import Headers, MutableScopeHeaders
-from litestar.enums import ScopeType
-from litestar.middleware.base import AbstractMiddleware
-
-__all__ = ("CORSMiddleware",)
-
-
-if TYPE_CHECKING:
-    from litestar.config.cors import CORSConfig
-    from litestar.types import ASGIApp, Message, Receive, Scope, Send
-
-
-class CORSMiddleware(AbstractMiddleware):
-    """CORS Middleware."""
-
-    def __init__(self, app: ASGIApp, config: CORSConfig) -> None:
-        """Middleware that adds CORS validation to the application.
-
-        Args:
-            app: The ``next`` ASGI app to call.
-            config: An instance of :class:`CORSConfig <litestar.config.cors.CORSConfig>`
-        """
-        super().__init__(app=app, scopes={ScopeType.HTTP})
-        self.config = config
-
-    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
-        """ASGI callable.
-
-        Args:
-            scope: The ASGI connection scope.
-            receive: The ASGI receive function.
-            send: The ASGI send function.
-
-        Returns:
-            None
-        """
-        headers = Headers.from_scope(scope=scope)
-        if origin := headers.get("origin"):
-            await self.app(scope, receive, self.send_wrapper(send=send, origin=origin, has_cookie="cookie" in headers))
-        else:
-            await self.app(scope, receive, send)
-
-    def send_wrapper(self, send: Send, origin: str, has_cookie: bool) -> Send:
-        """Wrap ``send`` to ensure that state is not disconnected.
-
-        Args:
-            has_cookie: Boolean flag dictating if the connection has a cookie set.
-            origin: The value of the ``Origin`` header.
-            send: The ASGI send function.
-
-        Returns:
-            An ASGI send function.
-        """
-
-        async def wrapped_send(message: Message) -> None:
-            if message["type"] == "http.response.start":
-                message.setdefault("headers", [])
-                headers = MutableScopeHeaders.from_message(message=message)
-                headers.update(self.config.simple_headers)
-
-                if (self.config.is_allow_all_origins and has_cookie) or (
-                    not self.config.is_allow_all_origins and self.config.is_origin_allowed(origin=origin)
-                ):
-                    headers["Access-Control-Allow-Origin"] = origin
-                    headers["Vary"] = "Origin"
-
-                # We don't want to overwrite this for preflight requests.
-                allow_headers = headers.get("Access-Control-Allow-Headers")
-                if not allow_headers and self.config.allow_headers:
-                    headers["Access-Control-Allow-Headers"] = ", ".join(sorted(set(self.config.allow_headers)))
-
-                allow_methods = headers.get("Access-Control-Allow-Methods")
-                if not allow_methods and self.config.allow_methods:
-                    headers["Access-Control-Allow-Methods"] = ", ".join(sorted(set(self.config.allow_methods)))
-
-            await send(message)
-
-        return wrapped_send
+from typing import Any
+
+from litestar.middleware import _internal
+from litestar.utils.deprecation import warn_deprecation
+
+
+def __getattr__(name: str) -> Any:
+    if name == "CORSMiddleware":
+        warn_deprecation(
+            version="2.9",
+            deprecated_name=name,
+            kind="class",
+            removal_in="3.0",
+            info="CORS middleware has been removed from the public API.",
+        )
+        return _internal.CORSMiddleware
+    raise AttributeError(f"module {__name__} has no attribute {name}")


Test patch:
diff --git a/litestar/testing/helpers.py b/litestar/testing/helpers.py
--- a/litestar/testing/helpers.py
+++ b/litestar/testing/helpers.py
@@ -169,7 +169,7 @@ def test_my_handler() -> None:
             this app. Can be overridden by route handlers.
         compression_config: Configures compression behaviour of the application, this enabled a builtin or user
             defined Compression middleware.
-        cors_config: If set, configures :class:`CORSMiddleware <.middleware.cors.CORSMiddleware>`.
+        cors_config: If set, configures CORS handling for the application.
         csrf_config: If set, configures :class:`CSRFMiddleware <.middleware.csrf.CSRFMiddleware>`.
         debug: If ``True``, app errors rendered as HTML with a stack trace.
         dependencies: A string keyed mapping of dependency :class:`Providers <.di.Provide>`.
@@ -430,7 +430,7 @@ async def test_my_handler() -> None:
             this app. Can be overridden by route handlers.
         compression_config: Configures compression behaviour of the application, this enabled a builtin or user
             defined Compression middleware.
-        cors_config: If set, configures :class:`CORSMiddleware <.middleware.cors.CORSMiddleware>`.
+        cors_config: If set, configures CORS handling for the application.
         csrf_config: If set, configures :class:`CSRFMiddleware <.middleware.csrf.CSRFMiddleware>`.
         debug: If ``True``, app errors rendered as HTML with a stack trace.
         dependencies: A string keyed mapping of dependency :class:`Providers <.di.Provide>`.
diff --git a/tests/unit/test_deprecations.py b/tests/unit/test_deprecations.py
--- a/tests/unit/test_deprecations.py
+++ b/tests/unit/test_deprecations.py
@@ -168,3 +168,8 @@ def test_openapi_config_enabled_endpoints_deprecation() -> None:
 
     with pytest.warns(DeprecationWarning):
         OpenAPIConfig(title="API", version="1.0", enabled_endpoints={"redoc"})
+
+
+def test_cors_middleware_public_interface_deprecation() -> None:
+    with pytest.warns(DeprecationWarning):
+        from litestar.middleware.cors import CORSMiddleware  # noqa: F401
diff --git a/tests/unit/test_middleware/test_cors_middleware.py b/tests/unit/test_middleware/test_cors_middleware.py
--- a/tests/unit/test_middleware/test_cors_middleware.py
+++ b/tests/unit/test_middleware/test_cors_middleware.py
@@ -4,7 +4,7 @@
 
 from litestar import get
 from litestar.config.cors import CORSConfig
-from litestar.middleware.cors import CORSMiddleware
+from litestar.middleware._internal import CORSMiddleware
 from litestar.status_codes import HTTP_200_OK, HTTP_404_NOT_FOUND
 from litestar.testing import create_test_client
 from litestar.types.asgi_types import Method