Repository: falconry/falcon

Issue:
Better handling of `cors_enable=True` + added `CORSMiddleware`
Although similar to https://github.com/falconry/falcon/issues/1930, this issue proposes to improve not only documentation, but also provide a clearer indication to the developer who has happened to pass this unsupported combination of parameters.

Maybe we could emit a warning, silently disable the implicitly added middleware component if we detect that any instance of `CORSMiddleware` is already present, or even raise an Exception? (Although the latter might be seen as a breaking change -- discuss).


Discussion:
connected to https://github.com/falconry/falcon/pull/1976

Probably the best choice here is to deprecate the flag, since any other case would not be immediately clear to understand.
An alternative may be to raise a deprecation warning and the raise an exception in the next major
Hm, on the one hand, it may make sense to be explicit, on the other, it's convenient to have a flag to quickly enable permissive CORS, since it is what quite many users are after.

Maybe it wouldn't be too complicated to only prepend `CORSMiddleware` when updating the middleware list only if there is no other object where `isinstance(component, CORSMiddleware)`?

In either case, I have removed the contributor/Hacktoberfest labels before we make a decision on this, not to get any potential contributor dragged into this beforehand.
See also https://github.com/falconry/falcon/discussions/2095. Rescheduling for 4.0.

Resolving patch:
diff --git a/falcon/app.py b/falcon/app.py
--- a/falcon/app.py
+++ b/falcon/app.py
@@ -244,6 +244,7 @@ def __init__(
         cors_enable=False,
         sink_before_static_route=True,
     ):
+        self._cors_enable = cors_enable
         self._sink_before_static_route = sink_before_static_route
         self._sinks = []
         self._static_routes = []
@@ -447,7 +448,7 @@ def __call__(  # noqa: C901
     def router_options(self):
         return self._router.options
 
-    def add_middleware(self, middleware: object) -> None:
+    def add_middleware(self, middleware: Union[object, Iterable]) -> None:
         """Add one or more additional middleware components.
 
         Arguments:
@@ -461,10 +462,28 @@ def add_middleware(self, middleware: object) -> None:
         #   the chance that middleware may be None.
         if middleware:
             try:
-                self._unprepared_middleware += middleware
+                middleware = list(middleware)  # type: ignore
             except TypeError:
                 # middleware is not iterable; assume it is just one bare component
-                self._unprepared_middleware.append(middleware)
+                middleware = [middleware]
+
+            if (
+                self._cors_enable
+                and len(
+                    [
+                        mc
+                        for mc in self._unprepared_middleware + middleware
+                        if isinstance(mc, CORSMiddleware)
+                    ]
+                )
+                > 1
+            ):
+                raise ValueError(
+                    'CORSMiddleware is not allowed in conjunction with '
+                    'cors_enable (which already constructs one instance)'
+                )
+
+            self._unprepared_middleware += middleware
 
         # NOTE(kgriffs): Even if middleware is None or an empty list, we still
         #   need to make sure self._middleware is initialized if this is the


Test patch:
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -1000,20 +1000,28 @@ def test_process_resource_cached(self, asgi, independent_middleware):
 
 class TestCORSMiddlewareWithAnotherMiddleware(TestMiddleware):
     @pytest.mark.parametrize(
-        'mw',
+        'mw,allowed',
         [
-            CaptureResponseMiddleware(),
-            [CaptureResponseMiddleware()],
-            (CaptureResponseMiddleware(),),
-            iter([CaptureResponseMiddleware()]),
+            (CaptureResponseMiddleware(), True),
+            ([CaptureResponseMiddleware()], True),
+            ((CaptureResponseMiddleware(),), True),
+            (iter([CaptureResponseMiddleware()]), True),
+            (falcon.CORSMiddleware(), False),
+            ([falcon.CORSMiddleware()], False),
         ],
     )
-    def test_api_initialization_with_cors_enabled_and_middleware_param(self, mw, asgi):
-        app = create_app(asgi, middleware=mw, cors_enable=True)
-        app.add_route('/', TestCorsResource())
-        client = testing.TestClient(app)
-        result = client.simulate_get(headers={'Origin': 'localhost'})
-        assert result.headers['Access-Control-Allow-Origin'] == '*'
+    def test_api_initialization_with_cors_enabled_and_middleware_param(
+        self, mw, asgi, allowed
+    ):
+        if allowed:
+            app = create_app(asgi, middleware=mw, cors_enable=True)
+            app.add_route('/', TestCorsResource())
+            client = testing.TestClient(app)
+            result = client.simulate_get(headers={'Origin': 'localhost'})
+            assert result.headers['Access-Control-Allow-Origin'] == '*'
+        else:
+            with pytest.raises(ValueError, match='CORSMiddleware'):
+                app = create_app(asgi, middleware=mw, cors_enable=True)
 
 
 @pytest.mark.skipif(cython, reason='Cythonized coroutine functions cannot be detected')