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