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