soaprs-http 0.5.0

Transport-neutral HTTP contracts and policies for soaprs
Documentation
//! Complete endpoint declarations without framework handler types.

use std::time::Duration;

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

use crate::{
    AuthorizationPolicy, BodyLimitPolicy, CacheVisibility, CorsPolicy, CsrfPolicy,
    EndpointContracts, EndpointId, OperationDocumentation, RateLimitPolicy, RequestContract,
    RequestContractLocation, ResponseCachePolicy, ResponseContract, RoutePath,
    SecurityHeadersPolicy, TelemetryPolicy,
};

/// Portable endpoint definition consumed by framework, auth, docs, and telemetry adapters.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointMetadata {
    /// Stable endpoint identity used by diagnostics and API documentation.
    pub id: EndpointId,
    /// HTTP method.
    pub method: Method,
    /// Portable route path.
    pub path: RoutePath,
    /// Successful response status used when a handler returns plain output.
    pub success_status: StatusCode,
    /// Authentication and authorization requirement.
    pub authorization: AuthorizationPolicy,
    /// Optional request rate limit.
    pub rate_limit: Option<RateLimitPolicy>,
    /// Optional request timeout.
    pub timeout: Option<Duration>,
    /// Optional maximum encoded request body size.
    pub body_limit: Option<BodyLimitPolicy>,
    /// Optional cross-origin policy.
    pub cors: Option<CorsPolicy>,
    /// Cross-site request-forgery requirement.
    pub csrf: CsrfPolicy,
    /// Optional security response headers. Secure defaults are enabled initially.
    pub security_headers: Option<SecurityHeadersPolicy>,
    /// Optional HTTP response caching policy.
    pub response_cache: Option<ResponseCachePolicy>,
    /// Logical validation and response schema references.
    pub contracts: EndpointContracts,
    /// Provider-neutral operation documentation.
    pub documentation: OperationDocumentation,
    /// Provider-neutral tracing and metrics instructions.
    pub telemetry: TelemetryPolicy,
    /// Documentation and grouping tags.
    pub tags: Vec<String>,
}

impl EndpointMetadata {
    /// Creates a public endpoint with secure headers and telemetry enabled.
    pub fn new(id: impl Into<String>, method: Method, path: RoutePath) -> SoapResult<Self> {
        Ok(Self {
            id: EndpointId::new(id)?,
            method,
            path,
            success_status: StatusCode::OK,
            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,
            contracts: EndpointContracts::default(),
            documentation: OperationDocumentation::default(),
            telemetry: TelemetryPolicy::enabled(),
            tags: Vec::new(),
        })
    }

    /// Sets a successful 2xx response status.
    pub fn success_status(mut self, status: StatusCode) -> SoapResult<Self> {
        if !status.is_success() {
            return Err(SoapError::validation(
                "endpoint success status must be in the 2xx class",
            ));
        }
        self.success_status = status;
        Ok(self)
    }

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

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

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

    /// Limits the encoded request body before extraction.
    #[must_use]
    pub fn body_limit(mut self, policy: BodyLimitPolicy) -> Self {
        self.body_limit = Some(policy);
        self
    }

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

    /// Requires a CSRF adapter to validate the request.
    #[must_use]
    pub const fn require_csrf(mut self) -> Self {
        self.csrf = CsrfPolicy::Required;
        self
    }

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

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

    /// Sets an HTTP response caching policy.
    pub fn response_cache(mut self, policy: ResponseCachePolicy) -> SoapResult<Self> {
        if policy.visibility == CacheVisibility::Public
            && !self.authorization.allows_public_response_cache()
        {
            return Err(SoapError::validation(
                "authenticated endpoint responses cannot use public caches",
            ));
        }
        self.response_cache = Some(policy);
        Ok(self)
    }

    /// Adds or replaces a request contract for one location.
    #[must_use]
    pub fn request_contract(mut self, contract: RequestContract) -> Self {
        self.contracts.add_request(contract);
        self
    }

