//! Transport-neutral routing between request shapes and handler outputs. use crate::{ handlers::{RpcHandlerContext, RpcHandlerRegistry}, jsonrpc::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse}, model::{RpcError, RpcMeta, RpcResultEnvelope, RpcValue}, xmlrpc::{ XmlRpcFault, XmlRpcMethodCall, XmlRpcMethodResponse, XmlRpcParam, rpc_value_to_xmlrpc, xmlrpc_value_to_rpc, }, }; #[derive(Debug, Clone, PartialEq)] /// Transport-neutral inbound request variants accepted by the router. pub enum RpcDispatchRequest { /// `JSON-RPC` request expecting a response. Json(JsonRpcRequest), /// `JSON-RPC` notification with no response body. JsonNotification(JsonRpcNotification), /// `XML-RPC` method call expecting a response. Xml(XmlRpcMethodCall), } #[derive(Debug, Clone, PartialEq)] /// Transport-neutral outbound response variants produced by the router. pub enum RpcDispatchResult { /// `JSON-RPC` response payload. Json(JsonRpcResponse), /// `XML-RPC` response payload. Xml(XmlRpcMethodResponse), /// No response should be emitted. Empty, } #[derive(Debug, Clone, Copy, Default)] /// Shared router that normalizes transport-specific requests into handler calls. pub struct RpcRouter { /// Shared handler registry reused across `JSON-RPC` and `XML-RPC` dispatch paths. handlers: RpcHandlerRegistry, } impl RpcRouter { #[must_use] /// Creates a router with the default handler registry. pub const fn new() -> Self { Self { handlers: RpcHandlerRegistry, } } #[must_use] /// Returns the shared handler registry. pub const fn handlers(&self) -> &RpcHandlerRegistry { &self.handlers } #[must_use] /// Returns a mutable handler registry reference. pub const fn handlers_mut(&mut self) -> &mut RpcHandlerRegistry { &mut self.handlers } /// Dispatches a transport-neutral request through the handler registry. pub fn dispatch( &mut self, request: RpcDispatchRequest, ctx: RpcHandlerContext, ) -> RpcDispatchResult { match request { RpcDispatchRequest::Json(request) => { let request_id = request.id.clone(); let envelope = self.handlers.handle_json(request, ctx); RpcDispatchResult::Json(envelope.into_response(request_id)) } RpcDispatchRequest::JsonNotification(notification) => { self.handlers.handle_notification(notification, ctx); RpcDispatchResult::Empty } RpcDispatchRequest::Xml(request) => RpcDispatchResult::Xml( self.handlers .handle_json(json_request_from_xmlrpc(request), ctx) .into_xml_response(), ), } } } impl RpcResultEnvelope { #[must_use] /// Converts an envelope into a `JSON-RPC` response with the provided id. pub fn into_response(self, id: Option) -> JsonRpcResponse { let (result, error) = self.normalize_for_response(); JsonRpcResponse { jsonrpc: Some("2.0".to_owned()), id, result, error, meta: RpcMeta::default(), } } #[must_use] /// Converts an envelope into an `XML-RPC` method response. pub fn into_xml_response(self) -> XmlRpcMethodResponse { let (result, error) = self.normalize_for_response(); match error { Some(error) => { let message = error.message.clone(); XmlRpcMethodResponse { value: None, fault: Some(XmlRpcFault { code: error.xml_fault_code(), message, error: Some(error), }), meta: RpcMeta::default(), } } None => XmlRpcMethodResponse { value: Some(rpc_value_to_xmlrpc( result.map_or(RpcValue::Null, |result| result), )), fault: None, meta: RpcMeta::default(), }, } } /// Normalizes the envelope so transport encoders always see either a result or an error. fn normalize_for_response(self) -> (Option, Option) { match (self.result, self.error) { (_, Some(error)) => (None, Some(error)), (Some(result), None) => (Some(result), None), (None, None) => (Some(RpcValue::Null), None), } } } /// Converts a normalized error into an envelope with no successful result payload. impl From for RpcResultEnvelope { fn from(error: RpcError) -> Self { Self { result: None, error: Some(error), } } } /// Re-expresses an `XML-RPC` method call as the equivalent `JSON-RPC` request shape. fn json_request_from_xmlrpc(request: XmlRpcMethodCall) -> JsonRpcRequest { JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: request.method_name, params: request .params .into_iter() .map(|XmlRpcParam { value }| xmlrpc_value_to_rpc(value)) .collect(), meta: request.meta, } } #[cfg(test)] /// Unit tests for the transport-neutral RPC router. mod tests { use super::*; use crate::{ jsonrpc::JsonRpcId, model::{RpcAuthContext, RpcErrorCode, RpcErrorKind}, xmlrpc::XmlRpcValue, }; /// Builds a default handler context for router tests. fn ctx() -> RpcHandlerContext { RpcHandlerContext { auth: RpcAuthContext::default(), meta: RpcMeta::default(), } } #[test] /// Verifies that an empty envelope becomes a `null` `JSON-RPC` result. fn json_response_normalizes_empty_envelope_to_null_result() { let response = RpcResultEnvelope { result: None, error: None, } .into_response(Some(JsonRpcId::Number(7))); assert_eq!(response.id, Some(JsonRpcId::Number(7))); assert_eq!(response.result, Some(RpcValue::Null)); assert!(response.error.is_none()); } #[test] /// Verifies that error payloads take precedence over successful results. fn json_response_prefers_error_over_result() { let response = RpcResultEnvelope { result: Some(RpcValue::String("ignored".to_owned())), error: Some(RpcError::invalid_params("bad params")), } .into_response(Some(JsonRpcId::String("req-1".to_owned()))); assert_eq!(response.id, Some(JsonRpcId::String("req-1".to_owned()))); assert!(response.result.is_none()); let error = response.error.expect("error should win"); assert_eq!(error.code, RpcErrorCode::InvalidParams); assert_eq!(error.kind, RpcErrorKind::InvalidParams); assert_eq!(error.message, "bad params"); } #[test] /// Verifies that an empty envelope becomes an `XML-RPC` nil value. fn xml_response_normalizes_empty_envelope_to_nil_value() { let response = RpcResultEnvelope { result: None, error: None, } .into_xml_response(); assert!(response.fault.is_none()); assert_eq!(response.value, Some(XmlRpcValue::Nil)); } #[test] /// Verifies that unknown `XML-RPC` methods reuse the shared fault shape. fn xml_dispatch_uses_shared_fault_shape_for_unknown_method() { let mut router = RpcRouter::new(); let response = match router.dispatch( RpcDispatchRequest::Xml(XmlRpcMethodCall { method_name: "aria2.notFound".to_owned(), params: Vec::new(), meta: RpcMeta::default(), }), ctx(), ) { RpcDispatchResult::Xml(response) => response, other => panic!("unexpected dispatch result: {other:?}"), }; assert!(response.value.is_none()); let fault = response.fault.expect("fault expected"); assert_eq!(fault.code, 1); assert_eq!(fault.message, "Method not found: aria2.notFound"); assert_eq!( fault.error, Some(RpcError::unknown_method("aria2.notFound")) ); } #[test] /// Verifies that `XML-RPC` dispatch reuses the shared JSON-backed handler results. fn xml_dispatch_reuses_json_handler_results_for_list_methods() { let mut router = RpcRouter::new(); let response = match router.dispatch( RpcDispatchRequest::Xml(XmlRpcMethodCall { method_name: "system.listMethods".to_owned(), params: Vec::new(), meta: RpcMeta::default(), }), ctx(), ) { RpcDispatchResult::Xml(response) => response, other => panic!("unexpected dispatch result: {other:?}"), }; assert!(response.fault.is_none()); match response.value { Some(XmlRpcValue::Array(items)) => { assert!(!items.is_empty(), "listMethods should not be empty"); assert!(items.iter().any(|item| matches!( item, XmlRpcValue::String(name) if name == "system.listMethods" ))); } other => panic!("unexpected XML value: {other:?}"), } } }