├── .github ├── dependabot.yml └── workflows │ └── rust.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── README.md ├── examples └── basic.rs ├── rustfmt.toml └── src └── lib.rs /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "cargo" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "daily" 13 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: {} 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | check: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - run: | 19 | rustup toolchain install nightly --profile minimal --component rustfmt --component clippy 20 | - uses: Swatinem/rust-cache@v2 21 | - name: clippy 22 | run: | 23 | cargo clippy --all --all-targets --all-features -- -Dwarnings 24 | - name: rustfmt 25 | run: | 26 | cargo +nightly fmt --all -- --check 27 | 28 | check-docs: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | - run: | 33 | rustup toolchain install stable --profile minimal 34 | - uses: Swatinem/rust-cache@v2 35 | - name: cargo doc 36 | env: 37 | RUSTDOCFLAGS: "-D rustdoc::broken-intra-doc-links" 38 | run: | 39 | cargo doc --all-features --no-deps 40 | 41 | test-docs: 42 | needs: check 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v4 46 | - run: | 47 | rustup toolchain install nightly --profile minimal 48 | - uses: Swatinem/rust-cache@v2 49 | - name: Run doc tests 50 | run: | 51 | cargo test --all-features --doc 52 | 53 | test-unit: 54 | needs: check 55 | runs-on: ubuntu-latest 56 | steps: 57 | - uses: actions/checkout@v4 58 | - run: | 59 | rustup toolchain install stable --profile minimal 60 | - uses: Swatinem/rust-cache@v2 61 | - name: Run unit tests 62 | run: | 63 | cargo test --lib --all-features 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Unreleased 2 | 3 | # 0.8.0 4 | 5 | - Update `tower-sessions` to 0.14.0 6 | 7 | # 0.7.0 8 | 9 | - Update `tower-sessions` to 0.13.0 10 | 11 | # 0.6.1 12 | 13 | - Update docs re web fundamentals 14 | - Provide tracing for error cases 15 | - Additional utility methods 16 | 17 | # 0.6.0 18 | 19 | - Update `tower-sessions` to 0.12.0 20 | 21 | # 0.5.0 22 | 23 | **Breaking Changes** 24 | 25 | - Allow providing optional metadata for messages #8, #9 26 | 27 | This change updates the `push` method to include an optional metadata argument; other methods are unchanged. A new set of `*_with_medata` postfixed methods is also provided. 28 | 29 | # 0.4.0 30 | 31 | - Update `tower-sessions` to 0.11.0 32 | 33 | # 0.3.0 34 | 35 | - Update `tower-sessions` to 0.10.0 36 | 37 | # 0.2.2 38 | 39 | - Implement `Display` for `Level` 40 | 41 | # 0.2.1 42 | 43 | - Save only when messages have been modified 44 | 45 | # 0.2.0 46 | 47 | **Breaking Changes** 48 | 49 | - Rework crate into a middleware 50 | 51 | This changes the structure of the crate such that it is now a middleware in addition to being an extractor. Doing so allows us to improve the ergonomics of the API such that calling `save` and awaiting a future is no longer needed. 52 | 53 | Now applications will need to install the `MeessagesManagerLayer` after `tower-sessions` has been installed (either directly or via a middleware that wraps it). 54 | 55 | Also note that the iterator impplementation has been updated to use `Message` directly. Fields of `Message` have been made public as well. 56 | 57 | # 0.1.0 58 | 59 | - Initial release :tada: 60 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axum-messages" 3 | version = "0.8.0" 4 | edition = "2021" 5 | authors = ["Max Countryman "] 6 | categories = ["asynchronous", "network-programming", "web-programming"] 7 | description = "🛎️ One-time notification messages for Axum." 8 | homepage = "https://github.com/maxcountryman/axum-messages" 9 | keywords = ["axum", "flash", "message", "messages", "notification"] 10 | license = "MIT" 11 | readme = "README.md" 12 | repository = "https://github.com/maxcountryman/axum-messages" 13 | 14 | [dependencies] 15 | axum-core = "0.5.0" 16 | http = "1.0.0" 17 | parking_lot = "0.12.1" 18 | serde = { version = "1.0.195", features = ["derive"] } 19 | serde_json = "1" 20 | tower = "0.5" 21 | tower-sessions-core = "0.14.0" 22 | tracing = { version = "0.1.40", features = ["log"] } 23 | 24 | [dev-dependencies] 25 | axum = { version = "0.8.1", features = ["macros"] } 26 | http-body-util = "0.1" 27 | hyper = "1.0" 28 | tokio = { version = "1.20", features = ["macros", "rt-multi-thread"] } 29 | tower = "0.5" 30 | tower-sessions = "0.14.0" 31 | 32 | [[example]] 33 | name = "basic" 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Max Countryman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | axum-messages 3 |

