Skip to main content

soaprs_http/
request.rs

1//! Framework-neutral request information used by auth and policy adapters.
2
3use std::{collections::BTreeMap, fmt, net::IpAddr};
4
5use http::{HeaderMap, HeaderName, HeaderValue, Method, Uri};
6use soaprs_core::{MessageId, SoapError, SoapResult};
7
8/// Read-only request view implemented by framework adapters or [`HttpRequestParts`].
9///
10/// Request bodies are intentionally absent. Framework extractors and validation
11/// adapters own body buffering, decoding, and typed input construction.
12pub trait HttpRequestView: Send + Sync {
13    /// Returns the HTTP method.
14    fn method(&self) -> &Method;
15
16    /// Returns the complete request URI.
17    fn uri(&self) -> &Uri;
18
19    /// Returns normalized request headers.
20    fn headers(&self) -> &HeaderMap;
21
22    /// Returns one parsed cookie value.
23    fn cookie(&self, name: &str) -> Option<&str>;
24
25    /// Returns one decoded route parameter.
26    fn path_parameter(&self, name: &str) -> Option<&str>;
27
28    /// Returns every decoded value for one query parameter.
29    fn query_parameters(&self, name: &str) -> Option<&[String]>;
30
31    /// Returns the normalized client IP when trusted proxy processing supplied it.
32    fn client_ip(&self) -> Option<IpAddr>;
33
34    /// Returns the request identity generated or accepted by the application boundary.
35    fn request_id(&self) -> Option<&MessageId>;
36}
37
38/// Owned neutral request parts useful for adapter composition and tests.
39#[derive(Clone)]
40pub struct HttpRequestParts {
41    method: Method,
42    uri: Uri,
43    headers: HeaderMap,
44    cookies: BTreeMap<String, String>,
45    path_parameters: BTreeMap<String, String>,
46    query_parameters: BTreeMap<String, Vec<String>>,
47    client_ip: Option<IpAddr>,
48    request_id: Option<MessageId>,
49}
50
51impl fmt::Debug for HttpRequestParts {
52    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53        formatter
54            .debug_struct("HttpRequestParts")
55            .field("method", &self.method)
56            .field("header_names", &self.headers.keys().collect::<Vec<_>>())
57            .field("cookie_names", &self.cookies.keys().collect::<Vec<_>>())
58            .field(
59                "path_parameter_names",
60                &self.path_parameters.keys().collect::<Vec<_>>(),
61            )
62            .field(
63                "query_parameter_names",
64                &self.query_parameters.keys().collect::<Vec<_>>(),
65            )
66            .field("client_ip", &self.client_ip)
67            .field("request_id", &self.request_id)
68            .finish_non_exhaustive()
69    }
70}
71
72impl HttpRequestParts {
73    /// Creates empty neutral request parts.
74    pub fn new(method: Method, uri: Uri) -> Self {
75        Self {
76            method,
77            uri,
78            headers: HeaderMap::new(),
79            cookies: BTreeMap::new(),
80            path_parameters: BTreeMap::new(),
81            query_parameters: BTreeMap::new(),
82            client_ip: None,
83            request_id: None,
84        }
85    }
86
87    /// Inserts or replaces one request header.
88    #[must_use]
89    pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
90        self.headers.insert(name, value);
91        self
92    }
93
94    /// Inserts or replaces one parsed cookie.
95    pub fn with_cookie(
96        mut self,
97        name: impl Into<String>,
98        value: impl Into<String>,
99    ) -> SoapResult<Self> {
100        let name = name.into();
101        let value = value.into();
102        validate_cookie(&name, &value)?;
103        self.cookies.insert(name, value);
104        Ok(self)
105    }
106
107    /// Inserts or replaces one decoded route parameter.
108    pub fn with_path_parameter(
109        mut self,
110        name: impl Into<String>,
111        value: impl Into<String>,
112    ) -> SoapResult<Self> {
113        let name = name.into();
114        let value = value.into();
115        validate_parameter_name(&name)?;
116        if value.is_empty() {
117            return Err(SoapError::validation(
118                "HTTP path parameter value cannot be empty",
119            ));
120        }
121        self.path_parameters.insert(name, value);
122        Ok(self)
123    }
124
125    /// Appends one decoded query parameter value.
126    pub fn with_query_parameter(
127        mut self,
128        name: impl Into<String>,
129        value: impl Into<String>,
130    ) -> SoapResult<Self> {
131        let name = name.into();
132        validate_parameter_name(&name)?;
133        self.query_parameters
134            .entry(name)
135            .or_default()
136            .push(value.into());
137        Ok(self)
138    }
139
140    /// Sets the normalized client IP after adapter-specific trusted proxy processing.
141    #[must_use]
142    pub const fn with_client_ip(mut self, client_ip: IpAddr) -> Self {
143        self.client_ip = Some(client_ip);
144        self
145    }
146
147    /// Sets the request identity.
148    #[must_use]
149    pub fn with_request_id(mut self, request_id: impl Into<MessageId>) -> Self {
150        self.request_id = Some(request_id.into());
151        self
152    }
153}
154
155impl HttpRequestView for HttpRequestParts {
156    fn method(&self) -> &Method {
157        &self.method
158    }
159
160    fn uri(&self) -> &Uri {
161        &self.uri
162    }
163
164    fn headers(&self) -> &HeaderMap {
165        &self.headers
166    }
167
168    fn cookie(&self, name: &str) -> Option<&str> {
169        self.cookies.get(name).map(String::as_str)
170    }
171
172    fn path_parameter(&self, name: &str) -> Option<&str> {
173        self.path_parameters.get(name).map(String::as_str)
174    }
175
176    fn query_parameters(&self, name: &str) -> Option<&[String]> {
177        self.query_parameters.get(name).map(Vec::as_slice)
178    }
179
180    fn client_ip(&self) -> Option<IpAddr> {
181        self.client_ip
182    }
183
184    fn request_id(&self) -> Option<&MessageId> {
185        self.request_id.as_ref()
186    }
187}
188
189fn validate_parameter_name(name: &str) -> SoapResult<()> {
190    let mut characters = name.chars();
191    let Some(first) = characters.next() else {
192        return Err(SoapError::validation("HTTP parameter name cannot be empty"));
193    };
194    if (first == '_' || first.is_ascii_alphabetic())
195        && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
196    {
197        Ok(())
198    } else {
199        Err(SoapError::validation(format!(
200            "invalid HTTP parameter name `{name}`"
201        )))
202    }
203}
204
205fn validate_cookie(name: &str, value: &str) -> SoapResult<()> {
206    if name.is_empty()
207        || !name.chars().all(valid_cookie_name_character)
208        || !value.bytes().all(valid_cookie_value_byte)
209    {
210        return Err(SoapError::validation("invalid HTTP cookie name or value"));
211    }
212    Ok(())
213}
214
215fn valid_cookie_value_byte(byte: u8) -> bool {
216    matches!(byte, 0x21 | 0x23..=0x2b | 0x2d..=0x3a | 0x3c..=0x5b | 0x5d..=0x7e)
217}
218
219fn valid_cookie_name_character(character: char) -> bool {
220    character.is_ascii_alphanumeric()
221        || matches!(
222            character,
223            '!' | '#'
224                | '$'
225                | '%'
226                | '&'
227                | '\''
228                | '*'
229                | '+'
230                | '-'
231                | '.'
232                | '^'
233                | '_'
234                | '`'
235                | '|'
236                | '~'
237        )
238}
239
240#[cfg(test)]
241mod tests {
242    use std::net::{IpAddr, Ipv4Addr};
243
244    use http::{HeaderValue, header::AUTHORIZATION};
245    use http::{Method, Uri};
246
247    use super::{HttpRequestParts, HttpRequestView};
248
249    #[test]
250    fn owned_request_parts_preserve_adapter_normalized_context() {
251        let uri = Uri::from_static("/users/42?expand=roles&expand=teams");
252        let result = HttpRequestParts::new(Method::GET, uri)
253            .with_path_parameter("user_id", "42")
254            .and_then(|request| request.with_query_parameter("expand", "roles"))
255            .and_then(|request| request.with_query_parameter("expand", "teams"))
256            .and_then(|request| request.with_cookie("session", "opaque-token"))
257            .map(|request| {
258                request
259                    .with_client_ip(IpAddr::V4(Ipv4Addr::LOCALHOST))
260                    .with_request_id("request-1")
261            });
262        let Some(request) = result.ok() else {
263            panic!("valid request parts");
264        };
265
266        assert_eq!(request.path_parameter("user_id"), Some("42"));
267        assert_eq!(
268            request.query_parameters("expand"),
269            Some(["roles".to_owned(), "teams".to_owned()].as_slice())
270        );
271        assert_eq!(request.cookie("session"), Some("opaque-token"));
272        assert_eq!(
273            request.request_id().map(|id| id.as_str()),
274            Some("request-1")
275        );
276    }
277
278    #[test]
279    fn request_parts_reject_ambiguous_parameters_and_cookie_injection() {
280        let uri = Uri::from_static("/");
281        assert!(
282            HttpRequestParts::new(Method::GET, uri.clone())
283                .with_path_parameter("bad-name", "42")
284                .is_err()
285        );
286        assert!(
287            HttpRequestParts::new(Method::GET, uri)
288                .with_cookie("session", "value; injected=true")
289                .is_err()
290        );
291        assert!(
292            HttpRequestParts::new(Method::GET, Uri::from_static("/"))
293                .with_cookie("session", "value with spaces")
294                .is_err()
295        );
296    }
297
298    #[test]
299    fn request_debug_output_redacts_headers_cookies_and_query_values() {
300        let request = HttpRequestParts::new(
301            Method::GET,
302            Uri::from_static("/users?access_token=query-secret"),
303        )
304        .with_header(
305            AUTHORIZATION,
306            HeaderValue::from_static("Bearer header-secret"),
307        )
308        .with_cookie("session", "cookie-secret")
309        .unwrap_or_else(|error| panic!("valid request fixture: {error}"))
310        .with_query_parameter("access_token", "normalized-query-secret")
311        .unwrap_or_else(|error| panic!("valid query fixture: {error}"));
312        let debug = format!("{request:?}");
313
314        assert!(debug.contains("authorization"));
315        assert!(debug.contains("session"));
316        assert!(!debug.contains("header-secret"));
317        assert!(!debug.contains("cookie-secret"));
318        assert!(!debug.contains("query-secret"));
319        assert!(!debug.contains("normalized-query-secret"));
320    }
321}