spikard_core/
router.rs

1//! Route management and handler registration
2
3use crate::parameters::ParameterValidator;
4use crate::schema_registry::SchemaRegistry;
5use crate::validation::SchemaValidator;
6use crate::{CorsConfig, Method, RouteMetadata};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10use std::sync::Arc;
11
12/// Handler function type (placeholder - will be enhanced with Python callbacks)
13pub type RouteHandler = Arc<dyn Fn() -> String + Send + Sync>;
14
15/// JSON-RPC method metadata for routes that support JSON-RPC
16///
17/// This struct captures the metadata needed to expose HTTP routes as JSON-RPC methods,
18/// enabling discovery and documentation of RPC-compatible endpoints.
19///
20/// # Examples
21///
22/// ```ignore
23/// use spikard_core::router::JsonRpcMethodInfo;
24/// use serde_json::json;
25///
26/// let rpc_info = JsonRpcMethodInfo {
27///     method_name: "user.create".to_string(),
28///     description: Some("Creates a new user".to_string()),
29///     params_schema: Some(json!({
30///         "type": "object",
31///         "properties": {
32///             "name": {"type": "string"}
33///         }
34///     })),
35///     result_schema: Some(json!({
36///         "type": "object",
37///         "properties": {
38///             "id": {"type": "integer"}
39///         }
40///     })),
41///     deprecated: false,
42///     tags: vec!["users".to_string()],
43/// };
44/// ```
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct JsonRpcMethodInfo {
47    /// The JSON-RPC method name (e.g., "user.create")
48    pub method_name: String,
49
50    /// Optional description of what the method does
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub description: Option<String>,
53
54    /// Optional JSON Schema for method parameters
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub params_schema: Option<Value>,
57
58    /// Optional JSON Schema for the result
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub result_schema: Option<Value>,
61
62    /// Whether this method is deprecated
63    #[serde(default)]
64    pub deprecated: bool,
65
66    /// Tags for categorizing and grouping methods
67    #[serde(default)]
68    pub tags: Vec<String>,
69}
70
71/// Route definition with compiled validators
72///
73/// Validators are Arc-wrapped to enable cheap cloning across route instances
74/// and to support schema deduplication via SchemaRegistry.
75///
76/// The `jsonrpc_method` field is optional and has zero overhead when None,
77/// enabling routes to optionally expose themselves as JSON-RPC methods.
78#[derive(Clone)]
79pub struct Route {
80    pub method: Method,
81    pub path: String,
82    pub handler_name: String,
83    pub request_validator: Option<Arc<SchemaValidator>>,
84    pub response_validator: Option<Arc<SchemaValidator>>,
85    pub parameter_validator: Option<ParameterValidator>,
86    pub file_params: Option<Value>,
87    pub is_async: bool,
88    pub cors: Option<CorsConfig>,
89    /// Precomputed flag: true if this route expects a JSON request body
90    /// Used by middleware to validate Content-Type headers
91    pub expects_json_body: bool,
92    /// List of dependency keys this handler requires (for DI)
93    #[cfg(feature = "di")]
94    pub handler_dependencies: Vec<String>,
95    /// Optional JSON-RPC method information
96    /// When present, this route can be exposed as a JSON-RPC method
97    pub jsonrpc_method: Option<JsonRpcMethodInfo>,
98}
99
100impl Route {
101    /// Create a route from metadata, using schema registry for deduplication
102    ///
103    /// Auto-generates parameter schema from type hints in the path if no explicit schema provided.
104    /// Type hints like `/items/{id:uuid}` generate appropriate JSON Schema validation.
105    /// Explicit parameter_schema overrides auto-generated schemas.
106    ///
107    /// The schema registry ensures each unique schema is compiled only once, improving
108    /// startup performance and memory usage for applications with many routes.
109    pub fn from_metadata(metadata: RouteMetadata, registry: &SchemaRegistry) -> Result<Self, String> {
110        let method = metadata.method.parse()?;
111
112        let request_validator = metadata
113            .request_schema
114            .as_ref()
115            .map(|schema| registry.get_or_compile(schema))
116            .transpose()?;
117
118        let response_validator = metadata
119            .response_schema
120            .as_ref()
121            .map(|schema| registry.get_or_compile(schema))
122            .transpose()?;
123
124        let final_parameter_schema = match (
125            crate::type_hints::auto_generate_parameter_schema(&metadata.path),
126            metadata.parameter_schema,
127        ) {
128            (Some(auto_schema), Some(explicit_schema)) => {
129                Some(crate::type_hints::merge_parameter_schemas(auto_schema, explicit_schema))
130            }
131            (Some(auto_schema), None) => Some(auto_schema),
132            (None, Some(explicit_schema)) => Some(explicit_schema),
133            (None, None) => None,
134        };
135
136        let parameter_validator = final_parameter_schema.map(ParameterValidator::new).transpose()?;
137
138        let expects_json_body = request_validator.is_some();
139
140        let jsonrpc_method = metadata
141            .jsonrpc_method
142            .as_ref()
143            .and_then(|json_value| serde_json::from_value(json_value.clone()).ok());
144
145        Ok(Self {
146            method,
147            path: metadata.path,
148            handler_name: metadata.handler_name,
149            request_validator,
150            response_validator,
151            parameter_validator,
152            file_params: metadata.file_params,
153            is_async: metadata.is_async,
154            cors: metadata.cors,
155            expects_json_body,
156            #[cfg(feature = "di")]
157            handler_dependencies: metadata.handler_dependencies.unwrap_or_default(),
158            jsonrpc_method,
159        })
160    }
161
162    /// Builder method to attach JSON-RPC method info to a route
163    ///
164    /// This is a convenient way to add JSON-RPC metadata after route creation.
165    /// It consumes the route and returns a new route with the metadata attached.
166    ///
167    /// # Examples
168    ///
169    /// ```ignore
170    /// let route = Route::from_metadata(metadata, &registry)?
171    ///     .with_jsonrpc_method(JsonRpcMethodInfo {
172    ///         method_name: "user.create".to_string(),
173    ///         description: Some("Creates a new user".to_string()),
174    ///         params_schema: Some(request_schema),
175    ///         result_schema: Some(response_schema),
176    ///         deprecated: false,
177    ///         tags: vec!["users".to_string()],
178    ///     });
179    /// ```
180    pub fn with_jsonrpc_method(mut self, info: JsonRpcMethodInfo) -> Self {
181        self.jsonrpc_method = Some(info);
182        self
183    }
184
185    /// Check if this route has JSON-RPC metadata
186    pub fn is_jsonrpc_method(&self) -> bool {
187        self.jsonrpc_method.is_some()
188    }
189
190    /// Get the JSON-RPC method name if present
191    pub fn jsonrpc_method_name(&self) -> Option<&str> {
192        self.jsonrpc_method.as_ref().map(|m| m.method_name.as_str())
193    }
194}
195
196/// Router that manages routes
197pub struct Router {
198    routes: HashMap<String, HashMap<Method, Route>>,
199}
200
201impl Router {
202    /// Create a new router
203    pub fn new() -> Self {
204        Self { routes: HashMap::new() }
205    }
206
207    /// Add a route to the router
208    pub fn add_route(&mut self, route: Route) {
209        let path_routes = self.routes.entry(route.path.clone()).or_default();
210        path_routes.insert(route.method.clone(), route);
211    }
212
213    /// Find a route by method and path
214    pub fn find_route(&self, method: &Method, path: &str) -> Option<&Route> {
215        self.routes.get(path)?.get(method)
216    }
217
218    /// Get all routes
219    pub fn routes(&self) -> Vec<&Route> {
220        self.routes.values().flat_map(|methods| methods.values()).collect()
221    }
222
223    /// Get route count
224    pub fn route_count(&self) -> usize {
225        self.routes.values().map(|m| m.len()).sum()
226    }
227}
228
229impl Default for Router {
230    fn default() -> Self {
231        Self::new()
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use serde_json::json;
239
240    #[test]
241    fn test_router_add_and_find() {
242        let mut router = Router::new();
243        let registry = SchemaRegistry::new();
244
245        let metadata = RouteMetadata {
246            method: "GET".to_string(),
247            path: "/users".to_string(),
248            handler_name: "get_users".to_string(),
249            request_schema: None,
250            response_schema: None,
251            parameter_schema: None,
252            file_params: None,
253            is_async: true,
254            cors: None,
255            body_param_name: None,
256            jsonrpc_method: None,
257            #[cfg(feature = "di")]
258            handler_dependencies: None,
259        };
260
261        let route = Route::from_metadata(metadata, &registry).unwrap();
262        router.add_route(route);
263
264        assert_eq!(router.route_count(), 1);
265        assert!(router.find_route(&Method::Get, "/users").is_some());
266        assert!(router.find_route(&Method::Post, "/users").is_none());
267    }
268
269    #[test]
270    fn test_route_with_validators() {
271        let registry = SchemaRegistry::new();
272
273        let metadata = RouteMetadata {
274            method: "POST".to_string(),
275            path: "/users".to_string(),
276            handler_name: "create_user".to_string(),
277            request_schema: Some(json!({
278                "type": "object",
279                "properties": {
280                    "name": {"type": "string"}
281                },
282                "required": ["name"]
283            })),
284            response_schema: None,
285            parameter_schema: None,
286            file_params: None,
287            is_async: true,
288            cors: None,
289            body_param_name: None,
290            jsonrpc_method: None,
291            #[cfg(feature = "di")]
292            handler_dependencies: None,
293        };
294
295        let route = Route::from_metadata(metadata, &registry).unwrap();
296        assert!(route.request_validator.is_some());
297        assert!(route.response_validator.is_none());
298    }
299
300    #[test]
301    fn test_schema_deduplication_in_routes() {
302        let registry = SchemaRegistry::new();
303
304        let shared_schema = json!({
305            "type": "object",
306            "properties": {
307                "id": {"type": "integer"}
308            }
309        });
310
311        let metadata1 = RouteMetadata {
312            method: "POST".to_string(),
313            path: "/items".to_string(),
314            handler_name: "create_item".to_string(),
315            request_schema: Some(shared_schema.clone()),
316            response_schema: None,
317            parameter_schema: None,
318            file_params: None,
319            is_async: true,
320            cors: None,
321            body_param_name: None,
322            jsonrpc_method: None,
323            #[cfg(feature = "di")]
324            handler_dependencies: None,
325        };
326
327        let metadata2 = RouteMetadata {
328            method: "PUT".to_string(),
329            path: "/items/{id}".to_string(),
330            handler_name: "update_item".to_string(),
331            request_schema: Some(shared_schema),
332            response_schema: None,
333            parameter_schema: None,
334            file_params: None,
335            is_async: true,
336            cors: None,
337            body_param_name: None,
338            jsonrpc_method: None,
339            #[cfg(feature = "di")]
340            handler_dependencies: None,
341        };
342
343        let route1 = Route::from_metadata(metadata1, &registry).unwrap();
344        let route2 = Route::from_metadata(metadata2, &registry).unwrap();
345
346        assert!(route1.request_validator.is_some());
347        assert!(route2.request_validator.is_some());
348
349        let validator1 = route1.request_validator.as_ref().unwrap();
350        let validator2 = route2.request_validator.as_ref().unwrap();
351        assert!(Arc::ptr_eq(validator1, validator2));
352
353        assert_eq!(registry.schema_count(), 1);
354    }
355
356    #[test]
357    fn test_jsonrpc_method_info() {
358        let rpc_info = JsonRpcMethodInfo {
359            method_name: "user.create".to_string(),
360            description: Some("Creates a new user account".to_string()),
361            params_schema: Some(json!({
362                "type": "object",
363                "properties": {
364                    "name": {"type": "string"},
365                    "email": {"type": "string"}
366                },
367                "required": ["name", "email"]
368            })),
369            result_schema: Some(json!({
370                "type": "object",
371                "properties": {
372                    "id": {"type": "integer"},
373                    "name": {"type": "string"},
374                    "email": {"type": "string"}
375                }
376            })),
377            deprecated: false,
378            tags: vec!["users".to_string(), "admin".to_string()],
379        };
380
381        assert_eq!(rpc_info.method_name, "user.create");
382        assert_eq!(rpc_info.description.as_ref().unwrap(), "Creates a new user account");
383        assert!(rpc_info.params_schema.is_some());
384        assert!(rpc_info.result_schema.is_some());
385        assert!(!rpc_info.deprecated);
386        assert_eq!(rpc_info.tags.len(), 2);
387        assert!(rpc_info.tags.contains(&"users".to_string()));
388    }
389
390    #[test]
391    fn test_route_with_jsonrpc_method() {
392        let registry = SchemaRegistry::new();
393
394        let metadata = RouteMetadata {
395            method: "POST".to_string(),
396            path: "/user/create".to_string(),
397            handler_name: "create_user".to_string(),
398            request_schema: Some(json!({
399                "type": "object",
400                "properties": {
401                    "name": {"type": "string"}
402                },
403                "required": ["name"]
404            })),
405            response_schema: Some(json!({
406                "type": "object",
407                "properties": {
408                    "id": {"type": "integer"}
409                }
410            })),
411            parameter_schema: None,
412            file_params: None,
413            is_async: true,
414            cors: None,
415            body_param_name: None,
416            jsonrpc_method: None,
417            #[cfg(feature = "di")]
418            handler_dependencies: None,
419        };
420
421        let rpc_info = JsonRpcMethodInfo {
422            method_name: "user.create".to_string(),
423            description: Some("Creates a new user".to_string()),
424            params_schema: Some(json!({
425                "type": "object",
426                "properties": {
427                    "name": {"type": "string"}
428                }
429            })),
430            result_schema: Some(json!({
431                "type": "object",
432                "properties": {
433                    "id": {"type": "integer"}
434                }
435            })),
436            deprecated: false,
437            tags: vec!["users".to_string()],
438        };
439
440        let route = Route::from_metadata(metadata, &registry)
441            .unwrap()
442            .with_jsonrpc_method(rpc_info);
443
444        assert!(route.is_jsonrpc_method());
445        assert_eq!(route.jsonrpc_method_name(), Some("user.create"));
446        assert!(route.jsonrpc_method.is_some());
447
448        let rpc = route.jsonrpc_method.as_ref().unwrap();
449        assert_eq!(rpc.method_name, "user.create");
450        assert_eq!(rpc.description.as_ref().unwrap(), "Creates a new user");
451        assert!(!rpc.deprecated);
452    }
453
454    #[test]
455    fn test_jsonrpc_method_serialization() {
456        let rpc_info = JsonRpcMethodInfo {
457            method_name: "test.method".to_string(),
458            description: Some("Test method".to_string()),
459            params_schema: Some(json!({"type": "object"})),
460            result_schema: Some(json!({"type": "string"})),
461            deprecated: false,
462            tags: vec!["test".to_string()],
463        };
464
465        let json = serde_json::to_value(&rpc_info).unwrap();
466        assert_eq!(json["method_name"], "test.method");
467        assert_eq!(json["description"], "Test method");
468
469        let deserialized: JsonRpcMethodInfo = serde_json::from_value(json).unwrap();
470        assert_eq!(deserialized.method_name, rpc_info.method_name);
471        assert_eq!(deserialized.description, rpc_info.description);
472    }
473
474    #[test]
475    fn test_route_without_jsonrpc_method_has_zero_overhead() {
476        let registry = SchemaRegistry::new();
477
478        let metadata = RouteMetadata {
479            method: "GET".to_string(),
480            path: "/status".to_string(),
481            handler_name: "status".to_string(),
482            request_schema: None,
483            response_schema: None,
484            parameter_schema: None,
485            file_params: None,
486            is_async: false,
487            cors: None,
488            body_param_name: None,
489            jsonrpc_method: None,
490            #[cfg(feature = "di")]
491            handler_dependencies: None,
492        };
493
494        let route = Route::from_metadata(metadata, &registry).unwrap();
495
496        assert!(!route.is_jsonrpc_method());
497        assert_eq!(route.jsonrpc_method_name(), None);
498        assert!(route.jsonrpc_method.is_none());
499    }
500}