soaprs-http 0.3.0

Transport-neutral HTTP contracts and policies for soaprs
Documentation
//! Framework-neutral request information used by auth and policy adapters.

use std::{collections::BTreeMap, fmt, net::IpAddr};

use http::{HeaderMap, HeaderName, HeaderValue, Method, Uri};
use soaprs_core::{MessageId, SoapError, SoapResult};

/// Read-only request view implemented by framework adapters or [`HttpRequestParts`].
///
/// Request bodies are intentionally absent. Framework extractors and validation
/// adapters own body buffering, decoding, and typed input construction.
pub trait HttpRequestView: Send + Sync {
    /// Returns the HTTP method.
    fn method(&self) -> &Method;

    /// Returns the complete request URI.
    fn uri(&self) -> &Uri;

    /// Returns normalized request headers.
    fn headers(&self) -> &HeaderMap;

    /// Returns one parsed cookie value.
    fn cookie(&self, name: &str) -> Option<&str>;

    /// Returns one decoded route parameter.
    fn path_parameter(&self, name: &str) -> Option<&str>;

    /// Returns every decoded value for one query parameter.
    fn query_parameters(&self, name: &str) -> Option<&[String]>;

    /// Returns the normalized client IP when trusted proxy processing supplied it.
    fn client_ip(&self) -> Option<IpAddr>;

    /// Returns the request identity generated or accepted by the application boundary.
    fn request_id(&self) -> Option<&MessageId>;
}

/// Owned neutral request parts useful for adapter composition and tests.
#[derive(Clone)]
pub struct HttpRequestParts {
    method: Method,
    uri: Uri,
    headers: HeaderMap,
    cookies: BTreeMap<String, String>,
    path_parameters: BTreeMap<String, String>,
    query_parameters: BTreeMap<String, Vec<String>>,
    client_ip: Option<IpAddr>,
    request_id: Option<MessageId>,
}

impl fmt::Debug for HttpRequestParts {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("HttpRequestParts")
            .field("method", &self.method)
            .field("header_names", &self.headers.keys().collect::<Vec<_>>())
            .field("cookie_names", &self.cookies.keys().collect::<Vec<_>>())
            .field(
                "path_parameter_names",
                &self.path_parameters.keys().collect::<Vec<_>>(),
            )
            .field(
                "query_parameter_names",
                &self.query_parameters.keys().collect::<Vec<_>>(),
            )
            .field("client_ip", &self.client_ip)
            .field("request_id", &self.request_id)
            .finish_non_exhaustive()
    }
}

impl HttpRequestParts {
    /// Creates empty neutral request parts.
    pub fn new(method: Method, uri: Uri) -> Self {
        Self {
            method,
            uri,
            headers: HeaderMap::new(),
            cookies: BTreeMap::new(),
            path_parameters: BTreeMap::new(),
            query_parameters: BTreeMap::new(),
            client_ip: None,
            request_id: None,
        }
    }

    /// Inserts or replaces one request header.
    #[must_use]
    pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
        self.headers.insert(name, value);
        self
    }

    /// Inserts or replaces one parsed cookie.
    pub fn with_cookie(
        mut self,
        name: impl Into<String>,
        value: impl Into<String>,
    ) -> SoapResult<Self> {
        let name = name.into();
        let value = value.into();
        validate_cookie(&name, &value)?;
        self.cookies.insert(name, value);
        Ok(self)
    }

    /// Inserts or replaces one decoded route parameter.
    pub fn with_path_parameter(
        mut self,
        name: impl Into<String>,
        value: impl Into<String>,
    ) -> SoapResult<Self> {
        let name = name.into();
        let value = value.into();
        validate_parameter_name(&name)?;
        if value.is_empty() {
            return Err(SoapError::validation(
                "HTTP path parameter value cannot be empty",
            ));
        }
        self.path_parameters.insert(name, value);
        Ok(self)
    }

    /// Appends one decoded query parameter value.
    pub fn with_query_parameter(
        mut self,
        name: impl Into<String>,
        value: impl Into<String>,
    ) -> SoapResult<Self> {
        let name = name.into();
        validate_parameter_name(&name)?;
        self.query_parameters
            .entry(name)
            .or_default()
            .push(value.into());
        Ok(self)
    }

    /// Sets the normalized client IP after adapter-specific trusted proxy processing.
    #[must_use]
    pub const fn with_client_ip(mut self, client_ip: IpAddr) -> Self {
        self.client_ip = Some(client_ip);
        self
    }

    /// Sets the request identity.
    #[must_use]
    pub fn with_request_id(mut self, request_id: impl Into<MessageId>) -> Self {
        self.request_id = Some(request_id.into());
        self
    }
}

impl HttpRequestView for HttpRequestParts {
    fn method(&self) -> &Method {
        &self.method
    }

    fn uri(&self) -> &Uri {
        &self.uri
    }

