use crate::Request;
use async_trait::async_trait;
use ferro_theme::Theme;
use std::sync::Arc;
#[async_trait]
pub trait ThemeResolver: Send + Sync {
async fn resolve(&self, req: &Request) -> Option<Arc<Theme>>;
}
pub struct TenantThemeResolver {
cache: moka::sync::Cache<String, Arc<Theme>>,
themes_dir: String,
}
impl TenantThemeResolver {
pub fn new(themes_dir: impl Into<String>) -> Self {
Self {
cache: moka::sync::Cache::builder()
.time_to_live(std::time::Duration::from_secs(300))
.max_capacity(100)
.build(),
themes_dir: themes_dir.into(),
}
}
}
#[async_trait]
impl ThemeResolver for TenantThemeResolver {
async fn resolve(&self, _req: &Request) -> Option<Arc<Theme>> {
let tenant = crate::tenant::current_tenant()?;
let theme_name = tenant.plan.as_deref()?;
if let Some(cached) = self.cache.get(theme_name) {
return Some(cached);
}
let path = format!("{}/{}", self.themes_dir, theme_name);
let theme = Arc::new(Theme::from_path(&path).ok()?);
self.cache
.insert(theme_name.to_string(), Arc::clone(&theme));
Some(theme)
}
}
pub struct HeaderThemeResolver {
themes_dir: String,
cache: moka::sync::Cache<String, Arc<Theme>>,
}
impl HeaderThemeResolver {
pub fn new(themes_dir: impl Into<String>) -> Self {
Self {
themes_dir: themes_dir.into(),
cache: moka::sync::Cache::builder()
.time_to_live(std::time::Duration::from_secs(300))
.max_capacity(100)
.build(),
}
}
}
#[async_trait]
impl ThemeResolver for HeaderThemeResolver {
async fn resolve(&self, req: &Request) -> Option<Arc<Theme>> {
let theme_name = req.header("x-theme")?;
if let Some(cached) = self.cache.get(theme_name) {
return Some(cached);
}
let path = format!("{}/{}", self.themes_dir, theme_name);
let theme = Arc::new(Theme::from_path(&path).ok()?);
self.cache
.insert(theme_name.to_string(), Arc::clone(&theme));
Some(theme)
}
}
pub struct DefaultResolver {
default: Arc<Theme>,
}
impl DefaultResolver {
pub fn new(theme: Theme) -> Self {
Self {
default: Arc::new(theme),
}
}
}
#[async_trait]
impl ThemeResolver for DefaultResolver {
async fn resolve(&self, _req: &Request) -> Option<Arc<Theme>> {
Some(Arc::clone(&self.default))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tenant::context::{tenant_scope, with_tenant_scope};
use crate::tenant::TenantContext;
use bytes::Bytes;
use http_body_util::Empty;
use hyper_util::rt::TokioIo;
use std::sync::Mutex;
use tokio::sync::oneshot;
fn make_tenant_with_plan(plan: &str) -> TenantContext {
TenantContext {
id: 1,
slug: "acme".to_string(),
name: "ACME Corp".to_string(),
plan: Some(plan.to_string()),
#[cfg(feature = "stripe")]
subscription: None,
}
}
fn make_tenant_no_plan() -> TenantContext {
TenantContext {
id: 1,
slug: "acme".to_string(),
name: "ACME Corp".to_string(),
plan: None,
#[cfg(feature = "stripe")]
subscription: None,
}
}
async fn make_request_with_header(header_name: &str, header_value: &str) -> Request {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = oneshot::channel();
let tx_holder = Arc::new(Mutex::new(Some(tx)));
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let io = TokioIo::new(stream);
let tx_holder = tx_holder.clone();
let service =
hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
let tx_holder = tx_holder.clone();
async move {
if let Some(tx) = tx_holder.lock().unwrap().take() {
let _ = tx.send(Request::new(req));
}
Ok::<_, hyper::Error>(hyper::Response::new(Empty::<Bytes>::new()))
}
});
hyper::server::conn::http1::Builder::new()
.serve_connection(io, service)
.await
.ok();
});
let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
let io = TokioIo::new(stream);
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await.unwrap();
tokio::spawn(async move {
conn.await.ok();
});
let req = hyper::Request::builder()
.uri("/test")
.header(header_name, header_value)
.body(Empty::<Bytes>::new())
.unwrap();
let _ = sender.send_request(req).await;
rx.await.unwrap()
}
async fn make_request() -> Request {
make_request_with_header("x-test", "1").await
}
fn make_theme_dir(name: &str) -> (tempfile::TempDir, String) {
let dir = tempfile::tempdir().unwrap();
let theme_dir = dir.path().join(name);
std::fs::create_dir_all(&theme_dir).unwrap();
std::fs::write(
theme_dir.join("tokens.css"),
"@theme { --color-primary: oklch(55% 0.2 250); }",
)
.unwrap();
let themes_dir = dir.path().to_str().unwrap().to_string();
(dir, themes_dir)
}
#[test]
fn theme_resolver_is_object_safe() {
let _: Box<dyn ThemeResolver>;
}
#[tokio::test]
async fn default_resolver_always_returns_default() {
let theme = Theme::default_theme();
let resolver = DefaultResolver::new(theme);
let req = make_request().await;
let result = resolver.resolve(&req).await;
assert!(result.is_some());
assert!(result.unwrap().css.contains("--color-primary"));
}
#[tokio::test]
async fn default_resolver_returns_theme_for_any_request() {
let theme = Theme::default_theme();
let resolver = DefaultResolver::new(theme);
let req = make_request_with_header("x-theme", "some-theme").await;
let result = resolver.resolve(&req).await;
assert!(result.is_some());
}
#[tokio::test]
async fn header_theme_resolver_returns_some_when_header_present_and_dir_exists() {
let (_dir, themes_dir) = make_theme_dir("pro");
let resolver = HeaderThemeResolver::new(&themes_dir);
let req = make_request_with_header("x-theme", "pro").await;
let result = resolver.resolve(&req).await;
assert!(
result.is_some(),
"expected Some(theme) for valid x-theme header"
);
}
#[tokio::test]
async fn header_theme_resolver_returns_none_when_header_absent() {
let (_dir, themes_dir) = make_theme_dir("pro");
let resolver = HeaderThemeResolver::new(&themes_dir);
let req = make_request().await; let result = resolver.resolve(&req).await;
assert!(result.is_none());
}
#[tokio::test]
async fn header_theme_resolver_returns_none_when_dir_does_not_exist() {
let resolver = HeaderThemeResolver::new("/nonexistent/themes");
let req = make_request_with_header("x-theme", "pro").await;
let result = resolver.resolve(&req).await;
assert!(result.is_none());
}
#[tokio::test]
async fn header_theme_resolver_cache_returns_on_second_resolve() {
let (_dir, themes_dir) = make_theme_dir("enterprise");
let resolver = HeaderThemeResolver::new(&themes_dir);
let req1 = make_request_with_header("x-theme", "enterprise").await;
let result1 = resolver.resolve(&req1).await;
assert!(result1.is_some());
std::fs::remove_dir_all(format!("{themes_dir}/enterprise")).unwrap();
let req2 = make_request_with_header("x-theme", "enterprise").await;
let result2 = resolver.resolve(&req2).await;
assert!(
result2.is_some(),
"second resolve should return cached theme even after disk deletion"
);
}
#[tokio::test]
async fn tenant_theme_resolver_returns_some_when_plan_matches_dir() {
let (_dir, themes_dir) = make_theme_dir("pro");
let resolver = TenantThemeResolver::new(&themes_dir);
let scope = tenant_scope();
{
let mut guard = scope.write().await;
*guard = Some(make_tenant_with_plan("pro"));
}
let req = make_request().await;
let result = with_tenant_scope(scope, resolver.resolve(&req)).await;
assert!(
result.is_some(),
"expected Some(theme) when plan matches dir"
);
}
#[tokio::test]
async fn tenant_theme_resolver_returns_none_when_no_tenant() {
let resolver = TenantThemeResolver::new("/some/themes");
let req = make_request().await;
let result = resolver.resolve(&req).await;
assert!(result.is_none());
}
#[tokio::test]
async fn tenant_theme_resolver_returns_none_when_no_plan() {
let resolver = TenantThemeResolver::new("/some/themes");
let scope = tenant_scope();
{
let mut guard = scope.write().await;
*guard = Some(make_tenant_no_plan());
}
let req = make_request().await;
let result = with_tenant_scope(scope, resolver.resolve(&req)).await;
assert!(result.is_none());
}
#[tokio::test]
async fn tenant_theme_resolver_cache_returns_on_second_resolve() {
let (_dir, themes_dir) = make_theme_dir("starter");
let resolver = TenantThemeResolver::new(&themes_dir);
let scope1 = tenant_scope();
{
let mut guard = scope1.write().await;
*guard = Some(make_tenant_with_plan("starter"));
}
let req1 = make_request().await;
let result1 = with_tenant_scope(scope1, resolver.resolve(&req1)).await;
assert!(result1.is_some());
std::fs::remove_dir_all(format!("{themes_dir}/starter")).unwrap();
let scope2 = tenant_scope();
{
let mut guard = scope2.write().await;
*guard = Some(make_tenant_with_plan("starter"));
}
let req2 = make_request().await;
let result2 = with_tenant_scope(scope2, resolver.resolve(&req2)).await;
assert!(
result2.is_some(),
"second resolve should return cached theme even after disk deletion"
);
}
}