use http::{Method, Request, Response, Uri};
use http_cache_semantics::{AfterResponse, CachePolicy};
use crate::{_prelude::*, http::client::HttpExchange, registry::IdentityProviderRegistration};
#[derive(Clone, Debug)]
pub struct Freshness {
pub ttl: Duration,
pub policy: CachePolicy,
}
#[derive(Debug)]
pub struct Revalidation {
pub freshness: Freshness,
pub response: Response<()>,
pub modified: bool,
}
pub fn base_request(registration: &IdentityProviderRegistration) -> Result<Request<()>> {
let uri = parse_uri(registration)?;
Request::builder()
.method(Method::GET)
.uri(uri)
.header("accept", "application/json")
.body(())
.map_err(Error::from)
}
pub fn evaluate_freshness(
registration: &IdentityProviderRegistration,
exchange: &HttpExchange,
) -> Result<Freshness> {
let policy = CachePolicy::new(&exchange.request, &exchange.response);
let storable = policy.is_storable();
let ttl = if storable {
clamp_ttl(
policy.time_to_live(SystemTime::now()),
registration.min_ttl,
registration.max_ttl,
)
} else {
registration.min_ttl
};
tracing::debug!(ttl=?ttl, storable, "evaluated freshness");
Ok(Freshness { ttl, policy })
}
pub fn evaluate_revalidation(
registration: &IdentityProviderRegistration,
policy: &CachePolicy,
request: &Request<()>,
response: &Response<()>,
) -> Result<Revalidation> {
let now = SystemTime::now();
let outcome = policy.after_response(request, response, now);
let (policy, parts, modified) = match outcome {
AfterResponse::NotModified(policy, parts) => (policy, parts, false),
AfterResponse::Modified(policy, parts) => (policy, parts, true),
};
let response = Response::from_parts(parts, ());
let ttl = clamp_ttl(policy.time_to_live(now), registration.min_ttl, registration.max_ttl);
Ok(Revalidation { freshness: Freshness { ttl, policy }, response, modified })
}
fn parse_uri(registration: &IdentityProviderRegistration) -> Result<Uri> {
registration.jwks_url.as_str().parse::<Uri>().map_err(|err| Error::Validation {
field: "jwks_url",
reason: format!("Failed to convert URL to http::Uri: {err}."),
})
}
fn clamp_ttl(ttl: Duration, min: Duration, max: Duration) -> Duration {
if ttl < min {
min
} else if ttl > max {
max
} else {
ttl
}
}
#[cfg(test)]
mod tests {
use http::{
StatusCode,
header::{CACHE_CONTROL, ETAG},
};
use http_cache_semantics::BeforeRequest;
use super::*;
fn make_registration() -> IdentityProviderRegistration {
IdentityProviderRegistration::new(
"tenant",
"provider",
"https://example.com/.well-known/jwks.json",
)
.expect("registration")
}
#[test]
fn clamps_ttl_to_registration_bounds() {
let mut registration = make_registration();
registration.min_ttl = Duration::from_secs(30);
registration.max_ttl = Duration::from_secs(60);
let request = base_request(®istration).expect("request");
let response = Response::builder()
.status(StatusCode::OK)
.header(CACHE_CONTROL, "max-age=5")
.body(())
.expect("response");
let exchange = HttpExchange::new(request, response, Duration::from_millis(12));
let freshness = evaluate_freshness(®istration, &exchange).expect("freshness");
assert_eq!(freshness.ttl, Duration::from_secs(30));
}
#[test]
fn adds_etag_to_conditional_revalidation_headers() {
let mut registration = make_registration();
registration.require_https = false;
registration.min_ttl = Duration::from_secs(1);
registration.max_ttl = Duration::from_secs(10);
let request = base_request(®istration).expect("request");
let response = Response::builder()
.status(StatusCode::OK)
.header(CACHE_CONTROL, "max-age=1")
.header(ETAG, "\"jwks-tag\"")
.body(())
.expect("response");
let exchange = HttpExchange::new(request.clone(), response, Duration::from_millis(8));
let freshness = evaluate_freshness(®istration, &exchange).expect("freshness");
let request = base_request(®istration).expect("request");
let decision =
freshness.policy.before_request(&request, SystemTime::now() + Duration::from_secs(5));
match decision {
BeforeRequest::Stale { request, .. } => {
let if_none_match = request.headers.get("if-none-match");
assert_eq!(
if_none_match.and_then(|value| value.to_str().ok()),
Some("\"jwks-tag\"")
);
},
BeforeRequest::Fresh(_) => {
panic!("expected stale decision triggering conditional headers")
},
}
}
}