1use super::cookie::Cookie;
2use bytes::Bytes;
3use http_body_util::Full;
4
5pub struct HttpResponse {
7 status: u16,
8 body: Bytes,
9 headers: Vec<(String, String)>,
10}
11
12pub type Response = Result<HttpResponse, HttpResponse>;
14
15impl HttpResponse {
16 pub fn new() -> Self {
17 Self {
18 status: 200,
19 body: Bytes::new(),
20 headers: Vec::new(),
21 }
22 }
23
24 pub fn text(body: impl Into<String>) -> Self {
26 let s: String = body.into();
27 Self {
28 status: 200,
29 body: Bytes::from(s),
30 headers: vec![("Content-Type".to_string(), "text/plain".to_string())],
31 }
32 }
33
34 pub fn json(body: serde_json::Value) -> Self {
36 Self {
37 status: 200,
38 body: Bytes::from(body.to_string()),
39 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
40 }
41 }
42
43 pub fn bytes(body: impl Into<Bytes>) -> Self {
47 Self {
48 status: 200,
49 body: body.into(),
50 headers: vec![],
51 }
52 }
53
54 pub fn download(body: impl Into<Bytes>, filename: &str) -> Self {
61 let safe_name: String = filename
62 .chars()
63 .filter(|c| !c.is_control() && *c != '"' && *c != '\\')
64 .collect();
65
66 let content_type = mime_guess::from_path(&safe_name)
67 .first()
68 .map(|m| m.to_string())
69 .unwrap_or_else(|| "application/octet-stream".to_string());
70
71 Self {
72 status: 200,
73 body: body.into(),
74 headers: vec![
75 ("Content-Type".to_string(), content_type),
76 (
77 "Content-Disposition".to_string(),
78 format!("attachment; filename=\"{safe_name}\""),
79 ),
80 ],
81 }
82 }
83
84 pub fn set_body(mut self, body: impl Into<String>) -> Self {
86 let s: String = body.into();
87 self.body = Bytes::from(s);
88 self
89 }
90
91 pub fn status(mut self, status: u16) -> Self {
93 self.status = status;
94 self
95 }
96
97 pub fn status_code(&self) -> u16 {
99 self.status
100 }
101
102 pub fn body(&self) -> &str {
107 std::str::from_utf8(&self.body).unwrap_or("")
108 }
109
110 pub fn body_bytes(&self) -> &Bytes {
112 &self.body
113 }
114
115 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
117 self.headers.push((name.into(), value.into()));
118 self
119 }
120
121 pub fn cookie(self, cookie: Cookie) -> Self {
133 let header_value = cookie.to_header_value();
134 self.header("Set-Cookie", header_value)
135 }
136
137 pub fn ok(self) -> Response {
139 Ok(self)
140 }
141
142 pub fn into_hyper(self) -> hyper::Response<Full<Bytes>> {
144 let mut builder = hyper::Response::builder().status(self.status);
145
146 for (name, value) in self.headers {
147 builder = builder.header(name, value);
148 }
149
150 builder.body(Full::new(self.body)).unwrap()
151 }
152}
153
154impl Default for HttpResponse {
155 fn default() -> Self {
156 Self::new()
157 }
158}
159
160pub trait ResponseExt {
162 fn status(self, code: u16) -> Self;
163 fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self;
164}
165
166impl ResponseExt for Response {
167 fn status(self, code: u16) -> Self {
168 self.map(|r| r.status(code))
169 }
170
171 fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self {
172 self.map(|r| r.header(name, value))
173 }
174}
175
176pub struct Redirect {
178 location: String,
179 query_params: Vec<(String, String)>,
180 status: u16,
181}
182
183impl Redirect {
184 pub fn to(path: impl Into<String>) -> Self {
186 Self {
187 location: path.into(),
188 query_params: Vec::new(),
189 status: 302,
190 }
191 }
192
193 pub fn route(name: &str) -> RedirectRouteBuilder {
195 RedirectRouteBuilder {
196 name: name.to_string(),
197 params: std::collections::HashMap::new(),
198 query_params: Vec::new(),
199 status: 302,
200 }
201 }
202
203 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
205 self.query_params.push((key.to_string(), value.into()));
206 self
207 }
208
209 pub fn permanent(mut self) -> Self {
211 self.status = 301;
212 self
213 }
214
215 fn build_url(&self) -> String {
216 if self.query_params.is_empty() {
217 self.location.clone()
218 } else {
219 let query = self
220 .query_params
221 .iter()
222 .map(|(k, v)| format!("{k}={v}"))
223 .collect::<Vec<_>>()
224 .join("&");
225 format!("{}?{}", self.location, query)
226 }
227 }
228}
229
230impl From<Redirect> for Response {
232 fn from(redirect: Redirect) -> Response {
233 Ok(HttpResponse::new()
234 .status(redirect.status)
235 .header("Location", redirect.build_url()))
236 }
237}
238
239pub struct RedirectRouteBuilder {
241 name: String,
242 params: std::collections::HashMap<String, String>,
243 query_params: Vec<(String, String)>,
244 status: u16,
245}
246
247impl RedirectRouteBuilder {
248 pub fn with(mut self, key: &str, value: impl Into<String>) -> Self {
250 self.params.insert(key.to_string(), value.into());
251 self
252 }
253
254 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
256 self.query_params.push((key.to_string(), value.into()));
257 self
258 }
259
260 pub fn permanent(mut self) -> Self {
262 self.status = 301;
263 self
264 }
265
266 fn build_url(&self) -> Option<String> {
267 use crate::routing::route_with_params;
268
269 let mut url = route_with_params(&self.name, &self.params)?;
270 if !self.query_params.is_empty() {
271 let query = self
272 .query_params
273 .iter()
274 .map(|(k, v)| format!("{k}={v}"))
275 .collect::<Vec<_>>()
276 .join("&");
277 url = format!("{url}?{query}");
278 }
279 Some(url)
280 }
281}
282
283impl From<RedirectRouteBuilder> for Response {
285 fn from(redirect: RedirectRouteBuilder) -> Response {
286 let url = redirect.build_url().ok_or_else(|| {
287 HttpResponse::text(format!("Route '{}' not found", redirect.name)).status(500)
288 })?;
289 Ok(HttpResponse::new()
290 .status(redirect.status)
291 .header("Location", url))
292 }
293}
294
295impl From<crate::error::FrameworkError> for HttpResponse {
303 fn from(err: crate::error::FrameworkError) -> HttpResponse {
304 let status = err.status_code();
305 let hint = err.hint();
306 let mut body = match &err {
307 crate::error::FrameworkError::ParamError { param_name } => {
308 serde_json::json!({
309 "message": format!("Missing required parameter: {}", param_name)
310 })
311 }
312 crate::error::FrameworkError::ValidationError { field, message } => {
313 serde_json::json!({
314 "message": "Validation failed",
315 "field": field,
316 "error": message
317 })
318 }
319 crate::error::FrameworkError::Validation(errors) => {
320 errors.to_json()
322 }
323 crate::error::FrameworkError::Unauthorized => {
324 serde_json::json!({
325 "message": "This action is unauthorized."
326 })
327 }
328 _ => {
329 serde_json::json!({
330 "message": err.to_string()
331 })
332 }
333 };
334 if let Some(hint_text) = hint {
335 if let Some(obj) = body.as_object_mut() {
336 obj.insert("hint".to_string(), serde_json::Value::String(hint_text));
337 }
338 }
339 HttpResponse::json(body).status(status)
340 }
341}
342
343impl From<crate::error::AppError> for HttpResponse {
347 fn from(err: crate::error::AppError) -> HttpResponse {
348 let framework_err: crate::error::FrameworkError = err.into();
350 framework_err.into()
351 }
352}
353
354pub struct InertiaRedirect<'a> {
372 request: &'a crate::http::Request,
373 location: String,
374 query_params: Vec<(String, String)>,
375}
376
377impl<'a> InertiaRedirect<'a> {
378 pub fn to(request: &'a crate::http::Request, path: impl Into<String>) -> Self {
380 Self {
381 request,
382 location: path.into(),
383 query_params: Vec::new(),
384 }
385 }
386
387 pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
389 self.query_params.push((key.to_string(), value.into()));
390 self
391 }
392
393 fn build_url(&self) -> String {
394 if self.query_params.is_empty() {
395 self.location.clone()
396 } else {
397 let query = self
398 .query_params
399 .iter()
400 .map(|(k, v)| format!("{k}={v}"))
401 .collect::<Vec<_>>()
402 .join("&");
403 format!("{}?{}", self.location, query)
404 }
405 }
406
407 fn is_post_like_method(&self) -> bool {
408 matches!(
409 self.request.method().as_str(),
410 "POST" | "PUT" | "PATCH" | "DELETE"
411 )
412 }
413}
414
415impl From<InertiaRedirect<'_>> for Response {
416 fn from(redirect: InertiaRedirect<'_>) -> Response {
417 let url = redirect.build_url();
418 let is_inertia = redirect.request.is_inertia();
419 let is_post_like = redirect.is_post_like_method();
420
421 if is_inertia {
422 let status = if is_post_like { 303 } else { 302 };
424 Ok(HttpResponse::new()
425 .status(status)
426 .header("X-Inertia", "true")
427 .header("Location", url))
428 } else {
429 Ok(HttpResponse::new().status(302).header("Location", url))
431 }
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn test_bytes_constructor() {
441 let resp = HttpResponse::bytes(vec![0xFF, 0xFE, 0x00]);
442 assert_eq!(resp.body_bytes().as_ref(), &[0xFF, 0xFE, 0x00]);
443 assert_eq!(resp.status_code(), 200);
444 assert!(
445 resp.headers.is_empty(),
446 "bytes() should set no default headers"
447 );
448 }
449
450 #[test]
451 fn test_bytes_from_vec_u8() {
452 let resp = HttpResponse::bytes(vec![1, 2, 3]);
453 assert_eq!(resp.body_bytes().len(), 3);
454 }
455
456 #[test]
457 fn test_bytes_with_content_type() {
458 let resp = HttpResponse::bytes(b"PNG data".to_vec()).header("Content-Type", "image/png");
459 let ct = resp
460 .headers
461 .iter()
462 .find(|(k, _)| k == "Content-Type")
463 .map(|(_, v)| v.as_str());
464 assert_eq!(ct, Some("image/png"));
465 }
466
467 #[test]
468 fn test_download_constructor() {
469 let resp = HttpResponse::download(b"pdf content".to_vec(), "report.pdf");
470 let ct = resp
471 .headers
472 .iter()
473 .find(|(k, _)| k == "Content-Type")
474 .map(|(_, v)| v.as_str());
475 assert_eq!(ct, Some("application/pdf"));
476
477 let cd = resp
478 .headers
479 .iter()
480 .find(|(k, _)| k == "Content-Disposition")
481 .map(|(_, v)| v.as_str());
482 assert_eq!(cd, Some("attachment; filename=\"report.pdf\""));
483 }
484
485 #[test]
486 fn test_download_unknown_extension() {
487 let resp = HttpResponse::download(b"data".to_vec(), "file.zzqx");
488 let ct = resp
489 .headers
490 .iter()
491 .find(|(k, _)| k == "Content-Type")
492 .map(|(_, v)| v.as_str());
493 assert_eq!(ct, Some("application/octet-stream"));
494 }
495
496 #[test]
497 fn test_download_filename_sanitization() {
498 let resp = HttpResponse::download(b"data".to_vec(), "evil\"file\nname.pdf");
499 let cd = resp
500 .headers
501 .iter()
502 .find(|(k, _)| k == "Content-Disposition")
503 .map(|(_, v)| v.as_str())
504 .unwrap();
505 assert!(
506 !cd.contains('"') || cd.matches('"').count() == 2,
507 "filename should be properly quoted"
508 );
509 assert!(!cd.contains('\n'), "filename should not contain newlines");
510 }
511
512 #[test]
513 fn test_text_still_works() {
514 let resp = HttpResponse::text("hello");
515 assert_eq!(resp.body(), "hello");
516 assert_eq!(resp.body_bytes().as_ref(), b"hello");
517 }
518
519 #[test]
520 fn test_json_still_works() {
521 let resp = HttpResponse::json(serde_json::json!({"ok": true}));
522 let body = resp.body();
523 assert!(!body.is_empty(), "json body should not be empty");
524 let parsed: serde_json::Value = serde_json::from_str(body).unwrap();
525 assert_eq!(parsed["ok"], true);
526 assert!(!resp.body_bytes().is_empty());
527 }
528
529 #[test]
530 fn test_body_returns_empty_for_binary() {
531 let resp = HttpResponse::bytes(vec![0xFF, 0xFE]);
532 assert_eq!(resp.body(), "");
533 }
534
535 #[test]
536 fn test_into_hyper_preserves_binary() {
537 use http_body_util::BodyExt;
538
539 let data = vec![0xFF, 0x00, 0xFE];
540 let resp = HttpResponse::bytes(data.clone());
541 let hyper_resp = resp.into_hyper();
542
543 let rt = tokio::runtime::Runtime::new().unwrap();
544 let collected =
545 rt.block_on(async { hyper_resp.into_body().collect().await.unwrap().to_bytes() });
546 assert_eq!(collected.as_ref(), &data);
547 }
548}