Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6ea66e6
fix: surface storage serialization error
fa-sharp Sep 3, 2025
4eb9ebc
preliminary identifier implementation for memory storage
fa-sharp Sep 4, 2025
846e5f0
refactor and more tests
fa-sharp Sep 4, 2025
912a85e
perf: remove unneeded Arc
fa-sharp Sep 4, 2025
b22645a
docs: update docs
fa-sharp Sep 4, 2025
9b76881
perf: unnecessary cloning for hashmap data
fa-sharp Sep 4, 2025
68accfc
feat: add identifier name
fa-sharp Sep 4, 2025
5e44d49
feat: add indexing support for sqlx postgres
fa-sharp Sep 5, 2025
a6b97c7
fix: hashmap support and doc fixes
fa-sharp Sep 5, 2025
0af7b55
renaming for consistency
fa-sharp Sep 5, 2025
fbf4086
storage: clean up expired sessions for sqlx postgres
fa-sharp Sep 5, 2025
abad2f5
storage: return number of invalidated sessions
fa-sharp Sep 5, 2025
a35de58
tests for indexed storage operations
fa-sharp Sep 6, 2025
b5dd901
fix docs
fa-sharp Sep 6, 2025
6a7bc95
add fred.rs with indexing support
fa-sharp Sep 7, 2025
ef4f153
organize fred.rs module
fa-sharp Sep 7, 2025
f2f880a
rename for clarity
fa-sharp Sep 7, 2025
be019cb
add stable (and customizable) expiration for fred.rs session index
fa-sharp Sep 7, 2025
13aed8b
auto clean up stale sessions in fred.rs session index
fa-sharp Sep 7, 2025
e521169
Update storage_indexed.rs
fa-sharp Sep 7, 2025
5d35a23
Update storage_indexed.rs
fa-sharp Sep 7, 2025
720f6f4
fix docs
fa-sharp Sep 7, 2025
0aeb83f
also return ttl when retrieving all user sessions
fa-sharp Sep 7, 2025
52b5fa4
doc tweaks
fa-sharp Sep 7, 2025
8dcc11b
add debug logs for saving and deleting session
fa-sharp Sep 7, 2025
e4db35f
show session retrieval errors as info level logs
fa-sharp Sep 7, 2025
dee436d
re-organize modules
fa-sharp Sep 7, 2025
286be0a
add interface for hashmap structures
fa-sharp Sep 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ rustdoc-args = ["--cfg", "docsrs"]
fred = { version = "10.1", optional = true, default-features = false, features = [
"i-keys",
"i-hashes",
"i-sets",
] }
rand = "0.8"
retainer = "0.3"
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ Add to your `Cargo.toml`:

```toml
[dependencies]
...
rocket = "0.5"
rocket-flex-session = { version = "0.1" }
```
Expand Down
40 changes: 40 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//! Error types

/// Result type for session operations
pub type SessionResult<T> = Result<T, SessionError>;

