soaprs-http 0.5.0

Transport-neutral HTTP contracts and policies for soaprs
Documentation
//! Deterministic endpoint catalog and route grouping.

use std::{collections::HashMap, time::Duration};

use http::Method;
use soaprs_core::{SoapError, SoapResult};

use crate::{
    AuthorizationPolicy, BodyLimitPolicy, CorsPolicy, CsrfPolicy, EndpointId, EndpointMetadata,
    RateLimitPolicy, ResponseCachePolicy, RoutePath, SecurityHeadersPolicy, TelemetryPolicy,
};

/// Ordered endpoint catalog with duplicate identity and route-shape detection.
#[derive(Debug, Clone, Default)]
pub struct EndpointCatalog {
    endpoints: Vec<EndpointMetadata>,
    identities: HashMap<EndpointId, usize>,
    routes: HashMap<(Method, String), usize>,
}

impl EndpointCatalog {
    /// Creates an empty endpoint catalog.
    pub fn new() -> Self {
        Self::default()
    }

    /// Registers one validated endpoint.
    pub fn register(&mut self, endpoint: EndpointMetadata) -> SoapResult<()> {
        endpoint.validate()?;
        if self.identities.contains_key(&endpoint.id) {
            return Err(SoapError::conflict(format!(
                "endpoint `{}` is already registered",
                endpoint.id
            )));
        }
        let route_key = (endpoint.method.clone(), endpoint.path.shape());
        if let Some(existing) = self.routes.get(&route_key) {
            return Err(SoapError::conflict(format!(
                "route {} {} conflicts with endpoint `{}`",
                endpoint.method, endpoint.path, self.endpoints[*existing].id
            )));
        }

        let index = self.endpoints.len();
        self.identities.insert(endpoint.id.clone(), index);
        self.routes.insert(route_key, index);
        self.endpoints.push(endpoint);
        Ok(())
    }

    /// Atomically registers every endpoint or leaves the catalog unchanged.
    pub fn register_all<I>(&mut self, endpoints: I) -> SoapResult<()>
    where
        I: IntoIterator<Item = EndpointMetadata>,
    {
        let mut candidate = self.clone();
        for endpoint in endpoints {
            candidate.register(endpoint)?;
        }
        *self = candidate;
        Ok(())
    }

    /// Returns one endpoint by stable identity.
    pub fn endpoint(&self, id: &EndpointId) -> Option<&EndpointMetadata> {
        self.identities
            .get(id)
            .and_then(|index| self.endpoints.get(*index))
    }

    /// Returns one endpoint by method and declared portable route shape.
    pub fn route(&self, method: &Method, path: &RoutePath) -> Option<&EndpointMetadata> {
        self.routes
            .get(&(method.clone(), path.shape()))
            .and_then(|index| self.endpoints.get(*index))
    }

    /// Returns endpoints in deterministic registration order.
    pub fn endpoints(&self) -> &[EndpointMetadata] {
        &self.endpoints
    }

    /// Returns the number of registered endpoints.
    pub fn len(&self) -> usize {
        self.endpoints.len()
    }

    /// Reports whether the catalog is empty.
    pub fn is_empty(&self) -> bool {
        self.endpoints.is_empty()
    }

    /// Consumes the catalog into deterministic endpoint order.
    pub fn into_endpoints(self) -> Vec<EndpointMetadata> {
        self.endpoints
    }
}

/// Endpoint group that applies a route prefix and shared safe defaults.
#[derive(Debug, Clone)]
pub struct EndpointGroup {
    prefix: RoutePath,
    authorization: AuthorizationPolicy,
    rate_limit: Option<RateLimitPolicy>,
    timeout: Option<Duration>,
    body_limit: Option<BodyLimitPolicy>,
    cors: Option<CorsPolicy>,
    csrf: CsrfPolicy,
    security_headers: Option<SecurityHeadersPolicy>,
    response_cache: Option<ResponseCachePolicy>,
    telemetry: TelemetryPolicy,
    tags: Vec<String>,
}

