use std::{collections::BTreeMap, fmt, net::IpAddr};
use http::{HeaderMap, HeaderName, HeaderValue, Method, Uri};
use soaprs_core::{MessageId, SoapError, SoapResult};
pub trait HttpRequestView: Send + Sync {
fn method(&self) -> &Method;
fn uri(&self) -> &Uri;
fn headers(&self) -> &HeaderMap;
fn cookie(&self, name: &str) -> Option<&str>;
fn path_parameter(&self, name: &str) -> Option<&str>;
fn query_parameters(&self, name: &str) -> Option<&[String]>;
fn client_ip(&self) -> Option<IpAddr>;
fn request_id(&self) -> Option<&MessageId>;
}
#[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 {
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,
}
}
#[must_use]
pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
self.headers.insert(name, value);
self
}
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)
}
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)
}
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)
}
#[must_use]
pub const fn with_client_ip(mut self, client_ip: IpAddr) -> Self {
self.client_ip = Some(client_ip);
self
}
#[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"));
}
}