Repository: litestar-org/litestar

Issue:
bug: Plugin "Flash Messages" not working with "Redirect" Response
### Litestar (2.8.0)

```python
from litestar import Litestar, Request, get, post
from litestar.contrib.jinja import JinjaTemplateEngine
from litestar.plugins.flash import FlashConfig, FlashPlugin, flash
from litestar.response import Template, Redirect
from litestar.template.config import TemplateConfig

template_config = TemplateConfig(engine=JinjaTemplateEngine, directory="templates")
flash_plugin = FlashPlugin(config=FlashConfig(template_config=template_config))


@get('/')
async def index() -> Redirect:
    return Redirect('/login')


@get('/login')
async def login(request: Request) -> Template:
    flash(request, "Flash Test!", category="info")
    return Template(template_str='{% for f in get_flashes() %} <p>{{ f.message }}</p>{% endfor %}'
                                 '<form action="/check" method="post"><button type="submit">login</button></form>')


@post('/check')
async def check(request: Request) -> Redirect:
    flash(request, "User not Found!", category="warning")
    return Redirect('/login')


app = Litestar(plugins=[flash_plugin], route_handlers=[index,login, check], template_config=template_config)

if __name__ == '__main__':
    import uvicorn

    uvicorn.run('1:app')

```

```
INFO:     Started server process [13056]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     127.0.0.1:50961 - "GET / HTTP/1.1" 302 Found
INFO:     127.0.0.1:50961 - "GET /login HTTP/1.1" 200 OK
INFO:     127.0.0.1:50961 - "GET /login HTTP/1.1" 200 OK
INFO:     127.0.0.1:50961 - "POST /check HTTP/1.1" 302 Found
INFO:     127.0.0.1:50961 - "GET /login HTTP/1.1" 200 OK
INFO:     Shutting down
INFO:     Waiting for application shutdown.
INFO:     Application shutdown complete.
INFO:     Finished server process [13056]

Process finished with exit code 0

```

