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