coil-runtime 0.1.1

HTTP runtime and request handling for the Coil framework.
Documentation
use super::*;
use std::sync::Arc;

#[derive(Debug, Error, PartialEq, Eq)]
pub enum RuntimeCacheError {
    #[error(transparent)]
    Cache(#[from] CacheModelError),
}

#[derive(Debug, Clone)]
pub struct CacheHost {
    pub customer_app: String,
    pub namespace: CacheNamespace,
    pub shared_backend_namespace: String,
    pub planner: CachePlanner,
    runtime: CacheRuntime,
}

impl CacheHost {
    pub(crate) fn new(
        customer_app: String,
        namespace: CacheNamespace,
        planner: CachePlanner,
        shared_runtime: Option<Arc<dyn coil_cache::DistributedCacheRuntime>>,
        shared_backend_namespace: String,
    ) -> Self {
        let runtime = match (
            planner.topology().supports_shared_invalidation(),
            shared_runtime,
        ) {
            (true, Some(runtime)) => CacheRuntime::with_shared_runtime(planner.topology(), runtime),
            _ => planner.runtime(),
        };
        Self {
            customer_app,
            namespace,
            planner,
            runtime,
            shared_backend_namespace,
        }
    }

    pub fn lookup_execution(
        &mut self,
        execution: &RequestExecution,
        now: CacheInstant,
    ) -> Option<CacheLookup> {
        execution
            .cache_plan
            .plan
            .application()
            .map(|plan| self.runtime.lookup(plan.key(), now))
    }

    pub fn begin_fill(
        &mut self,
        execution: &RequestExecution,
        holder: impl Into<String>,
    ) -> Option<FillDecision> {
        execution.cache_plan.plan.application().map(|plan| {
            self.runtime
                .begin_fill(plan.key(), plan.coalescing(), holder)
        })
    }

    pub fn complete_fill(&mut self, decision: &FillDecision) -> Result<(), RuntimeCacheError> {
        match decision {
            FillDecision::Start(lease) => Ok(self.runtime.complete_fill(lease)?),
            FillDecision::Coalesced { .. } | FillDecision::Uncoalesced => Ok(()),
        }
    }

    pub fn store_execution(
        &mut self,
        execution: &RequestExecution,
        value: impl Into<String>,
        now: CacheInstant,
    ) -> Option<CacheKey> {
        execution.cache_plan.plan.application().map(|plan| {
            self.runtime.insert(plan, value, now);
            plan.key().clone()
        })
    }

    pub fn invalidate(&mut self, tags: &InvalidationSet) -> Vec<CacheKey> {
        self.runtime.invalidate(tags)
    }

