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