impl EndpointGroup {
    /// Creates a public endpoint group under one portable prefix.
    pub fn new(prefix: RoutePath) -> Self {
        Self {
            prefix,
            authorization: AuthorizationPolicy::Public,
            rate_limit: None,
            timeout: None,
            body_limit: None,
            cors: None,
            csrf: CsrfPolicy::Disabled,
            security_headers: Some(SecurityHeadersPolicy::secure_defaults()),
            response_cache: None,
            telemetry: TelemetryPolicy::enabled(),
            tags: Vec::new(),
        }
    }

    /// Sets the authorization inherited by new endpoints.
    pub fn authorize(mut self, policy: AuthorizationPolicy) -> SoapResult<Self> {
        policy.validate()?;
        self.authorization = policy;
        Ok(self)
    }

    /// Sets the rate limit inherited by new endpoints.
    #[must_use]
    pub fn rate_limit(mut self, policy: RateLimitPolicy) -> Self {
        self.rate_limit = Some(policy);
        self
    }

    /// Sets the timeout inherited by new endpoints.
    pub fn timeout(mut self, timeout: Duration) -> SoapResult<Self> {
        if timeout.is_zero() {
            return Err(SoapError::validation(
                "endpoint group timeout must be greater than zero",
            ));
        }
        self.timeout = Some(timeout);
        Ok(self)
    }

    /// Sets the encoded body limit inherited by new endpoints.
    #[must_use]
    pub fn body_limit(mut self, policy: BodyLimitPolicy) -> Self {
        self.body_limit = Some(policy);
        self
    }

    /// Sets the cross-origin policy inherited by new endpoints.
    #[must_use]
    pub fn cors(mut self, policy: CorsPolicy) -> Self {
        self.cors = Some(policy);
        self
    }

    /// Requires CSRF validation for new endpoints.
    #[must_use]
    pub const fn require_csrf(mut self) -> Self {
        self.csrf = CsrfPolicy::Required;
        self
    }

    /// Replaces security-header defaults inherited by new endpoints.
    #[must_use]
    pub fn security_headers(mut self, policy: SecurityHeadersPolicy) -> Self {
        self.security_headers = Some(policy);
        self
    }

    /// Explicitly delegates security headers for new endpoints to the application.
    #[must_use]
    pub fn without_security_headers(mut self) -> Self {
        self.security_headers = None;
        self
    }

    /// Sets the response-cache policy inherited by new endpoints.
    pub fn response_cache(mut self, policy: ResponseCachePolicy) -> SoapResult<Self> {
        policy.validate()?;
        self.response_cache = Some(policy);
        Ok(self)
    }

    /// Replaces telemetry instructions inherited by new endpoints.
    pub fn telemetry(mut self, policy: TelemetryPolicy) -> SoapResult<Self> {
        policy.validate()?;
        self.telemetry = policy;
        Ok(self)
    }

    /// Adds one documentation tag inherited by new endpoints.
    pub fn tag(mut self, tag: impl Into<String>) -> SoapResult<Self> {
        let tag = tag.into();
        if tag.trim().is_empty() {
            return Err(SoapError::validation("endpoint group tag cannot be empty"));
        }
        if !self.tags.contains(&tag) {
            self.tags.push(tag);
        }
        Ok(self)
    }

    /// Creates a new endpoint with the group prefix and policies applied.
    pub fn endpoint(
        &self,
        id: impl Into<String>,
        method: Method,
        path: RoutePath,
    ) -> SoapResult<EndpointMetadata> {
        let mut endpoint = EndpointMetadata::new(id, method, self.prefix.join(&path)?)?
            .authorize(self.authorization.clone())?;
        if let Some(policy) = &self.rate_limit {
            endpoint = endpoint.rate_limit(policy.clone());
        }
        if let Some(timeout) = self.timeout {
            endpoint = endpoint.timeout(timeout)?;
        }
        if let Some(policy) = self.body_limit {
            endpoint = endpoint.body_limit(policy);
        }
        if let Some(policy) = &self.cors {
            endpoint = endpoint.cors(policy.clone());
        }
        if self.csrf == CsrfPolicy::Required {
            endpoint = endpoint.require_csrf();
        }
        endpoint = match &self.security_headers {
            Some(policy) => endpoint.security_headers(policy.clone()),
            None => endpoint.without_security_headers(),
        };
        if let Some(policy) = &self.response_cache {
            endpoint = endpoint.response_cache(policy.clone())?;
        }
        endpoint = endpoint.telemetry(self.telemetry.clone());
        for tag in &self.tags {
            endpoint = endpoint.tag(tag.clone())?;
        }
        Ok(endpoint)
    }
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use http::Method;
    use soaprs_core::SoapError;

