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