1use super::cookie::Cookie;
2use bytes::Bytes;
3use http_body_util::Full;
4
5#[derive(Debug)]
7pub struct HttpResponse {
8 status: u16,
9 body: Bytes,
10 headers: Vec<(String, String)>,
11}
12
13pub type Response = Result<HttpResponse, HttpResponse>;
15
16impl HttpResponse {
17 pub fn new() -> Self {
19 Self {
20 status: 200,
21 body: Bytes::new(),
22 headers: Vec::new(),
23 }
24 }
25
26 pub fn text(body: impl Into<String>) -> Self {
28 let s: String = body.into();
29 Self {
30 status: 200,
31 body: Bytes::from(s),
32 headers: vec![("Content-Type".to_string(), "text/plain".to_string())],
33 }
34 }
35
36 pub fn json(body: serde_json::Value) -> Self {
38 Self {
39 status: 200,
40 body: Bytes::from(body.to_string()),
41 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
42 }
43 }
44
45 pub fn bytes(body: impl Into<Bytes>) -> Self {
49 Self {
50 status: 200,
51 body: body.into(),
52 headers: vec![],
53 }
54 }
55
56 pub fn download(body: impl Into<Bytes>, filename: &str) -> Self {
63 let safe_name: String = filename
64 .chars()
65 .filter(|c| !c.is_control() && *c != '"' && *c != '\\')
66 .collect();
67
68 let content_type = mime_guess::from_path(&safe_name)
69 .first()
70 .map(|m| m.to_string())
71 .unwrap_or_else(|| "application/octet-stream".to_string());
72
73 Self {
74 status: 200,
75 body: body.into(),
76 headers: vec![
77 ("Content-Type".to_string(), content_type),
78 (
79 "Content-Disposition".to_string(),
80 format!("attachment; filename=\"{safe_name}\""),
81 ),
82 ],
83 }
84 }
85
86 pub fn set_body(mut self, body: impl Into<String>) -> Self {
88 let s: String = body.into();
89 self.body = Bytes::from(s);
90 self
91 }
92
93 pub fn status(mut self, status: u16) -> Self {
95 self.status = status;
96 self
97 }
98
99 pub fn status_code(&self) -> u16 {
101 self.status
102 }
103
104 pub fn body(&self) -> &str {
109 std::str::from_utf8(&self.body).unwrap_or("")
110 }
111
112 pub fn body_bytes(&self) -> &Bytes {
114 &self.body
115 }
116
117 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
122 let name = name.into();
123 self.headers.retain(|(n, _)| !n.eq_ignore_ascii_case(&name));
124 self.headers.push((name, value.into()));
125 self
126 }
127
128 pub fn append_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
134 self.headers.push((name.into(), value.into()));
135 self
136 }
137
138 pub fn headers(&self) -> &[(String, String)] {
143 &self.headers
144 }
145
146 pub fn cookie(self, cookie: Cookie) -> Self {
158 let header_value = cookie.to_header_value();
159 self.append_header("Set-Cookie", header_value)
160 }
161
162 pub fn ok(self) -> Response {
164 Ok(self)
165 }
166
167 pub fn into_hyper(self) -> hyper::Response<Full<Bytes>> {
169 let mut builder = hyper::Response::builder().status(self.status);
170
171 for (name, value) in self.headers {
172 builder = builder.header(name, value);
173 }
174
175 builder.body(Full::new(self.body)).unwrap()
176 }
177}
178
179impl Default for HttpResponse {
180 fn default() -> Self {
181 Self::new()
182 }
183}
184
185pub trait ResponseExt {
187 fn status(self, code: u16) -> Self;
189 fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self;
191}
192
193impl ResponseExt for Response {
194 fn status(self, code: u16) -> Self {
195 self.map(|r| r.status(code))
196 }
197
198 fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self {
199 self.map(|r| r.header(name, value))
200 }
201}
202
203pub struct Redirect {
205 location: String,
206 query_params: Vec<(String, String)>,
207 status: u16,
208}
209
210impl Redirect {
211 pub fn to(path: impl Into<String>) -> Self {
213 Self {
214 location: path.into(),
215 query_params: Vec::new(),
216 status: 302,
217 }
218 }
219
220 pub fn back(req: &crate::http::Request, fallback: impl Into<String>) -> Self {
232 let location = same_origin_path_from_referer(req).unwrap_or_else(|| fallback.into());
233 Self {
234 location,
235 query_params: Vec::new(),
236 status: 302,
237 }
238 }
239
240 pub fn route(name: &str) -> RedirectRouteBuilder {
242 RedirectRouteBuilder {
243 name: name.to_string(),
244 params: std::collections::HashMap::new(),
245 query_params: Vec::new(),
246 status: 302,
247 }
248 }
249
250 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
252 self.query_params.push((key.to_string(), value.into()));
253 self
254 }
255
256 pub fn permanent(mut self) -> Self {
258 self.status = 301;
259 self
260 }
261
262 fn build_url(&self) -> String {
263 if self.query_params.is_empty() {
264 self.location.clone()
265 } else {
266 let query = self
267 .query_params
268 .iter()
269 .map(|(k, v)| format!("{k}={v}"))
270 .collect::<Vec<_>>()
271 .join("&");
272 format!("{}?{}", self.location, query)
273 }
274 }
275}
276
277impl From<Redirect> for Response {
279 fn from(redirect: Redirect) -> Response {
280 Ok(HttpResponse::new()
281 .status(redirect.status)
282 .header("Location", redirect.build_url()))
283 }
284}
285
286fn same_origin_path_from_referer(req: &crate::http::Request) -> Option<String> {
295 let referer = req.header("referer")?;
296 if referer.starts_with("//") {
298 return None;
299 }
300 if referer.starts_with('/') {
302 return Some(referer.to_string());
303 }
304 let rest = referer
306 .strip_prefix("http://")
307 .or_else(|| referer.strip_prefix("https://"))?;
308 let (referer_host, path) = match rest.find('/') {
309 Some(i) => (&rest[..i], &rest[i..]),
310 None => (rest, "/"),
311 };
312 let request_host = req.header("host")?;
313 if referer_host == request_host {
314 Some(path.to_string())
315 } else {
316 None
317 }
318}
319
320pub struct RedirectRouteBuilder {
322 name: String,
323 params: std::collections::HashMap<String, String>,
324 query_params: Vec<(String, String)>,
325 status: u16,
326}
327
328impl RedirectRouteBuilder {
329 pub fn with(mut self, key: &str, value: impl Into<String>) -> Self {
331 self.params.insert(key.to_string(), value.into());
332 self
333 }
334
335 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
337 self.query_params.push((key.to_string(), value.into()));
338 self
339 }
340
341 pub fn permanent(mut self) -> Self {
343 self.status = 301;
344 self
345 }
346
347 fn build_url(&self) -> Option<String> {
348 use crate::routing::route_with_params;
349
350 let mut url = route_with_params(&self.name, &self.params)?;
351 if !self.query_params.is_empty() {
352 let query = self
353 .query_params
354 .iter()
355 .map(|(k, v)| format!("{k}={v}"))
356 .collect::<Vec<_>>()
357 .join("&");
358 url = format!("{url}?{query}");
359 }
360 Some(url)
361 }
362}
363
364impl From<RedirectRouteBuilder> for Response {
366 fn from(redirect: RedirectRouteBuilder) -> Response {
367 let url = redirect.build_url().ok_or_else(|| {
368 HttpResponse::text(format!("Route '{}' not found", redirect.name)).status(500)
369 })?;
370 Ok(HttpResponse::new()
371 .status(redirect.status)
372 .header("Location", url))
373 }
374}
375
376impl From<crate::error::FrameworkError> for HttpResponse {
384 fn from(err: crate::error::FrameworkError) -> HttpResponse {
385 let status = err.status_code();
386 let hint = err.hint();
387 let mut body = match &err {
388 crate::error::FrameworkError::ParamError { param_name } => {
389 serde_json::json!({
390 "message": format!("Missing required parameter: {}", param_name)
391 })
392 }
393 crate::error::FrameworkError::ValidationError { field, message } => {
394 serde_json::json!({
395 "message": "Validation failed",
396 "field": field,
397 "error": message
398 })
399 }
400 crate::error::FrameworkError::Validation(errors) => {
401 errors.to_json()
403 }
404 crate::error::FrameworkError::Unauthorized => {
405 serde_json::json!({
406 "message": "This action is unauthorized."
407 })
408 }
409 _ => {
410 serde_json::json!({
411 "message": err.to_string()
412 })
413 }
414 };
415 if let Some(hint_text) = hint {
416 if let Some(obj) = body.as_object_mut() {
417 obj.insert("hint".to_string(), serde_json::Value::String(hint_text));
418 }
419 }
420 HttpResponse::json(body).status(status)
421 }
422}
423
424impl From<crate::error::AppError> for HttpResponse {
428 fn from(err: crate::error::AppError) -> HttpResponse {
429 let framework_err: crate::error::FrameworkError = err.into();
431 framework_err.into()
432 }
433}
434
435#[cfg(feature = "projections")]
439impl From<ferro_projections::Error> for HttpResponse {
440 fn from(err: ferro_projections::Error) -> HttpResponse {
441 let framework_err: crate::error::FrameworkError = err.into();
442 framework_err.into()
443 }
444}
445
446pub struct InertiaRedirect<'a> {
464 request: &'a crate::http::Request,
465 location: String,
466 query_params: Vec<(String, String)>,
467}
468
469impl<'a> InertiaRedirect<'a> {
470 pub fn to(request: &'a crate::http::Request, path: impl Into<String>) -> Self {
472 Self {
473 request,
474 location: path.into(),
475 query_params: Vec::new(),
476 }
477 }
478
479 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
481 self.query_params.push((key.to_string(), value.into()));
482 self
483 }
484
485 fn build_url(&self) -> String {
486 if self.query_params.is_empty() {
487 self.location.clone()
488 } else {
489 let query = self
490 .query_params
491 .iter()
492 .map(|(k, v)| format!("{k}={v}"))
493 .collect::<Vec<_>>()
494 .join("&");
495 format!("{}?{}", self.location, query)
496 }
497 }
498
499 fn is_post_like_method(&self) -> bool {
500 matches!(
501 self.request.method().as_str(),
502 "POST" | "PUT" | "PATCH" | "DELETE"
503 )
504 }
505}
506
507impl From<InertiaRedirect<'_>> for Response {
508 fn from(redirect: InertiaRedirect<'_>) -> Response {
509 let url = redirect.build_url();
510 let is_inertia = redirect.request.is_inertia();
511 let is_post_like = redirect.is_post_like_method();
512
513 if is_inertia {
514 let status = if is_post_like { 303 } else { 302 };
516 Ok(HttpResponse::new()
517 .status(status)
518 .header("X-Inertia", "true")
519 .header("Location", url))
520 } else {
521 Ok(HttpResponse::new().status(302).header("Location", url))
523 }
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn test_bytes_constructor() {
533 let resp = HttpResponse::bytes(vec![0xFF, 0xFE, 0x00]);
534 assert_eq!(resp.body_bytes().as_ref(), &[0xFF, 0xFE, 0x00]);
535 assert_eq!(resp.status_code(), 200);
536 assert!(
537 resp.headers.is_empty(),
538 "bytes() should set no default headers"
539 );
540 }
541
542 #[test]
543 fn test_bytes_from_vec_u8() {
544 let resp = HttpResponse::bytes(vec![1, 2, 3]);
545 assert_eq!(resp.body_bytes().len(), 3);
546 }
547
548 #[test]
549 fn test_bytes_with_content_type() {
550 let resp = HttpResponse::bytes(b"PNG data".to_vec()).header("Content-Type", "image/png");
551 let ct = resp
552 .headers
553 .iter()
554 .find(|(k, _)| k == "Content-Type")
555 .map(|(_, v)| v.as_str());
556 assert_eq!(ct, Some("image/png"));
557 }
558
559 #[test]
560 fn test_download_constructor() {
561 let resp = HttpResponse::download(b"pdf content".to_vec(), "report.pdf");
562 let ct = resp
563 .headers
564 .iter()
565 .find(|(k, _)| k == "Content-Type")
566 .map(|(_, v)| v.as_str());
567 assert_eq!(ct, Some("application/pdf"));
568
569 let cd = resp
570 .headers
571 .iter()
572 .find(|(k, _)| k == "Content-Disposition")
573 .map(|(_, v)| v.as_str());
574 assert_eq!(cd, Some("attachment; filename=\"report.pdf\""));
575 }
576
577 #[test]
578 fn test_download_unknown_extension() {
579 let resp = HttpResponse::download(b"data".to_vec(), "file.zzqx");
580 let ct = resp
581 .headers
582 .iter()
583 .find(|(k, _)| k == "Content-Type")
584 .map(|(_, v)| v.as_str());
585 assert_eq!(ct, Some("application/octet-stream"));
586 }
587
588 #[test]
589 fn test_download_filename_sanitization() {
590 let resp = HttpResponse::download(b"data".to_vec(), "evil\"file\nname.pdf");
591 let cd = resp
592 .headers
593 .iter()
594 .find(|(k, _)| k == "Content-Disposition")
595 .map(|(_, v)| v.as_str())
596 .unwrap();
597 assert!(
598 !cd.contains('"') || cd.matches('"').count() == 2,
599 "filename should be properly quoted"
600 );
601 assert!(!cd.contains('\n'), "filename should not contain newlines");
602 }
603
604 #[test]
605 fn test_text_still_works() {
606 let resp = HttpResponse::text("hello");
607 assert_eq!(resp.body(), "hello");
608 assert_eq!(resp.body_bytes().as_ref(), b"hello");
609 }
610
611 #[test]
612 fn test_json_still_works() {
613 let resp = HttpResponse::json(serde_json::json!({"ok": true}));
614 let body = resp.body();
615 assert!(!body.is_empty(), "json body should not be empty");
616 let parsed: serde_json::Value = serde_json::from_str(body).unwrap();
617 assert_eq!(parsed["ok"], true);
618 assert!(!resp.body_bytes().is_empty());
619 }
620
621 #[test]
622 fn test_body_returns_empty_for_binary() {
623 let resp = HttpResponse::bytes(vec![0xFF, 0xFE]);
624 assert_eq!(resp.body(), "");
625 }
626
627 #[test]
628 fn test_into_hyper_preserves_binary() {
629 use http_body_util::BodyExt;
630
631 let data = vec![0xFF, 0x00, 0xFE];
632 let resp = HttpResponse::bytes(data.clone());
633 let hyper_resp = resp.into_hyper();
634
635 let rt = tokio::runtime::Runtime::new().unwrap();
636 let collected =
637 rt.block_on(async { hyper_resp.into_body().collect().await.unwrap().to_bytes() });
638 assert_eq!(collected.as_ref(), &data);
639 }
640
641 #[test]
642 fn test_header_replaces_existing() {
643 let resp = HttpResponse::text("x").header("Content-Type", "text/html");
644 let ct: Vec<_> = resp
645 .headers()
646 .iter()
647 .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
648 .collect();
649 assert_eq!(ct.len(), 1, "expected exactly one Content-Type entry");
650 assert_eq!(ct[0].1, "text/html");
651 }
652
653 #[test]
654 fn test_multi_cookie_preserved() {
655 let resp = HttpResponse::new()
656 .cookie(Cookie::new("a", "1"))
657 .cookie(Cookie::new("b", "2"));
658 let cookies: Vec<_> = resp
659 .headers()
660 .iter()
661 .filter(|(k, _)| k == "Set-Cookie")
662 .collect();
663 assert_eq!(
664 cookies.len(),
665 2,
666 "both Set-Cookie entries must be preserved"
667 );
668 }
669
670 #[test]
671 fn test_header_case_insensitive_replace() {
672 let resp = HttpResponse::new()
673 .append_header("content-type", "text/plain")
674 .header("Content-Type", "text/html");
675 let ct: Vec<_> = resp
676 .headers()
677 .iter()
678 .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
679 .collect();
680 assert_eq!(ct.len(), 1, "lowercase prior entry must be replaced");
681 assert_eq!(ct[0].1, "text/html");
682 }
683
684 #[test]
685 fn test_append_header_does_not_replace() {
686 let resp = HttpResponse::new()
687 .append_header("X-Tag", "a")
688 .append_header("X-Tag", "b");
689 let count = resp.headers().iter().filter(|(k, _)| k == "X-Tag").count();
690 assert_eq!(count, 2, "append_header must not strip existing entries");
691 }
692
693 #[test]
694 fn test_headers_accessor() {
695 let resp = HttpResponse::text("x");
696 assert!(
697 !resp.headers().is_empty(),
698 "headers() accessor should return the prepopulated Content-Type"
699 );
700 }
701}