![litestar](https://github.com/litestar-org/litestar/assets/139426944/dfbf6bf6-264e-4cf8-8970-ce97a23cdab1)

### Problem: Flash Message from "POST /check" is missing at final page (screenshot above)

---

### Flask (3.0.2)

```python
from flask import Flask, flash, render_template_string, redirect

app = Flask(__name__)
app.secret_key = b'123'


@app.get('/')
def index() -> redirect:
    return redirect('/login')


@app.get('/login')
def login():
    flash('Flash Test!')
    return render_template_string('{% for m in get_flashed_messages() %} <p>{{ m }}</p>{% endfor %}'
                                  '<form action="/check" method="post"><button type="submit">login</button></form>')


@app.post('/check')
def check():
    flash("User not Found!")
    return redirect('/login')


if __name__ == '__main__':
    app.run()

```

```
 * Serving Flask app '2'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
127.0.0.1 - - [06/Apr/2024 14:50:47] "GET / HTTP/1.1" 302 -
127.0.0.1 - - [06/Apr/2024 14:50:47] "GET /login HTTP/1.1" 200 -
127.0.0.1 - - [06/Apr/2024 14:50:48] "POST /check HTTP/1.1" 302 -
127.0.0.1 - - [06/Apr/2024 14:50:48] "GET /login HTTP/1.1" 200 -

```

![flask](https://github.com/litestar-org/litestar/assets/139426944/26ca13b7-027a-47f7-9821-6f935ce5c907)


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



Discussion:
@euri10? :eyes: 
### If it helps, this is how we are doing it manually in Litestar

```python
from litestar import Litestar, Request, get, post
from litestar.contrib.jinja import JinjaTemplateEngine
from litestar.middleware.session.server_side import ServerSideSessionConfig
from litestar.response import Template, Redirect
from litestar.template.config import TemplateConfig

template_config = TemplateConfig(engine=JinjaTemplateEngine, directory="templates")


def flash(request: Request, message: str, category: str = "primary") -> None:
    if "_messages" not in request.session:
        request.session["_messages"] = []
    request.session["_messages"].append({"message": message, "category": category})


def get_flashed_messages(request: Request) -> list[str]:
    return request.session.pop("_messages") if "_messages" in request.session else []


template_config.engine_instance.engine.globals["get_flashed_messages"] = get_flashed_messages


@get('/')
async def index() -> Redirect:
    return Redirect('/login')


@get('/login')
async def login(request: Request) -> Template:
    flash(request, "Flash Test!", category="info")
    return Template(template_str='{% for f in get_flashed_messages(request) %} <p>{{ f.message }}</p>{% endfor %}'
                                 '<form action="/check" method="post"><button type="submit">login</button></form>')


@post('/check')
async def check(request: Request) -> Redirect:
    flash(request, "User not Found!", category="warning")
    return Redirect('/login')


app = Litestar(route_handlers=[index, login, check], template_config=template_config,
               middleware=[ServerSideSessionConfig().middleware])

if __name__ == '__main__':
    import uvicorn

    uvicorn.run('1:app')
```

```
INFO:     Started server process [8416]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     127.0.0.1:51887 - "GET / HTTP/1.1" 302 Found
INFO:     127.0.0.1:51887 - "GET /login HTTP/1.1" 200 OK
INFO:     127.0.0.1:51887 - "POST /check HTTP/1.1" 302 Found
INFO:     127.0.0.1:51887 - "GET /login HTTP/1.1" 200 OK
INFO:     Shutting down
INFO:     Waiting for application shutdown.
INFO:     Application shutdown complete.
INFO:     Finished server process [8416]
```

![litestar_fix](https://github.com/litestar-org/litestar/assets/139426944/35ddcf3b-e5c1-4b89-8dc9-4e253ad0754e)

We hoped 'flash Messages' would be integrated into the core functionality rather than as a plugin, which can often get abandoned. It's a feature present in Flask and Django, so it's likely quite important. Nonetheless, appreciate the work done so far. Litestar is at its best and continually improving. That's wonderful. Thank you!
Configuring Litestar via plugins is an explicit architectural decision - we also use them extensively for internal things. This plugin is in the main code base and is maintained alongside the rest of the library code so there should be no need to be concerned about commitment to its maintenance any more than any other part of the library.
ok I have the same issue now that I'm trying to switch from my implenetation (which is exactly the same as @saltcable )  to the official plugin:)

I'm not sure why I went passing the messages in the request state rather than in the session in the plugin though.

This said, this could be either fixed using the same method for passing flash messages around ie in a session (but that means the plugin requires to have the session middleware installed, we could check for that in the app_init and / or install a default config for it, I'd prefer just raise a misconfiguration error rather than providing a default config)
Or there is a way for the Redirect response to be aware of the scope state from the handler that issued the flash message, but I'm not sure that is possible.


Resolving patch:
diff --git a/litestar/plugins/flash.py b/litestar/plugins/flash.py
--- a/litestar/plugins/flash.py
+++ b/litestar/plugins/flash.py
@@ -6,16 +6,20 @@
 from dataclasses import dataclass
 from typing import TYPE_CHECKING, Any, Mapping
 
+import litestar.exceptions
+from litestar import Request
 from litestar.exceptions import MissingDependencyException
+from litestar.middleware import DefineMiddleware
+from litestar.middleware.session import SessionMiddleware
 from litestar.plugins import InitPluginProtocol
+from litestar.security.session_auth.middleware import MiddlewareWrapper
 from litestar.template.base import _get_request_from_context
-from litestar.utils.scope.state import ScopeState
+from litestar.utils.predicates import is_class_and_subclass
 
 if TYPE_CHECKING:
     from collections.abc import Callable
 
     from litestar.config.app import AppConfig
-    from litestar.connection import ASGIConnection
     from litestar.template import TemplateConfig
 
 
@@ -46,6 +50,13 @@ def on_app_init(self, app_config: AppConfig) -> AppConfig:
         Returns:
             The application configuration with the message callable registered.
         """
+        for mw in app_config.middleware:
+            if isinstance(mw, DefineMiddleware) and is_class_and_subclass(
+                mw.middleware, (MiddlewareWrapper, SessionMiddleware)
+            ):
+                break
+        else:
+            raise litestar.exceptions.ImproperlyConfiguredException("Flash messages require a session middleware.")
         template_callable: Callable[[Any], Any] = get_flashes
         with suppress(MissingDependencyException):
             from litestar.contrib.minijinja import MiniJinjaTemplateEngine, _transform_state
@@ -57,26 +68,13 @@ def on_app_init(self, app_config: AppConfig) -> AppConfig:
         return app_config
 
 
-def flash(connection: ASGIConnection, message: str, category: str) -> None:
-    """Add a flash message to the request scope.
-
-    Args:
-        connection: The connection instance.
-        message: The message to flash.
-        category: The category of the message.
-    """
-    scope_state = ScopeState.from_scope(connection.scope)
-    scope_state.flash_messages.append({"message": message, "category": category})
+def flash(
+    request: Request,
+    message: Any,
+    category: str,
+) -> None:
+    request.session.setdefault("_messages", []).append({"message": message, "category": category})
 
 
 def get_flashes(context: Mapping[str, Any]) -> Any:
-    """Get flash messages from the request scope, if any.
-
-    Args:
-        context: The context dictionary.
-
-    Returns:
-        The flash messages, if any.
-    """
-    scope_state = ScopeState.from_scope(_get_request_from_context(context).scope)
-    return scope_state.flash_messages
+    return _get_request_from_context(context).session.pop("_messages", [])


Test patch:
diff --git a/tests/unit/test_plugins/test_flash.py b/tests/unit/test_plugins/test_flash.py
--- a/tests/unit/test_plugins/test_flash.py
+++ b/tests/unit/test_plugins/test_flash.py
@@ -5,12 +5,15 @@
 
 import pytest
 
-from litestar import Request, get
+from litestar import Litestar, Request, get, post
 from litestar.contrib.jinja import JinjaTemplateEngine
 from litestar.contrib.mako import MakoTemplateEngine
 from litestar.contrib.minijinja import MiniJinjaTemplateEngine
+from litestar.exceptions import ImproperlyConfiguredException
+from litestar.middleware.rate_limit import RateLimitConfig
+from litestar.middleware.session.server_side import ServerSideSessionConfig
 from litestar.plugins.flash import FlashConfig, FlashPlugin, flash
-from litestar.response import Template
+from litestar.response import Redirect, Template
 from litestar.template import TemplateConfig, TemplateEngineProtocol
 from litestar.testing import create_test_client
 
@@ -56,25 +59,55 @@ def test_flash_plugin(
     category_enum: Enum,
 ) -> None:
     Path(tmp_path / "flash.html").write_text(template_str)
-    text_expected = "".join(
-        [f'<span class="{category.value}">message {category.value}</span>' for category in category_enum]  # type: ignore[attr-defined]
-    )
 
-    @get("/flash")
-    def flash_handler(request: Request) -> Template:
-        for category in category_enum:  # type: ignore[attr-defined]
-            flash(request, f"message {category.value}", category=category.value)
+    @get("/")
+    async def index() -> Redirect:
+        return Redirect("/login")
+
+    @get("/login")
+    async def login(request: Request) -> Template:
+        flash(request, "Flash Test!", category="info")
         return Template("flash.html")
 
+    @post("/check")
+    async def check(request: Request) -> Redirect:
+        flash(request, "User not Found!", category="warning")
+        return Redirect("/login")
+
     template_config: TemplateConfig = TemplateConfig(
         directory=Path(tmp_path),
         engine=engine,
     )
+    session_config = ServerSideSessionConfig()
+    flash_config = FlashConfig(template_config=template_config)
     with create_test_client(
-        [flash_handler],
+        plugins=[FlashPlugin(config=flash_config)],
+        route_handlers=[index, login, check],
         template_config=template_config,
-        plugins=[FlashPlugin(config=FlashConfig(template_config=template_config))],
+        middleware=[session_config.middleware],
     ) as client:
-        r = client.get("/flash")
+        r = client.get("/")
+        assert r.status_code == 200
+        assert "Flash Test!" in r.text
+        r = client.get("/login")
+        assert r.status_code == 200
+        assert "Flash Test!" in r.text
+        r = client.post("/check")
         assert r.status_code == 200
-        assert r.text == text_expected
+        assert "User not Found!" in r.text
+        assert "Flash Test!" in r.text
+
+
+def test_flash_config_doesnt_have_session() -> None:
+    template_config = TemplateConfig(directory=Path("tests/templates"), engine=JinjaTemplateEngine)
+    flash_config = FlashConfig(template_config=template_config)
+    with pytest.raises(ImproperlyConfiguredException):
+        Litestar(plugins=[FlashPlugin(config=flash_config)])
+
+
+def test_flash_config_has_wrong_middleware_type() -> None:
+    template_config = TemplateConfig(directory=Path("tests/templates"), engine=JinjaTemplateEngine)
+    flash_config = FlashConfig(template_config=template_config)
+    rate_limit_config = RateLimitConfig(rate_limit=("minute", 1), exclude=["/schema"])
+    with pytest.raises(ImproperlyConfiguredException):
+        Litestar(plugins=[FlashPlugin(config=flash_config)], middleware=[rate_limit_config.middleware])