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}