reinhardt_middleware/session/value.rs
1//! Typed session-value extractors usable directly in handler signatures.
2//!
3//! Four flavours mirror the rest of the Reinhardt extractor surface:
4//!
5//! - [`SessionValue<T>`] reads the identity reference at `session["user_id"]`
6//! and deserialises it as `T`; 401 when the session or key is missing.
7//! - [`OptionalSessionValue<T>`] is the optional variant: any failure
8//! collapses to `OptionalSessionValue(None)` rather than propagating.
9//! - [`SessionValueNamed<K, T>`] reads a custom session key chosen at
10//! compile time via a marker type implementing [`SessionKey`].
11//! - [`OptionalSessionValueNamed<K, T>`] is the optional variant of
12//! [`SessionValueNamed<K, T>`]: a missing/unreadable value collapses to
13//! `None` instead of failing extraction.
14//!
15//! Each extractor is wired through both `Injectable` (for `#[inject]`
16//! parameters) **and** `FromRequest` (for `Path(...)`-style auto-extraction
17//! without the `#[inject]` attribute). Pick whichever ergonomics you
18//! prefer:
19//!
20//! ```rust,ignore
21//! use reinhardt::middleware::session::{OptionalSessionValue, SessionValue};
22//!
23//! // Auto-extraction (no `#[inject]`, matches `Path(...)` ergonomics).
24//! #[server_fn]
25//! pub async fn session_identity(
26//! SessionValue(user_id): SessionValue<i64>,
27//! ) -> Result<i64, ServerFnError> { Ok(user_id) }
28//!
29//! // Equivalent legacy form with `#[inject]`.
30//! #[server_fn]
31//! pub async fn session_identity(
32//! #[inject] SessionValue(user_id): SessionValue<i64>,
33//! ) -> Result<i64, ServerFnError> { Ok(user_id) }
34//! ```
35//!
36//! A stored identity is not proof of current authentication or authorization.
37//! Before granting access, resolve the account and validate its current active
38//! and privilege state, or consume an [`reinhardt_http::AuthState`] populated by
39//! middleware that performs that validation.
40//!
41//! See issue #4446 for the motivating discussion.
42
43use async_trait::async_trait;
44use reinhardt_di::params::{ParamContext, ParamError, ParamResult, extract::FromRequest};
45use reinhardt_di::{DiError, DiResult, Injectable, InjectionContext};
46use reinhardt_http::Request;
47use serde::de::DeserializeOwned;
48use std::fmt::{self, Debug};
49use std::marker::PhantomData;
50use std::ops::Deref;
51
52use super::data::{SessionData, USER_ID_SESSION_KEY};
53
54/// Marker trait identifying a session-storage key at the type level.
55///
56/// Implementors are zero-sized marker types similar to
57/// `reinhardt_di::params::CookieName` — define one type per logical key
58/// and reuse it across handlers:
59///
60/// ```rust,ignore
61/// use reinhardt::middleware::session::{SessionKey, SessionValueNamed};
62///
63/// pub struct TenantIdKey;
64/// impl SessionKey for TenantIdKey {
65/// const KEY: &'static str = "tenant_id";
66/// }
67///
68/// #[server_fn]
69/// pub async fn current_tenant(
70/// SessionValueNamed::<TenantIdKey, i64>(tenant_id): SessionValueNamed<TenantIdKey, i64>,
71/// ) -> Result<TenantInfo, ServerFnError> { /* ... */ }
72/// ```
73pub trait SessionKey: Send + Sync + 'static {
74 /// The session-store key whose value this marker maps to.
75 const KEY: &'static str;
76}
77
78/// Default marker pointing at [`USER_ID_SESSION_KEY`], which stores an identity
79/// reference and does not by itself establish current authentication.
80#[derive(Debug, Clone, Copy)]
81pub struct UserIdKey;
82
83impl SessionKey for UserIdKey {
84 const KEY: &'static str = USER_ID_SESSION_KEY;
85}
86
87/// Required typed session-value extractor.
88///
89/// Resolves the [`USER_ID_SESSION_KEY`] entry from the active
90/// [`SessionData`], deserialises it as `T`, and fails extraction when the
91/// key is missing or the value cannot be deserialised. Use this extractor
92/// to retrieve an identity reference. The absent case surfaces as HTTP 401 via
93/// `CoreError::Authentication`. This extractor does not load or validate the
94/// referenced account; authorization requires a separate current-account
95/// check or validated [`reinhardt_http::AuthState`].
96///
97/// # Usage
98///
99/// ```rust,ignore
100/// use reinhardt::middleware::session::SessionValue;
101///
102/// #[server_fn]
103/// pub async fn session_identity(
104/// SessionValue(user_id): SessionValue<i64>,
105/// ) -> Result<i64, ServerFnError> {
106/// // Resolve and validate the account before using this identity for access.
107/// Ok(user_id)
108/// }
109/// ```
110///
111/// Adding `#[inject]` continues to work for code that prefers explicit
112/// dependency markers (see the module-level docs).
113#[derive(Debug, Clone)]
114pub struct SessionValue<T>(pub T);
115
116/// Optional typed session-value extractor.
117///
118/// Identical to [`SessionValue<T>`] except extraction never fails: when
119/// the session is missing, expired, or carries no value at
120/// [`USER_ID_SESSION_KEY`], the extractor yields
121/// `OptionalSessionValue(None)`. Use this on handlers that may serve
122/// both anonymous and authenticated callers (a public "/current_user"
123/// endpoint, for instance).
124#[derive(Debug, Clone)]
125pub struct OptionalSessionValue<T>(pub Option<T>);
126
127/// Typed session-value extractor parameterised by a [`SessionKey`].
128///
129/// Generalises [`SessionValue<T>`] to keys other than
130/// [`USER_ID_SESSION_KEY`]. Construct one marker per logical key (see
131/// the [`SessionKey`] trait docs) and use the marker as the first type
132/// parameter:
133///
134/// ```rust,ignore
135/// use reinhardt::middleware::session::{SessionKey, SessionValueNamed};
136///
137/// pub struct TenantIdKey;
138/// impl SessionKey for TenantIdKey {
139/// const KEY: &'static str = "tenant_id";
140/// }
141///
142/// #[server_fn]
143/// pub async fn current_tenant(
144/// SessionValueNamed::<TenantIdKey, i64>(tenant_id): SessionValueNamed<TenantIdKey, i64>,
145/// ) -> Result<TenantInfo, ServerFnError> { /* ... */ }
146/// ```
147pub struct SessionValueNamed<K: SessionKey, T> {
148 value: T,
149 _phantom: PhantomData<fn() -> K>,
150}
151
152impl<K: SessionKey, T> SessionValueNamed<K, T> {
153 /// Construct a `SessionValueNamed` directly from a value. Primarily
154 /// useful in tests where extraction is bypassed.
155 pub fn new(value: T) -> Self {
156 Self {
157 value,
158 _phantom: PhantomData,
159 }
160 }
161
162 /// Unwrap the extractor and return the inner value.
163 pub fn into_inner(self) -> T {
164 self.value
165 }
166}
167
168impl<K: SessionKey, T> Deref for SessionValueNamed<K, T> {
169 type Target = T;
170
171 fn deref(&self) -> &Self::Target {
172 &self.value
173 }
174}
175
176impl<K: SessionKey, T: Debug> Debug for SessionValueNamed<K, T> {
177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178 f.debug_struct("SessionValueNamed")
179 .field("key", &K::KEY)
180 .field("value", &self.value)
181 .finish()
182 }
183}
184
185impl<K: SessionKey, T: Clone> Clone for SessionValueNamed<K, T> {
186 fn clone(&self) -> Self {
187 Self {
188 value: self.value.clone(),
189 _phantom: PhantomData,
190 }
191 }
192}
193
194/// Optional typed session-value extractor parameterised by a [`SessionKey`].
195///
196/// Generalises [`OptionalSessionValue<T>`] to keys other than
197/// [`USER_ID_SESSION_KEY`], mirroring the relationship between
198/// [`SessionValue<T>`] and [`SessionValueNamed<K, T>`]. Extraction never
199/// fails: when the session is missing, expired, or carries no value at
200/// `K::KEY`, the extractor yields `None` rather than propagating the
201/// underlying error. Use this on handlers that accept a custom session key
202/// and may serve both anonymous and authenticated callers.
203///
204/// ```rust,ignore
205/// use reinhardt::middleware::session::{OptionalSessionValueNamed, SessionKey};
206///
207/// pub struct TenantIdKey;
208/// impl SessionKey for TenantIdKey {
209/// const KEY: &'static str = "tenant_id";
210/// }
211///
212/// #[server_fn]
213/// pub async fn current_tenant_opt(
214/// extractor: OptionalSessionValueNamed<TenantIdKey, i64>,
215/// ) -> Result<Option<TenantInfo>, ServerFnError> {
216/// let tenant_id: Option<i64> = extractor.into_inner();
217/// /* ... */
218/// }
219/// ```
220pub struct OptionalSessionValueNamed<K: SessionKey, T> {
221 value: Option<T>,
222 _phantom: PhantomData<fn() -> K>,
223}
224
225impl<K: SessionKey, T> OptionalSessionValueNamed<K, T> {
226 /// Construct an `OptionalSessionValueNamed` directly from an
227 /// `Option<T>`. Primarily useful in tests where extraction is
228 /// bypassed.
229 pub fn new(value: Option<T>) -> Self {
230 Self {
231 value,
232 _phantom: PhantomData,
233 }
234 }
235
236 /// Unwrap the extractor and return the inner `Option<T>`.
237 pub fn into_inner(self) -> Option<T> {
238 self.value
239 }
240}
241
242impl<K: SessionKey, T> Deref for OptionalSessionValueNamed<K, T> {
243 type Target = Option<T>;
244
245 fn deref(&self) -> &Self::Target {
246 &self.value
247 }
248}
249
250impl<K: SessionKey, T: Debug> Debug for OptionalSessionValueNamed<K, T> {
251 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252 f.debug_struct("OptionalSessionValueNamed")
253 .field("key", &K::KEY)
254 .field("value", &self.value)
255 .finish()
256 }
257}
258
259impl<K: SessionKey, T: Clone> Clone for OptionalSessionValueNamed<K, T> {
260 fn clone(&self) -> Self {
261 Self {
262 value: self.value.clone(),
263 _phantom: PhantomData,
264 }
265 }
266}
267
268// ---------------------------------------------------------------------------
269// Internal helpers shared between `Injectable` and `FromRequest` impls.
270// ---------------------------------------------------------------------------
271
272/// Load the active `SessionData` via the standard `Injectable` path,
273/// then extract the value at `key` and deserialise it as `T`.
274async fn load_session_value_via_di<T>(ctx: &InjectionContext, key: &str) -> DiResult<T>
275where
276 T: DeserializeOwned + Send + Sync + 'static,
277{
278 let session = SessionData::inject(ctx).await?;
279 session.get::<T>(key).ok_or_else(|| {
280 DiError::Authentication(format!(
281 "SessionValue<{}>: no value stored under session key '{}'",
282 std::any::type_name::<T>(),
283 key,
284 ))
285 })
286}
287
288/// Reach the request-scoped `InjectionContext` and delegate to
289/// [`load_session_value_via_di`]. Wraps the resulting `DiError` into a
290/// `ParamError` so the handler macro can surface the right HTTP status.
291async fn load_session_value_via_request<T>(req: &Request, key: &str) -> ParamResult<T>
292where
293 T: DeserializeOwned + Send + Sync + 'static,
294{
295 let di_ctx = req.get_di_context::<InjectionContext>().ok_or_else(|| {
296 // Missing DI context is a server-side misconfiguration (the router
297 // was not wired with `.with_di_context()` or `SessionMiddleware`),
298 // not an unauthenticated request. Surface it as `Internal` so the
299 // handler returns HTTP 500 rather than masking it as a 401.
300 ParamError::Internal(
301 "SessionValue: DI context not available on the request. \
302 Ensure the router is configured with `.with_di_context()` and \
303 `SessionMiddleware` is installed in the middleware chain."
304 .to_string(),
305 )
306 })?;
307 load_session_value_via_di::<T>(&di_ctx, key)
308 .await
309 .map_err(di_error_to_param_error)
310}
311
312/// Project `DiError` into the matching `ParamError` variant. Only the
313/// variants that genuinely represent a missing or unauthenticated identity
314/// (`Authentication`, `NotFound`) collapse into `ParamError::Authentication`
315/// so they reach the response as HTTP 401 (see #4446 + `ParamError::Authentication`
316/// in `reinhardt-di`). Other variants describe infrastructure-level failures
317/// (DI scope corruption, provider errors, type mismatches, etc.) and are
318/// surfaced as `ParamError::Internal` so the handler returns HTTP 500 rather
319/// than masking a misconfiguration as a 401.
320fn di_error_to_param_error(err: DiError) -> ParamError {
321 match err {
322 DiError::Authentication(msg) | DiError::NotFound(msg) => ParamError::Authentication(msg),
323 other => ParamError::Internal(other.to_string()),
324 }
325}
326
327// ---------------------------------------------------------------------------
328// Injectable impls (back-compat with `#[inject]` parameters).
329// ---------------------------------------------------------------------------
330
331#[async_trait]
332impl<T> Injectable for SessionValue<T>
333where
334 T: DeserializeOwned + Send + Sync + 'static,
335{
336 async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
337 load_session_value_via_di::<T>(ctx, USER_ID_SESSION_KEY)
338 .await
339 .map(SessionValue)
340 }
341}
342
343#[async_trait]
344impl<T> Injectable for OptionalSessionValue<T>
345where
346 T: DeserializeOwned + Send + Sync + 'static,
347{
348 async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
349 // Mirror `SessionValue`, but collapse "no session"/"no value" into
350 // `None` rather than propagating an injection error. Any other
351 // error (such as a corrupted singleton scope) still bubbles up so
352 // genuine misconfigurations remain visible.
353 match SessionData::inject(ctx).await {
354 Ok(session) => Ok(OptionalSessionValue(session.get::<T>(USER_ID_SESSION_KEY))),
355 Err(DiError::NotFound(_)) => Ok(OptionalSessionValue(None)),
356 Err(e) => Err(e),
357 }
358 }
359}
360
361#[async_trait]
362impl<K, T> Injectable for SessionValueNamed<K, T>
363where
364 K: SessionKey,
365 T: DeserializeOwned + Send + Sync + 'static,
366{
367 async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
368 load_session_value_via_di::<T>(ctx, K::KEY)
369 .await
370 .map(Self::new)
371 }
372}
373
374#[async_trait]
375impl<K, T> Injectable for OptionalSessionValueNamed<K, T>
376where
377 K: SessionKey,
378 T: DeserializeOwned + Send + Sync + 'static,
379{
380 async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
381 // Mirror `OptionalSessionValue`, but parameterise the key over
382 // `K::KEY`. Collapse "no session"/"no value" into `None`; any
383 // other error (e.g. corrupted singleton scope) still bubbles up.
384 match SessionData::inject(ctx).await {
385 Ok(session) => Ok(Self::new(session.get::<T>(K::KEY))),
386 Err(DiError::NotFound(_)) => Ok(Self::new(None)),
387 Err(e) => Err(e),
388 }
389 }
390}
391
392// ---------------------------------------------------------------------------
393// FromRequest impls (auto-extraction without `#[inject]`).
394// ---------------------------------------------------------------------------
395
396#[async_trait]
397impl<T> FromRequest for SessionValue<T>
398where
399 T: DeserializeOwned + Send + Sync + 'static,
400{
401 async fn from_request(req: &Request, _ctx: &ParamContext) -> ParamResult<Self> {
402 load_session_value_via_request::<T>(req, USER_ID_SESSION_KEY)
403 .await
404 .map(SessionValue)
405 }
406}
407
408#[async_trait]
409impl<T> FromRequest for OptionalSessionValue<T>
410where
411 T: DeserializeOwned + Send + Sync + 'static,
412{
413 async fn from_request(req: &Request, _ctx: &ParamContext) -> ParamResult<Self> {
414 // Mirror the `Injectable` semantics: any failure to reach a live
415 // session collapses to `None`. Successful session lookups still
416 // honour the `session.get::<T>(...) -> Option<T>` semantics for
417 // missing keys and deserialisation failures.
418 let di_ctx = match req.get_di_context::<InjectionContext>() {
419 Some(c) => c,
420 None => return Ok(OptionalSessionValue(None)),
421 };
422 match SessionData::inject(&di_ctx).await {
423 Ok(session) => Ok(OptionalSessionValue(session.get::<T>(USER_ID_SESSION_KEY))),
424 Err(_) => Ok(OptionalSessionValue(None)),
425 }
426 }
427}
428
429#[async_trait]
430impl<K, T> FromRequest for SessionValueNamed<K, T>
431where
432 K: SessionKey,
433 T: DeserializeOwned + Send + Sync + 'static,
434{
435 async fn from_request(req: &Request, _ctx: &ParamContext) -> ParamResult<Self> {
436 load_session_value_via_request::<T>(req, K::KEY)
437 .await
438 .map(Self::new)
439 }
440}
441
442#[async_trait]
443impl<K, T> FromRequest for OptionalSessionValueNamed<K, T>
444where
445 K: SessionKey,
446 T: DeserializeOwned + Send + Sync + 'static,
447{
448 async fn from_request(req: &Request, _ctx: &ParamContext) -> ParamResult<Self> {
449 // Mirror `OptionalSessionValue::from_request`, parameterised on
450 // `K::KEY`: any failure to reach a live session collapses to
451 // `None` rather than 401/500, so this extractor never blocks the
452 // handler from running.
453 let di_ctx = match req.get_di_context::<InjectionContext>() {
454 Some(c) => c,
455 None => return Ok(Self::new(None)),
456 };
457 match SessionData::inject(&di_ctx).await {
458 Ok(session) => Ok(Self::new(session.get::<T>(K::KEY))),
459 Err(_) => Ok(Self::new(None)),
460 }
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::super::test_support::TenantIdKey;
467 use super::*;
468 use rstest::rstest;
469
470 #[rstest]
471 fn user_id_key_resolves_to_canonical_session_key() {
472 // Arrange + Act
473 let key = UserIdKey::KEY;
474
475 // Assert
476 assert_eq!(key, USER_ID_SESSION_KEY);
477 }
478
479 #[rstest]
480 fn session_value_named_constructor_and_deref_roundtrip() {
481 // Arrange
482 let extractor = SessionValueNamed::<TenantIdKey, i64>::new(42);
483
484 // Act
485 let via_deref: i64 = *extractor;
486 let via_into_inner = extractor.into_inner();
487
488 // Assert
489 assert_eq!(via_deref, 42);
490 assert_eq!(via_into_inner, 42);
491 }
492
493 #[rstest]
494 fn optional_session_value_named_constructor_and_deref_roundtrip_some() {
495 // Arrange
496 let extractor = OptionalSessionValueNamed::<TenantIdKey, i64>::new(Some(7));
497
498 // Act
499 let via_deref: Option<i64> = *extractor;
500 let via_into_inner = extractor.into_inner();
501
502 // Assert
503 assert_eq!(via_deref, Some(7));
504 assert_eq!(via_into_inner, Some(7));
505 }
506
507 #[rstest]
508 fn optional_session_value_named_constructor_and_deref_roundtrip_none() {
509 // Arrange
510 let extractor = OptionalSessionValueNamed::<TenantIdKey, i64>::new(None);
511
512 // Act
513 let via_deref: Option<i64> = *extractor;
514 let via_into_inner = extractor.into_inner();
515
516 // Assert
517 assert_eq!(via_deref, None);
518 assert_eq!(via_into_inner, None);
519 }
520
521 #[rstest]
522 fn optional_session_value_named_debug_includes_key_name() {
523 // Arrange
524 let extractor = OptionalSessionValueNamed::<TenantIdKey, i64>::new(Some(99));
525
526 // Act
527 let rendered = format!("{extractor:?}");
528
529 // Assert: the Debug impl should surface the `K::KEY` constant so
530 // failure diagnostics in handler logs identify which session key the
531 // extractor targeted. Mirror the contract verified for
532 // `SessionValueNamed` Debug output.
533 assert!(
534 rendered.contains("OptionalSessionValueNamed"),
535 "Debug output should name the struct, got {rendered:?}"
536 );
537 assert!(
538 rendered.contains("tenant_id"),
539 "Debug output should include the session key name, got {rendered:?}"
540 );
541 }
542
543 #[rstest]
544 fn optional_session_value_named_clone_preserves_inner_some() {
545 // Arrange
546 let original = OptionalSessionValueNamed::<TenantIdKey, i64>::new(Some(123));
547
548 // Act
549 let cloned = original.clone();
550
551 // Assert
552 assert_eq!(*cloned, Some(123));
553 assert_eq!(*original, Some(123));
554 }
555
556 #[rstest]
557 fn di_error_authentication_maps_to_param_authentication() {
558 // Arrange
559 let di_err = DiError::Authentication("nope".to_string());
560
561 // Act
562 let param_err = di_error_to_param_error(di_err);
563
564 // Assert
565 match param_err {
566 ParamError::Authentication(msg) => assert_eq!(msg, "nope"),
567 other => panic!("expected ParamError::Authentication, got {other:?}"),
568 }
569 }
570
571 #[rstest]
572 fn di_error_not_found_maps_to_param_authentication() {
573 // Arrange
574 let di_err = DiError::NotFound("missing session".to_string());
575
576 // Act
577 let param_err = di_error_to_param_error(di_err);
578
579 // Assert: missing session collapses to 401 (Authentication) so the
580 // handler macro returns the right status. See #4446.
581 assert!(matches!(param_err, ParamError::Authentication(_)));
582 }
583}