    pub fn metrics(&self) -> CacheMetrics {
        self.runtime.metrics()
    }
}

pub(crate) fn cache_disposition_for_route(
    method: HttpMethod,
    auth: &RouteAuthGate,
    session: &SessionContext,
) -> CacheDisposition {
    if method.is_state_changing() {
        return CacheDisposition::Uncacheable;
    }

    match auth {
        RouteAuthGate::Public if session.session_id.is_none() => CacheDisposition::Public,
        _ => CacheDisposition::Private,
    }
}

pub(crate) fn build_execution_cache_plan(
    runtime: &RuntimePlan,
    request: &RequestInput,
    route: &RouteDefinition,
    resolved: &ResolvedRoute,
    session: &SessionContext,
    principal: &PrincipalContext,
    disposition: CacheDisposition,
) -> Result<ExecutedCachePlan, CacheModelError> {
    let scope = cache_scope_for_request(request, resolved, session, principal, disposition)?;
    let tags = cache_tags_for_request(runtime, route, resolved, request)?;
    let validators = cache_validators_for_request(request, resolved, session, principal)?;
    let freshness = cache_freshness_for_request(route, request.method, disposition);
    let http_policy = HttpCachePolicy::new(scope.clone(), freshness, validators, tags.clone())?;
    let mut cache_request = CachePlanRequest::new(
        runtime.cache_namespace()?,
        request.path.clone(),
        http_policy,
    )?;

    if let Some(freshness) = freshness.filter(|_| disposition != CacheDisposition::Uncacheable) {
        cache_request = cache_request
            .with_application_policy(ApplicationCachePolicy::new(scope, freshness, tags)?);
    }

    let plan = runtime.cache_planner.plan(cache_request)?;
    let headers = cache_headers_from_plan(&plan);

    Ok(ExecutedCachePlan { plan, headers })
}

fn cache_scope_for_request(
    request: &RequestInput,
    resolved: &ResolvedRoute,
    session: &SessionContext,
    principal: &PrincipalContext,
    disposition: CacheDisposition,
) -> Result<CacheScope, CacheModelError> {
    let mut scope = match disposition {
        CacheDisposition::Public => CacheScope::public(),
        CacheDisposition::Private => CacheScope::private(),
        CacheDisposition::Uncacheable => CacheScope::no_store(),
    }
    .with_site(
        resolved
            .site_id
            .clone()
            .unwrap_or_else(|| request.host.clone()),
    )?;

    if let Some(locale) = resolved.locale.as_deref() {
        scope = scope.with_locale(locale.to_string())?;
    }

    if disposition == CacheDisposition::Private {
        if let Some(principal_id) = principal.principal_id.as_deref() {
            scope = scope.with_user(principal_id.to_string())?;
        } else if let Some(session_id) = session.session_id.as_deref() {
            scope = scope.with_session(session_id.to_string())?;
        }
    }

    Ok(scope)
}

fn cache_tags_for_request(
    runtime: &RuntimePlan,
    route: &RouteDefinition,
    resolved: &ResolvedRoute,
    request: &RequestInput,
) -> Result<InvalidationSet, CacheModelError> {
    let mut tags = InvalidationSet::new();
    tags.insert(InvalidationTag::new(format!(
        "customer_app:{}",
        runtime.config.app.name
    ))?);
    if let Some(site_id) = resolved.site_id.as_deref() {
        tags.insert(InvalidationTag::new(format!("site:{site_id}"))?);
    }
    tags.insert(InvalidationTag::new(format!(
        "route:{}",
        resolved.route_name
    ))?);
    tags.insert(InvalidationTag::new(format!("path:{}", request.path))?);

    if let Some(module) = route.module.as_deref() {
        tags.insert(InvalidationTag::new(format!("module:{module}"))?);
    }

    if let Some(locale) = resolved.locale.as_deref() {
        tags.insert(InvalidationTag::new(format!("locale:{locale}"))?);
    }

    Ok(tags)
}

fn cache_validators_for_request(
    request: &RequestInput,
    resolved: &ResolvedRoute,
    session: &SessionContext,
    principal: &PrincipalContext,
) -> Result<ResponseValidators, CacheModelError> {
    let mut parts = vec![
        "etag".to_string(),
        resolved.route_name.clone(),
        resolved
            .site_id
            .clone()
            .unwrap_or_else(|| request.host.clone()),
        request.path.clone(),
    ];

    if let Some(locale) = resolved.locale.as_deref() {
        parts.push(format!("locale:{locale}"));
    }
    if let Some(principal_id) = principal.principal_id.as_deref() {
        parts.push(format!("user:{principal_id}"));
    } else if let Some(session_id) = session.session_id.as_deref() {
        parts.push(format!("session:{session_id}"));
    }

    Ok(ResponseValidators {
        etag: Some(EntityTag::new(parts.join(":"))?),
        last_modified_unix_seconds: None,
    })
}

fn cache_freshness_for_request(
    route: &RouteDefinition,
    method: HttpMethod,
    disposition: CacheDisposition,
) -> Option<FreshnessPolicy> {
    if method.is_state_changing() || disposition == CacheDisposition::Uncacheable {
        return None;
    }

    match disposition {
        CacheDisposition::Public => Some(
            FreshnessPolicy::new(Duration::from_secs(300), Some(Duration::from_secs(30)))
                .expect("constant public freshness is valid"),
        ),
        CacheDisposition::Private if route.area == RouteArea::Account => Some(
            FreshnessPolicy::new(Duration::from_secs(60), Some(Duration::from_secs(30)))
                .expect("constant account freshness is valid"),
        ),
        CacheDisposition::Private => Some(
            FreshnessPolicy::new(Duration::from_secs(30), Some(Duration::from_secs(15)))
                .expect("constant private freshness is valid"),
        ),
        CacheDisposition::Uncacheable => None,
    }
}

fn cache_headers_from_plan(plan: &CachePlan) -> BTreeMap<String, String> {
    let mut headers = BTreeMap::new();
    headers.insert(
        "Cache-Control".to_string(),
        plan.http().cache_control().to_string(),
    );

    if let Some(variation) = plan.http().variation() {
        headers.insert(
            "X-Coil-Variation-Key".to_string(),
            variation.as_str().to_string(),
        );
    }

    if let Some(etag) = plan.http().validators().etag.as_ref() {
        headers.insert("ETag".to_string(), etag.as_str().to_string());
    }

    if let Some(surrogate_tags) = plan.http().surrogate_tags().header_value() {
        headers.insert("Surrogate-Key".to_string(), surrogate_tags);
    }

    headers
}