soaprs-http 0.3.0

Transport-neutral HTTP contracts and policies for soaprs
Documentation
//! Provider-neutral telemetry declarations with safe defaults.

use http::HeaderName;
use http::header::{AUTHORIZATION, COOKIE, PROXY_AUTHORIZATION, SET_COOKIE};
use soaprs_core::{SoapError, SoapResult};

/// Endpoint-level telemetry instructions consumed by tracing and metrics adapters.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TelemetryPolicy {
    /// Whether request tracing and metrics are enabled.
    pub enabled: bool,
    /// Optional stable span name; endpoint identity is used when absent.
    pub span_name: Option<String>,
    request_headers: Vec<HeaderName>,
    response_headers: Vec<HeaderName>,
}

impl TelemetryPolicy {
    /// Creates enabled telemetry without recording request or response data.
    pub const fn enabled() -> Self {
        Self {
            enabled: true,
            span_name: None,
            request_headers: Vec::new(),
            response_headers: Vec::new(),
        }
    }

    /// Disables endpoint tracing and metrics explicitly.
    pub const fn disabled() -> Self {
        Self {
            enabled: false,
            span_name: None,
            request_headers: Vec::new(),
            response_headers: Vec::new(),
        }
    }

    /// Sets a stable non-empty span name.
    pub fn span_name(mut self, span_name: impl Into<String>) -> SoapResult<Self> {
        let span_name = span_name.into();
        if span_name.trim().is_empty() || span_name.chars().any(char::is_control) {
            return Err(SoapError::validation(
                "telemetry span name is empty or contains control characters",
            ));
        }
        self.span_name = Some(span_name);
        Ok(self)
    }

    /// Allows one non-sensitive request header to be recorded.
    pub fn record_request_header(mut self, header: HeaderName) -> SoapResult<Self> {
        if sensitive_request_header(&header) {
            return Err(SoapError::validation(format!(
                "sensitive request header `{header}` cannot be recorded"
            )));
        }
        push_once(&mut self.request_headers, header);
        Ok(self)
    }

    /// Allows one non-sensitive response header to be recorded.
    pub fn record_response_header(mut self, header: HeaderName) -> SoapResult<Self> {
        if header == SET_COOKIE {
            return Err(SoapError::validation(
                "sensitive response header `set-cookie` cannot be recorded",
            ));
        }
        push_once(&mut self.response_headers, header);
        Ok(self)
    }

    /// Returns the allowlist of request headers safe to record.
    pub fn request_headers(&self) -> &[HeaderName] {
        &self.request_headers
    }

    /// Returns the allowlist of response headers safe to record.
    pub fn response_headers(&self) -> &[HeaderName] {
        &self.response_headers
    }

    /// Validates public telemetry fields after direct mutation.
    pub fn validate(&self) -> SoapResult<()> {
        if self.span_name.as_ref().is_some_and(|span_name| {
            span_name.trim().is_empty() || span_name.chars().any(char::is_control)
        }) {
            return Err(SoapError::validation(
                "telemetry span name is empty or contains control characters",
            ));
        }
        Ok(())
    }
}

impl Default for TelemetryPolicy {
    fn default() -> Self {
        Self::enabled()
    }
}

fn sensitive_request_header(header: &HeaderName) -> bool {
    header == AUTHORIZATION
        || header == PROXY_AUTHORIZATION
        || header == COOKIE
        || header == SET_COOKIE
}

fn push_once(headers: &mut Vec<HeaderName>, header: HeaderName) {
    if !headers.contains(&header) {
        headers.push(header);
    }
}

#[cfg(test)]
mod tests {
    use http::header::{AUTHORIZATION, CONTENT_TYPE, SET_COOKIE};

    use super::TelemetryPolicy;

    #[test]
    fn sensitive_headers_are_denied_from_telemetry_allowlists() {
        assert!(
            TelemetryPolicy::enabled()
                .record_request_header(AUTHORIZATION)
                .is_err()
        );
        assert!(
            TelemetryPolicy::enabled()
                .record_response_header(SET_COOKIE)
                .is_err()
        );
        let policy = TelemetryPolicy::enabled().record_request_header(CONTENT_TYPE);
        assert_eq!(
            policy.ok().map(|value| value.request_headers().len()),
            Some(1)
        );
    }
}