ferro_rs/http/request.rs
1use super::body::{collect_body, parse_form, parse_json};
2use super::cookie::parse_cookies;
3use super::ParamError;
4use crate::error::FrameworkError;
5use bytes::Bytes;
6use serde::de::DeserializeOwned;
7use std::any::{Any, TypeId};
8use std::collections::HashMap;
9
10/// HTTP Request wrapper providing Laravel-like access to request data
11pub struct Request {
12 inner: hyper::Request<hyper::body::Incoming>,
13 params: HashMap<String, String>,
14 extensions: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
15 /// Route pattern for metrics (e.g., "/users/{id}" instead of "/users/123")
16 route_pattern: Option<String>,
17}
18
19impl Request {
20 /// Create a new request from a raw hyper request.
21 pub fn new(inner: hyper::Request<hyper::body::Incoming>) -> Self {
22 Self {
23 inner,
24 params: HashMap::new(),
25 extensions: HashMap::new(),
26 route_pattern: None,
27 }
28 }
29
30 /// Attach route parameters extracted from the URL path.
31 pub fn with_params(mut self, params: HashMap<String, String>) -> Self {
32 self.params = params;
33 self
34 }
35
36 /// Set the route pattern (e.g., "/users/{id}")
37 pub fn with_route_pattern(mut self, pattern: String) -> Self {
38 self.route_pattern = Some(pattern);
39 self
40 }
41
42 /// Get the route pattern for metrics grouping
43 pub fn route_pattern(&self) -> Option<String> {
44 self.route_pattern.clone()
45 }
46
47 /// Insert a value into the request extensions (type-map pattern)
48 ///
49 /// This is async-safe unlike thread-local storage.
50 pub fn insert<T: Send + Sync + 'static>(&mut self, value: T) {
51 self.extensions.insert(TypeId::of::<T>(), Box::new(value));
52 }
53
54 /// Get a reference to a value from the request extensions
55 pub fn get<T: Send + Sync + 'static>(&self) -> Option<&T> {
56 self.extensions
57 .get(&TypeId::of::<T>())
58 .and_then(|boxed| boxed.downcast_ref::<T>())
59 }
60
61 /// Get a mutable reference to a value from the request extensions
62 pub fn get_mut<T: Send + Sync + 'static>(&mut self) -> Option<&mut T> {
63 self.extensions
64 .get_mut(&TypeId::of::<T>())
65 .and_then(|boxed| boxed.downcast_mut::<T>())
66 }
67
68 /// Get the request method
69 pub fn method(&self) -> &hyper::Method {
70 self.inner.method()
71 }
72
73 /// Get the request path
74 pub fn path(&self) -> &str {
75 self.inner.uri().path()
76 }
77
78 /// Rewrite the request path (server-side only — the browser URL is unchanged).
79 ///
80 /// Replaces the URI path component while preserving the scheme, authority, and
81 /// query string. Used by pre-route middleware (e.g. `HostMiddleware`) to map
82 /// custom-domain requests onto internal slug-based routes before routing occurs.
83 ///
84 /// `new_path` must begin with `/`. Panics in debug mode if it does not.
85 pub fn set_path(&mut self, new_path: &str) {
86 debug_assert!(
87 new_path.starts_with('/'),
88 "set_path: path must begin with '/', got {new_path:?}"
89 );
90 let old_uri = self.inner.uri();
91 // Preserve scheme, authority, and query string; replace path only.
92 let mut parts = old_uri.clone().into_parts();
93 let path_and_query = match old_uri.query() {
94 Some(q) => format!("{new_path}?{q}"),
95 None => new_path.to_string(),
96 };
97 parts.path_and_query = Some(
98 path_and_query
99 .parse()
100 .unwrap_or_else(|_| new_path.parse().expect("invalid path")),
101 );
102 if let Ok(new_uri) = http::Uri::from_parts(parts) {
103 *self.inner.uri_mut() = new_uri;
104 }
105 }
106
107 /// Get a route parameter by name (e.g., /users/{id})
108 /// Returns Err(ParamError) if the parameter is missing, enabling use of `?` operator
109 pub fn param(&self, name: &str) -> Result<&str, ParamError> {
110 self.params
111 .get(name)
112 .map(|s| s.as_str())
113 .ok_or_else(|| ParamError {
114 param_name: name.to_string(),
115 })
116 }
117
118 /// Get a route parameter parsed as a specific type
119 ///
120 /// Combines `param()` with parsing, returning a typed value.
121 ///
122 /// # Example
123 ///
124 /// ```rust,ignore
125 /// pub async fn show(req: Request) -> Response {
126 /// let id: i32 = req.param_as("id")?;
127 /// // ...
128 /// }
129 /// ```
130 pub fn param_as<T: std::str::FromStr>(&self, name: &str) -> Result<T, ParamError>
131 where
132 T::Err: std::fmt::Display,
133 {
134 let value = self.param(name)?;
135 value.parse::<T>().map_err(|e| ParamError {
136 param_name: format!("{name} (parse error: {e})"),
137 })
138 }
139
140 /// Get all route parameters
141 pub fn params(&self) -> &HashMap<String, String> {
142 &self.params
143 }
144
145 /// Get a query string parameter by name
146 ///
147 /// # Example
148 ///
149 /// ```rust,ignore
150 /// // URL: /users?page=2&limit=10
151 /// let page = req.query("page"); // Some("2")
152 /// let sort = req.query("sort"); // None
153 /// ```
154 pub fn query(&self, name: &str) -> Option<String> {
155 self.inner.uri().query().and_then(|q| {
156 form_urlencoded::parse(q.as_bytes())
157 .find(|(key, _)| key == name)
158 .map(|(_, value)| value.into_owned())
159 })
160 }
161
162 /// Get a query string parameter or a default value
163 ///
164 /// # Example
165 ///
166 /// ```rust,ignore
167 /// // URL: /users?page=2
168 /// let page = req.query_or("page", "1"); // "2"
169 /// let limit = req.query_or("limit", "10"); // "10"
170 /// ```
171 pub fn query_or(&self, name: &str, default: &str) -> String {
172 self.query(name).unwrap_or_else(|| default.to_string())
173 }
174
175 /// Get a query string parameter parsed as a specific type
176 ///
177 /// # Example
178 ///
179 /// ```rust,ignore
180 /// // URL: /users?page=2&limit=10
181 /// let page: Option<i32> = req.query_as("page"); // Some(2)
182 /// ```
183 pub fn query_as<T: std::str::FromStr>(&self, name: &str) -> Option<T> {
184 self.query(name).and_then(|v| v.parse().ok())
185 }
186
187 /// Get a query string parameter parsed as a specific type, or a default
188 ///
189 /// # Example
190 ///
191 /// ```rust,ignore
192 /// // URL: /users?page=2
193 /// let page: i32 = req.query_as_or("page", 1); // 2
194 /// let limit: i32 = req.query_as_or("limit", 10); // 10
195 /// ```
196 pub fn query_as_or<T: std::str::FromStr>(&self, name: &str, default: T) -> T {
197 self.query_as(name).unwrap_or(default)
198 }
199
200 // ── Phase 137: validation flash round-trip helpers ────────────────────────
201
202 /// Read a previously-submitted form value from session flash.
203 ///
204 /// After a POST handler calls `ValidationError::with_old_input(&data).redirect_back(...)`,
205 /// the GET handler retrieves the value with `req.old("field_name")` and passes it as
206 /// `InputProps.default_value` to repopulate the form.
207 ///
208 /// Reads from `_flash.old._old_input.<field>` without clearing (read-only semantics).
209 /// Flash aging (move new→old→deleted) is handled by the session middleware at request
210 /// boundaries, so multiple reads in the same GET handler are safe.
211 ///
212 /// Returns `None` when no flash value exists, no session is active, or the key is absent.
213 pub fn old(&self, field: &str) -> Option<String> {
214 let key = format!("_flash.old._old_input.{field}");
215 crate::session::session().and_then(|s| s.get::<String>(&key))
216 }
217
218 /// Read the first validation error message for a field from session flash.
219 ///
220 /// After a POST handler calls `errors.redirect_back(...)`, the GET handler calls
221 /// `req.validation_error("field_name")` and passes the result as `InputProps.error`.
222 ///
223 /// Reads from `_flash.old._validation_errors` without clearing (read-only semantics).
224 ///
225 /// Returns `None` when no flash errors exist, no session is active, or the field has no error.
226 pub fn validation_error(&self, field: &str) -> Option<String> {
227 let errors: Option<std::collections::HashMap<String, Vec<String>>> =
228 crate::session::session().and_then(|s| {
229 s.get::<std::collections::HashMap<String, Vec<String>>>(
230 "_flash.old._validation_errors",
231 )
232 });
233 errors.and_then(|map| map.get(field).and_then(|v| v.first()).cloned())
234 }
235
236 /// Returns `true` when any validation errors were flashed from a prior request.
237 ///
238 /// Useful for rendering a form-wide error summary banner.
239 pub fn has_validation_errors(&self) -> bool {
240 crate::session::session()
241 .and_then(|s| {
242 s.get::<std::collections::HashMap<String, Vec<String>>>(
243 "_flash.old._validation_errors",
244 )
245 })
246 .map(|m| !m.is_empty())
247 .unwrap_or(false)
248 }
249
250 /// Get the inner hyper request
251 pub fn inner(&self) -> &hyper::Request<hyper::body::Incoming> {
252 &self.inner
253 }
254
255 /// Get a header value by name
256 pub fn header(&self, name: &str) -> Option<&str> {
257 self.inner.headers().get(name).and_then(|v| v.to_str().ok())
258 }
259
260 /// Get the Content-Type header
261 pub fn content_type(&self) -> Option<&str> {
262 self.header("content-type")
263 }
264
265 /// Resolve the URL the current request was triggered from, falling
266 /// back to `fallback` when the `Referer` is absent, malformed, or
267 /// points off-origin.
268 ///
269 /// Use to capture a "back" target at handler entry before the request
270 /// body is consumed (e.g. before [`body_bytes`](Self::body_bytes)). The
271 /// returned `String` then feeds [`crate::http::Redirect::to`] to send
272 /// the user back to where they came from, preserving query strings
273 /// (e.g. `?tab=note`) and any other URL state.
274 ///
275 /// Same-origin rule mirrors [`crate::http::Redirect::back`]: absolute
276 /// URLs must share the request's `Host`; scheme-relative URLs are
277 /// rejected.
278 pub fn back_or(&self, fallback: impl Into<String>) -> String {
279 let referer = match self.header("referer") {
280 Some(r) => r,
281 None => return fallback.into(),
282 };
283 if referer.starts_with("//") {
284 return fallback.into();
285 }
286 if referer.starts_with('/') {
287 return referer.to_string();
288 }
289 let rest = match referer
290 .strip_prefix("http://")
291 .or_else(|| referer.strip_prefix("https://"))
292 {
293 Some(r) => r,
294 None => return fallback.into(),
295 };
296 let (referer_host, path) = match rest.find('/') {
297 Some(i) => (&rest[..i], &rest[i..]),
298 None => (rest, "/"),
299 };
300 let request_host = match self.header("host") {
301 Some(h) => h,
302 None => return fallback.into(),
303 };
304 if referer_host == request_host {
305 path.to_string()
306 } else {
307 fallback.into()
308 }
309 }
310
311 /// Check if this is an Inertia XHR request
312 pub fn is_inertia(&self) -> bool {
313 self.header("X-Inertia")
314 .map(|v| v == "true")
315 .unwrap_or(false)
316 }
317
318 /// Get all cookies from the request
319 ///
320 /// Parses the Cookie header and returns a HashMap of cookie names to values.
321 ///
322 /// # Example
323 ///
324 /// ```rust,ignore
325 /// let cookies = req.cookies();
326 /// if let Some(session) = cookies.get("session") {
327 /// println!("Session: {}", session);
328 /// }
329 /// ```
330 pub fn cookies(&self) -> HashMap<String, String> {
331 self.header("Cookie").map(parse_cookies).unwrap_or_default()
332 }
333
334 /// Get a specific cookie value by name
335 ///
336 /// # Example
337 ///
338 /// ```rust,ignore
339 /// if let Some(session_id) = req.cookie("session") {
340 /// // Use session_id
341 /// }
342 /// ```
343 pub fn cookie(&self, name: &str) -> Option<String> {
344 self.cookies().get(name).cloned()
345 }
346
347 /// Get the Inertia version from request headers
348 pub fn inertia_version(&self) -> Option<&str> {
349 self.header("X-Inertia-Version")
350 }
351
352 /// Get partial component name for partial reloads
353 pub fn inertia_partial_component(&self) -> Option<&str> {
354 self.header("X-Inertia-Partial-Component")
355 }
356
357 /// Get partial data keys for partial reloads
358 pub fn inertia_partial_data(&self) -> Option<Vec<&str>> {
359 self.header("X-Inertia-Partial-Data")
360 .map(|v| v.split(',').collect())
361 }
362
363 /// Consume the request and collect the body as bytes
364 pub async fn body_bytes(self) -> Result<(RequestParts, Bytes), FrameworkError> {
365 let content_type = self
366 .inner
367 .headers()
368 .get("content-type")
369 .and_then(|v| v.to_str().ok())
370 .map(|s| s.to_string());
371
372 let params = self.params;
373 let bytes = collect_body(self.inner.into_body()).await?;
374
375 Ok((
376 RequestParts {
377 params,
378 content_type,
379 },
380 bytes,
381 ))
382 }
383
384 /// Parse the request body as JSON
385 ///
386 /// Consumes the request since the body can only be read once.
387 ///
388 /// # Example
389 ///
390 /// ```rust,ignore
391 /// #[derive(Deserialize)]
392 /// struct CreateUser { name: String, email: String }
393 ///
394 /// pub async fn store(req: Request) -> Response {
395 /// let data: CreateUser = req.json().await?;
396 /// // ...
397 /// }
398 /// ```
399 pub async fn json<T: DeserializeOwned>(self) -> Result<T, FrameworkError> {
400 let (_, bytes) = self.body_bytes().await?;
401 parse_json(&bytes)
402 }
403
404 /// Parse the request body as form-urlencoded
405 ///
406 /// Consumes the request since the body can only be read once.
407 ///
408 /// # Example
409 ///
410 /// ```rust,ignore
411 /// #[derive(Deserialize)]
412 /// struct LoginForm { username: String, password: String }
413 ///
414 /// pub async fn login(req: Request) -> Response {
415 /// let form: LoginForm = req.form().await?;
416 /// // ...
417 /// }
418 /// ```
419 pub async fn form<T: DeserializeOwned>(self) -> Result<T, FrameworkError> {
420 let (_, bytes) = self.body_bytes().await?;
421 parse_form(&bytes)
422 }
423
424 /// Parse the request body as `multipart/form-data`.
425 ///
426 /// Consumes the request since the body can only be read once.
427 /// The per-field byte cap is read from `UPLOAD_MAX_SIZE_MB` (default 10 MiB),
428 /// and the per-request field cap from `UPLOAD_MAX_FIELDS` (default 100).
429 ///
430 /// # Errors
431 ///
432 /// Returns `FrameworkError::internal(...)` with the literal message
433 /// `"Content-Type is not multipart/form-data or missing boundary"` when
434 /// the request's `Content-Type` header is absent, malformed, or not a
435 /// multipart value.
436 ///
437 /// # Example
438 ///
439 /// ```rust,ignore
440 /// pub async fn upload(req: Request) -> Response {
441 /// let form = req.multipart().await?;
442 /// let title = form.field("title").unwrap_or_default();
443 /// let file = form.file("attachment");
444 /// // ...
445 /// }
446 /// ```
447 pub async fn multipart(self) -> Result<super::multipart::MultipartForm, FrameworkError> {
448 let content_type = self
449 .inner
450 .headers()
451 .get("content-type")
452 .and_then(|v| v.to_str().ok())
453 .map(|s| s.to_string())
454 .unwrap_or_default();
455 let body = self.inner.into_body();
456 super::multipart::parse_multipart_body(
457 body,
458 &content_type,
459 super::multipart::max_file_bytes(),
460 super::multipart::max_fields(),
461 )
462 .await
463 }
464
465 /// Parse the body as multipart/form-data and return the first file
466 /// uploaded under `field`.
467 ///
468 /// Consumes the request since the body can only be read once. Returns
469 /// `Ok(None)` when the multipart body parses successfully but contains
470 /// no file with that field name.
471 ///
472 /// # Example
473 ///
474 /// ```rust,ignore
475 /// pub async fn upload_avatar(req: Request) -> Response {
476 /// let file = req.file("avatar").await?
477 /// .ok_or_else(|| FrameworkError::internal("missing avatar"))?;
478 /// // file.store(&disk, &path).await?;
479 /// Ok(json!({"size": file.size()}))
480 /// }
481 /// ```
482 pub async fn file(
483 self,
484 field: &str,
485 ) -> Result<Option<super::multipart::UploadedFile>, FrameworkError> {
486 let mut form = self.multipart().await?;
487 Ok(form.files_map.remove(field).and_then(|mut v| {
488 if v.is_empty() {
489 None
490 } else {
491 Some(v.swap_remove(0))
492 }
493 }))
494 }
495
496 /// Parse the request body based on Content-Type header
497 ///
498 /// - `application/json` -> JSON parsing
499 /// - `application/x-www-form-urlencoded` -> Form parsing
500 /// - Otherwise -> JSON parsing (default)
501 ///
502 /// Consumes the request since the body can only be read once.
503 pub async fn input<T: DeserializeOwned>(self) -> Result<T, FrameworkError> {
504 let (parts, bytes) = self.body_bytes().await?;
505
506 match parts.content_type.as_deref() {
507 Some(ct) if ct.starts_with("application/x-www-form-urlencoded") => parse_form(&bytes),
508 _ => parse_json(&bytes),
509 }
510 }
511
512 /// Consume the request and return its parts along with the inner hyper request body
513 ///
514 /// This is used internally by the handler macro for FormRequest extraction.
515 pub fn into_parts(self) -> (RequestParts, hyper::body::Incoming) {
516 let content_type = self
517 .inner
518 .headers()
519 .get("content-type")
520 .and_then(|v| v.to_str().ok())
521 .map(|s| s.to_string());
522
523 let params = self.params;
524 let body = self.inner.into_body();
525
526 (
527 RequestParts {
528 params,
529 content_type,
530 },
531 body,
532 )
533 }
534}
535
536/// Request parts after body has been separated
537///
538/// Contains metadata needed for body parsing without the body itself.
539#[derive(Clone)]
540pub struct RequestParts {
541 /// Route parameters extracted from the URL path.
542 pub params: HashMap<String, String>,
543 /// Content-Type header value, if present.
544 pub content_type: Option<String>,
545}
546
547#[cfg(test)]
548mod tests {
549 // Phase 137: unit tests for old() / validation_error() / has_validation_errors().
550 //
551 // The Request struct wraps hyper::body::Incoming which cannot be constructed
552 // in unit tests. We therefore test the underlying session-reading logic
553 // directly (the same code path the methods delegate to) using
554 // SESSION_CONTEXT.scope() to inject a session.
555 //
556 // Full end-to-end round-trips (POST → flash → GET → InputProps) live in the
557 // gestiscilo integration test scaffold (validation_roundtrip_tests.rs).
558
559 use crate::session::middleware::SESSION_CONTEXT;
560 use crate::session::store::SessionData;
561 use std::collections::HashMap;
562 use std::sync::Arc;
563 use tokio::sync::RwLock;
564
565 // ── No-session guard tests ────────────────────────────────────────────────
566
567 #[tokio::test]
568 async fn test_session_absent_old_returns_none() {
569 // Outside any SESSION_CONTEXT scope, session() returns None.
570 // old() delegates to session().and_then(...) so it must also return None.
571 let val =
572 crate::session::session().and_then(|s| s.get::<String>("_flash.old._old_input.email"));
573 assert_eq!(val, None);
574 }
575
576 #[tokio::test]
577 async fn test_session_absent_validation_error_returns_none() {
578 let val = crate::session::session().and_then(|s| {
579 s.get::<HashMap<String, Vec<String>>>("_flash.old._validation_errors")
580 .and_then(|map| map.get("email").and_then(|v| v.first()).cloned())
581 });
582 assert_eq!(val, None);
583 }
584
585 #[tokio::test]
586 async fn test_session_absent_has_validation_errors_false() {
587 let val = crate::session::session()
588 .and_then(|s| s.get::<HashMap<String, Vec<String>>>("_flash.old._validation_errors"))
589 .map(|m| !m.is_empty())
590 .unwrap_or(false);
591 assert!(!val);
592 }
593
594 // ── Session-present tests (direct logic, mirrors Request method bodies) ───
595
596 #[tokio::test]
597 async fn test_old_reads_from_flash_old_key() {
598 let mut session = SessionData::new("test-id".to_string(), "csrf".to_string());
599 // Simulate age_flash_data() having moved the flash to _flash.old.*
600 session.put(
601 "_flash.old._old_input.email",
602 "user@example.com".to_string(),
603 );
604
605 let ctx = Arc::new(RwLock::new(Some(session)));
606 let val = SESSION_CONTEXT
607 .scope(ctx, async {
608 crate::session::session()
609 .and_then(|s| s.get::<String>("_flash.old._old_input.email"))
610 })
611 .await;
612
613 assert_eq!(val, Some("user@example.com".to_string()));
614 }
615
616 #[tokio::test]
617 async fn test_validation_error_reads_first_message_for_field() {
618 let mut session = SessionData::new("test-id".to_string(), "csrf".to_string());
619 let mut errors: HashMap<String, Vec<String>> = HashMap::new();
620 errors.insert(
621 "email".to_string(),
622 vec!["Inserisci un indirizzo email valido".to_string()],
623 );
624 session.put("_flash.old._validation_errors", &errors);
625
626 let ctx = Arc::new(RwLock::new(Some(session)));
627 let (email_err, other_err) = SESSION_CONTEXT
628 .scope(ctx, async {
629 let email_err = crate::session::session().and_then(|s| {
630 s.get::<HashMap<String, Vec<String>>>("_flash.old._validation_errors")
631 .and_then(|map| map.get("email").and_then(|v| v.first()).cloned())
632 });
633 // Reading the same session twice must not clear the data.
634 let other_err = crate::session::session().and_then(|s| {
635 s.get::<HashMap<String, Vec<String>>>("_flash.old._validation_errors")
636 .and_then(|map| map.get("name").and_then(|v| v.first()).cloned())
637 });
638 (email_err, other_err)
639 })
640 .await;
641
642 assert_eq!(
643 email_err,
644 Some("Inserisci un indirizzo email valido".to_string())
645 );
646 assert_eq!(other_err, None);
647 }
648
649 #[tokio::test]
650 async fn test_multiple_reads_do_not_clear_flash() {
651 // Validates read-only semantics: calling session().get() twice returns
652 // the same value (unlike get_flash which clears on read).
653 let mut session = SessionData::new("test-id".to_string(), "csrf".to_string());
654 session.put("_flash.old._old_input.name", "Mario".to_string());
655
656 let ctx = Arc::new(RwLock::new(Some(session)));
657 let (first, second) = SESSION_CONTEXT
658 .scope(ctx, async {
659 let a = crate::session::session()
660 .and_then(|s| s.get::<String>("_flash.old._old_input.name"));
661 let b = crate::session::session()
662 .and_then(|s| s.get::<String>("_flash.old._old_input.name"));
663 (a, b)
664 })
665 .await;
666
667 assert_eq!(first, Some("Mario".to_string()));
668 assert_eq!(second, Some("Mario".to_string()));
669 }
670}