    fn headers(&self) -> &HeaderMap {
        &self.headers
    }

    fn cookie(&self, name: &str) -> Option<&str> {
        self.cookies.get(name).map(String::as_str)
    }

    fn path_parameter(&self, name: &str) -> Option<&str> {
        self.path_parameters.get(name).map(String::as_str)
    }

    fn query_parameters(&self, name: &str) -> Option<&[String]> {
        self.query_parameters.get(name).map(Vec::as_slice)
    }

    fn client_ip(&self) -> Option<IpAddr> {
        self.client_ip
    }

    fn request_id(&self) -> Option<&MessageId> {
        self.request_id.as_ref()
    }
}

fn validate_parameter_name(name: &str) -> SoapResult<()> {
    let mut characters = name.chars();
    let Some(first) = characters.next() else {
        return Err(SoapError::validation("HTTP parameter name cannot be empty"));
    };
    if (first == '_' || first.is_ascii_alphabetic())
        && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
    {
        Ok(())
    } else {
        Err(SoapError::validation(format!(
            "invalid HTTP parameter name `{name}`"
        )))
    }
}

fn validate_cookie(name: &str, value: &str) -> SoapResult<()> {
    if name.is_empty()
        || !name.chars().all(valid_cookie_name_character)
        || !value.bytes().all(valid_cookie_value_byte)
    {
        return Err(SoapError::validation("invalid HTTP cookie name or value"));
    }
    Ok(())
}

fn valid_cookie_value_byte(byte: u8) -> bool {
    matches!(byte, 0x21 | 0x23..=0x2b | 0x2d..=0x3a | 0x3c..=0x5b | 0x5d..=0x7e)
}

fn valid_cookie_name_character(character: char) -> bool {
    character.is_ascii_alphanumeric()
        || matches!(
            character,
            '!' | '#'
                | '$'
                | '%'
                | '&'
                | '\''
                | '*'
                | '+'
                | '-'
                | '.'
                | '^'
                | '_'
                | '`'
                | '|'
                | '~'
        )
}

#[cfg(test)]
mod tests {
    use std::net::{IpAddr, Ipv4Addr};

    use http::{HeaderValue, header::AUTHORIZATION};
    use http::{Method, Uri};

    use super::{HttpRequestParts, HttpRequestView};

    #[test]
    fn owned_request_parts_preserve_adapter_normalized_context() {
        let uri = Uri::from_static("/users/42?expand=roles&expand=teams");
        let result = HttpRequestParts::new(Method::GET, uri)
            .with_path_parameter("user_id", "42")
            .and_then(|request| request.with_query_parameter("expand", "roles"))
            .and_then(|request| request.with_query_parameter("expand", "teams"))
            .and_then(|request| request.with_cookie("session", "opaque-token"))
            .map(|request| {
                request
                    .with_client_ip(IpAddr::V4(Ipv4Addr::LOCALHOST))
                    .with_request_id("request-1")
            });
        let Some(request) = result.ok() else {
            panic!("valid request parts");
        };

        assert_eq!(request.path_parameter("user_id"), Some("42"));
        assert_eq!(
            request.query_parameters("expand"),
            Some(["roles".to_owned(), "teams".to_owned()].as_slice())
        );
        assert_eq!(request.cookie("session"), Some("opaque-token"));
        assert_eq!(
            request.request_id().map(|id| id.as_str()),
            Some("request-1")
        );
    }

    #[test]
    fn request_parts_reject_ambiguous_parameters_and_cookie_injection() {
        let uri = Uri::from_static("/");
        assert!(
            HttpRequestParts::new(Method::GET, uri.clone())
                .with_path_parameter("bad-name", "42")
                .is_err()
        );
        assert!(
            HttpRequestParts::new(Method::GET, uri)
                .with_cookie("session", "value; injected=true")
                .is_err()
        );
        assert!(
            HttpRequestParts::new(Method::GET, Uri::from_static("/"))
                .with_cookie("session", "value with spaces")
                .is_err()
        );
    }

    #[test]
    fn request_debug_output_redacts_headers_cookies_and_query_values() {
        let request = HttpRequestParts::new(
            Method::GET,
            Uri::from_static("/users?access_token=query-secret"),
        )
        .with_header(
            AUTHORIZATION,
            HeaderValue::from_static("Bearer header-secret"),
        )
        .with_cookie("session", "cookie-secret")
        .unwrap_or_else(|error| panic!("valid request fixture: {error}"))
        .with_query_parameter("access_token", "normalized-query-secret")
        .unwrap_or_else(|error| panic!("valid query fixture: {error}"));
        let debug = format!("{request:?}");

        assert!(debug.contains("authorization"));
        assert!(debug.contains("session"));
        assert!(!debug.contains("header-secret"));
        assert!(!debug.contains("cookie-secret"));
        assert!(!debug.contains("query-secret"));
        assert!(!debug.contains("normalized-query-secret"));
    }
}