use axum::http::{HeaderMap, HeaderValue, header};
use fraiseql_core::schema::{CompiledSchema, LocaleSource, SessionVariableSource};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Caching {
NoStore,
Private {
max_age: u64,
},
Public {
max_age: u64,
s_maxage: Option<u64>,
},
}
pub struct Answered<'a> {
pub body: &'a serde_json::Value,
pub ttl: Option<u64>,
pub principal: bool,
pub tenant: bool,
pub s_maxage: Option<u64>,
}
pub fn decide(answered: &Answered<'_>, headers: &HeaderMap, schema: &CompiledSchema) -> Caching {
if answered.body.get("errors").is_some() {
return Caching::NoStore;
}
let Some(max_age) = answered.ttl.filter(|ttl| *ttl > 0) else {
return Caching::NoStore;
};
let credential = [header::AUTHORIZATION, header::COOKIE]
.iter()
.any(|name| headers.contains_key(name))
|| headers.contains_key("x-api-key");
let session_header = schema
.session_variables
.variables
.iter()
.any(|v| matches!(v.source, SessionVariableSource::Header { .. }));
if answered.principal
|| credential
|| answered.tenant
|| headers.contains_key("x-tenant-id")
|| session_header
{
Caching::Private { max_age }
} else {
Caching::Public {
max_age,
s_maxage: answered.s_maxage,
}
}
}
pub fn apply(
headers: &mut HeaderMap,
caching: Caching,
schema: Option<&CompiledSchema>,
negotiated: &[&str],
) {
let value = match caching {
Caching::NoStore => {
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
return;
},
Caching::Private { max_age } => format!("private, max-age={max_age}"),
Caching::Public {
max_age,
s_maxage: None,
} => format!("public, max-age={max_age}"),
Caching::Public {
max_age,
s_maxage: Some(s_maxage),
} => format!("public, max-age={max_age}, s-maxage={s_maxage}"),
};
if let Ok(value) = HeaderValue::from_str(&value) {
headers.insert(header::CACHE_CONTROL, value);
} else {
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
return;
}
if let Ok(vary) = HeaderValue::from_str(&vary(schema, negotiated)) {
headers.insert(header::VARY, vary);
} else {
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
}
fn vary(schema: Option<&CompiledSchema>, negotiated: &[&str]) -> String {
let mut names = vec![
"Authorization".to_string(),
"Cookie".to_string(),
"X-API-Key".to_string(),
"X-Tenant-ID".to_string(),
];
names.extend(negotiated.iter().map(ToString::to_string));
if let Some(schema) = schema {
if let Some(locale) = schema.locale.as_ref() {
names.extend(locale.resolve.iter().filter_map(|source| match source {
LocaleSource::Header { header } => Some(header.clone()),
LocaleSource::Argument { .. } | LocaleSource::Enrichment { .. } => None,
}));
}
names.extend(schema.session_variables.variables.iter().filter_map(|v| match &v.source {
SessionVariableSource::Header { header } => Some(header.clone()),
SessionVariableSource::Jwt { .. }
| SessionVariableSource::Literal { .. }
| SessionVariableSource::Enrichment { .. } => None,
}));
}
let mut seen = std::collections::HashSet::new();
names.retain(|name| seen.insert(name.to_ascii_lowercase()));
names.join(", ")
}
#[cfg(test)]
mod tests;