use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tonic::metadata::MetadataValue;
use tonic::service::Interceptor;
use tonic::{Request, Status};
use jammi_db::TenantId;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SessionId(String);
impl SessionId {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy)]
pub struct SessionTenant(pub Option<TenantId>);
#[derive(Debug, Default, Clone)]
pub struct SessionStore {
inner: Arc<RwLock<HashMap<SessionId, Option<TenantId>>>>,
}
impl SessionStore {
pub fn new() -> Self {
Self::default()
}
pub fn set(&self, session: SessionId, tenant: Option<TenantId>) {
self.inner
.write()
.expect("session store lock poisoned")
.insert(session, tenant);
}
pub fn get(&self, session: &SessionId) -> Option<TenantId> {
self.inner
.read()
.expect("session store lock poisoned")
.get(session)
.copied()
.flatten()
}
pub fn clear(&self, session: &SessionId) {
self.inner
.write()
.expect("session store lock poisoned")
.remove(session);
}
}
pub use jammi_wire::SESSION_HEADER;
#[derive(Clone)]
pub struct TenantInterceptor {
store: SessionStore,
}
impl TenantInterceptor {
pub fn new(store: SessionStore) -> Self {
Self { store }
}
}
impl Interceptor for TenantInterceptor {
fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
let tenant = match read_session_header(&request) {
Some(session) => self.store.get(&session),
None => None,
};
request.extensions_mut().insert(SessionTenant(tenant));
Ok(request)
}
}
fn read_session_header<T>(request: &Request<T>) -> Option<SessionId> {
request
.metadata()
.get(SESSION_HEADER)
.and_then(|v: &MetadataValue<_>| v.to_str().ok())
.map(SessionId::new)
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn t_a() -> TenantId {
TenantId::from_str("01906c83-d4c8-7e10-9c4f-3b6f7c5a8e9a").unwrap()
}
fn t_b() -> TenantId {
TenantId::from_str("01906c83-d4c8-7e10-9c4f-3b6f7c5a8e9b").unwrap()
}
#[test]
fn store_get_set_clear_roundtrip() {
let store = SessionStore::new();
let sid = SessionId::new("conn-1");
assert!(store.get(&sid).is_none());
store.set(sid.clone(), Some(t_a()));
assert_eq!(store.get(&sid), Some(t_a()));
store.set(sid.clone(), Some(t_b()));
assert_eq!(store.get(&sid), Some(t_b()));
store.set(sid.clone(), None);
assert!(store.get(&sid).is_none());
store.set(sid.clone(), Some(t_a()));
store.clear(&sid);
assert!(store.get(&sid).is_none());
}
#[test]
fn store_isolates_sessions() {
let store = SessionStore::new();
let s1 = SessionId::new("conn-1");
let s2 = SessionId::new("conn-2");
store.set(s1.clone(), Some(t_a()));
store.set(s2.clone(), Some(t_b()));
assert_eq!(store.get(&s1), Some(t_a()));
assert_eq!(store.get(&s2), Some(t_b()));
}
#[tokio::test]
async fn interceptor_attaches_unscoped_when_no_header() {
let mut interceptor = TenantInterceptor::new(SessionStore::new());
let req = Request::new(());
let req = interceptor.call(req).unwrap();
let tenant = req.extensions().get::<SessionTenant>().unwrap();
assert!(tenant.0.is_none());
}
#[tokio::test]
async fn interceptor_attaches_bound_tenant_when_header_present() {
let store = SessionStore::new();
store.set(SessionId::new("conn-1"), Some(t_a()));
let mut interceptor = TenantInterceptor::new(store);
let mut req = Request::new(());
req.metadata_mut()
.insert(SESSION_HEADER, "conn-1".parse().unwrap());
let req = interceptor.call(req).unwrap();
let tenant = req.extensions().get::<SessionTenant>().unwrap();
assert_eq!(tenant.0, Some(t_a()));
}
}