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 {
119 self.headers.push((name.into(), value.into()));
120 self
121 }
122
123 pub fn cookie(self, cookie: Cookie) -> Self {
135 let header_value = cookie.to_header_value();
136 self.header("Set-Cookie", header_value)
137 }
138
139 pub fn ok(self) -> Response {
141 Ok(self)
142 }
143
144 pub fn into_hyper(self) -> hyper::Response<Full<Bytes>> {
146 let mut builder = hyper::Response::builder().status(self.status);
147
148 for (name, value) in self.headers {
149 builder = builder.header(name, value);
150 }
151
152 builder.body(Full::new(self.body)).unwrap()
153 }
154}
155
156impl Default for HttpResponse {
157 fn default() -> Self {
158 Self::new()
159 }
160}
161
162pub trait ResponseExt {
164 fn status(self, code: u16) -> Self;
166 fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self;
168}
169
170impl ResponseExt for Response {
171 fn status(self, code: u16) -> Self {
172 self.map(|r| r.status(code))
173 }
174
175 fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self {
176 self.map(|r| r.header(name, value))
177 }
178}
179
180pub struct Redirect {
182 location: String,
183 query_params: Vec<(String, String)>,
184 status: u16,
185}
186
187impl Redirect {
188 pub fn to(path: impl Into<String>) -> Self {
190 Self {
191 location: path.into(),
192 query_params: Vec::new(),
193 status: 302,
194 }
195 }
196
197 pub fn route(name: &str) -> RedirectRouteBuilder {
199 RedirectRouteBuilder {
200 name: name.to_string(),
201 params: std::collections::HashMap::new(),
202 query_params: Vec::new(),
203 status: 302,
204 }
205 }
206
207 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
209 self.query_params.push((key.to_string(), value.into()));
210 self
211 }
212
213 pub fn permanent(mut self) -> Self {
215 self.status = 301;
216 self
217 }
218
219 fn build_url(&self) -> String {
220 if self.query_params.is_empty() {
221 self.location.clone()
222 } else {
223 let query = self
224 .query_params
225 .iter()
226 .map(|(k, v)| format!("{k}={v}"))
227 .collect::<Vec<_>>()
228 .join("&");
229 format!("{}?{}", self.location, query)
230 }
231 }
232}
233
234impl From<Redirect> for Response {
236 fn from(redirect: Redirect) -> Response {
237 Ok(HttpResponse::new()
238 .status(redirect.status)
239 .header("Location", redirect.build_url()))
240 }
241}
242
243pub struct RedirectRouteBuilder {
245 name: String,
246 params: std::collections::HashMap<String, String>,
247 query_params: Vec<(String, String)>,
248 status: u16,
249}
250
251impl RedirectRouteBuilder {
252 pub fn with(mut self, key: &str, value: impl Into<String>) -> Self {
254 self.params.insert(key.to_string(), value.into());
255 self
256 }
257
258 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
260 self.query_params.push((key.to_string(), value.into()));
261 self
262 }
263
264 pub fn permanent(mut self) -> Self {
266 self.status = 301;
267 self
268 }
269
270 fn build_url(&self) -> Option<String> {
271 use crate::routing::route_with_params;
272
273 let mut url = route_with_params(&self.name, &self.params)?;
274 if !self.query_params.is_empty() {
275 let query = self
276 .query_params
277 .iter()
278 .map(|(k, v)| format!("{k}={v}"))
279 .collect::<Vec<_>>()
280 .join("&");
281 url = format!("{url}?{query}");
282 }
283 Some(url)
284 }
285}
286
287impl From<RedirectRouteBuilder> for Response {
289 fn from(redirect: RedirectRouteBuilder) -> Response {
290 let url = redirect.build_url().ok_or_else(|| {
291 HttpResponse::text(format!("Route '{}' not found", redirect.name)).status(500)
292 })?;
293 Ok(HttpResponse::new()
294 .status(redirect.status)
295 .header("Location", url))
296 }
297}
298
299impl From<crate::error::FrameworkError> for HttpResponse {
307 fn from(err: crate::error::FrameworkError) -> HttpResponse {
308 let status = err.status_code();
309 let hint = err.hint();
310 let mut body = match &err {
311 crate::error::FrameworkError::ParamError { param_name } => {
312 serde_json::json!({
313 "message": format!("Missing required parameter: {}", param_name)
314 })
315 }
316 crate::error::FrameworkError::ValidationError { field, message } => {
317 serde_json::json!({
318 "message": "Validation failed",
319 "field": field,
320 "error": message
321 })
322 }
323 crate::error::FrameworkError::Validation(errors) => {
324 errors.to_json()
326 }
327 crate::error::FrameworkError::Unauthorized => {
328 serde_json::json!({
329 "message": "This action is unauthorized."
330 })
331 }
332 _ => {
333 serde_json::json!({
334 "message": err.to_string()
335 })
336 }
337 };
338 if let Some(hint_text) = hint {
339 if let Some(obj) = body.as_object_mut() {
340 obj.insert("hint".to_string(), serde_json::Value::String(hint_text));
341 }
342 }
343 HttpResponse::json(body).status(status)
344 }
345}
346
347impl From<crate::error::AppError> for HttpResponse {
351 fn from(err: crate::error::AppError) -> HttpResponse {
352 let framework_err: crate::error::FrameworkError = err.into();
354 framework_err.into()
355 }
356}
357
358#[cfg(feature = "projections")]
362impl From<ferro_projections::Error> for HttpResponse {
363 fn from(err: ferro_projections::Error) -> HttpResponse {
364 let framework_err: crate::error::FrameworkError = err.into();
365 framework_err.into()
366 }
367}
368
369pub struct InertiaRedirect<'a> {
387 request: &'a crate::http::Request,
388 location: String,
389 query_params: Vec<(String, String)>,
390}
391
392impl<'a> InertiaRedirect<'a> {
393 pub fn to(request: &'a crate::http::Request, path: impl Into<String>) -> Self {
395 Self {
396 request,
397 location: path.into(),
398 query_params: Vec::new(),
399 }
400 }
401
402 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
404 self.query_params.push((key.to_string(), value.into()));
405 self
406 }
407
408 fn build_url(&self) -> String {
409 if self.query_params.is_empty() {
410 self.location.clone()
411 } else {
412 let query = self
413 .query_params
414 .iter()
415 .map(|(k, v)| format!("{k}={v}"))
416 .collect::<Vec<_>>()
417 .join("&");
418 format!("{}?{}", self.location, query)
419 }
420 }
421
422 fn is_post_like_method(&self) -> bool {
423 matches!(
424 self.request.method().as_str(),
425 "POST" | "PUT" | "PATCH" | "DELETE"
426 )
427 }
428}
429
430impl From<InertiaRedirect<'_>> for Response {
431 fn from(redirect: InertiaRedirect<'_>) -> Response {
432 let url = redirect.build_url();
433 let is_inertia = redirect.request.is_inertia();
434 let is_post_like = redirect.is_post_like_method();
435
436 if is_inertia {
437 let status = if is_post_like { 303 } else { 302 };
439 Ok(HttpResponse::new()
440 .status(status)
441 .header("X-Inertia", "true")
442 .header("Location", url))
443 } else {
444 Ok(HttpResponse::new().status(302).header("Location", url))
446 }
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
455 fn test_bytes_constructor() {
456 let resp = HttpResponse::bytes(vec![0xFF, 0xFE, 0x00]);
457 assert_eq!(resp.body_bytes().as_ref(), &[0xFF, 0xFE, 0x00]);
458 assert_eq!(resp.status_code(), 200);
459 assert!(
460 resp.headers.is_empty(),
461 "bytes() should set no default headers"
462 );
463 }
464
465 #[test]
466 fn test_bytes_from_vec_u8() {
467 let resp = HttpResponse::bytes(vec![1, 2, 3]);
468 assert_eq!(resp.body_bytes().len(), 3);
469 }
470
471 #[test]
472 fn test_bytes_with_content_type() {
473 let resp = HttpResponse::bytes(b"PNG data".to_vec()).header("Content-Type", "image/png");
474 let ct = resp
475 .headers
476 .iter()
477 .find(|(k, _)| k == "Content-Type")
478 .map(|(_, v)| v.as_str());
479 assert_eq!(ct, Some("image/png"));
480 }
481
482 #[test]
483 fn test_download_constructor() {
484 let resp = HttpResponse::download(b"pdf content".to_vec(), "report.pdf");
485 let ct = resp
486 .headers
487 .iter()
488 .find(|(k, _)| k == "Content-Type")
489 .map(|(_, v)| v.as_str());
490 assert_eq!(ct, Some("application/pdf"));
491
492 let cd = resp
493 .headers
494 .iter()
495 .find(|(k, _)| k == "Content-Disposition")
496 .map(|(_, v)| v.as_str());
497 assert_eq!(cd, Some("attachment; filename=\"report.pdf\""));
498 }
499
500 #[test]
501 fn test_download_unknown_extension() {
502 let resp = HttpResponse::download(b"data".to_vec(), "file.zzqx");
503 let ct = resp
504 .headers
505 .iter()
506 .find(|(k, _)| k == "Content-Type")
507 .map(|(_, v)| v.as_str());
508 assert_eq!(ct, Some("application/octet-stream"));
509 }
510
511 #[test]
512 fn test_download_filename_sanitization() {
513 let resp = HttpResponse::download(b"data".to_vec(), "evil\"file\nname.pdf");
514 let cd = resp
515 .headers
516 .iter()
517 .find(|(k, _)| k == "Content-Disposition")
518 .map(|(_, v)| v.as_str())
519 .unwrap();
520 assert!(
521 !cd.contains('"') || cd.matches('"').count() == 2,
522 "filename should be properly quoted"
523 );
524 assert!(!cd.contains('\n'), "filename should not contain newlines");
525 }
526
527 #[test]
528 fn test_text_still_works() {
529 let resp = HttpResponse::text("hello");
530 assert_eq!(resp.body(), "hello");
531 assert_eq!(resp.body_bytes().as_ref(), b"hello");
532 }
533
534 #[test]
535 fn test_json_still_works() {
536 let resp = HttpResponse::json(serde_json::json!({"ok": true}));
537 let body = resp.body();
538 assert!(!body.is_empty(), "json body should not be empty");
539 let parsed: serde_json::Value = serde_json::from_str(body).unwrap();
540 assert_eq!(parsed["ok"], true);
541 assert!(!resp.body_bytes().is_empty());
542 }
543
544 #[test]
545 fn test_body_returns_empty_for_binary() {
546 let resp = HttpResponse::bytes(vec![0xFF, 0xFE]);
547 assert_eq!(resp.body(), "");
548 }
549
550 #[test]
551 fn test_into_hyper_preserves_binary() {
552 use http_body_util::BodyExt;
553
554 let data = vec![0xFF, 0x00, 0xFE];
555 let resp = HttpResponse::bytes(data.clone());
556 let hyper_resp = resp.into_hyper();
557
558 let rt = tokio::runtime::Runtime::new().unwrap();
559 let collected =
560 rt.block_on(async { hyper_resp.into_body().collect().await.unwrap().to_bytes() });
561 assert_eq!(collected.as_ref(), &data);
562 }
563}