spikard_core/
http.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4/// HTTP method
5#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
6pub enum Method {
7    Get,
8    Post,
9    Put,
10    Patch,
11    Delete,
12    Head,
13    Options,
14    Trace,
15}
16
17impl Method {
18    pub fn as_str(&self) -> &'static str {
19        match self {
20            Method::Get => "GET",
21            Method::Post => "POST",
22            Method::Put => "PUT",
23            Method::Patch => "PATCH",
24            Method::Delete => "DELETE",
25            Method::Head => "HEAD",
26            Method::Options => "OPTIONS",
27            Method::Trace => "TRACE",
28        }
29    }
30}
31
32impl std::fmt::Display for Method {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        write!(f, "{}", self.as_str())
35    }
36}
37
38impl std::str::FromStr for Method {
39    type Err = String;
40
41    fn from_str(s: &str) -> Result<Self, Self::Err> {
42        match s.to_uppercase().as_str() {
43            "GET" => Ok(Method::Get),
44            "POST" => Ok(Method::Post),
45            "PUT" => Ok(Method::Put),
46            "PATCH" => Ok(Method::Patch),
47            "DELETE" => Ok(Method::Delete),
48            "HEAD" => Ok(Method::Head),
49            "OPTIONS" => Ok(Method::Options),
50            "TRACE" => Ok(Method::Trace),
51            _ => Err(format!("Unknown HTTP method: {}", s)),
52        }
53    }
54}
55
56/// CORS configuration for a route
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct CorsConfig {
59    pub allowed_origins: Vec<String>,
60    pub allowed_methods: Vec<String>,
61    #[serde(default)]
62    pub allowed_headers: Vec<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub expose_headers: Option<Vec<String>>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub max_age: Option<u32>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub allow_credentials: Option<bool>,
69}
70
71/// Route metadata extracted from bindings
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct RouteMetadata {
74    pub method: String,
75    pub path: String,
76    pub handler_name: String,
77    pub request_schema: Option<Value>,
78    pub response_schema: Option<Value>,
79    pub parameter_schema: Option<Value>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub file_params: Option<Value>,
82    #[serde(default)]
83    pub is_async: bool,
84    pub cors: Option<CorsConfig>,
85    /// Name of the body parameter (defaults to "body" if not specified)
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub body_param_name: Option<String>,
88    /// List of dependency keys this handler requires (for DI)
89    #[cfg(feature = "di")]
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub handler_dependencies: Option<Vec<String>>,
92    /// JSON-RPC method metadata (if this route is exposed as a JSON-RPC method)
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub jsonrpc_method: Option<Value>,
95}
96
97/// Compression configuration shared across runtimes
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct CompressionConfig {
100    /// Enable gzip compression
101    #[serde(default = "default_true")]
102    pub gzip: bool,
103    /// Enable brotli compression
104    #[serde(default = "default_true")]
105    pub brotli: bool,
106    /// Minimum response size to compress (bytes)
107    #[serde(default = "default_compression_min_size")]
108    pub min_size: usize,
109    /// Compression quality (0-11 for brotli, 0-9 for gzip)
110    #[serde(default = "default_compression_quality")]
111    pub quality: u32,
112}
113
114const fn default_true() -> bool {
115    true
116}
117
118const fn default_compression_min_size() -> usize {
119    1024
120}
121
122const fn default_compression_quality() -> u32 {
123    6
124}
125
126impl Default for CompressionConfig {
127    fn default() -> Self {
128        Self {
129            gzip: true,
130            brotli: true,
131            min_size: default_compression_min_size(),
132            quality: default_compression_quality(),
133        }
134    }
135}
136
137/// Rate limiting configuration shared across runtimes
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct RateLimitConfig {
140    /// Requests per second
141    pub per_second: u64,
142    /// Burst allowance
143    pub burst: u32,
144    /// Use IP-based rate limiting
145    #[serde(default = "default_true")]
146    pub ip_based: bool,
147}
148
149impl Default for RateLimitConfig {
150    fn default() -> Self {
151        Self {
152            per_second: 100,
153            burst: 200,
154            ip_based: true,
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use std::str::FromStr;
163
164    #[test]
165    fn test_method_as_str_get() {
166        assert_eq!(Method::Get.as_str(), "GET");
167    }
168
169    #[test]
170    fn test_method_as_str_post() {
171        assert_eq!(Method::Post.as_str(), "POST");
172    }
173
174    #[test]
175    fn test_method_as_str_put() {
176        assert_eq!(Method::Put.as_str(), "PUT");
177    }
178
179    #[test]
180    fn test_method_as_str_patch() {
181        assert_eq!(Method::Patch.as_str(), "PATCH");
182    }
183
184    #[test]
185    fn test_method_as_str_delete() {
186        assert_eq!(Method::Delete.as_str(), "DELETE");
187    }
188
189    #[test]
190    fn test_method_as_str_head() {
191        assert_eq!(Method::Head.as_str(), "HEAD");
192    }
193
194    #[test]
195    fn test_method_as_str_options() {
196        assert_eq!(Method::Options.as_str(), "OPTIONS");
197    }
198
199    #[test]
200    fn test_method_as_str_trace() {
201        assert_eq!(Method::Trace.as_str(), "TRACE");
202    }
203
204    #[test]
205    fn test_method_display_get() {
206        assert_eq!(Method::Get.to_string(), "GET");
207    }
208
209    #[test]
210    fn test_method_display_post() {
211        assert_eq!(Method::Post.to_string(), "POST");
212    }
213
214    #[test]
215    fn test_method_display_put() {
216        assert_eq!(Method::Put.to_string(), "PUT");
217    }
218
219    #[test]
220    fn test_method_display_patch() {
221        assert_eq!(Method::Patch.to_string(), "PATCH");
222    }
223
224    #[test]
225    fn test_method_display_delete() {
226        assert_eq!(Method::Delete.to_string(), "DELETE");
227    }
228
229    #[test]
230    fn test_method_display_head() {
231        assert_eq!(Method::Head.to_string(), "HEAD");
232    }
233
234    #[test]
235    fn test_method_display_options() {
236        assert_eq!(Method::Options.to_string(), "OPTIONS");
237    }
238
239    #[test]
240    fn test_method_display_trace() {
241        assert_eq!(Method::Trace.to_string(), "TRACE");
242    }
243
244    #[test]
245    fn test_from_str_get() {
246        assert_eq!(Method::from_str("GET"), Ok(Method::Get));
247    }
248
249    #[test]
250    fn test_from_str_post() {
251        assert_eq!(Method::from_str("POST"), Ok(Method::Post));
252    }
253
254    #[test]
255    fn test_from_str_put() {
256        assert_eq!(Method::from_str("PUT"), Ok(Method::Put));
257    }
258
259    #[test]
260    fn test_from_str_patch() {
261        assert_eq!(Method::from_str("PATCH"), Ok(Method::Patch));
262    }
263
264    #[test]
265    fn test_from_str_delete() {
266        assert_eq!(Method::from_str("DELETE"), Ok(Method::Delete));
267    }
268
269    #[test]
270    fn test_from_str_head() {
271        assert_eq!(Method::from_str("HEAD"), Ok(Method::Head));
272    }
273
274    #[test]
275    fn test_from_str_options() {
276        assert_eq!(Method::from_str("OPTIONS"), Ok(Method::Options));
277    }
278
279    #[test]
280    fn test_from_str_trace() {
281        assert_eq!(Method::from_str("TRACE"), Ok(Method::Trace));
282    }
283
284    #[test]
285    fn test_from_str_lowercase() {
286        assert_eq!(Method::from_str("get"), Ok(Method::Get));
287    }
288
289    #[test]
290    fn test_from_str_mixed_case() {
291        assert_eq!(Method::from_str("PoSt"), Ok(Method::Post));
292    }
293
294    #[test]
295    fn test_from_str_invalid_method() {
296        let result = Method::from_str("INVALID");
297        assert!(result.is_err());
298        assert_eq!(result.unwrap_err(), "Unknown HTTP method: INVALID");
299    }
300
301    #[test]
302    fn test_from_str_empty_string() {
303        let result = Method::from_str("");
304        assert!(result.is_err());
305        assert_eq!(result.unwrap_err(), "Unknown HTTP method: ");
306    }
307
308    #[test]
309    fn test_compression_config_default() {
310        let config = CompressionConfig::default();
311        assert!(config.gzip);
312        assert!(config.brotli);
313        assert_eq!(config.min_size, 1024);
314        assert_eq!(config.quality, 6);
315    }
316
317    #[test]
318    fn test_default_true() {
319        assert!(default_true());
320    }
321
322    #[test]
323    fn test_default_compression_min_size() {
324        assert_eq!(default_compression_min_size(), 1024);
325    }
326
327    #[test]
328    fn test_default_compression_quality() {
329        assert_eq!(default_compression_quality(), 6);
330    }
331
332    #[test]
333    fn test_rate_limit_config_default() {
334        let config = RateLimitConfig::default();
335        assert_eq!(config.per_second, 100);
336        assert_eq!(config.burst, 200);
337        assert!(config.ip_based);
338    }
339
340    #[test]
341    fn test_method_equality() {
342        assert_eq!(Method::Get, Method::Get);
343        assert_ne!(Method::Get, Method::Post);
344    }
345
346    #[test]
347    fn test_method_clone() {
348        let method = Method::Post;
349        let cloned = method.clone();
350        assert_eq!(method, cloned);
351    }
352
353    #[test]
354    fn test_compression_config_custom_values() {
355        let config = CompressionConfig {
356            gzip: false,
357            brotli: false,
358            min_size: 2048,
359            quality: 11,
360        };
361        assert!(!config.gzip);
362        assert!(!config.brotli);
363        assert_eq!(config.min_size, 2048);
364        assert_eq!(config.quality, 11);
365    }
366
367    #[test]
368    fn test_rate_limit_config_custom_values() {
369        let config = RateLimitConfig {
370            per_second: 50,
371            burst: 100,
372            ip_based: false,
373        };
374        assert_eq!(config.per_second, 50);
375        assert_eq!(config.burst, 100);
376        assert!(!config.ip_based);
377    }
378
379    #[test]
380    fn test_cors_config_construction() {
381        let cors = CorsConfig {
382            allowed_origins: vec!["http://localhost:3000".to_string()],
383            allowed_methods: vec!["GET".to_string(), "POST".to_string()],
384            allowed_headers: vec![],
385            expose_headers: None,
386            max_age: None,
387            allow_credentials: None,
388        };
389        assert_eq!(cors.allowed_origins.len(), 1);
390        assert_eq!(cors.allowed_methods.len(), 2);
391        assert_eq!(cors.allowed_headers.len(), 0);
392    }
393
394    #[test]
395    fn test_route_metadata_construction() {
396        let metadata = RouteMetadata {
397            method: "GET".to_string(),
398            path: "/api/users".to_string(),
399            handler_name: "get_users".to_string(),
400            request_schema: None,
401            response_schema: None,
402            parameter_schema: None,
403            file_params: None,
404            is_async: true,
405            cors: None,
406            body_param_name: None,
407            #[cfg(feature = "di")]
408            handler_dependencies: None,
409            jsonrpc_method: None,
410        };
411        assert_eq!(metadata.method, "GET");
412        assert_eq!(metadata.path, "/api/users");
413        assert_eq!(metadata.handler_name, "get_users");
414        assert!(metadata.is_async);
415    }
416}