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        fn is_empty_schema(schema: &Value) -> bool {
113            matches!(schema, Value::Object(map) if map.is_empty())
114        }
115
116        let request_validator = metadata
117            .request_schema
118            .as_ref()
119            .filter(|schema| !is_empty_schema(schema))
120            .map(|schema| registry.get_or_compile(schema))
121            .transpose()?;
122
123        let response_validator = metadata
124            .response_schema
125            .as_ref()
126            .filter(|schema| !is_empty_schema(schema))
127            .map(|schema| registry.get_or_compile(schema))
128            .transpose()?;
129
130        let final_parameter_schema = match (
131            crate::type_hints::auto_generate_parameter_schema(&metadata.path),
132            metadata.parameter_schema,
133        ) {
134            (Some(auto_schema), Some(explicit_schema)) => {
135                if is_empty_schema(&explicit_schema) {
136                    Some(auto_schema)
137                } else {
138                    Some(crate::type_hints::merge_parameter_schemas(auto_schema, explicit_schema))
139                }
140            }
141            (Some(auto_schema), None) => Some(auto_schema),
142            (None, Some(explicit_schema)) => (!is_empty_schema(&explicit_schema)).then_some(explicit_schema),
143            (None, None) => None,
144        };
145
146        let parameter_validator = final_parameter_schema.map(ParameterValidator::new).transpose()?;
147
148        let expects_json_body = request_validator.is_some();
149
150        let jsonrpc_method = metadata
151            .jsonrpc_method
152            .as_ref()
153            .and_then(|json_value| serde_json::from_value(json_value.clone()).ok());
154
155        Ok(Self {
156            method,
157            path: metadata.path,
158            handler_name: metadata.handler_name,
159            request_validator,
160            response_validator,
161            parameter_validator,
162            file_params: metadata.file_params,
163            is_async: metadata.is_async,
164            cors: metadata.cors,
165            expects_json_body,
166            #[cfg(feature = "di")]
167            handler_dependencies: metadata.handler_dependencies.unwrap_or_default(),
168            jsonrpc_method,
169        })
170    }
171
172    /// Builder method to attach JSON-RPC method info to a route
173    ///
174    /// This is a convenient way to add JSON-RPC metadata after route creation.
175    /// It consumes the route and returns a new route with the metadata attached.
176    ///
177    /// # Examples
178    ///
179    /// ```ignore
180    /// let route = Route::from_metadata(metadata, &registry)?
181    ///     .with_jsonrpc_method(JsonRpcMethodInfo {
182    ///         method_name: "user.create".to_string(),
183    ///         description: Some("Creates a new user".to_string()),
184    ///         params_schema: Some(request_schema),
185    ///         result_schema: Some(response_schema),
186    ///         deprecated: false,
187    ///         tags: vec!["users".to_string()],
188    ///     });
189    /// ```
190    pub fn with_jsonrpc_method(mut self, info: JsonRpcMethodInfo) -> Self {
191        self.jsonrpc_method = Some(info);
192        self
193    }
194
195    /// Check if this route has JSON-RPC metadata
196    pub fn is_jsonrpc_method(&self) -> bool {
197        self.jsonrpc_method.is_some()
198    }
199
200    /// Get the JSON-RPC method name if present
201    pub fn jsonrpc_method_name(&self) -> Option<&str> {
202        self.jsonrpc_method.as_ref().map(|m| m.method_name.as_str())
203    }
204}
205
206/// Router that manages routes
207pub struct Router {
208    routes: HashMap<String, HashMap<Method, Route>>,
209}
210
211impl Router {
212    /// Create a new router
213    pub fn new() -> Self {
214        Self { routes: HashMap::new() }
215    }
216
217    /// Add a route to the router
218    pub fn add_route(&mut self, route: Route) {
219        let path_routes = self.routes.entry(route.path.clone()).or_default();
220        path_routes.insert(route.method.clone(), route);
221    }
222
223    /// Find a route by method and path
224    pub fn find_route(&self, method: &Method, path: &str) -> Option<&Route> {
225        self.routes.get(path)?.get(method)
226    }
227
228    /// Get all routes
229    pub fn routes(&self) -> Vec<&Route> {
230        self.routes.values().flat_map(|methods| methods.values()).collect()
231    }
232
233    /// Get route count
234    pub fn route_count(&self) -> usize {
235        self.routes.values().map(|m| m.len()).sum()
236    }
237}
238
239impl Default for Router {
240    fn default() -> Self {
241        Self::new()
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use serde_json::json;
249
250    #[test]
251    fn test_router_add_and_find() {
252        let mut router = Router::new();
253        let registry = SchemaRegistry::new();
254
255        let metadata = RouteMetadata {
256            method: "GET".to_string(),
257            path: "/users".to_string(),
258            handler_name: "get_users".to_string(),
259            request_schema: None,
260            response_schema: None,
261            parameter_schema: None,
262            file_params: None,
263            is_async: true,
264            cors: None,
265            body_param_name: None,
266            jsonrpc_method: None,
267            #[cfg(feature = "di")]
268            handler_dependencies: None,
269        };
270
271        let route = Route::from_metadata(metadata, &registry).unwrap();
272        router.add_route(route);
273
274        assert_eq!(router.route_count(), 1);
275        assert!(router.find_route(&Method::Get, "/users").is_some());
276        assert!(router.find_route(&Method::Post, "/users").is_none());
277    }
278
279    #[test]
280    fn test_route_with_validators() {
281        let registry = SchemaRegistry::new();
282
283        let metadata = RouteMetadata {
284            method: "POST".to_string(),
285            path: "/users".to_string(),
286            handler_name: "create_user".to_string(),
287            request_schema: Some(json!({
288                "type": "object",
289                "properties": {
290                    "name": {"type": "string"}
291                },
292                "required": ["name"]
293            })),
294            response_schema: None,
295            parameter_schema: None,
296            file_params: None,
297            is_async: true,
298            cors: None,
299            body_param_name: None,
300            jsonrpc_method: None,
301            #[cfg(feature = "di")]
302            handler_dependencies: None,
303        };
304
305        let route = Route::from_metadata(metadata, &registry).unwrap();
306        assert!(route.request_validator.is_some());
307        assert!(route.response_validator.is_none());
308    }
309
310    #[test]
311    fn test_schema_deduplication_in_routes() {
312        let registry = SchemaRegistry::new();
313
314        let shared_schema = json!({
315            "type": "object",
316            "properties": {
317                "id": {"type": "integer"}
318            }
319        });
320
321        let metadata1 = RouteMetadata {
322            method: "POST".to_string(),
323            path: "/items".to_string(),
324            handler_name: "create_item".to_string(),
325            request_schema: Some(shared_schema.clone()),
326            response_schema: None,
327            parameter_schema: None,
328            file_params: None,
329            is_async: true,
330            cors: None,
331            body_param_name: None,
332            jsonrpc_method: None,
333            #[cfg(feature = "di")]
334            handler_dependencies: None,
335        };
336
337        let metadata2 = RouteMetadata {
338            method: "PUT".to_string(),
339            path: "/items/{id}".to_string(),
340            handler_name: "update_item".to_string(),
341            request_schema: Some(shared_schema),
342            response_schema: None,
343            parameter_schema: None,
344            file_params: None,
345            is_async: true,
346            cors: None,
347            body_param_name: None,
348            jsonrpc_method: None,
349            #[cfg(feature = "di")]
350            handler_dependencies: None,
351        };
352
353        let route1 = Route::from_metadata(metadata1, &registry).unwrap();
354        let route2 = Route::from_metadata(metadata2, &registry).unwrap();
355
356        assert!(route1.request_validator.is_some());
357        assert!(route2.request_validator.is_some());
358
359        let validator1 = route1.request_validator.as_ref().unwrap();
360        let validator2 = route2.request_validator.as_ref().unwrap();
361        assert!(Arc::ptr_eq(validator1, validator2));
362
363        assert_eq!(registry.schema_count(), 1);
364    }
365
366    #[test]
367    fn test_jsonrpc_method_info() {
368        let rpc_info = JsonRpcMethodInfo {
369            method_name: "user.create".to_string(),
370            description: Some("Creates a new user account".to_string()),
371            params_schema: Some(json!({
372                "type": "object",
373                "properties": {
374                    "name": {"type": "string"},
375                    "email": {"type": "string"}
376                },
377                "required": ["name", "email"]
378            })),
379            result_schema: Some(json!({
380                "type": "object",
381                "properties": {
382                    "id": {"type": "integer"},
383                    "name": {"type": "string"},
384                    "email": {"type": "string"}
385                }
386            })),
387            deprecated: false,
388            tags: vec!["users".to_string(), "admin".to_string()],
389        };
390
391        assert_eq!(rpc_info.method_name, "user.create");
392        assert_eq!(rpc_info.description.as_ref().unwrap(), "Creates a new user account");
393        assert!(rpc_info.params_schema.is_some());
394        assert!(rpc_info.result_schema.is_some());
395        assert!(!rpc_info.deprecated);
396        assert_eq!(rpc_info.tags.len(), 2);
397        assert!(rpc_info.tags.contains(&"users".to_string()));
398    }
399
400    #[test]
401    fn test_route_with_jsonrpc_method() {
402        let registry = SchemaRegistry::new();
403
404        let metadata = RouteMetadata {
405            method: "POST".to_string(),
406            path: "/user/create".to_string(),
407            handler_name: "create_user".to_string(),
408            request_schema: Some(json!({
409                "type": "object",
410                "properties": {
411                    "name": {"type": "string"}
412                },
413                "required": ["name"]
414            })),
415            response_schema: Some(json!({
416                "type": "object",
417                "properties": {
418                    "id": {"type": "integer"}
419                }
420            })),
421            parameter_schema: None,
422            file_params: None,
423            is_async: true,
424            cors: None,
425            body_param_name: None,
426            jsonrpc_method: None,
427            #[cfg(feature = "di")]
428            handler_dependencies: None,
429        };
430
431        let rpc_info = JsonRpcMethodInfo {
432            method_name: "user.create".to_string(),
433            description: Some("Creates a new user".to_string()),
434            params_schema: Some(json!({
435                "type": "object",
436                "properties": {
437                    "name": {"type": "string"}
438                }
439            })),
440            result_schema: Some(json!({
441                "type": "object",
442                "properties": {
443                    "id": {"type": "integer"}
444                }
445            })),
446            deprecated: false,
447            tags: vec!["users".to_string()],
448        };
449
450        let route = Route::from_metadata(metadata, &registry)
451            .unwrap()
452            .with_jsonrpc_method(rpc_info);
453
454        assert!(route.is_jsonrpc_method());
455        assert_eq!(route.jsonrpc_method_name(), Some("user.create"));
456        assert!(route.jsonrpc_method.is_some());
457
458        let rpc = route.jsonrpc_method.as_ref().unwrap();
459        assert_eq!(rpc.method_name, "user.create");
460        assert_eq!(rpc.description.as_ref().unwrap(), "Creates a new user");
461        assert!(!rpc.deprecated);
462    }
463
464    #[test]
465    fn test_jsonrpc_method_serialization() {
466        let rpc_info = JsonRpcMethodInfo {
467            method_name: "test.method".to_string(),
468            description: Some("Test method".to_string()),
469            params_schema: Some(json!({"type": "object"})),
470            result_schema: Some(json!({"type": "string"})),
471            deprecated: false,
472            tags: vec!["test".to_string()],
473        };
474
475        let json = serde_json::to_value(&rpc_info).unwrap();
476        assert_eq!(json["method_name"], "test.method");
477        assert_eq!(json["description"], "Test method");
478
479        let deserialized: JsonRpcMethodInfo = serde_json::from_value(json).unwrap();
480        assert_eq!(deserialized.method_name, rpc_info.method_name);
481        assert_eq!(deserialized.description, rpc_info.description);
482    }
483
484    #[test]
485    fn test_route_without_jsonrpc_method_has_zero_overhead() {
486        let registry = SchemaRegistry::new();
487
488        let metadata = RouteMetadata {
489            method: "GET".to_string(),
490            path: "/status".to_string(),
491            handler_name: "status".to_string(),
492            request_schema: None,
493            response_schema: None,
494            parameter_schema: None,
495            file_params: None,
496            is_async: false,
497            cors: None,
498            body_param_name: None,
499            jsonrpc_method: None,
500            #[cfg(feature = "di")]
501            handler_dependencies: None,
502        };
503
504        let route = Route::from_metadata(metadata, &registry).unwrap();
505
506        assert!(!route.is_jsonrpc_method());
507        assert_eq!(route.jsonrpc_method_name(), None);
508        assert!(route.jsonrpc_method.is_none());
509    }
510}