af-context 0.4.0

Transport-neutral tenant, subject, locale, and entitlement context.
Documentation
//! Test doubles for the context ports: a provider that resolves one fixed
//! identity for every request.

use std::collections::BTreeSet;

use async_trait::async_trait;

use crate::{
    ContextError, PlatformContextProvider, RequestContext, RequestId, RequestMetadata,
    ScheduledContextProvider, SubjectId, TenantId,
};

/// Resolves every transport request and scheduled job to one tenant/subject.
/// `x-locale` metadata overrides the default locale; everything else is ignored.
#[derive(Debug, Clone)]
pub struct StaticContextProvider {
    /// Tenant that owns this record.
    pub tenant_id: TenantId,
    /// Subject (user or service principal) acting on or owning this record.
    pub subject_id: SubjectId,
    /// Roles granted to the subject.
    pub roles: BTreeSet<String>,
    /// Product entitlements granted to the subject.
    pub entitlements: BTreeSet<String>,
    /// BCP 47 locale for user-facing text.
    pub locale: String,
}

impl StaticContextProvider {
    /// A provider that answers every request as `tenant_id` / `subject_id`
    /// with no roles or entitlements.
    ///
    /// # Panics
    ///
    /// When either identifier is blank; a test double with an invalid
    /// identity is a test bug, not a runtime condition.
    pub fn new(tenant_id: &str, subject_id: &str) -> Self {
        Self {
            tenant_id: tenant_id.parse().expect("StaticContextProvider tenant_id"),
            subject_id: subject_id
                .parse()
                .expect("StaticContextProvider subject_id"),
            roles: BTreeSet::new(),
            entitlements: BTreeSet::new(),
            locale: "en".into(),
        }
    }

    /// Grant these roles to every resolved context.
    pub fn with_roles(mut self, roles: impl IntoIterator<Item = impl Into<String>>) -> Self {
        self.roles = roles.into_iter().map(Into::into).collect();
        self
    }

    /// Grant these entitlements to every resolved context.
    pub fn with_entitlements(
        mut self,
        entitlements: impl IntoIterator<Item = impl Into<String>>,
    ) -> Self {
        self.entitlements = entitlements.into_iter().map(Into::into).collect();
        self
    }

    fn context(&self, request_id: RequestId, locale: &str) -> RequestContext {
        RequestContext {
            tenant_id: self.tenant_id.clone(),
            subject_id: self.subject_id.clone(),
            roles: self.roles.clone(),
            locale: locale.to_owned(),
            request_id,
            entitlements: self.entitlements.clone(),
        }
    }
}

#[async_trait]
impl PlatformContextProvider for StaticContextProvider {
    async fn resolve(&self, metadata: &RequestMetadata) -> Result<RequestContext, ContextError> {
        let locale = metadata
            .0
            .get("x-locale")
            .map(String::as_str)
            .unwrap_or(&self.locale);
        let request_id = metadata
            .0
            .get("x-request-id")
            .cloned()
            .unwrap_or_else(|| format!("static-{}", self.roles.len()))
            .parse()
            .map_err(|error: crate::EmptyId| ContextError::Rejected(error.to_string()))?;
        Ok(self.context(request_id, locale))
    }
}

#[async_trait]
impl ScheduledContextProvider for StaticContextProvider {
    async fn resolve_scheduled(
        &self,
        tenant_id: &TenantId,
        subject_id: &SubjectId,
        request_id: &RequestId,
        locale: &str,
    ) -> Result<RequestContext, ContextError> {
        if tenant_id != &self.tenant_id || subject_id != &self.subject_id {
            return Err(ContextError::Rejected(format!(
                "static provider only serves {}/{}",
                self.tenant_id, self.subject_id
            )));
        }
        Ok(self.context(request_id.clone(), locale))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn static_provider_serves_one_identity() {
        let provider = StaticContextProvider::new("tenant", "subject").with_roles(["admin"]);
        let resolved = provider.resolve(&RequestMetadata::default()).await.unwrap();
        assert_eq!(resolved.tenant_id, "tenant");
        assert!(resolved.roles.contains("admin"));
        assert!(provider
            .resolve_scheduled(
                &"other".parse().unwrap(),
                &"subject".parse().unwrap(),
                &"r".parse().unwrap(),
                "en",
            )
            .await
            .is_err());
    }
}