Skip to main content

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