use once_cell::sync::Lazy;
use regex::Regex;
#[derive(Clone, Copy)]
pub enum CacheKeyStrategy {
FullUri,
}
#[derive(Clone, Copy)]
pub enum BodyKeyStrategy {
Ignore,
AutoContentType,
RawHash,
JsonCanonical,
FormUrlEncodedCanonical,
}
const IGNORED_QUERY_KEYS: &[&str] = &["UserId"];
pub struct CacheableRoute {
pub pattern: &'static str,
pub methods: &'static [&'static str],
pub ttl_seconds: u64,
pub description: &'static str,
pub key_strategy: CacheKeyStrategy,
pub body_key_strategy: BodyKeyStrategy,
}
pub const CACHEABLE_ROUTES: &[CacheableRoute] = &[
CacheableRoute {
pattern: r"(?i)^/(?:emby/)?Users/[a-z0-9]+/Items/[0-9]+$",
methods: &["GET"],
ttl_seconds: 7200, description: "User item details",
key_strategy: CacheKeyStrategy::FullUri,
body_key_strategy: BodyKeyStrategy::Ignore,
},
CacheableRoute {
pattern: r"(?i)^/(?:emby/)?Shows/NextUp$",
methods: &["GET"],
ttl_seconds: 7200, description: "Next-up episode for a series",
key_strategy: CacheKeyStrategy::FullUri,
body_key_strategy: BodyKeyStrategy::Ignore,
},
CacheableRoute {
pattern: r"(?i)^/(?:emby/)?Shows/[^/]+/Episodes$",
methods: &["GET"],
ttl_seconds: 7200, description: "Episode list for a series season",
key_strategy: CacheKeyStrategy::FullUri,
body_key_strategy: BodyKeyStrategy::Ignore,
},
];
pub struct CompiledCacheableRoute {
pub regex: Regex,
pub methods: &'static [&'static str],
pub ttl_seconds: u64,
pub key_strategy: CacheKeyStrategy,
pub body_key_strategy: BodyKeyStrategy,
}
pub static COMPILED_ROUTES: Lazy<Vec<CompiledCacheableRoute>> =
Lazy::new(|| {
CACHEABLE_ROUTES
.iter()
.filter_map(|route| {
Regex::new(route.pattern).ok().map(|regex| {
CompiledCacheableRoute {
regex,
methods: route.methods,
ttl_seconds: route.ttl_seconds,
key_strategy: route.key_strategy,
body_key_strategy: route.body_key_strategy,
}
})
})
.collect()
});
pub fn find_cacheable_route(
path: &str,
method: &str,
) -> Option<&'static CompiledCacheableRoute> {
COMPILED_ROUTES.iter().find(|route| {
route.regex.is_match(path)
&& route.methods.iter().any(|m| m.eq_ignore_ascii_case(method))
})
}
pub fn build_semantic_cache_key(
route: &CompiledCacheableRoute,
method: &str,
path: &str,
query: Option<&str>,
) -> String {
let method = method.to_ascii_lowercase();
let canonical_uri = canonical_uri_for_cache(path, query);
match route.key_strategy {
CacheKeyStrategy::FullUri => {
format!("api:full_uri:method:{method}:uri:{canonical_uri}")
}
}
}
fn canonical_uri_for_cache(path: &str, query: Option<&str>) -> String {
let Some(query_str) = query.filter(|query| !query.is_empty()) else {
return path.to_string();
};
let mut query_pairs: Vec<(String, String)> =
form_urlencoded::parse(query_str.as_bytes())
.filter_map(|(key, value)| {
if IGNORED_QUERY_KEYS
.iter()
.any(|ignored| key.eq_ignore_ascii_case(ignored))
{
return None;
}
Some((key.to_ascii_lowercase(), value.into_owned()))
})
.collect();
if query_pairs.is_empty() {
return path.to_string();
}
query_pairs.sort_by(|(left_key, left_value), (right_key, right_value)| {
left_key
.cmp(right_key)
.then_with(|| left_value.cmp(right_value))
});
let normalized_query = form_urlencoded::Serializer::new(String::new())
.extend_pairs(
query_pairs
.iter()
.map(|(key, value)| (key.as_str(), value.as_str())),
)
.finish();
format!("{path}?{normalized_query}")
}
#[cfg(test)]
mod tests {
use super::{
BodyKeyStrategy, CacheKeyStrategy, CompiledCacheableRoute,
build_semantic_cache_key, find_cacheable_route,
};
use regex::Regex;
fn compiled(strategy: CacheKeyStrategy) -> CompiledCacheableRoute {
CompiledCacheableRoute {
regex: Regex::new(".*").unwrap_or_else(|_| unreachable!()),
methods: &["GET"],
ttl_seconds: 1,
key_strategy: strategy,
body_key_strategy: BodyKeyStrategy::Ignore,
}
}
#[test]
fn next_up_semantic_key_uses_full_uri() {
let route = compiled(CacheKeyStrategy::FullUri);
let key = build_semantic_cache_key(
&route,
"GET",
"/emby/Shows/NextUp",
Some("Limit=1&SeriesId=Series-ABC_01&UserId=u1"),
);
assert_eq!(
key,
"api:full_uri:method:get:uri:/emby/Shows/NextUp?limit=1&seriesid=Series-ABC_01"
);
}
#[test]
fn next_up_route_matches_exact_path_only() {
let route = find_cacheable_route("/emby/Shows/NextUp", "GET");
assert!(route.is_some());
}
#[test]
fn next_up_route_does_not_match_extra_segments() {
let route = find_cacheable_route("/emby/Shows/NextUp/resume", "GET");
assert!(route.is_none());
}
#[test]
fn next_up_route_does_not_match_missing_segment() {
let route = find_cacheable_route("/emby/Shows", "GET");
assert!(route.is_none());
}
#[test]
fn episodes_semantic_key_distinguishes_season_id() {
let route = compiled(CacheKeyStrategy::FullUri);
let key1 = build_semantic_cache_key(
&route,
"GET",
"/emby/Shows/Show-XYZ_09/Episodes",
Some("SeasonId=s1&UserId=u1"),
);
let key = build_semantic_cache_key(
&route,
"GET",
"/emby/Shows/Show-XYZ_09/Episodes",
Some("SeasonId=s2&UserId=u1"),
);
assert_ne!(key1, key);
}
#[test]
fn episodes_route_matches_exact_path_only() {
let route =
find_cacheable_route("/emby/Shows/Show-XYZ_09/Episodes", "GET");
assert!(route.is_some());
}
#[test]
fn episodes_route_does_not_match_extra_segments() {
let route = find_cacheable_route(
"/emby/Shows/Show-XYZ_09/Episodes/resume",
"GET",
);
assert!(route.is_none());
}
#[test]
fn episodes_route_does_not_match_missing_show_id() {
let route = find_cacheable_route("/emby/Shows/Episodes", "GET");
assert!(route.is_none());
}
#[test]
fn user_item_semantic_key_uses_full_uri() {
let route = compiled(CacheKeyStrategy::FullUri);
let key = build_semantic_cache_key(
&route,
"GET",
"/emby/Users/UserABC01/Items/257023",
Some("Fields=Path%2COverview&UserId=u1"),
);
assert_eq!(
key,
"api:full_uri:method:get:uri:/emby/Users/UserABC01/Items/257023?fields=Path%2COverview"
);
}
#[test]
fn full_uri_strategy_normalizes_query_order() {
let route = compiled(CacheKeyStrategy::FullUri);
let key1 = build_semantic_cache_key(
&route,
"GET",
"/emby/Items",
Some("b=2&a=1"),
);
let key2 = build_semantic_cache_key(
&route,
"GET",
"/emby/Items",
Some("a=1&b=2"),
);
assert_eq!(key1, key2);
assert_eq!(key1, "api:full_uri:method:get:uri:/emby/Items?a=1&b=2");
}
#[test]
fn full_uri_strategy_ignores_user_id() {
let route = compiled(CacheKeyStrategy::FullUri);
let key1 = build_semantic_cache_key(
&route,
"GET",
"/emby/Shows/49619/Episodes",
Some("SeasonId=49708&UserId=u1"),
);
let key2 = build_semantic_cache_key(
&route,
"GET",
"/emby/Shows/49619/Episodes",
Some("UserId=u2&SeasonId=49708"),
);
assert_eq!(key1, key2);
assert_eq!(
key1,
"api:full_uri:method:get:uri:/emby/Shows/49619/Episodes?seasonid=49708"
);
}
#[test]
fn full_uri_strategy_treats_query_keys_case_insensitively() {
let route = compiled(CacheKeyStrategy::FullUri);
let key1 = build_semantic_cache_key(
&route,
"GET",
"/emby/Shows/49619/Episodes",
Some("SeasonId=49708&Fields=A"),
);
let key2 = build_semantic_cache_key(
&route,
"GET",
"/emby/Shows/49619/Episodes",
Some("seasonid=49708&fields=A"),
);
assert_eq!(key1, key2);
}
#[test]
fn user_item_route_does_not_match_extra_segments() {
let route =
find_cacheable_route("/emby/Users/u1/Items/i1/resume", "GET");
assert!(route.is_none());
}
#[test]
fn user_item_route_does_not_match_missing_item_id() {
let route = find_cacheable_route("/emby/Users/u1/Items", "GET");
assert!(route.is_none());
}
#[test]
fn user_item_route_does_not_match_non_alphanumeric_user_id() {
let route =
find_cacheable_route("/emby/Users/user-1/Items/257023", "GET");
assert!(route.is_none());
}
#[test]
fn user_item_route_does_not_match_non_numeric_item_id() {
let route =
find_cacheable_route("/emby/Users/user1/Items/item257023", "GET");
assert!(route.is_none());
}
}