cja/server/session.rs
1use axum::extract::FromRequestParts;
2use http::StatusCode;
3use serde::{Deserialize, Serialize};
4
5use crate::app_state::AppState;
6
7use super::cookies::CookieJar;
8
9/// Core session data that all sessions must contain.
10///
11/// This struct represents the minimal session information stored in the database.
12/// Custom session implementations should include this as a field and add any
13/// additional data needed by the application.
14///
15/// # Example
16///
17/// ```rust
18/// use cja::server::session::CJASession;
19///
20/// #[derive(Debug, Clone)]
21/// struct MySession {
22/// // Required: include the core session data
23/// inner: CJASession,
24///
25/// // Add custom fields for your application
26/// user_id: Option<i32>,
27/// theme: String,
28/// last_page_visited: Option<String>,
29/// }
30/// ```
31#[derive(Debug, Clone, Deserialize, Serialize, sqlx::FromRow)]
32pub struct CJASession {
33 /// Unique identifier for this session
34 pub session_id: uuid::Uuid,
35 /// Timestamp of last activity (updated on each request)
36 pub updated_at: chrono::DateTime<chrono::Utc>,
37 /// Timestamp when the session was first created
38 pub created_at: chrono::DateTime<chrono::Utc>,
39}
40
41impl CJASession {
42 fn save_cookie<A: AppState>(&self, jar: &CookieJar<A>) {
43 let cookie = tower_cookies::Cookie::build(("session_id", self.session_id.to_string()))
44 .path("/")
45 .http_only(true)
46 .secure(true)
47 .expires(None);
48
49 jar.add(cookie.into());
50 }
51}
52
53// #[async_trait::async_trait]
54// impl<AppState: AS> FromRequestParts<AppState> for Session {
55// type Rejection = Redirect;
56
57// async fn from_request_parts(
58// parts: &mut http::request::Parts,
59// state: &AppState,
60// ) -> Result<Self, Self::Rejection> {
61// let cookies = CookieJar::from_request_parts(parts, state)
62// .await
63// .map_err(|_| Redirect::temporary("/login"))?;
64
65// let SessionCookie(session_id) = SessionCookie::from_request_parts(parts, state)
66// .await
67// .map_err(|_| Redirect::temporary("/login"))?;
68
69// let session_cookie = cookies.get("session_id");
70// let session = if let Some(session_cookie) = session_cookie {
71// let session_id = session_cookie.value().to_string();
72// let Ok(session_id) = uuid::Uuid::parse_str(&session_id) else {
73// tracing::error!("Failed to parse session id: {session_id}");
74
75// Err(Redirect::temporary("/login"))?
76// };
77
78// sqlx::query_as!(
79// Session,
80// r"
81// SELECT session_id, updated_at, created_at
82// FROM Sessions
83// WHERE session_id = $1
84// ",
85// session_id
86// )
87// .fetch_one(state.db())
88// .await
89// .map_err(|e| {
90// tracing::error!("Failed to fetch session: {e}");
91
92// Redirect::temporary("/login")
93// })?
94// } else {
95// Session::create(state, &cookies)
96// .await
97// .map_err(|_| Redirect::temporary("/login"))?
98// };
99
100// Ok(session)
101// }
102// }
103
104// impl Session {
105// pub async fn create<AppState: AS>(
106// app_state: &AppState,
107// jar: &CookieJar<AppState>,
108// ) -> color_eyre::Result<Self> {
109// let session = sqlx::query_as!(
110// Session,
111// r"
112// INSERT INTO Sessions DEFAULT VALUES
113// RETURNING session_id, updated_at, created_at
114// ",
115// )
116// .fetch_one(app_state.db())
117// .await?;
118
119// let session_cookie =
120// tower_cookies::Cookie::build(("session_id", session.session_id.to_string()))
121// .path("/")
122// .http_only(true)
123// .secure(true)
124// .expires(None);
125// jar.add(session_cookie.into());
126
127// Ok(session)
128// }
129// }
130
131/// A trait for implementing custom session types that integrate with the framework's session management.
132///
133/// Sessions are automatically created when needed and persisted across requests using secure cookies.
134/// Your session type must include the `CJASession` as an inner field and can add any additional
135/// fields needed by your application.
136///
137/// # Example
138///
139/// ```rust,no_run
140/// use cja::server::session::{AppSession, CJASession};
141/// use serde::{Serialize, Deserialize};
142///
143/// #[derive(Debug, Clone)]
144/// struct UserSession {
145/// inner: CJASession,
146/// user_id: Option<i32>,
147/// preferences: serde_json::Value,
148/// }
149///
150/// #[async_trait::async_trait]
151/// impl AppSession for UserSession {
152/// async fn from_db(pool: &sqlx::PgPool, session_id: uuid::Uuid) -> cja::Result<Self> {
153/// // Query your session data including any custom fields
154/// // In a real app, you'd have extended the sessions table with these columns
155/// let row = sqlx::query_as::<_, CJASession>(
156/// "SELECT session_id, created_at, updated_at FROM sessions WHERE session_id = $1"
157/// )
158/// .bind(session_id)
159/// .fetch_one(pool)
160/// .await?;
161///
162/// // For this example, we're just using default values for custom fields
163/// // In a real app, you'd query your extended session data here
164/// Ok(Self {
165/// inner: row,
166/// user_id: None,
167/// preferences: serde_json::json!({}),
168/// })
169/// }
170///
171/// async fn create(pool: &sqlx::PgPool) -> cja::Result<Self> {
172/// let row = sqlx::query_as::<_, CJASession>(
173/// "INSERT INTO sessions DEFAULT VALUES RETURNING session_id, created_at, updated_at"
174/// )
175/// .fetch_one(pool)
176/// .await?;
177///
178/// Ok(Self {
179/// inner: row,
180/// user_id: None,
181/// preferences: serde_json::json!({}),
182/// })
183/// }
184///
185/// fn from_inner(inner: CJASession) -> Self {
186/// Self {
187/// inner,
188/// user_id: None,
189/// preferences: serde_json::json!({}),
190/// }
191/// }
192///
193/// fn inner(&self) -> &CJASession {
194/// &self.inner
195/// }
196/// }
197/// ```
198///
199/// # Using Sessions in Handlers
200///
201/// ```rust
202/// use axum::response::IntoResponse;
203/// use cja::server::session::Session;
204/// # use cja::server::session::{AppSession, CJASession};
205/// #
206/// # #[derive(Debug, Clone)]
207/// # struct UserSession { inner: CJASession, user_id: Option<i32> }
208/// #
209/// # #[async_trait::async_trait]
210/// # impl AppSession for UserSession {
211/// # async fn from_db(_: &sqlx::PgPool, _: uuid::Uuid) -> cja::Result<Self> { todo!() }
212/// # async fn create(_: &sqlx::PgPool) -> cja::Result<Self> { todo!() }
213/// # fn from_inner(inner: CJASession) -> Self { Self { inner, user_id: None } }
214/// # fn inner(&self) -> &CJASession { &self.inner }
215/// # }
216///
217/// async fn handler(
218/// Session(session): Session<UserSession>
219/// ) -> impl IntoResponse {
220/// format!("Session ID: {}, User: {:?}",
221/// session.session_id(),
222/// session.user_id)
223/// }
224/// ```
225#[async_trait::async_trait]
226pub trait AppSession: Sized {
227 /// Load a session from the database by its ID.
228 ///
229 /// This method should fetch the session record and any associated data
230 /// from your sessions table.
231 async fn from_db(pool: &sqlx::PgPool, session_id: uuid::Uuid) -> crate::Result<Self>;
232
233 /// Create a new session in the database.
234 ///
235 /// This method should insert a new session record with default values
236 /// and return the created session.
237 async fn create(pool: &sqlx::PgPool) -> crate::Result<Self>;
238
239 /// Create a session instance from the inner `CJASession`.
240 ///
241 /// This is used internally when reconstructing sessions. Custom fields
242 /// should be initialized with default values.
243 fn from_inner(inner: CJASession) -> Self;
244
245 /// Get a reference to the inner `CJASession`.
246 ///
247 /// This provides access to the core session fields like ID and timestamps.
248 fn inner(&self) -> &CJASession;
249
250 fn session_id(&self) -> &uuid::Uuid {
251 &self.inner().session_id
252 }
253
254 fn created_at(&self) -> &chrono::DateTime<chrono::Utc> {
255 &self.inner().created_at
256 }
257
258 fn updated_at(&self) -> &chrono::DateTime<chrono::Utc> {
259 &self.inner().updated_at
260 }
261}
262
263/// An Axum extractor that provides access to the current session.
264///
265/// This extractor automatically handles session creation and loading based on cookies.
266/// If no session exists, a new one is created automatically.
267///
268/// # Example
269///
270/// ```rust
271/// use axum::{Router, routing::get, response::IntoResponse};
272/// use cja::server::session::Session;
273/// # use cja::server::session::{AppSession, CJASession};
274/// #
275/// # #[derive(Debug, Clone)]
276/// # struct UserSession {
277/// # inner: CJASession,
278/// # user_id: Option<i32>,
279/// # login_count: i32,
280/// # }
281/// #
282/// # #[async_trait::async_trait]
283/// # impl AppSession for UserSession {
284/// # async fn from_db(_: &sqlx::PgPool, _: uuid::Uuid) -> cja::Result<Self> { todo!() }
285/// # async fn create(_: &sqlx::PgPool) -> cja::Result<Self> { todo!() }
286/// # fn from_inner(inner: CJASession) -> Self {
287/// # Self { inner, user_id: None, login_count: 0 }
288/// # }
289/// # fn inner(&self) -> &CJASession { &self.inner }
290/// # }
291///
292/// async fn profile_handler(
293/// Session(session): Session<UserSession>
294/// ) -> impl IntoResponse {
295/// match session.user_id {
296/// Some(id) => format!("Welcome back, user {}! Login count: {}", id, session.login_count),
297/// None => "Please log in".to_string(),
298/// }
299/// }
300///
301/// async fn login_handler(
302/// Session(mut session): Session<UserSession>,
303/// // ... other extractors for login data
304/// ) -> impl IntoResponse {
305/// // After successful authentication:
306/// session.user_id = Some(123);
307/// session.login_count += 1;
308/// // Don't forget to save the session!
309///
310/// "Logged in successfully"
311/// }
312///
313/// # #[derive(Clone)]
314/// # struct MyAppState;
315/// # impl cja::app_state::AppState for MyAppState {
316/// # fn version(&self) -> &str { "1.0.0" }
317/// # fn db(&self) -> &sqlx::PgPool { todo!() }
318/// # fn cookie_key(&self) -> &cja::server::cookies::CookieKey { todo!() }
319/// # }
320/// # fn app() -> Router {
321/// let app = Router::new()
322/// .route("/profile", get(profile_handler))
323/// .route("/login", get(login_handler))
324/// .with_state(MyAppState);
325/// # app
326/// # }
327/// ```
328#[derive(Clone)]
329pub struct Session<A: AppSession>(pub A);
330
331impl<A: AppState + Send + Sync, S: AppSession + Send + Sync> FromRequestParts<A> for Session<S> {
332 type Rejection = StatusCode;
333
334 async fn from_request_parts(
335 parts: &mut http::request::Parts,
336 state: &A,
337 ) -> Result<Self, Self::Rejection> {
338 async fn create_session<A: AppState, S: AppSession>(
339 state: &A,
340 cookies: &CookieJar<A>,
341 ) -> Result<Session<S>, StatusCode> {
342 let session = S::create(state.db())
343 .await
344 .map(Session)
345 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
346
347 session.0.inner().save_cookie(cookies);
348
349 Ok(session)
350 }
351
352 let cookies = CookieJar::from_request_parts(parts, state)
353 .await
354 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
355
356 let Some(session_cookie) = cookies.get("session_id") else {
357 return create_session(state, &cookies).await;
358 };
359
360 let session_id = session_cookie.value().to_string();
361 let Ok(session_id) = uuid::Uuid::parse_str(&session_id) else {
362 return create_session(state, &cookies).await;
363 };
364
365 let Ok(session) = S::from_db(state.db(), session_id).await.map(Session) else {
366 return create_session(state, &cookies).await;
367 };
368
369 Ok(session)
370 }
371}