Skip to main content

reinhardt_http/request/
params.rs

1use super::Request;
2use hyper::Uri;
3use percent_encoding::percent_decode_str;
4use std::collections::HashMap;
5
6impl Request {
7	/// Parse query parameters from URI
8	pub(super) fn parse_query_params(uri: &Uri) -> HashMap<String, String> {
9		uri.query()
10			.map(|q| {
11				q.split('&')
12					.filter_map(|pair| {
13						// Split on first '=' only to preserve '=' in values (e.g., Base64)
14						let mut parts = pair.splitn(2, '=');
15						Some((
16							parts.next()?.to_string(),
17							parts.next().unwrap_or("").to_string(),
18						))
19					})
20					.collect()
21			})
22			.unwrap_or_default()
23	}
24
25	/// Get the request path
26	///
27	/// # Examples
28	///
29	/// ```
30	/// use reinhardt_http::Request;
31	/// use hyper::Method;
32	///
33	/// let request = Request::builder()
34	///     .method(Method::GET)
35	///     .uri("/api/users")
36	///     .build()
37	///     .unwrap();
38	///
39	/// assert_eq!(request.path(), "/api/users");
40	/// ```
41	pub fn path(&self) -> &str {
42		self.uri.path()
43	}
44
45	/// Get URL-decoded query parameters
46	///
47	/// Returns a new HashMap with all query parameter keys and values URL-decoded.
48	/// This is useful when query parameters contain special characters or Unicode.
49	///
50	/// # Examples
51	///
52	/// ```
53	/// use reinhardt_http::Request;
54	/// use hyper::Method;
55	///
56	/// let request = Request::builder()
57	///     .method(Method::GET)
58	///     .uri("/test?name=John%20Doe")
59	///     .build()
60	///     .unwrap();
61	///
62	/// let decoded = request.decoded_query_params();
63	/// assert_eq!(decoded.get("name"), Some(&"John Doe".to_string()));
64	/// ```
65	pub fn decoded_query_params(&self) -> HashMap<String, String> {
66		self.query_params
67			.iter()
68			.map(|(k, v)| {
69				let decoded_key = percent_decode_str(k).decode_utf8_lossy().to_string();
70				let decoded_value = percent_decode_str(v).decode_utf8_lossy().to_string();
71				(decoded_key, decoded_value)
72			})
73			.collect()
74	}
75
76	/// Set a path parameter (used by routers for path variable extraction)
77	///
78	/// This method is typically called by routers when extracting path parameters
79	/// from URL patterns like `/users/{id}/`.
80	///
81	/// # Examples
82	///
83	/// ```
84	/// use reinhardt_http::Request;
85	/// use hyper::{Method, Uri, Version, HeaderMap};
86	/// use bytes::Bytes;
87	///
88	/// let mut request = Request::builder()
89	///     .method(Method::GET)
90	///     .uri("/users/123")
91	///     .build()
92	///     .unwrap();
93	///
94	/// request.set_path_param("id", "123");
95	/// assert_eq!(request.path_params.get("id"), Some(&"123".to_string()));
96	/// ```
97	pub fn set_path_param(&mut self, key: impl Into<String>, value: impl Into<String>) {
98		self.path_params.insert(key.into(), value.into());
99	}
100
101	/// Parse Accept-Language header and return ordered list of language codes
102	///
103	/// Returns languages sorted by quality value (q parameter), highest first.
104	/// Example: "en-US,en;q=0.9,ja;q=0.8" -> ["en-US", "en", "ja"]
105	///
106	/// # Examples
107	///
108	/// ```
109	/// use reinhardt_http::Request;
110	/// use hyper::{Method, Uri, Version, HeaderMap};
111	/// use bytes::Bytes;
112	///
113	/// let mut headers = HeaderMap::new();
114	/// headers.insert("accept-language", "en-US,en;q=0.9,ja;q=0.8".parse().unwrap());
115	///
116	/// let request = Request::builder()
117	///     .method(Method::GET)
118	///     .uri("/")
119	///     .headers(headers)
120	///     .build()
121	///     .unwrap();
122	///
123	/// let languages = request.get_accepted_languages();
124	/// assert_eq!(languages[0].0, "en-US");
125	/// assert_eq!(languages[0].1, 1.0);
126	/// assert_eq!(languages[1].0, "en");
127	/// assert_eq!(languages[1].1, 0.9);
128	/// ```
129	pub fn get_accepted_languages(&self) -> Vec<(String, f32)> {
130		use hyper::header::ACCEPT_LANGUAGE;
131		self.headers
132			.get(ACCEPT_LANGUAGE)
133			.and_then(|h| h.to_str().ok())
134			.map(Self::parse_accept_language)
135			.unwrap_or_default()
136	}
137
138	/// Get the most preferred language from Accept-Language header
139	///
140	/// # Examples
141	///
142	/// ```
143	/// use reinhardt_http::Request;
144	/// use hyper::{Method, Uri, Version, HeaderMap};
145	/// use bytes::Bytes;
146	///
147	/// let mut headers = HeaderMap::new();
148	/// headers.insert("accept-language", "ja;q=0.8,en-US,en;q=0.9".parse().unwrap());
149	///
150	/// let request = Request::builder()
151	///     .method(Method::GET)
152	///     .uri("/")
153	///     .headers(headers)
154	///     .build()
155	///     .unwrap();
156	///
157	/// assert_eq!(request.get_preferred_language(), Some("en-US".to_string()));
158	/// ```
159	pub fn get_preferred_language(&self) -> Option<String> {
160		self.get_accepted_languages()
161			.into_iter()
162			.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
163			.map(|(lang, _)| lang)
164	}
165
166	/// Parse Accept-Language header value
167	///
168	/// Handles both weighted (q=) and unweighted language preferences.
169	/// Example: "en-US,en;q=0.9,ja;q=0.8" -> [("en-US", 1.0), ("en", 0.9), ("ja", 0.8)]
170	fn parse_accept_language(header: &str) -> Vec<(String, f32)> {
171		let mut languages: Vec<(String, f32)> = header
172			.split(',')
173			.filter_map(|lang_part| {
174				let lang_part = lang_part.trim();
175				if lang_part.is_empty() {
176					return None;
177				}
178
179				// Split on ';' to separate language from quality
180				let parts: Vec<&str> = lang_part.split(';').collect();
181				let language = parts[0].trim().to_string();
182
183				// Parse quality value if present
184				let quality = if parts.len() > 1 {
185					parts[1]
186						.trim()
187						.strip_prefix("q=")
188						.and_then(|q| q.parse::<f32>().ok())
189						.unwrap_or(1.0)
190				} else {
191					1.0
192				};
193
194				// Validate language code
195				if Self::is_valid_language_code(&language) {
196					Some((language, quality))
197				} else {
198					None
199				}
200			})
201			.collect();
202
203		// Sort by quality (descending)
204		languages.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
205		languages
206	}
207
208	/// Validate language code format
209	///
210	/// Accepts formats like:
211	/// - "en"
212	/// - "en-US"
213	/// - "zh-Hans"
214	/// - "sr-Latn-RS"
215	/// - "nl-nl-x-informal" (with private use subtag)
216	///
217	/// Rejects:
218	/// - Too long (>255 chars)
219	/// - Invalid characters
220	/// - Starting/ending with hyphen
221	fn is_valid_language_code(code: &str) -> bool {
222		if code.is_empty() || code.len() > 255 {
223			return false;
224		}
225
226		// Must not start or end with hyphen
227		if code.starts_with('-') || code.ends_with('-') {
228			return false;
229		}
230
231		// Check for valid characters (alphanumeric and hyphen)
232		code.chars().all(|c| c.is_alphanumeric() || c == '-')
233	}
234
235	/// Get language from cookie
236	///
237	/// Looks for a language cookie (typically named "reinhardt_language" or similar)
238	///
239	/// # Examples
240	///
241	/// ```
242	/// use reinhardt_http::Request;
243	/// use hyper::{Method, Uri, Version, HeaderMap};
244	/// use bytes::Bytes;
245	///
246	/// let mut headers = HeaderMap::new();
247	/// headers.insert("cookie", "session_id=abc123; language=ja; theme=dark".parse().unwrap());
248	///
249	/// let request = Request::builder()
250	///     .method(Method::GET)
251	///     .uri("/")
252	///     .headers(headers)
253	///     .build()
254	///     .unwrap();
255	///
256	/// assert_eq!(request.get_language_from_cookie("language"), Some("ja".to_string()));
257	/// assert_eq!(request.get_language_from_cookie("nonexistent"), None);
258	/// ```
259	pub fn get_language_from_cookie(&self, cookie_name: &str) -> Option<String> {
260		use hyper::header::COOKIE;
261
262		self.headers
263			.get(COOKIE)
264			.and_then(|h| h.to_str().ok())
265			.and_then(Self::parse_cookies)
266			.and_then(|parsed| {
267				parsed.into_iter().find_map(|(name, value)| {
268					if name == cookie_name {
269						Some(value)
270					} else {
271						None
272					}
273				})
274			})
275			.filter(|lang| Self::is_valid_language_code(lang))
276	}
277
278	/// Parse cookie header with strict validation.
279	///
280	/// Rejects malformed cookies:
281	/// - Missing `=` separator
282	/// - Cookie name containing separators (`;`, `=`, whitespace, control chars)
283	/// - Empty cookie name
284	fn parse_cookies(header: &str) -> Option<Vec<(String, String)>> {
285		let mut cookies = Vec::new();
286		for cookie in header.split(';') {
287			let cookie = cookie.trim();
288			if cookie.is_empty() {
289				continue;
290			}
291			// Cookie must contain '=' separator
292			let mut parts = cookie.splitn(2, '=');
293			let name = parts.next()?.trim();
294			let value = match parts.next() {
295				Some(v) => v.trim(),
296				// Missing '=' means malformed cookie - skip it
297				None => continue,
298			};
299			// Validate cookie name: must not be empty or contain separators/control chars
300			if name.is_empty() || !Self::is_valid_cookie_name(name) {
301				continue;
302			}
303			cookies.push((name.to_string(), value.to_string()));
304		}
305		Some(cookies)
306	}
307
308	/// Validate cookie name per RFC 6265.
309	///
310	/// Cookie name must not contain separators, whitespace, or control characters.
311	fn is_valid_cookie_name(name: &str) -> bool {
312		name.chars().all(|c| {
313			// Must be a visible ASCII character (0x21-0x7E) excluding separators
314			let code = c as u32;
315			(0x21..=0x7E).contains(&code)
316				&& !matches!(
317					c,
318					'(' | ')'
319						| '<' | '>' | '@' | ','
320						| ';' | ':' | '\\' | '"'
321						| '/' | '[' | ']' | '?'
322						| '=' | '{' | '}' | ' '
323						| '\t'
324				)
325		})
326	}
327}
328
329#[cfg(test)]
330mod tests {
331	use super::*;
332	use rstest::rstest;
333
334	// =================================================================
335	// Query parameter '=' preservation tests (Issue #362)
336	// =================================================================
337
338	#[rstest]
339	fn test_parse_query_params_preserves_equals_in_value() {
340		// Arrange
341		let uri: hyper::Uri = "/test?token=abc==".parse().unwrap();
342
343		// Act
344		let params = Request::parse_query_params(&uri);
345
346		// Assert
347		assert_eq!(params.get("token"), Some(&"abc==".to_string()));
348	}
349
350	#[rstest]
351	fn test_parse_query_params_base64_encoded_value() {
352		// Arrange
353		let uri: hyper::Uri = "/test?data=dGVzdA==".parse().unwrap();
354
355		// Act
356		let params = Request::parse_query_params(&uri);
357
358		// Assert
359		assert_eq!(params.get("data"), Some(&"dGVzdA==".to_string()));
360	}
361
362	#[rstest]
363	fn test_parse_query_params_multiple_equals_in_value() {
364		// Arrange
365		let uri: hyper::Uri = "/test?formula=a=b=c".parse().unwrap();
366
367		// Act
368		let params = Request::parse_query_params(&uri);
369
370		// Assert
371		assert_eq!(params.get("formula"), Some(&"a=b=c".to_string()));
372	}
373
374	#[rstest]
375	fn test_parse_query_params_simple_key_value() {
376		// Arrange
377		let uri: hyper::Uri = "/test?key=value".parse().unwrap();
378
379		// Act
380		let params = Request::parse_query_params(&uri);
381
382		// Assert
383		assert_eq!(params.get("key"), Some(&"value".to_string()));
384	}
385
386	#[rstest]
387	fn test_parse_query_params_key_without_value() {
388		// Arrange
389		let uri: hyper::Uri = "/test?key=".parse().unwrap();
390
391		// Act
392		let params = Request::parse_query_params(&uri);
393
394		// Assert
395		assert_eq!(params.get("key"), Some(&"".to_string()));
396	}
397
398	#[rstest]
399	fn test_parse_query_params_no_query_string() {
400		// Arrange
401		let uri: hyper::Uri = "/test".parse().unwrap();
402
403		// Act
404		let params = Request::parse_query_params(&uri);
405
406		// Assert
407		assert!(params.is_empty());
408	}
409
410	#[rstest]
411	fn test_parse_query_params_multiple_params_with_equals() {
412		// Arrange
413		let uri: hyper::Uri = "/test?a=1&b=x=y=z&c=3".parse().unwrap();
414
415		// Act
416		let params = Request::parse_query_params(&uri);
417
418		// Assert
419		assert_eq!(params.get("a"), Some(&"1".to_string()));
420		assert_eq!(params.get("b"), Some(&"x=y=z".to_string()));
421		assert_eq!(params.get("c"), Some(&"3".to_string()));
422	}
423}