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