1use super::body::FerroBody;
2use super::cookie::Cookie;
3use bytes::Bytes;
4use http_body_util::Full;
5
6#[derive(Debug)]
8pub struct HttpResponse {
9 status: u16,
10 body: Bytes,
11 headers: Vec<(String, String)>,
12}
13
14pub type Response = Result<HttpResponse, HttpResponse>;
16
17impl HttpResponse {
18 pub fn new() -> Self {
20 Self {
21 status: 200,
22 body: Bytes::new(),
23 headers: Vec::new(),
24 }
25 }
26
27 pub fn text(body: impl Into<String>) -> Self {
29 let s: String = body.into();
30 Self {
31 status: 200,
32 body: Bytes::from(s),
33 headers: vec![("Content-Type".to_string(), "text/plain".to_string())],
34 }
35 }
36
37 pub fn json(body: serde_json::Value) -> Self {
39 Self {
40 status: 200,
41 body: Bytes::from(body.to_string()),
42 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
43 }
44 }
45
46 pub fn bytes(body: impl Into<Bytes>) -> Self {
50 Self {
51 status: 200,
52 body: body.into(),
53 headers: vec![],
54 }
55 }
56
57 pub fn download(body: impl Into<Bytes>, filename: &str) -> Self {
64 let safe_name: String = filename
65 .chars()
66 .filter(|c| !c.is_control() && *c != '"' && *c != '\\')
67 .collect();
68
69 let content_type = mime_guess::from_path(&safe_name)
70 .first()
71 .map(|m| m.to_string())
72 .unwrap_or_else(|| "application/octet-stream".to_string());
73
74 Self {
75 status: 200,
76 body: body.into(),
77 headers: vec![
78 ("Content-Type".to_string(), content_type),
79 (
80 "Content-Disposition".to_string(),
81 format!("attachment; filename=\"{safe_name}\""),
82 ),
83 ],
84 }
85 }
86
87 pub fn set_body(mut self, body: impl Into<String>) -> Self {
89 let s: String = body.into();
90 self.body = Bytes::from(s);
91 self
92 }
93
94 pub fn status(mut self, status: u16) -> Self {
96 self.status = status;
97 self
98 }
99
100 pub fn status_code(&self) -> u16 {
102 self.status
103 }
104
105 pub fn body(&self) -> &str {
110 std::str::from_utf8(&self.body).unwrap_or("")
111 }
112
113 pub fn body_bytes(&self) -> &Bytes {
115 &self.body
116 }
117
118 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
123 let name = name.into();
124 self.headers.retain(|(n, _)| !n.eq_ignore_ascii_case(&name));
125 self.headers.push((name, value.into()));
126 self
127 }
128
129 pub fn append_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
135 self.headers.push((name.into(), value.into()));
136 self
137 }
138
139 pub fn headers(&self) -> &[(String, String)] {
144 &self.headers
145 }
146
147 pub fn cookie(self, cookie: Cookie) -> Self {
159 let header_value = cookie.to_header_value();
160 self.append_header("Set-Cookie", header_value)
161 }
162
163 pub fn ok(self) -> Response {
165 Ok(self)
166 }
167
168 pub fn into_hyper(self) -> hyper::Response<FerroBody> {
174 let mut builder = hyper::Response::builder().status(self.status);
175
176 for (name, value) in self.headers {
177 builder = builder.header(name, value);
178 }
179
180 builder.body(FerroBody::Full(Full::new(self.body))).unwrap()
181 }
182
183 pub fn sse_channel(
205 buffer: usize,
206 ) -> (
207 tokio::sync::mpsc::Sender<super::sse::SseEvent>,
208 hyper::Response<FerroBody>,
209 ) {
210 let (tx, stream) = super::sse::SseStream::channel(buffer);
211 let response = hyper::Response::builder()
212 .status(200)
213 .header("Content-Type", "text/event-stream")
214 .header("Cache-Control", "no-cache")
215 .header("Connection", "keep-alive")
216 .header("X-Accel-Buffering", "no")
217 .body(FerroBody::Stream(stream))
218 .unwrap();
219 (tx, response)
220 }
221
222 pub fn sse(stream: super::sse::SseStream) -> hyper::Response<FerroBody> {
228 hyper::Response::builder()
229 .status(200)
230 .header("Content-Type", "text/event-stream")
231 .header("Cache-Control", "no-cache")
232 .header("Connection", "keep-alive")
233 .header("X-Accel-Buffering", "no")
234 .body(FerroBody::Stream(stream))
235 .unwrap()
236 }
237}
238
239impl Default for HttpResponse {
240 fn default() -> Self {
241 Self::new()
242 }
243}
244
245pub trait ResponseExt {
247 fn status(self, code: u16) -> Self;
249 fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self;
251}
252
253impl ResponseExt for Response {
254 fn status(self, code: u16) -> Self {
255 self.map(|r| r.status(code))
256 }
257
258 fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self {
259 self.map(|r| r.header(name, value))
260 }
261}
262
263pub struct Redirect {
265 location: String,
266 query_params: Vec<(String, String)>,
267 status: u16,
268}
269
270impl Redirect {
271 pub fn to(path: impl Into<String>) -> Self {
273 Self {
274 location: path.into(),
275 query_params: Vec::new(),
276 status: 302,
277 }
278 }
279
280 pub fn back(req: &crate::http::Request, fallback: impl Into<String>) -> Self {
292 let location = same_origin_path_from_referer(req).unwrap_or_else(|| fallback.into());
293 Self {
294 location,
295 query_params: Vec::new(),
296 status: 302,
297 }
298 }
299
300 pub fn route(name: &str) -> RedirectRouteBuilder {
302 RedirectRouteBuilder {
303 name: name.to_string(),
304 params: std::collections::HashMap::new(),
305 query_params: Vec::new(),
306 status: 302,
307 }
308 }
309
310 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
312 self.query_params.push((key.to_string(), value.into()));
313 self
314 }
315
316 pub fn permanent(mut self) -> Self {
318 self.status = 301;
319 self
320 }
321
322 fn build_url(&self) -> String {
323 if self.query_params.is_empty() {
324 self.location.clone()
325 } else {
326 let query = self
327 .query_params
328 .iter()
329 .map(|(k, v)| format!("{k}={v}"))
330 .collect::<Vec<_>>()
331 .join("&");
332 format!("{}?{}", self.location, query)
333 }
334 }
335}
336
337impl From<Redirect> for Response {
339 fn from(redirect: Redirect) -> Response {
340 Ok(HttpResponse::new()
341 .status(redirect.status)
342 .header("Location", redirect.build_url()))
343 }
344}
345
346fn same_origin_path_from_referer(req: &crate::http::Request) -> Option<String> {
355 let referer = req.header("referer")?;
356 if referer.starts_with("//") {
358 return None;
359 }
360 if referer.starts_with('/') {
362 return Some(referer.to_string());
363 }
364 let rest = referer
366 .strip_prefix("http://")
367 .or_else(|| referer.strip_prefix("https://"))?;
368 let (referer_host, path) = match rest.find('/') {
369 Some(i) => (&rest[..i], &rest[i..]),
370 None => (rest, "/"),
371 };
372 let request_host = req.header("host")?;
373 if referer_host == request_host {
374 Some(path.to_string())
375 } else {
376 None
377 }
378}
379
380pub struct RedirectRouteBuilder {
382 name: String,
383 params: std::collections::HashMap<String, String>,
384 query_params: Vec<(String, String)>,
385 status: u16,
386}
387
388impl RedirectRouteBuilder {
389 pub fn with(mut self, key: &str, value: impl Into<String>) -> Self {
391 self.params.insert(key.to_string(), value.into());
392 self
393 }
394
395 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
397 self.query_params.push((key.to_string(), value.into()));
398 self
399 }
400
401 pub fn permanent(mut self) -> Self {
403 self.status = 301;
404 self
405 }
406
407 fn build_url(&self) -> Option<String> {
408 use crate::routing::route_with_params;
409
410 let mut url = route_with_params(&self.name, &self.params)?;
411 if !self.query_params.is_empty() {
412 let query = self
413 .query_params
414 .iter()
415 .map(|(k, v)| format!("{k}={v}"))
416 .collect::<Vec<_>>()
417 .join("&");
418 url = format!("{url}?{query}");
419 }
420 Some(url)
421 }
422}
423
424impl From<RedirectRouteBuilder> for Response {
426 fn from(redirect: RedirectRouteBuilder) -> Response {
427 let url = redirect.build_url().ok_or_else(|| {
428 HttpResponse::text(format!("Route '{}' not found", redirect.name)).status(500)
429 })?;
430 Ok(HttpResponse::new()
431 .status(redirect.status)
432 .header("Location", url))
433 }
434}
435
436impl From<crate::error::FrameworkError> for HttpResponse {
444 fn from(err: crate::error::FrameworkError) -> HttpResponse {
445 let status = err.status_code();
446 let hint = err.hint();
447 let mut body = match &err {
448 crate::error::FrameworkError::ParamError { param_name } => {
449 serde_json::json!({
450 "message": format!("Missing required parameter: {}", param_name)
451 })
452 }
453 crate::error::FrameworkError::ValidationError { field, message } => {
454 serde_json::json!({
455 "message": "Validation failed",
456 "field": field,
457 "error": message
458 })
459 }
460 crate::error::FrameworkError::Validation(errors) => {
461 errors.to_json()
463 }
464 crate::error::FrameworkError::Unauthorized => {
465 serde_json::json!({
466 "message": "This action is unauthorized."
467 })
468 }
469 _ => {
470 serde_json::json!({
471 "message": err.to_string()
472 })
473 }
474 };
475 if let Some(hint_text) = hint {
476 if let Some(obj) = body.as_object_mut() {
477 obj.insert("hint".to_string(), serde_json::Value::String(hint_text));
478 }
479 }
480 HttpResponse::json(body).status(status)
481 }
482}
483
484impl From<crate::error::AppError> for HttpResponse {
488 fn from(err: crate::error::AppError) -> HttpResponse {
489 let framework_err: crate::error::FrameworkError = err.into();
491 framework_err.into()
492 }
493}
494
495#[cfg(feature = "projections")]
499impl From<ferro_projections::Error> for HttpResponse {
500 fn from(err: ferro_projections::Error) -> HttpResponse {
501 let framework_err: crate::error::FrameworkError = err.into();
502 framework_err.into()
503 }
504}
505
506pub struct InertiaRedirect<'a> {
524 request: &'a crate::http::Request,
525 location: String,
526 query_params: Vec<(String, String)>,
527}
528
529impl<'a> InertiaRedirect<'a> {
530 pub fn to(request: &'a crate::http::Request, path: impl Into<String>) -> Self {
532 Self {
533 request,
534 location: path.into(),
535 query_params: Vec::new(),
536 }
537 }
538
539 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
541 self.query_params.push((key.to_string(), value.into()));
542 self
543 }
544
545 fn build_url(&self) -> String {
546 if self.query_params.is_empty() {
547 self.location.clone()
548 } else {
549 let query = self
550 .query_params
551 .iter()
552 .map(|(k, v)| format!("{k}={v}"))
553 .collect::<Vec<_>>()
554 .join("&");
555 format!("{}?{}", self.location, query)
556 }
557 }
558
559 fn is_post_like_method(&self) -> bool {
560 matches!(
561 self.request.method().as_str(),
562 "POST" | "PUT" | "PATCH" | "DELETE"
563 )
564 }
565}
566
567impl From<InertiaRedirect<'_>> for Response {
568 fn from(redirect: InertiaRedirect<'_>) -> Response {
569 let url = redirect.build_url();
570 let is_inertia = redirect.request.is_inertia();
571 let is_post_like = redirect.is_post_like_method();
572
573 if is_inertia {
574 let status = if is_post_like { 303 } else { 302 };
576 Ok(HttpResponse::new()
577 .status(status)
578 .header("X-Inertia", "true")
579 .header("Location", url))
580 } else {
581 Ok(HttpResponse::new().status(302).header("Location", url))
583 }
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
592 fn test_bytes_constructor() {
593 let resp = HttpResponse::bytes(vec![0xFF, 0xFE, 0x00]);
594 assert_eq!(resp.body_bytes().as_ref(), &[0xFF, 0xFE, 0x00]);
595 assert_eq!(resp.status_code(), 200);
596 assert!(
597 resp.headers.is_empty(),
598 "bytes() should set no default headers"
599 );
600 }
601
602 #[test]
603 fn test_bytes_from_vec_u8() {
604 let resp = HttpResponse::bytes(vec![1, 2, 3]);
605 assert_eq!(resp.body_bytes().len(), 3);
606 }
607
608 #[test]
609 fn test_bytes_with_content_type() {
610 let resp = HttpResponse::bytes(b"PNG data".to_vec()).header("Content-Type", "image/png");
611 let ct = resp
612 .headers
613 .iter()
614 .find(|(k, _)| k == "Content-Type")
615 .map(|(_, v)| v.as_str());
616 assert_eq!(ct, Some("image/png"));
617 }
618
619 #[test]
620 fn test_download_constructor() {
621 let resp = HttpResponse::download(b"pdf content".to_vec(), "report.pdf");
622 let ct = resp
623 .headers
624 .iter()
625 .find(|(k, _)| k == "Content-Type")
626 .map(|(_, v)| v.as_str());
627 assert_eq!(ct, Some("application/pdf"));
628
629 let cd = resp
630 .headers
631 .iter()
632 .find(|(k, _)| k == "Content-Disposition")
633 .map(|(_, v)| v.as_str());
634 assert_eq!(cd, Some("attachment; filename=\"report.pdf\""));
635 }
636
637 #[test]
638 fn test_download_unknown_extension() {
639 let resp = HttpResponse::download(b"data".to_vec(), "file.zzqx");
640 let ct = resp
641 .headers
642 .iter()
643 .find(|(k, _)| k == "Content-Type")
644 .map(|(_, v)| v.as_str());
645 assert_eq!(ct, Some("application/octet-stream"));
646 }
647
648 #[test]
649 fn test_download_filename_sanitization() {
650 let resp = HttpResponse::download(b"data".to_vec(), "evil\"file\nname.pdf");
651 let cd = resp
652 .headers
653 .iter()
654 .find(|(k, _)| k == "Content-Disposition")
655 .map(|(_, v)| v.as_str())
656 .unwrap();
657 assert!(
658 !cd.contains('"') || cd.matches('"').count() == 2,
659 "filename should be properly quoted"
660 );
661 assert!(!cd.contains('\n'), "filename should not contain newlines");
662 }
663
664 #[test]
665 fn test_text_still_works() {
666 let resp = HttpResponse::text("hello");
667 assert_eq!(resp.body(), "hello");
668 assert_eq!(resp.body_bytes().as_ref(), b"hello");
669 }
670
671 #[test]
672 fn test_json_still_works() {
673 let resp = HttpResponse::json(serde_json::json!({"ok": true}));
674 let body = resp.body();
675 assert!(!body.is_empty(), "json body should not be empty");
676 let parsed: serde_json::Value = serde_json::from_str(body).unwrap();
677 assert_eq!(parsed["ok"], true);
678 assert!(!resp.body_bytes().is_empty());
679 }
680
681 #[test]
682 fn test_body_returns_empty_for_binary() {
683 let resp = HttpResponse::bytes(vec![0xFF, 0xFE]);
684 assert_eq!(resp.body(), "");
685 }
686
687 #[test]
688 fn test_into_hyper_preserves_binary() {
689 use http_body_util::BodyExt;
690
691 let data = vec![0xFF, 0x00, 0xFE];
692 let resp = HttpResponse::bytes(data.clone());
693 let hyper_resp = resp.into_hyper();
694
695 let rt = tokio::runtime::Runtime::new().unwrap();
696 let collected =
697 rt.block_on(async { hyper_resp.into_body().collect().await.unwrap().to_bytes() });
698 assert_eq!(collected.as_ref(), &data);
699 }
700
701 #[test]
702 fn test_header_replaces_existing() {
703 let resp = HttpResponse::text("x").header("Content-Type", "text/html");
704 let ct: Vec<_> = resp
705 .headers()
706 .iter()
707 .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
708 .collect();
709 assert_eq!(ct.len(), 1, "expected exactly one Content-Type entry");
710 assert_eq!(ct[0].1, "text/html");
711 }
712
713 #[test]
714 fn test_multi_cookie_preserved() {
715 let resp = HttpResponse::new()
716 .cookie(Cookie::new("a", "1"))
717 .cookie(Cookie::new("b", "2"));
718 let cookies: Vec<_> = resp
719 .headers()
720 .iter()
721 .filter(|(k, _)| k == "Set-Cookie")
722 .collect();
723 assert_eq!(
724 cookies.len(),
725 2,
726 "both Set-Cookie entries must be preserved"
727 );
728 }
729
730 #[test]
731 fn test_header_case_insensitive_replace() {
732 let resp = HttpResponse::new()
733 .append_header("content-type", "text/plain")
734 .header("Content-Type", "text/html");
735 let ct: Vec<_> = resp
736 .headers()
737 .iter()
738 .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
739 .collect();
740 assert_eq!(ct.len(), 1, "lowercase prior entry must be replaced");
741 assert_eq!(ct[0].1, "text/html");
742 }
743
744 #[test]
745 fn test_append_header_does_not_replace() {
746 let resp = HttpResponse::new()
747 .append_header("X-Tag", "a")
748 .append_header("X-Tag", "b");
749 let count = resp.headers().iter().filter(|(k, _)| k == "X-Tag").count();
750 assert_eq!(count, 2, "append_header must not strip existing entries");
751 }
752
753 #[test]
754 fn test_headers_accessor() {
755 let resp = HttpResponse::text("x");
756 assert!(
757 !resp.headers().is_empty(),
758 "headers() accessor should return the prepopulated Content-Type"
759 );
760 }
761}