/// Errors that can happen during session retrieval/handling
#[derive(Debug, thiserror::Error)]
pub enum SessionError {
/// There was no session cookie, or decryption of the cookie failed
#[error("No session cookie")]
NoSessionCookie,
/// Session wasn't found in storage
#[error("Session not found")]
NotFound,
/// Session was found but it was expired
#[error("Session expired")]
Expired,
/// Error serializing or deserializing the session data
#[error("Failed to serialize/deserialize session: {0}")]
Serialization(Box<dyn std::error::Error + Send + Sync>),
/// An indexing operation failed because the storage provider doesn't
/// implement [SessionStorageIndexed](crate::storage::SessionStorageIndexed)
#[error("Storage doesn't support indexing")]
NonIndexedStorage,
/// A generic error from the storage backend. This error type can be
/// used when implementing a custom session storage.
#[error("Storage backend error: {0}")]
Backend(Box<dyn std::error::Error + Send + Sync>),
/// Error occurred while setting up or tearing down the session storage
#[error("Error during storage setup or teardown: {0}")]
SetupTeardown(String),

#[cfg(feature = "redis_fred")]
#[error("fred.rs client error: {0}")]
RedisFredError(#[from] fred::error::Error),

#[cfg(feature = "sqlx_postgres")]
#[error("Sqlx error: {0}")]
SqlxError(#[from] sqlx::Error),
}
143 changes: 133 additions & 10 deletions src/fairing.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,129 @@
use std::{
marker::{Send, Sync},
sync::Arc,
sync::{Arc, Mutex},
};

use rocket::{fairing::Fairing, Build, Orbit, Request, Response, Rocket};

use crate::{guard::LocalCachedSession, RocketFlexSession};
use crate::{
guard::LocalCachedSession,
storage::{memory::MemoryStorage, SessionStorage},
RocketFlexSessionOptions,
};

/**
A Rocket fairing that enables sessions.

# Type Parameters
* `T` - The type of your session data. Must be thread-safe and
implement Clone. The storage provider you use may have additional
trait bounds as well.

# Example
```rust
use rocket_flex_session::{RocketFlexSession, storage::cookie::CookieStorage};
use rocket::time::Duration;
use rocket::serde::{Deserialize, Serialize};

#[derive(Clone, Serialize, Deserialize)]
struct MySession {
user_id: String,
role: String,
}

#[rocket::launch]
fn rocket() -> _ {
// Use default settings with in-memory storage
let session_fairing = RocketFlexSession::<MySession>::default();

// Or customize settings with the builder
let custom_session = RocketFlexSession::<MySession>::builder()
.storage(CookieStorage::default()) // or a custom storage provider
.with_options(|opt| {
opt.cookie_name = "my_cookie".to_string();
opt.path = "/app".to_string();
opt.max_age = 7 * 24 * 60 * 60; // 7 days
})
.build();

rocket::build()
.attach(session_fairing)
// ... other configuration ...
}
```
*/
#[derive(Clone)]
pub struct RocketFlexSession<T> {
pub(crate) options: RocketFlexSessionOptions,
pub(crate) storage: Arc<dyn SessionStorage<T>>,
}
impl<T> RocketFlexSession<T>
where
T: Send + Sync + Clone + 'static,
{
/// Build a session configuration
pub fn builder() -> RocketFlexSessionBuilder<T> {
RocketFlexSessionBuilder::default()
}
}
impl<T> Default for RocketFlexSession<T>
where
T: Send + Sync + Clone + 'static,
{
fn default() -> Self {
Self {
options: Default::default(),
storage: Arc::new(MemoryStorage::default()),
}
}
}

/// Builder to configure the [RocketFlexSession] fairing
pub struct RocketFlexSessionBuilder<T>
where
T: Send + Sync + Clone + 'static,
{
fairing: RocketFlexSession<T>,
}
impl<T> Default for RocketFlexSessionBuilder<T>
where
T: Send + Sync + Clone + 'static,
{
fn default() -> Self {
Self {
fairing: Default::default(),
}
}
}
impl<T> RocketFlexSessionBuilder<T>
where
T: Send + Sync + Clone + 'static,
{
/// Set the session options via a closure. If you're using a cookie-based storage
/// provider, make sure to set the corresponding cookie settings
/// in the storage configuration as well.
pub fn with_options<OptionsFn>(&mut self, options_fn: OptionsFn) -> &mut Self
where
OptionsFn: FnOnce(&mut RocketFlexSessionOptions),
{
options_fn(&mut self.fairing.options);
self
}

/// Set the session storage provider
pub fn storage<S>(&mut self, storage: S) -> &mut Self
where
S: SessionStorage<T> + 'static,
{
self.fairing.storage = Arc::new(storage);
self
}

/// Build the fairing
pub fn build(&self) -> RocketFlexSession<T> {
self.fairing.clone()
}
}

#[rocket::async_trait]
impl<T> Fairing for RocketFlexSession<T>
Expand Down Expand Up @@ -34,32 +152,37 @@ where

async fn on_response<'r>(&self, req: &'r Request<'_>, _res: &mut Response<'r>) {
// Get session data from request local cache, or generate a default empty one
let (session_inner, _): &LocalCachedSession<T> = req.local_cache(|| (Arc::default(), None));
let (session_inner, _): &LocalCachedSession<T> =
req.local_cache(|| (Mutex::default(), None));

// Take inner session data
let (updated, deleted) = session_inner.lock().unwrap().take_for_storage();

// Handle deleted session
if let Some(deleted_id) = deleted {
let delete_result = self.storage.delete(&deleted_id).await;
if let Err(e) = delete_result {
rocket::error!("Error while deleting session '{}': {}", deleted_id, e);
rocket::debug!("Found deleted session. Deleting session '{deleted_id}'...");
if let Err(e) = self.storage.delete(&deleted_id, req.cookies()).await {
rocket::warn!("Error while deleting session '{deleted_id}': {e}");
} else {
rocket::debug!("Deleted session '{deleted_id}' successfully");
}
}

// Handle updated session
if let Some((id, pending_data, ttl)) = updated {
let save_result = self.storage.save(&id, pending_data, ttl).await;
if let Err(e) = save_result {
rocket::error!("Error while saving session '{}': {}", &id, e);
rocket::debug!("Found updated session. Saving session '{id}'...");
if let Err(e) = self.storage.save(&id, pending_data, ttl).await {
rocket::error!("Error while saving session '{id}': {e}");
} else {
rocket::debug!("Saved session '{id}' successfully");
}
}
}

async fn on_shutdown(&self, _rocket: &Rocket<Orbit>) {
rocket::debug!("Shutting down session resources...");
if let Err(e) = self.storage.shutdown().await {
rocket::warn!("Error during session storage shutdown: {}", e);
rocket::warn!("Error during session storage shutdown: {e}");
}
}
}
48 changes: 21 additions & 27 deletions src/guard.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
use std::{
any::type_name,
sync::{Arc, Mutex},
};
use std::{any::type_name, sync::Mutex};

use rocket::{
http::{Cookie, CookieJar},
http::CookieJar,
request::{FromRequest, Outcome},
Request,
};

use crate::{
session::Session,
session_inner::SessionInner,
storage::interface::{SessionError, SessionStorage},
RocketFlexSession,
error::SessionError, session_inner::SessionInner, storage::SessionStorage, RocketFlexSession,
Session,
};

/// Type of the cached inner session data in Rocket's request local cache
pub(crate) type LocalCachedSession<T> = (Arc<Mutex<SessionInner<T>>>, Option<SessionError>);
pub(crate) type LocalCachedSession<T> = (Mutex<SessionInner<T>>, Option<SessionError>);

#[rocket::async_trait]
impl<'r, T> FromRequest<'r> for Session<'r, T>
Expand All @@ -31,24 +26,24 @@ where
let fairing = get_fairing::<T>(req.rocket());
let cookie_jar = req.cookies();

// Use rocket's local cache so that the session data is only fetched once per request
let (cached_inner, session_error): &LocalCachedSession<T> = req
.local_cache_async(async {
let session_cookie = cookie_jar.get_private(&fairing.options.cookie_name);
get_session_data(
session_cookie,
fetch_session_data(
cookie_jar,
&fairing.options.cookie_name,
fairing
.options
.rolling
.then(|| fairing.options.ttl.unwrap_or(fairing.options.max_age)),
fairing.storage.as_ref(),
cookie_jar,
)
.await
})
.await;

Outcome::Success(Session::new(
cached_inner.clone(),
cached_inner,
session_error.as_ref(),
cookie_jar,
&fairing.options,
Expand All @@ -71,33 +66,32 @@ where
})
}

/// Get session data from storage
/// Fetch session data from storage
#[inline(always)]
async fn get_session_data<'r, T: Send + Sync + Clone>(
session_cookie: Option<Cookie<'static>>,
async fn fetch_session_data<'r, T: Send + Sync + Clone>(
cookie_jar: &'r CookieJar<'_>,
cookie_name: &str,
rolling_ttl: Option<u32>,
storage: &'r dyn SessionStorage<T>,
cookie_jar: &'r CookieJar<'_>,
) -> LocalCachedSession<T> {
let session_cookie = cookie_jar.get_private(cookie_name);
if let Some(cookie) = session_cookie {
let id = cookie.value();
rocket::debug!("Got session id '{}' from cookie. Retrieving session...", id);
rocket::debug!("Got session id '{id}' from cookie. Retrieving session...");
match storage.load(id, rolling_ttl, cookie_jar).await {
Ok((data, ttl)) => {
rocket::debug!("Session found. Creating existing session...");
(
Arc::new(Mutex::new(SessionInner::new_existing(id, data, ttl))),
None,
)
let session_inner = SessionInner::new_existing(id, data, ttl);
(Mutex::new(session_inner), None)
}
Err(e) => {
rocket::debug!("Error from session storage, creating empty session: {}", e);
(Arc::default(), Some(e))
rocket::info!("Error from session storage, creating empty session: {e}");
(Mutex::default(), Some(e))
}
}
} else {
rocket::debug!("No valid session cookie found. Creating empty session...");
(Arc::default(), Some(SessionError::NoSessionCookie))
(Mutex::default(), Some(SessionError::NoSessionCookie))
}
}

Expand Down
Loading