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 route(name: &str) -> RedirectRouteBuilder {
222 RedirectRouteBuilder {
223 name: name.to_string(),
224 params: std::collections::HashMap::new(),
225 query_params: Vec::new(),
226 status: 302,
227 }
228 }
229
230 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
232 self.query_params.push((key.to_string(), value.into()));
233 self
234 }
235
236 pub fn permanent(mut self) -> Self {
238 self.status = 301;
239 self
240 }
241
242 fn build_url(&self) -> String {
243 if self.query_params.is_empty() {
244 self.location.clone()
245 } else {
246 let query = self
247 .query_params
248 .iter()
249 .map(|(k, v)| format!("{k}={v}"))
250 .collect::<Vec<_>>()
251 .join("&");
252 format!("{}?{}", self.location, query)
253 }
254 }
255}
256
257impl From<Redirect> for Response {
259 fn from(redirect: Redirect) -> Response {
260 Ok(HttpResponse::new()
261 .status(redirect.status)
262 .header("Location", redirect.build_url()))
263 }
264}
265
266pub struct RedirectRouteBuilder {
268 name: String,
269 params: std::collections::HashMap<String, String>,
270 query_params: Vec<(String, String)>,
271 status: u16,
272}
273
274impl RedirectRouteBuilder {
275 pub fn with(mut self, key: &str, value: impl Into<String>) -> Self {
277 self.params.insert(key.to_string(), value.into());
278 self
279 }
280
281 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
283 self.query_params.push((key.to_string(), value.into()));
284 self
285 }
286
287 pub fn permanent(mut self) -> Self {
289 self.status = 301;
290 self
291 }
292
293 fn build_url(&self) -> Option<String> {
294 use crate::routing::route_with_params;
295
296 let mut url = route_with_params(&self.name, &self.params)?;
297 if !self.query_params.is_empty() {
298 let query = self
299 .query_params
300 .iter()
301 .map(|(k, v)| format!("{k}={v}"))
302 .collect::<Vec<_>>()
303 .join("&");
304 url = format!("{url}?{query}");
305 }
306 Some(url)
307 }
308}
309
310impl From<RedirectRouteBuilder> for Response {
312 fn from(redirect: RedirectRouteBuilder) -> Response {
313 let url = redirect.build_url().ok_or_else(|| {
314 HttpResponse::text(format!("Route '{}' not found", redirect.name)).status(500)
315 })?;
316 Ok(HttpResponse::new()
317 .status(redirect.status)
318 .header("Location", url))
319 }
320}
321
322impl From<crate::error::FrameworkError> for HttpResponse {
330 fn from(err: crate::error::FrameworkError) -> HttpResponse {
331 let status = err.status_code();
332 let hint = err.hint();
333 let mut body = match &err {
334 crate::error::FrameworkError::ParamError { param_name } => {
335 serde_json::json!({
336 "message": format!("Missing required parameter: {}", param_name)
337 })
338 }
339 crate::error::FrameworkError::ValidationError { field, message } => {
340 serde_json::json!({
341 "message": "Validation failed",
342 "field": field,
343 "error": message
344 })
345 }
346 crate::error::FrameworkError::Validation(errors) => {
347 errors.to_json()
349 }
350 crate::error::FrameworkError::Unauthorized => {
351 serde_json::json!({
352 "message": "This action is unauthorized."
353 })
354 }
355 _ => {
356 serde_json::json!({
357 "message": err.to_string()
358 })
359 }
360 };
361 if let Some(hint_text) = hint {
362 if let Some(obj) = body.as_object_mut() {
363 obj.insert("hint".to_string(), serde_json::Value::String(hint_text));
364 }
365 }
366 HttpResponse::json(body).status(status)
367 }
368}
369
370impl From<crate::error::AppError> for HttpResponse {
374 fn from(err: crate::error::AppError) -> HttpResponse {
375 let framework_err: crate::error::FrameworkError = err.into();
377 framework_err.into()
378 }
379}
380
381#[cfg(feature = "projections")]
385impl From<ferro_projections::Error> for HttpResponse {
386 fn from(err: ferro_projections::Error) -> HttpResponse {
387 let framework_err: crate::error::FrameworkError = err.into();
388 framework_err.into()
389 }
390}
391
392pub struct InertiaRedirect<'a> {
410 request: &'a crate::http::Request,
411 location: String,
412 query_params: Vec<(String, String)>,
413}
414
415impl<'a> InertiaRedirect<'a> {
416 pub fn to(request: &'a crate::http::Request, path: impl Into<String>) -> Self {
418 Self {
419 request,
420 location: path.into(),
421 query_params: Vec::new(),
422 }
423 }
424
425 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
427 self.query_params.push((key.to_string(), value.into()));
428 self
429 }
430
431 fn build_url(&self) -> String {
432 if self.query_params.is_empty() {
433 self.location.clone()
434 } else {
435 let query = self
436 .query_params
437 .iter()
438 .map(|(k, v)| format!("{k}={v}"))
439 .collect::<Vec<_>>()
440 .join("&");
441 format!("{}?{}", self.location, query)
442 }
443 }
444
445 fn is_post_like_method(&self) -> bool {
446 matches!(
447 self.request.method().as_str(),
448 "POST" | "PUT" | "PATCH" | "DELETE"
449 )
450 }
451}
452
453impl From<InertiaRedirect<'_>> for Response {
454 fn from(redirect: InertiaRedirect<'_>) -> Response {
455 let url = redirect.build_url();
456 let is_inertia = redirect.request.is_inertia();
457 let is_post_like = redirect.is_post_like_method();
458
459 if is_inertia {
460 let status = if is_post_like { 303 } else { 302 };
462 Ok(HttpResponse::new()
463 .status(status)
464 .header("X-Inertia", "true")
465 .header("Location", url))
466 } else {
467 Ok(HttpResponse::new().status(302).header("Location", url))
469 }
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn test_bytes_constructor() {
479 let resp = HttpResponse::bytes(vec![0xFF, 0xFE, 0x00]);
480 assert_eq!(resp.body_bytes().as_ref(), &[0xFF, 0xFE, 0x00]);
481 assert_eq!(resp.status_code(), 200);
482 assert!(
483 resp.headers.is_empty(),
484 "bytes() should set no default headers"
485 );
486 }
487
488 #[test]
489 fn test_bytes_from_vec_u8() {
490 let resp = HttpResponse::bytes(vec![1, 2, 3]);
491 assert_eq!(resp.body_bytes().len(), 3);
492 }
493
494 #[test]
495 fn test_bytes_with_content_type() {
496 let resp = HttpResponse::bytes(b"PNG data".to_vec()).header("Content-Type", "image/png");
497 let ct = resp
498 .headers
499 .iter()
500 .find(|(k, _)| k == "Content-Type")
501 .map(|(_, v)| v.as_str());
502 assert_eq!(ct, Some("image/png"));
503 }
504
505 #[test]
506 fn test_download_constructor() {
507 let resp = HttpResponse::download(b"pdf content".to_vec(), "report.pdf");
508 let ct = resp
509 .headers
510 .iter()
511 .find(|(k, _)| k == "Content-Type")
512 .map(|(_, v)| v.as_str());
513 assert_eq!(ct, Some("application/pdf"));
514
515 let cd = resp
516 .headers
517 .iter()
518 .find(|(k, _)| k == "Content-Disposition")
519 .map(|(_, v)| v.as_str());
520 assert_eq!(cd, Some("attachment; filename=\"report.pdf\""));
521 }
522
523 #[test]
524 fn test_download_unknown_extension() {
525 let resp = HttpResponse::download(b"data".to_vec(), "file.zzqx");
526 let ct = resp
527 .headers
528 .iter()
529 .find(|(k, _)| k == "Content-Type")
530 .map(|(_, v)| v.as_str());
531 assert_eq!(ct, Some("application/octet-stream"));
532 }
533
534 #[test]
535 fn test_download_filename_sanitization() {
536 let resp = HttpResponse::download(b"data".to_vec(), "evil\"file\nname.pdf");
537 let cd = resp
538 .headers
539 .iter()
540 .find(|(k, _)| k == "Content-Disposition")
541 .map(|(_, v)| v.as_str())
542 .unwrap();
543 assert!(
544 !cd.contains('"') || cd.matches('"').count() == 2,
545 "filename should be properly quoted"
546 );
547 assert!(!cd.contains('\n'), "filename should not contain newlines");
548 }
549
550 #[test]
551 fn test_text_still_works() {
552 let resp = HttpResponse::text("hello");
553 assert_eq!(resp.body(), "hello");
554 assert_eq!(resp.body_bytes().as_ref(), b"hello");
555 }
556
557 #[test]
558 fn test_json_still_works() {
559 let resp = HttpResponse::json(serde_json::json!({"ok": true}));
560 let body = resp.body();
561 assert!(!body.is_empty(), "json body should not be empty");
562 let parsed: serde_json::Value = serde_json::from_str(body).unwrap();
563 assert_eq!(parsed["ok"], true);
564 assert!(!resp.body_bytes().is_empty());
565 }
566
567 #[test]
568 fn test_body_returns_empty_for_binary() {
569 let resp = HttpResponse::bytes(vec![0xFF, 0xFE]);
570 assert_eq!(resp.body(), "");
571 }
572
573 #[test]
574 fn test_into_hyper_preserves_binary() {
575 use http_body_util::BodyExt;
576
577 let data = vec![0xFF, 0x00, 0xFE];
578 let resp = HttpResponse::bytes(data.clone());
579 let hyper_resp = resp.into_hyper();
580
581 let rt = tokio::runtime::Runtime::new().unwrap();
582 let collected =
583 rt.block_on(async { hyper_resp.into_body().collect().await.unwrap().to_bytes() });
584 assert_eq!(collected.as_ref(), &data);
585 }
586
587 #[test]
588 fn test_header_replaces_existing() {
589 let resp = HttpResponse::text("x").header("Content-Type", "text/html");
590 let ct: Vec<_> = resp
591 .headers()
592 .iter()
593 .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
594 .collect();
595 assert_eq!(ct.len(), 1, "expected exactly one Content-Type entry");
596 assert_eq!(ct[0].1, "text/html");
597 }
598
599 #[test]
600 fn test_multi_cookie_preserved() {
601 let resp = HttpResponse::new()
602 .cookie(Cookie::new("a", "1"))
603 .cookie(Cookie::new("b", "2"));
604 let cookies: Vec<_> = resp
605 .headers()
606 .iter()
607 .filter(|(k, _)| k == "Set-Cookie")
608 .collect();
609 assert_eq!(
610 cookies.len(),
611 2,
612 "both Set-Cookie entries must be preserved"
613 );
614 }
615
616 #[test]
617 fn test_header_case_insensitive_replace() {
618 let resp = HttpResponse::new()
619 .append_header("content-type", "text/plain")
620 .header("Content-Type", "text/html");
621 let ct: Vec<_> = resp
622 .headers()
623 .iter()
624 .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
625 .collect();
626 assert_eq!(ct.len(), 1, "lowercase prior entry must be replaced");
627 assert_eq!(ct[0].1, "text/html");
628 }
629
630 #[test]
631 fn test_append_header_does_not_replace() {
632 let resp = HttpResponse::new()
633 .append_header("X-Tag", "a")
634 .append_header("X-Tag", "b");
635 let count = resp.headers().iter().filter(|(k, _)| k == "X-Tag").count();
636 assert_eq!(count, 2, "append_header must not strip existing entries");
637 }
638
639 #[test]
640 fn test_headers_accessor() {
641 let resp = HttpResponse::text("x");
642 assert!(
643 !resp.headers().is_empty(),
644 "headers() accessor should return the prepopulated Content-Type"
645 );
646 }
647}