281 lines
9.4 KiB
Rust
281 lines
9.4 KiB
Rust
//! 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<crate::jsonrpc::JsonRpcId>) -> 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<RpcValue>, Option<RpcError>) {
|
|
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<RpcError> 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:?}"),
|
|
}
|
|
}
|
|
}
|