4 | 5 |

6 | 🛎️ One-time notification messages for Axum. 7 |

8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | ## 🎨 Overview 22 | 23 | This crate provides one-time notification messages, or flash messages, for `axum` applications. 24 | 25 | It's built on top of [`tower-sessions`](https://github.com/maxcountryman/tower-sessions), so applications that already use `tower-sessions` can use this crate with minimal setup. 26 | 27 | For an implementation that uses `axum-extra` cookies, please see [`axum-flash`](https://crates.io/crates/axum-flash); `axum-messages` borrows from that crate, but simplifies the API by leveraging `tower-sessions`. 28 | 29 | This crate's implementation is inspired by the [Django messages framework](https://docs.djangoproject.com/en/5.0/ref/contrib/messages/). 30 | 31 | ## 📦 Install 32 | 33 | To use the crate in your project, add the following to your `Cargo.toml` file: 34 | 35 | ```toml 36 | [dependencies] 37 | axum-messages = "0.7.0" 38 | ``` 39 | 40 | ## 🤸 Usage 41 | 42 | ### Example 43 | 44 | ```rust 45 | use std::net::SocketAddr; 46 | 47 | use axum::{ 48 | response::{IntoResponse, Redirect}, 49 | routing::get, 50 | Router, 51 | }; 52 | use axum_messages::{Messages, MessagesManagerLayer}; 53 | use tower_sessions::{MemoryStore, SessionManagerLayer}; 54 | 55 | async fn set_messages_handler(messages: Messages) -> impl IntoResponse { 56 | messages 57 | .info("Hello, world!") 58 | .debug("This is a debug message."); 59 | 60 | Redirect::to("/read-messages") 61 | } 62 | 63 | async fn read_messages_handler(messages: Messages) -> impl IntoResponse { 64 | let messages = messages 65 | .into_iter() 66 | .map(|message| format!("{}: {}", message.level, message)) 67 | .collect::>() 68 | .join(", "); 69 | 70 | if messages.is_empty() { 71 | "No messages yet!".to_string() 72 | } else { 73 | messages 74 | } 75 | } 76 | 77 | #[tokio::main] 78 | async fn main() { 79 | let session_store = MemoryStore::default(); 80 | let session_layer = SessionManagerLayer::new(session_store).with_secure(false); 81 | 82 | let app = Router::new() 83 | .route("/", get(set_messages_handler)) 84 | .route("/read-messages", get(read_messages_handler)) 85 | .layer(MessagesManagerLayer) 86 | .layer(session_layer); 87 | 88 | let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); 89 | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); 90 | axum::serve(listener, app.into_make_service()) 91 | .await 92 | .unwrap(); 93 | } 94 | ``` 95 | 96 | You can find this [example][basic-example] in the [example directory][examples]. 97 | 98 | ## 🦺 Safety 99 | 100 | This crate uses `#![forbid(unsafe_code)]` to ensure everything is implemented in 100% safe Rust. 101 | 102 | ## 🛟 Getting Help 103 | 104 | We've put together a number of [examples][examples] to help get you started. You're also welcome to [open a discussion](https://github.com/maxcountryman/axum-messages/discussions/new?category=q-a) and ask additional questions you might have. 105 | 106 | ## 👯 Contributing 107 | 108 | We appreciate all kinds of contributions, thank you! 109 | 110 | [basic-example]: https://github.com/maxcountryman/axum-messages/tree/main/examples/basic.rs 111 | [examples]: https://github.com/maxcountryman/axum-messages/tree/main/examples 112 | -------------------------------------------------------------------------------- /examples/basic.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | 3 | use axum::{ 4 | response::{IntoResponse, Redirect}, 5 | routing::get, 6 | Router, 7 | }; 8 | use axum_messages::{Messages, MessagesManagerLayer}; 9 | use tower_sessions::{MemoryStore, SessionManagerLayer}; 10 | 11 | async fn set_messages_handler(messages: Messages) -> impl IntoResponse { 12 | messages 13 | .info("Hello, world!") 14 | .debug("This is a debug message."); 15 | 16 | Redirect::to("/read-messages") 17 | } 18 | 19 | async fn read_messages_handler(messages: Messages) -> impl IntoResponse { 20 | let messages = messages 21 | .into_iter() 22 | .map(|message| format!("{}: {}", message.level, message)) 23 | .collect::>() 24 | .join(", "); 25 | 26 | if messages.is_empty() { 27 | "No messages yet!".to_string() 28 | } else { 29 | messages 30 | } 31 | } 32 | 33 | #[tokio::main] 34 | async fn main() { 35 | let session_store = MemoryStore::default(); 36 | let session_layer = SessionManagerLayer::new(session_store).with_secure(false); 37 | 38 | let app = Router::new() 39 | .route("/", get(set_messages_handler)) 40 | .route("/read-messages", get(read_messages_handler)) 41 | .layer(MessagesManagerLayer) 42 | .layer(session_layer); 43 | 44 | let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); 45 | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); 46 | axum::serve(listener, app.into_make_service()) 47 | .await 48 | .unwrap(); 49 | } 50 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | format_code_in_doc_comments = true 2 | format_strings = true 3 | imports_granularity = "Crate" 4 | group_imports = "StdExternalCrate" 5 | wrap_comments = true 6 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate provides one-time notification messages, or flash messages, for 2 | //! `axum` applications. 3 | //! 4 | //! # Example 5 | //! 6 | //! ```rust,no_run 7 | //! use std::net::SocketAddr; 8 | //! 9 | //! use axum::{ 10 | //! response::{IntoResponse, Redirect}, 11 | //! routing::get, 12 | //! Router, 13 | //! }; 14 | //! use axum_messages::{Messages, MessagesManagerLayer}; 15 | //! use tower_sessions::{MemoryStore, SessionManagerLayer}; 16 | //! 17 | //! async fn set_messages_handler(messages: Messages) -> impl IntoResponse { 18 | //! messages 19 | //! .info("Hello, world!") 20 | //! .debug("This is a debug message."); 21 | //! 22 | //! Redirect::to("/read-messages") 23 | //! } 24 | //! 25 | //! async fn read_messages_handler(messages: Messages) -> impl IntoResponse { 26 | //! let messages = messages 27 | //! .into_iter() 28 | //! .map(|message| format!("{}: {}", message.level, message)) 29 | //! .collect::>() 30 | //! .join(", "); 31 | //! 32 | //! if messages.is_empty() { 33 | //! "No messages yet!".to_string() 34 | //! } else { 35 | //! messages 36 | //! } 37 | //! } 38 | //! 39 | //! #[tokio::main] 40 | //! async fn main() { 41 | //! let session_store = MemoryStore::default(); 42 | //! let session_layer = SessionManagerLayer::new(session_store).with_secure(false); 43 | //! 44 | //! let app = Router::new() 45 | //! .route("/", get(set_messages_handler)) 46 | //! .route("/read-messages", get(read_messages_handler)) 47 | //! .layer(MessagesManagerLayer) 48 | //! .layer(session_layer); 49 | //! 50 | //! let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); 51 | //! let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); 52 | //! axum::serve(listener, app.into_make_service()) 53 | //! .await 54 | //! .unwrap(); 55 | //! } 56 | //! ``` 57 | //! 58 | //! # Design 59 | //! 60 | //! This library, while compact and straightforward, benefits from an 61 | //! understanding of its design and its integration with core web application 62 | //! fundamentals like HTTP. 63 | //! 64 | //! ## How Messages are Managed 65 | //! 66 | //! Messages in this library are written during one HTTP request and are 67 | //! accessible only in subsequent requests. This lifecycle management is handled 68 | //! transparently by the library, ensuring simplicity for the consuming 69 | //! application. 70 | //! 71 | //! At first glance, this approach might seem to limit functionality: if 72 | //! messages are delayed until the next request, how does this not result in 73 | //! lost presentation state during user interactions? This is where the state 74 | //! management capabilities of HTTP sessions come into play. 75 | //! 76 | //! ## Use of Sessions for State Management 77 | //! 78 | //! HTTP is a stateless protocol, but managing state over HTTP is a long-solved 79 | //! problem with various effective solutions, sessions being one of the most 80 | //! prominent. This library leverages sessions to maintain state, allowing 81 | //! messages to persist between requests without affecting the continuity of the 82 | //! user experience. 83 | //! 84 | //! Sessions can handle complex state scenarios, and when used in conjunction 85 | //! with HTTP, they provide a robust foundation for modern, feature-rich 86 | //! web applications. This library slots naturally into this well-established 87 | //! paradigm by serializing state to and from the session. 88 | //! 89 | //! ## Practical Example: Managing User Registration State 90 | //! 91 | //! To better illustrate the concept, consider an application that uses 92 | //! `axum-messages` with an HTML user registration form containing inputs for 93 | //! the user's name, desired username, and password. 94 | //! 95 | //! - **GET Request**: When users navigate to `/registration`, the server 96 | //! renders the form. 97 | //! - **POST Request**: Users submit the form, which is then sent to 98 | //! `/registration` where the server processes the data. 99 | //! 100 | //! During form processing, if the desired username is unavailable, the server 101 | //! sets a flash message indicating the issue and redirects back to the 102 | //! `/registration` URL. However, instead of presenting a blank form upon 103 | //! redirection, the server can utilize the session to repopulate the form 104 | //! fields with previously submitted data (except for sensitive fields like 105 | //! passwords). This not only enhances user experience by reducing the need to 106 | //! re-enter information but also demonstrates efficient state management using 107 | //! sessions. 108 | //! 109 | //! By integrating messages and form data into sessions, we facilitate a 110 | //! smoother and more intuitive interaction flow, illustrating the powerful 111 | //! synergy between state management via sessions and HTTP's stateless nature. 112 | #![warn( 113 | clippy::all, 114 | nonstandard_style, 115 | future_incompatible, 116 | missing_debug_implementations 117 | )] 118 | #![deny(missing_docs)] 119 | #![forbid(unsafe_code)] 120 | 121 | use core::fmt; 122 | use std::{ 123 | collections::{HashMap, VecDeque}, 124 | future::Future, 125 | pin::Pin, 126 | sync::{ 127 | atomic::{self, AtomicBool}, 128 | Arc, 129 | }, 130 | task::{Context, Poll}, 131 | }; 132 | 133 | use axum_core::{ 134 | extract::{FromRequestParts, Request}, 135 | response::Response, 136 | }; 137 | use http::{request::Parts, StatusCode}; 138 | use parking_lot::Mutex; 139 | use serde::{Deserialize, Serialize}; 140 | use tower::{Layer, Service}; 141 | use tower_sessions_core::{session, Session}; 142 | 143 | // N.B.: Code structure directly borrowed from `axum-flash`: https://github.com/davidpdrsn/axum-flash/blob/5e8b2bded97fd10bb275d5bc66f4d020dec465b9/src/lib.rs 144 | 145 | /// Mapping of key-value pairs which provide additional context to the message. 146 | pub type Metadata = HashMap; 147 | 148 | /// Container for a message which provides a level and message content. 149 | #[derive(Debug, Clone, Serialize, Deserialize)] 150 | pub struct Message { 151 | /// Message level, i.e. `Level`. 152 | #[serde(rename = "l")] 153 | pub level: Level, 154 | 155 | /// The message itself. 156 | #[serde(rename = "m")] 157 | pub message: String, 158 | 159 | /// Additional context related to the message. 160 | #[serde(rename = "d")] 161 | pub metadata: Option, 162 | } 163 | 164 | impl fmt::Display for Message { 165 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 166 | f.write_str(&self.message) 167 | } 168 | } 169 | 170 | type MessageQueue = VecDeque; 171 | 172 | /// Enumeration of message levels. 173 | /// 174 | /// This folllows directly from the [Django 175 | /// implementation][django-message-levels]. 176 | /// 177 | /// [django-message-levels]: https://docs.djangoproject.com/en/5.0/ref/contrib/messages/#message-levels 178 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] 179 | pub enum Level { 180 | /// Development-related messages that will be ignored (or removed) in a 181 | /// production deployment. 182 | Debug = 0, 183 | 184 | /// Informational messages for the user. 185 | Info = 1, 186 | 187 | /// An action was successful, e.g. “Your profile was updated successfully”. 188 | Success = 2, 189 | 190 | /// A failure did not occur but may be imminent. 191 | Warning = 3, 192 | 193 | /// An action was not successful or some other failure occurred. 194 | Error = 4, 195 | } 196 | 197 | impl fmt::Display for Level { 198 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 199 | f.write_str(match self { 200 | Self::Debug => "Debug", 201 | Self::Info => "Info", 202 | Self::Success => "Success", 203 | Self::Warning => "Warning", 204 | Self::Error => "Error", 205 | }) 206 | } 207 | } 208 | 209 | #[derive(Debug, Clone, Default, Deserialize, Serialize)] 210 | struct Data { 211 | pending_messages: MessageQueue, 212 | messages: MessageQueue, 213 | } 214 | 215 | /// An extractor which holds the state of messages, using the session to ensure 216 | /// messages are persisted between requests. 217 | #[derive(Debug, Clone)] 218 | pub struct Messages { 219 | session: Session, 220 | data: Arc>, 221 | is_modified: Arc, 222 | } 223 | 224 | impl Messages { 225 | const DATA_KEY: &'static str = "axum-messages.data"; 226 | 227 | fn new(session: Session, data: Data) -> Self { 228 | Self { 229 | session, 230 | data: Arc::new(Mutex::new(data)), 231 | is_modified: Arc::new(AtomicBool::new(false)), 232 | } 233 | } 234 | 235 | /// Push a `Debug` message. 236 | pub fn debug(self, message: impl Into) -> Self { 237 | self.push(Level::Debug, message, None) 238 | } 239 | 240 | /// Push an `Info` message. 241 | pub fn info(self, message: impl Into) -> Self { 242 | self.push(Level::Info, message, None) 243 | } 244 | 245 | /// Push a `Success` message. 246 | pub fn success(self, message: impl Into) -> Self { 247 | self.push(Level::Success, message, None) 248 | } 249 | 250 | /// Push a `Warning` message. 251 | pub fn warning(self, message: impl Into) -> Self { 252 | self.push(Level::Warning, message, None) 253 | } 254 | 255 | /// Push an `Error` message. 256 | pub fn error(self, message: impl Into) -> Self { 257 | self.push(Level::Error, message, None) 258 | } 259 | 260 | /// Push a `Debug` message with metadata. 261 | pub fn debug_with_metadata(self, message: impl Into, metadata: Metadata) -> Self { 262 | self.push(Level::Debug, message, Some(metadata)) 263 | } 264 | 265 | /// Push an `Info` message with metadata. 266 | pub fn info_with_metadata(self, message: impl Into, metadata: Metadata) -> Self { 267 | self.push(Level::Info, message, Some(metadata)) 268 | } 269 | 270 | /// Push a `Success` message with metadata. 271 | pub fn success_with_metadata(self, message: impl Into, metadata: Metadata) -> Self { 272 | self.push(Level::Success, message, Some(metadata)) 273 | } 274 | 275 | /// Push a `Warning` message with metadata. 276 | pub fn warning_with_metadata(self, message: impl Into, metadata: Metadata) -> Self { 277 | self.push(Level::Warning, message, Some(metadata)) 278 | } 279 | 280 | /// Push an `Error` message with metadata. 281 | pub fn error_with_metadata(self, message: impl Into, metadata: Metadata) -> Self { 282 | self.push(Level::Error, message, Some(metadata)) 283 | } 284 | 285 | /// Push a message with the given level. 286 | pub fn push( 287 | self, 288 | level: Level, 289 | message: impl Into, 290 | metadata: Option, 291 | ) -> Self { 292 | { 293 | let mut data = self.data.lock(); 294 | data.pending_messages.push_back(Message { 295 | message: message.into(), 296 | level, 297 | metadata, 298 | }); 299 | } 300 | 301 | if !self.is_modified() { 302 | self.is_modified.store(true, atomic::Ordering::Release); 303 | } 304 | 305 | self 306 | } 307 | 308 | /// Returns the number of messages to be read. 309 | pub fn len(&self) -> usize { 310 | self.data.lock().messages.len() 311 | } 312 | 313 | /// Returns `true` if there are no messages. 314 | pub fn is_empty(&self) -> bool { 315 | self.data.lock().messages.is_empty() 316 | } 317 | 318 | async fn save(self) -> Result { 319 | self.session 320 | .insert(Self::DATA_KEY, self.data.clone()) 321 | .await?; 322 | Ok(self) 323 | } 324 | 325 | fn load(self) -> Self { 326 | { 327 | // Load messages by taking them from the pending queue. 328 | let mut data = self.data.lock(); 329 | data.messages = std::mem::take(&mut data.pending_messages); 330 | } 331 | self 332 | } 333 | 334 | fn is_modified(&self) -> bool { 335 | self.is_modified.load(atomic::Ordering::Acquire) 336 | } 337 | } 338 | 339 | impl Iterator for Messages { 340 | type Item = Message; 341 | 342 | fn next(&mut self) -> Option { 343 | let mut data = self.data.lock(); 344 | let message = data.messages.pop_front(); 345 | if message.is_some() && !self.is_modified() { 346 | self.is_modified.store(true, atomic::Ordering::Release); 347 | } 348 | message 349 | } 350 | } 351 | 352 | impl FromRequestParts for Messages 353 | where 354 | S: Send + Sync, 355 | { 356 | type Rejection = (StatusCode, &'static str); 357 | 358 | async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { 359 | parts 360 | .extensions 361 | .get::() 362 | .cloned() 363 | .ok_or(( 364 | StatusCode::INTERNAL_SERVER_ERROR, 365 | "Could not extract messages. Is `MessagesManagerLayer` installed?", 366 | )) 367 | .map(Messages::load) 368 | } 369 | } 370 | 371 | /// MIddleware provider `Messages` as a request extension. 372 | #[derive(Debug, Clone)] 373 | pub struct MessagesManager { 374 | inner: S, 375 | } 376 | 377 | impl Service> for MessagesManager 378 | where 379 | S: Service, Response = Response> + Clone + Send + 'static, 380 | S::Future: Send + 'static, 381 | S::Error: Send, 382 | ReqBody: Send + 'static, 383 | ResBody: Default + Send, 384 | { 385 | type Response = S::Response; 386 | type Error = S::Error; 387 | type Future = Pin> + Send>>; 388 | 389 | #[inline] 390 | fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { 391 | self.inner.poll_ready(cx) 392 | } 393 | 394 | fn call(&mut self, mut req: Request) -> Self::Future { 395 | // Because the inner service can panic until ready, we need to ensure we only 396 | // use the ready service. 397 | // 398 | // See: https://docs.rs/tower/latest/tower/trait.Service.html#be-careful-when-cloning-inner-services 399 | let clone = self.inner.clone(); 400 | let mut inner = std::mem::replace(&mut self.inner, clone); 401 | 402 | Box::pin(async move { 403 | let Some(session) = req.extensions().get::().cloned() else { 404 | tracing::error!( 405 | "session not found in request extensions; do tower-sessions versions match?" 406 | ); 407 | let mut res = Response::default(); 408 | *res.status_mut() = http::StatusCode::INTERNAL_SERVER_ERROR; 409 | return Ok(res); 410 | }; 411 | 412 | let data = match session.get(Messages::DATA_KEY).await { 413 | Ok(Some(data)) => data, 414 | Ok(None) => Data::default(), 415 | Err(err) => { 416 | tracing::error!(err = %err, "could not load messages data"); 417 | let mut res = Response::default(); 418 | *res.status_mut() = http::StatusCode::INTERNAL_SERVER_ERROR; 419 | return Ok(res); 420 | } 421 | }; 422 | 423 | let messages = Messages::new(session, data); 424 | req.extensions_mut().insert(messages.clone()); 425 | 426 | let res = inner.call(req).await; 427 | 428 | if messages.is_modified() { 429 | if let Err(err) = messages.save().await { 430 | tracing::error!(err = %err, "could not save messages data"); 431 | let mut res = Response::default(); 432 | *res.status_mut() = http::StatusCode::INTERNAL_SERVER_ERROR; 433 | return Ok(res); 434 | } 435 | }; 436 | 437 | res 438 | }) 439 | } 440 | } 441 | 442 | /// Layer for `MessagesManager`. 443 | #[derive(Debug, Clone)] 444 | pub struct MessagesManagerLayer; 445 | 446 | impl Layer for MessagesManagerLayer { 447 | type Service = MessagesManager; 448 | 449 | fn layer(&self, inner: S) -> Self::Service { 450 | MessagesManager { inner } 451 | } 452 | } 453 | 454 | #[cfg(test)] 455 | mod tests { 456 | use axum::{response::Redirect, routing::get, Router}; 457 | use axum_core::{body::Body, response::IntoResponse}; 458 | use http::header; 459 | use http_body_util::BodyExt; 460 | use tower::ServiceExt; 461 | use tower_sessions::{MemoryStore, SessionManagerLayer}; 462 | 463 | use super::*; 464 | 465 | #[tokio::test] 466 | async fn basic() { 467 | let session_store = MemoryStore::default(); 468 | let session_layer = SessionManagerLayer::new(session_store).with_secure(false); 469 | 470 | let app = Router::new() 471 | .route("/", get(root)) 472 | .route("/set-message", get(set_message)) 473 | .layer(MessagesManagerLayer) 474 | .layer(session_layer); 475 | 476 | async fn root(messages: Messages) -> impl IntoResponse { 477 | messages 478 | .into_iter() 479 | .map(|message| format!("{}: {} {:?}", message.level, message, message.metadata)) 480 | .collect::>() 481 | .join(", ") 482 | } 483 | 484 | #[axum::debug_handler] 485 | async fn set_message(messages: Messages) -> impl IntoResponse { 486 | let mut metadata = Metadata::default(); 487 | metadata.insert("foo".to_string(), "bar".into()); 488 | 489 | messages 490 | .debug_with_metadata("Hello, world!", metadata) 491 | .info("This is an info message."); 492 | Redirect::to("/") 493 | } 494 | 495 | let request = Request::builder() 496 | .uri("/set-message") 497 | .body(Body::empty()) 498 | .unwrap(); 499 | let mut response = app.clone().oneshot(request).await.unwrap(); 500 | assert!(response.status().is_redirection()); 501 | let cookie = response.headers_mut().remove(header::SET_COOKIE).unwrap(); 502 | 503 | let request = Request::builder() 504 | .uri("/") 505 | .header(header::COOKIE, cookie) 506 | .body(Body::empty()) 507 | .unwrap(); 508 | let response = app.oneshot(request).await.unwrap(); 509 | 510 | let bytes = response.into_body().collect().await.unwrap().to_bytes(); 511 | let body = String::from_utf8(bytes.to_vec()).unwrap(); 512 | assert_eq!( 513 | body, 514 | "Debug: Hello, world! Some({\"foo\": String(\"bar\")}), Info: This is an info \ 515 | message. None" 516 | ); 517 | } 518 | } 519 | --------------------------------------------------------------------------------