    use super::{EndpointCatalog, EndpointGroup};
    use crate::{
        AuthorizationPolicy, CsrfPolicy, EndpointId, EndpointMetadata, ResponseCachePolicy,
        RoutePath, TelemetryPolicy,
    };

    #[test]
    fn catalog_rejects_duplicate_ids_and_equivalent_parameter_shapes() {
        let Some(first_path) = RoutePath::new("/users/{id}").ok() else {
            panic!("valid route");
        };
        let Some(second_path) = RoutePath::new("/users/{user_id}").ok() else {
            panic!("valid route");
        };
        let Some(first) = EndpointMetadata::new("users.get", Method::GET, first_path).ok() else {
            panic!("valid endpoint");
        };
        let Some(second) = EndpointMetadata::new("users.find", Method::GET, second_path).ok()
        else {
            panic!("valid endpoint");
        };
        let mut catalog = EndpointCatalog::new();
        assert!(catalog.register(first).is_ok());
        let conflict = catalog.register(second);
        assert_eq!(
            conflict.as_ref().map_err(SoapError::kind),
            Err(soaprs_core::SoapErrorKind::Conflict)
        );
    }

    #[test]
    fn bulk_registration_is_atomic_and_groups_apply_defaults() {
        let Some(prefix) = RoutePath::new("/api/v1").ok() else {
            panic!("valid prefix");
        };
        let group = EndpointGroup::new(prefix)
            .authorize(AuthorizationPolicy::Authenticated)
            .and_then(|group| group.tag("users"));
        let Some(group) = group.ok() else {
            panic!("valid group");
        };
        let Some(list_path) = RoutePath::new("/users").ok() else {
            panic!("valid path");
        };
        let Some(duplicate_path) = RoutePath::new("/users").ok() else {
            panic!("valid path");
        };
        let first = group.endpoint("users.list", Method::GET, list_path);
        let second = group.endpoint("users.other", Method::GET, duplicate_path);
        let (Some(first), Some(second)) = (first.ok(), second.ok()) else {
            panic!("valid endpoints");
        };
        let mut catalog = EndpointCatalog::new();
        assert!(catalog.register_all([first, second]).is_err());
        assert!(catalog.is_empty());

        let Some(id) = EndpointId::new("users.list").ok() else {
            panic!("valid endpoint id");
        };
        assert!(catalog.endpoint(&id).is_none());
    }

    #[test]
    fn groups_apply_security_cache_and_telemetry_defaults_consistently() {
        let Some(prefix) = RoutePath::new("/api").ok() else {
            panic!("valid prefix");
        };
        let Some(cache) = ResponseCachePolicy::private(Duration::from_secs(30)).ok() else {
            panic!("valid cache policy");
        };
        let group = EndpointGroup::new(prefix)
            .require_csrf()
            .without_security_headers()
            .response_cache(cache)
            .and_then(|group| group.telemetry(TelemetryPolicy::disabled()));
        let Some(group) = group.ok() else {
            panic!("valid endpoint group");
        };
        let Some(path) = RoutePath::new("/sessions").ok() else {
            panic!("valid endpoint path");
        };
        let Some(endpoint) = group.endpoint("sessions.create", Method::POST, path).ok() else {
            panic!("valid grouped endpoint");
        };

        assert_eq!(endpoint.csrf, CsrfPolicy::Required);
        assert!(endpoint.security_headers.is_none());
        assert!(endpoint.response_cache.is_some());
        assert!(!endpoint.telemetry.enabled);
        assert!(endpoint.validate().is_ok());
    }
}