    /// Adds or replaces a response contract for one status.
    #[must_use]
    pub fn response_contract(mut self, contract: ResponseContract) -> Self {
        self.contracts.add_response(contract);
        self
    }

    /// Replaces operation documentation.
    #[must_use]
    pub fn documentation(mut self, documentation: OperationDocumentation) -> Self {
        self.documentation = documentation;
        self
    }

    /// Replaces endpoint telemetry instructions.
    #[must_use]
    pub fn telemetry(mut self, telemetry: TelemetryPolicy) -> Self {
        self.telemetry = telemetry;
        self
    }

    /// Adds a non-empty documentation tag once.
    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 tag cannot be empty"));
        }
        if !self.tags.contains(&tag) {
            self.tags.push(tag);
        }
        Ok(self)
    }

    /// Validates invariants after direct public-field construction or mutation.
    pub fn validate(&self) -> SoapResult<()> {
        self.authorization.validate()?;
        if !self.success_status.is_success() {
            return Err(SoapError::validation(
                "endpoint success status must be in the 2xx class",
            ));
        }
        if self.timeout.is_some_and(|timeout| timeout.is_zero()) {
            return Err(SoapError::validation(
                "endpoint timeout must be greater than zero",
            ));
        }
        if let Some(policy) = &self.rate_limit {
            policy.validate()?;
        }
        if let Some(policy) = &self.cors {
            policy.validate()?;
        }
        if let Some(policy) = &self.security_headers {
            policy.validate()?;
        }
        if let Some(policy) = &self.response_cache {
            policy.validate()?;
        }
        self.documentation.validate()?;
        self.telemetry.validate()?;
        if self.tags.iter().any(|tag| tag.trim().is_empty()) {
            return Err(SoapError::validation("endpoint tag cannot be empty"));
        }
        if self.contracts.requests().iter().any(|contract| {
            contract.location != RequestContractLocation::Body && contract.content_type.is_some()
        }) {
            return Err(SoapError::validation(
                "only body request contracts may declare a content type",
            ));
        }
        if self.response_cache.as_ref().is_some_and(|policy| {
            policy.visibility == CacheVisibility::Public
                && !self.authorization.allows_public_response_cache()
        }) {
            return Err(SoapError::validation(
                "authenticated endpoint responses cannot use public caches",
            ));
        }
        Ok(())
    }
}

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

    use http::{Method, StatusCode};

    use crate::{
        AuthorizationPolicy, BodyLimitPolicy, EndpointMetadata, ResponseCachePolicy, RoutePath,
    };

    #[test]
    fn builds_complete_metadata_without_a_framework_handler() {
        let path = RoutePath::new("/users/{id}");
        let Some(path) = path.ok() else {
            panic!("valid route path");
        };
        let result = EndpointMetadata::new("users.get", Method::GET, path)
            .and_then(|metadata| metadata.authorize(AuthorizationPolicy::Authenticated))
            .and_then(|metadata| metadata.timeout(Duration::from_secs(5)))
            .and_then(|metadata| metadata.success_status(StatusCode::OK))
            .map(|metadata| metadata.body_limit(BodyLimitPolicy::new(NonZeroU64::MIN)))
            .and_then(|metadata| metadata.tag("users"));

        assert!(result.and_then(|metadata| metadata.validate()).is_ok());
    }

    #[test]
    fn protected_endpoints_cannot_be_publicly_cached() {
        let path = RoutePath::new("/me");
        let cache = ResponseCachePolicy::public(Duration::from_secs(60));
        let (Some(path), Some(cache)) = (path.ok(), cache.ok()) else {
            panic!("valid fixtures");
        };
        let result = EndpointMetadata::new("users.me", Method::GET, path)
            .and_then(|metadata| metadata.authorize(AuthorizationPolicy::Authenticated))
            .and_then(|metadata| metadata.response_cache(cache));
        assert!(result.is_err());
    }
}