1use std::{collections::BTreeMap, fmt, net::IpAddr};
4
5use http::{HeaderMap, HeaderName, HeaderValue, Method, Uri};
6use soaprs_core::{MessageId, SoapError, SoapResult};
7
8pub trait HttpRequestView: Send + Sync {
13 fn method(&self) -> &Method;
15
16 fn uri(&self) -> &Uri;
18
19 fn headers(&self) -> &HeaderMap;
21
22 fn cookie(&self, name: &str) -> Option<&str>;
24
25 fn path_parameter(&self, name: &str) -> Option<&str>;
27
28 fn query_parameters(&self, name: &str) -> Option<&[String]>;
30
31 fn client_ip(&self) -> Option<IpAddr>;
33
34 fn request_id(&self) -> Option<&MessageId>;
36}
37
38#[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 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 #[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 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 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 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 #[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 #[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}