Skip to main content

spikard_http/
response.rs

1//! HTTP Response types
2//!
3//! Response types for returning custom responses with status codes, headers, and content
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// HTTP Response with custom status code, headers, and content
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Response {
11    /// Response body content
12    pub content: Option<serde_json::Value>,
13    /// HTTP status code (defaults to 200)
14    pub status_code: u16,
15    /// Response headers
16    pub headers: HashMap<String, String>,
17}
18
19impl Response {
20    /// Create a new Response with default status 200
21    pub fn new(content: Option<serde_json::Value>) -> Self {
22        Self {
23            content,
24            status_code: 200,
25            headers: HashMap::new(),
26        }
27    }
28
29    /// Create a response with a specific status code
30    pub fn with_status(content: Option<serde_json::Value>, status_code: u16) -> Self {
31        Self {
32            content,
33            status_code,
34            headers: HashMap::new(),
35        }
36    }
37
38    /// Set a header
39    pub fn set_header(&mut self, key: String, value: String) {
40        self.headers.insert(key, value);
41    }
42
43    /// Set a cookie in the response
44    // reason: cookie attributes (secure, http_only, max_age, domain, path, same_site) are
45    // all distinct options; collapsing into a struct would require a separate CookieOptions
46    // type in the public API — tracked separately.
47    #[allow(clippy::too_many_arguments)]
48    pub fn set_cookie(
49        &mut self,
50        key: String,
51        value: String,
52        secure: bool,
53        http_only: bool,
54        max_age: Option<i64>,
55        domain: Option<String>,
56        path: Option<String>,
57        same_site: Option<String>,
58    ) {
59        let mut cookie_value = format!("{}={}", key, value);
60
61        if let Some(age) = max_age {
62            cookie_value.push_str(&format!("; Max-Age={}", age));
63        }
64        if let Some(d) = domain {
65            cookie_value.push_str(&format!("; Domain={}", d));
66        }
67        if let Some(p) = path {
68            cookie_value.push_str(&format!("; Path={}", p));
69        }
70        if secure {
71            cookie_value.push_str("; Secure");
72        }
73        if http_only {
74            cookie_value.push_str("; HttpOnly");
75        }
76        if let Some(ss) = same_site {
77            cookie_value.push_str(&format!("; SameSite={}", ss));
78        }
79
80        self.headers.insert("set-cookie".to_string(), cookie_value);
81    }
82}
83
84impl Default for Response {
85    fn default() -> Self {
86        Self::new(None)
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use serde_json::json;
94
95    #[test]
96    fn response_new_creates_default_status() {
97        let response = Response::new(None);
98        assert_eq!(response.status_code, 200);
99        assert!(response.headers.is_empty());
100        assert!(response.content.is_none());
101    }
102
103    #[test]
104    fn response_new_with_content() {
105        let content = json!({"key": "value"});
106        let response = Response::new(Some(content.clone()));
107        assert_eq!(response.status_code, 200);
108        assert_eq!(response.content, Some(content));
109    }
110
111    #[test]
112    fn response_with_status() {
113        let response = Response::with_status(None, 404);
114        assert_eq!(response.status_code, 404);
115        assert!(response.headers.is_empty());
116    }
117
118    #[test]
119    fn response_with_status_and_content() {
120        let content = json!({"error": "not found"});
121        let response = Response::with_status(Some(content.clone()), 404);
122        assert_eq!(response.status_code, 404);
123        assert_eq!(response.content, Some(content));
124    }
125
126    #[test]
127    fn response_set_header() {
128        let mut response = Response::new(None);
129        response.set_header("X-Custom".to_string(), "custom-value".to_string());
130        assert_eq!(response.headers.get("X-Custom"), Some(&"custom-value".to_string()));
131    }
132
133    #[test]
134    fn response_set_multiple_headers() {
135        let mut response = Response::new(None);
136        response.set_header("Content-Type".to_string(), "application/json".to_string());
137        response.set_header("X-Custom".to_string(), "custom-value".to_string());
138        assert_eq!(response.headers.len(), 2);
139        assert_eq!(
140            response.headers.get("Content-Type"),
141            Some(&"application/json".to_string())
142        );
143        assert_eq!(response.headers.get("X-Custom"), Some(&"custom-value".to_string()));
144    }
145
146    #[test]
147    fn response_set_header_overwrites() {
148        let mut response = Response::new(None);
149        response.set_header("X-Custom".to_string(), "value1".to_string());
150        response.set_header("X-Custom".to_string(), "value2".to_string());
151        assert_eq!(response.headers.get("X-Custom"), Some(&"value2".to_string()));
152    }
153
154    #[test]
155    fn response_set_cookie_minimal() {
156        let mut response = Response::new(None);
157        response.set_cookie(
158            "session_id".to_string(),
159            "abc123".to_string(),
160            false,
161            false,
162            None,
163            None,
164            None,
165            None,
166        );
167        let cookie = response.headers.get("set-cookie").unwrap();
168        assert_eq!(cookie, "session_id=abc123");
169    }
170
171    #[test]
172    fn response_set_cookie_with_max_age() {
173        let mut response = Response::new(None);
174        response.set_cookie(
175            "session".to_string(),
176            "token".to_string(),
177            false,
178            false,
179            Some(3600),
180            None,
181            None,
182            None,
183        );
184        let cookie = response.headers.get("set-cookie").unwrap();
185        assert!(cookie.contains("session=token"));
186        assert!(cookie.contains("Max-Age=3600"));
187    }
188
189    #[test]
190    fn response_set_cookie_with_domain() {
191        let mut response = Response::new(None);
192        response.set_cookie(
193            "session".to_string(),
194            "token".to_string(),
195            false,
196            false,
197            None,
198            Some("example.com".to_string()),
199            None,
200            None,
201        );
202        let cookie = response.headers.get("set-cookie").unwrap();
203        assert!(cookie.contains("Domain=example.com"));
204    }
205
206    #[test]
207    fn response_set_cookie_with_path() {
208        let mut response = Response::new(None);
209        response.set_cookie(
210            "session".to_string(),
211            "token".to_string(),
212            false,
213            false,
214            None,
215            None,
216            Some("/app".to_string()),
217            None,
218        );
219        let cookie = response.headers.get("set-cookie").unwrap();
220        assert!(cookie.contains("Path=/app"));
221    }
222
223    #[test]
224    fn response_set_cookie_secure() {
225        let mut response = Response::new(None);
226        response.set_cookie(
227            "session".to_string(),
228            "token".to_string(),
229            true,
230            false,
231            None,
232            None,
233            None,
234            None,
235        );
236        let cookie = response.headers.get("set-cookie").unwrap();
237        assert!(cookie.contains("Secure"));
238    }
239
240    #[test]
241    fn response_set_cookie_http_only() {
242        let mut response = Response::new(None);
243        response.set_cookie(
244            "session".to_string(),
245            "token".to_string(),
246            false,
247            true,
248            None,
249            None,
250            None,
251            None,
252        );
253        let cookie = response.headers.get("set-cookie").unwrap();
254        assert!(cookie.contains("HttpOnly"));
255    }
256
257    #[test]
258    fn response_set_cookie_same_site() {
259        let mut response = Response::new(None);
260        response.set_cookie(
261            "session".to_string(),
262            "token".to_string(),
263            false,
264            false,
265            None,
266            None,
267            None,
268            Some("Strict".to_string()),
269        );
270        let cookie = response.headers.get("set-cookie").unwrap();
271        assert!(cookie.contains("SameSite=Strict"));
272    }
273
274    #[test]
275    fn response_set_cookie_all_attributes() {
276        let mut response = Response::new(None);
277        response.set_cookie(
278            "session".to_string(),
279            "token123".to_string(),
280            true,
281            true,
282            Some(3600),
283            Some("example.com".to_string()),
284            Some("/app".to_string()),
285            Some("Lax".to_string()),
286        );
287        let cookie = response.headers.get("set-cookie").unwrap();
288        assert!(cookie.contains("session=token123"));
289        assert!(cookie.contains("Max-Age=3600"));
290        assert!(cookie.contains("Domain=example.com"));
291        assert!(cookie.contains("Path=/app"));
292        assert!(cookie.contains("Secure"));
293        assert!(cookie.contains("HttpOnly"));
294        assert!(cookie.contains("SameSite=Lax"));
295    }
296
297    #[test]
298    fn response_set_cookie_overwrites_previous() {
299        let mut response = Response::new(None);
300        response.set_cookie(
301            "session".to_string(),
302            "old_token".to_string(),
303            false,
304            false,
305            None,
306            None,
307            None,
308            None,
309        );
310        response.set_cookie(
311            "session".to_string(),
312            "new_token".to_string(),
313            false,
314            false,
315            None,
316            None,
317            None,
318            None,
319        );
320        let cookie = response.headers.get("set-cookie").unwrap();
321        assert!(cookie.contains("new_token"));
322        assert!(!cookie.contains("old_token"));
323    }
324
325    #[test]
326    fn response_default() {
327        let response = Response::default();
328        assert_eq!(response.status_code, 200);
329        assert!(response.headers.is_empty());
330        assert!(response.content.is_none());
331    }
332
333    #[test]
334    fn response_cookie_with_special_chars_in_value() {
335        let mut response = Response::new(None);
336        response.set_cookie(
337            "name".to_string(),
338            "value%3D123".to_string(),
339            false,
340            false,
341            None,
342            None,
343            None,
344            None,
345        );
346        let cookie = response.headers.get("set-cookie").unwrap();
347        assert_eq!(cookie, "name=value%3D123");
348    }
349
350    #[test]
351    fn response_same_site_variants() {
352        for same_site in &["Strict", "Lax", "None"] {
353            let mut response = Response::new(None);
354            response.set_cookie(
355                "test".to_string(),
356                "value".to_string(),
357                false,
358                false,
359                None,
360                None,
361                None,
362                Some(same_site.to_string()),
363            );
364            let cookie = response.headers.get("set-cookie").unwrap();
365            assert!(cookie.contains(&format!("SameSite={}", same_site)));
366        }
367    }
368
369    #[test]
370    fn response_zero_max_age() {
371        let mut response = Response::new(None);
372        response.set_cookie(
373            "session".to_string(),
374            "token".to_string(),
375            false,
376            false,
377            Some(0),
378            None,
379            None,
380            None,
381        );
382        let cookie = response.headers.get("set-cookie").unwrap();
383        assert!(cookie.contains("Max-Age=0"));
384    }
385
386    #[test]
387    fn response_negative_max_age() {
388        let mut response = Response::new(None);
389        response.set_cookie(
390            "session".to_string(),
391            "token".to_string(),
392            false,
393            false,
394            Some(-1),
395            None,
396            None,
397            None,
398        );
399        let cookie = response.headers.get("set-cookie").unwrap();
400        assert!(cookie.contains("Max-Age=-1"));
401    }
402
403    #[test]
404    fn response_various_status_codes() {
405        let status_codes = vec![
406            (200, "OK"),
407            (201, "Created"),
408            (204, "No Content"),
409            (301, "Moved Permanently"),
410            (302, "Found"),
411            (304, "Not Modified"),
412            (400, "Bad Request"),
413            (401, "Unauthorized"),
414            (403, "Forbidden"),
415            (404, "Not Found"),
416            (500, "Internal Server Error"),
417            (502, "Bad Gateway"),
418            (503, "Service Unavailable"),
419        ];
420
421        for (code, _name) in status_codes {
422            let response = Response::with_status(None, code);
423            assert_eq!(response.status_code, code);
424            assert!(response.headers.is_empty());
425        }
426    }
427
428    #[test]
429    fn response_with_large_json_body() {
430        let mut items = vec![];
431        for i in 0..1000 {
432            items.push(json!({"id": i, "name": format!("item_{}", i)}));
433        }
434        let large_array = serde_json::Value::Array(items);
435        let response = Response::new(Some(large_array.clone()));
436        assert_eq!(response.status_code, 200);
437        assert_eq!(response.content, Some(large_array));
438    }
439
440    #[test]
441    fn response_with_deeply_nested_json() {
442        let nested = json!({
443            "level1": {
444                "level2": {
445                    "level3": {
446                        "level4": {
447                            "level5": {
448                                "data": "deeply nested value"
449                            }
450                        }
451                    }
452                }
453            }
454        });
455        let response = Response::new(Some(nested.clone()));
456        assert_eq!(response.content, Some(nested));
457    }
458
459    #[test]
460    fn response_with_empty_json_object() {
461        let empty_obj = json!({});
462        let response = Response::new(Some(empty_obj.clone()));
463        assert_eq!(response.content, Some(empty_obj));
464        assert_ne!(response.content, None);
465    }
466
467    #[test]
468    fn response_with_empty_json_array() {
469        let empty_array = json!([]);
470        let response = Response::new(Some(empty_array.clone()));
471        assert_eq!(response.content, Some(empty_array));
472        assert_ne!(response.content, None);
473    }
474
475    #[test]
476    fn response_with_null_vs_none() {
477        let null_value = json!(null);
478        let response_with_null = Response::new(Some(null_value.clone()));
479        let response_with_none = Response::new(None);
480
481        assert_eq!(response_with_null.content, Some(null_value));
482        assert_eq!(response_with_none.content, None);
483        assert_ne!(response_with_null.content, response_with_none.content);
484    }
485
486    #[test]
487    fn response_with_json_primitives() {
488        let test_cases = vec![
489            json!(true),
490            json!(false),
491            json!(0),
492            json!(-1),
493            json!(42),
494            json!(3.14),
495            json!("string"),
496            json!(""),
497        ];
498
499        for test_value in test_cases {
500            let response = Response::new(Some(test_value.clone()));
501            assert_eq!(response.content, Some(test_value));
502        }
503    }
504
505    #[test]
506    fn response_header_case_sensitivity() {
507        let mut response = Response::new(None);
508        response.set_header("Content-Type".to_string(), "application/json".to_string());
509        response.set_header("content-type".to_string(), "text/plain".to_string());
510
511        assert_eq!(response.headers.len(), 2);
512        assert_eq!(
513            response.headers.get("Content-Type"),
514            Some(&"application/json".to_string())
515        );
516        assert_eq!(response.headers.get("content-type"), Some(&"text/plain".to_string()));
517    }
518
519    #[test]
520    fn response_header_with_empty_value() {
521        let mut response = Response::new(None);
522        response.set_header("X-Empty".to_string(), "".to_string());
523        assert_eq!(response.headers.get("X-Empty"), Some(&"".to_string()));
524    }
525
526    #[test]
527    fn response_header_with_special_chars() {
528        let mut response = Response::new(None);
529        response.set_header("X-Special".to_string(), "value; charset=utf-8".to_string());
530        assert_eq!(
531            response.headers.get("X-Special"),
532            Some(&"value; charset=utf-8".to_string())
533        );
534    }
535
536    #[test]
537    fn response_multiple_different_cookies() {
538        let mut response = Response::new(None);
539        response.set_cookie(
540            "session".to_string(),
541            "abc123".to_string(),
542            false,
543            false,
544            None,
545            None,
546            None,
547            None,
548        );
549        let cookie_count = response.headers.iter().filter(|(k, _)| *k == "set-cookie").count();
550        assert_eq!(cookie_count, 1);
551    }
552
553    #[test]
554    fn response_cookie_empty_value() {
555        let mut response = Response::new(None);
556        response.set_cookie(
557            "empty".to_string(),
558            "".to_string(),
559            false,
560            false,
561            None,
562            None,
563            None,
564            None,
565        );
566        let cookie = response.headers.get("set-cookie").unwrap();
567        assert_eq!(cookie, "empty=");
568    }
569
570    #[test]
571    fn response_cookie_with_equals_in_value() {
572        let mut response = Response::new(None);
573        response.set_cookie(
574            "data".to_string(),
575            "key=value&other=123".to_string(),
576            false,
577            false,
578            None,
579            None,
580            None,
581            None,
582        );
583        let cookie = response.headers.get("set-cookie").unwrap();
584        assert!(cookie.contains("key=value&other=123"));
585    }
586
587    #[test]
588    fn response_cookie_attribute_order() {
589        let mut response = Response::new(None);
590        response.set_cookie(
591            "test".to_string(),
592            "value".to_string(),
593            true,
594            true,
595            Some(3600),
596            Some("example.com".to_string()),
597            Some("/".to_string()),
598            Some("Strict".to_string()),
599        );
600        let cookie = response.headers.get("set-cookie").unwrap();
601
602        let parts: Vec<&str> = cookie.split("; ").collect();
603        assert_eq!(parts.len(), 7);
604        assert!(parts[0].starts_with("test="));
605        assert!(parts[1].starts_with("Max-Age="));
606        assert!(parts[2].starts_with("Domain="));
607        assert!(parts[3].starts_with("Path="));
608        assert_eq!(parts[4], "Secure");
609        assert_eq!(parts[5], "HttpOnly");
610        assert!(parts[6].starts_with("SameSite="));
611    }
612
613    #[test]
614    fn response_cookie_with_very_long_value() {
615        let mut response = Response::new(None);
616        let long_value = "x".repeat(4096);
617        response.set_cookie(
618            "long".to_string(),
619            long_value.clone(),
620            false,
621            false,
622            None,
623            None,
624            None,
625            None,
626        );
627        let cookie = response.headers.get("set-cookie").unwrap();
628        assert!(cookie.contains(&format!("long={}", long_value)));
629    }
630
631    #[test]
632    fn response_cookie_max_age_large_value() {
633        let mut response = Response::new(None);
634        let max_age_value = 86400 * 365;
635        response.set_cookie(
636            "session".to_string(),
637            "token".to_string(),
638            false,
639            false,
640            Some(max_age_value),
641            None,
642            None,
643            None,
644        );
645        let cookie = response.headers.get("set-cookie").unwrap();
646        assert!(cookie.contains(&format!("Max-Age={}", max_age_value)));
647    }
648
649    #[test]
650    fn response_status_code_informational() {
651        let response = Response::with_status(None, 100);
652        assert_eq!(response.status_code, 100);
653    }
654
655    #[test]
656    fn response_status_code_redirect_with_location() {
657        let mut response = Response::with_status(None, 301);
658        response.set_header("Location".to_string(), "https://example.com/new".to_string());
659        assert_eq!(response.status_code, 301);
660        assert_eq!(
661            response.headers.get("Location"),
662            Some(&"https://example.com/new".to_string())
663        );
664    }
665
666    #[test]
667    fn response_with_error_status_and_content() {
668        let error_content = json!({
669            "error": "Unauthorized",
670            "code": 401,
671            "message": "Invalid credentials"
672        });
673        let response = Response::with_status(Some(error_content.clone()), 401);
674        assert_eq!(response.status_code, 401);
675        assert_eq!(response.content, Some(error_content));
676    }
677
678    #[test]
679    fn response_clone_preserves_state() {
680        let mut original = Response::with_status(Some(json!({"key": "value"})), 202);
681        original.set_header("X-Custom".to_string(), "header-value".to_string());
682        original.set_cookie(
683            "session".to_string(),
684            "token".to_string(),
685            true,
686            false,
687            Some(3600),
688            None,
689            None,
690            None,
691        );
692
693        let cloned = original.clone();
694
695        assert_eq!(cloned.status_code, 202);
696        assert_eq!(cloned.content, original.content);
697        assert_eq!(cloned.headers, original.headers);
698    }
699
700    #[test]
701    fn response_with_numeric_status_boundaries() {
702        let boundary_codes = vec![1, 99, 100, 199, 200, 299, 300, 399, 400, 499, 500, 599, 600, 999, 65535];
703        for code in boundary_codes {
704            let response = Response::with_status(None, code);
705            assert_eq!(response.status_code, code);
706        }
707    }
708
709    #[test]
710    fn response_header_unicode_value() {
711        let mut response = Response::new(None);
712        response.set_header("X-Unicode".to_string(), "こんにちは".to_string());
713        assert_eq!(response.headers.get("X-Unicode"), Some(&"こんにちは".to_string()));
714    }
715
716    #[test]
717    fn response_debug_trait() {
718        let response = Response::with_status(Some(json!({"test": "data"})), 200);
719        let debug_str = format!("{:?}", response);
720        assert!(debug_str.contains("Response"));
721        assert!(debug_str.contains("200"));
722    }
723}