use serde_json::value::RawValue;
use crate::domain::feature::FeatureProfile;
use crate::domain::ir::IrErrorCode;
use crate::domain::jsonrpc::{Envelope, MessageKind, Method, Outcome, WireId, decode};
use super::BatchPolicy;
pub type RoutedFrame<'a, M> = Vec<RoutedMessage<'a, M>>;
pub type RoutedMessage<'a, M> = Routed<
'a,
<M as MethodTable>::MethodId,
<M as MethodTable>::RequestParams<'a>,
<M as MethodTable>::NotificationParams<'a>,
>;
pub trait MethodTable {
type MethodId: Copy + std::fmt::Debug + PartialEq;
type RequestParams<'a>;
type NotificationParams<'a>;
fn lookup(&self, name: &str) -> Option<Self::MethodId>;
fn decode_request_params<'a>(
&self,
method: Self::MethodId,
params: Option<&'a RawValue>,
) -> Result<Self::RequestParams<'a>, RouteError<'a>>;
fn decode_notification_params<'a>(
&self,
method: Self::MethodId,
params: Option<&'a RawValue>,
) -> Result<Self::NotificationParams<'a>, RouteError<'a>>;
}
#[derive(Debug, Clone)]
pub enum Routed<'a, M, R, N> {
Request {
method: M,
id: WireId<'a>,
params: R,
},
Notification {
method: M,
params: N,
},
Reply {
id: WireId<'a>,
payload: ReplyPayload<'a>,
},
}
impl<M, R, N> Routed<'_, M, R, N> {
#[must_use]
pub const fn method(&self) -> Option<M>
where
M: Copy,
{
match self {
Self::Request { method, .. } | Self::Notification { method, .. } => Some(*method),
Self::Reply { .. } => None,
}
}
}
#[derive(Debug, Clone)]
pub enum ReplyPayload<'a> {
Result(&'a RawValue),
Error {
code: i64,
message: &'a str,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RouteError<'a> {
#[error(transparent)]
Decode(#[from] crate::domain::jsonrpc::DecodeError),
#[error("this session does not accept batch frames")]
BatchNotAllowed,
#[error("unknown method `{0}`")]
MethodNotFound(Method<'a>),
#[error("invalid params for `{method}`: {reason}")]
InvalidParams {
method: Method<'a>,
reason: &'static str,
},
}
impl RouteError<'_> {
#[must_use]
pub const fn code(&self) -> IrErrorCode {
match self {
Self::Decode(decode) => decode.code(),
Self::BatchNotAllowed | Self::InvalidParams { .. } => IrErrorCode::InvalidRequest,
Self::MethodNotFound(_) => IrErrorCode::MethodNotFound,
}
}
}
pub fn route_one<'a, M: MethodTable>(
envelope: Envelope<'a>,
table: &M,
) -> Result<RoutedMessage<'a, M>, RouteError<'a>> {
let Envelope {
kind,
method,
params,
id,
outcome,
} = envelope;
let method = method.unwrap_or_default();
match kind {
MessageKind::Request => {
let Some(id) = id else {
return Err(RouteError::InvalidParams {
method,
reason: "a request must carry an id",
});
};
let Some(found) = table.lookup(method.as_ref()) else {
return Err(RouteError::MethodNotFound(method));
};
let params = table.decode_request_params(found, params)?;
Ok(Routed::Request {
method: found,
id,
params,
})
}
MessageKind::Notification => {
let Some(found) = table.lookup(method.as_ref()) else {
return Err(RouteError::MethodNotFound(method));
};
let params = table.decode_notification_params(found, params)?;
Ok(Routed::Notification {
method: found,
params,
})
}
MessageKind::Result | MessageKind::Error => {
let Some(id) = id else {
return Err(RouteError::InvalidParams {
method: Method::Borrowed("a reply"),
reason: "must carry an id",
});
};
let payload = match outcome {
Some(Outcome::Result(value)) => ReplyPayload::Result(value),
Some(Outcome::Error(error)) => ReplyPayload::Error {
code: error.code,
message: error.message,
},
None => {
return Err(RouteError::InvalidParams {
method: Method::Borrowed("a reply"),
reason: "must carry a result or an error",
});
}
};
Ok(Routed::Reply { id, payload })
}
}
}
pub fn route_frame<'a, M: MethodTable, P: BatchPolicy>(
input: &'a [u8],
table: &M,
profile: &FeatureProfile,
policy: &P,
) -> Result<RoutedFrame<'a, M>, RouteError<'a>> {
let frame = decode(input)?;
if frame.is_batch() && !policy.allows_batch(profile) {
return Err(RouteError::BatchNotAllowed);
}
frame
.into_envelopes()
.into_iter()
.map(|envelope| route_one(envelope, table))
.collect()
}
#[cfg(test)]
mod tests {
#![expect(
clippy::expect_used,
clippy::panic,
reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
)]
use assert_matches::assert_matches;
use serde_json::value::RawValue;
use crate::domain::feature::FeatureProfile;
use crate::domain::ir::IrErrorCode;
use crate::domain::jsonrpc::WireId;
use super::{MethodTable, ReplyPayload, RouteError, Routed, route_frame, route_one};
use crate::application::jsonrpc::{AllowBatching, DenyBatching, ProfileBatching};
type TestError = Box<dyn std::error::Error>;
type Params<'a> = Option<&'a RawValue>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TestMethod {
Alpha,
Beta,
}
struct TestTable {
decoded: std::cell::Cell<usize>,
}
impl TestTable {
const fn new() -> Self {
Self {
decoded: std::cell::Cell::new(0),
}
}
}
impl MethodTable for TestTable {
type MethodId = TestMethod;
type RequestParams<'a> = Params<'a>;
type NotificationParams<'a> = Params<'a>;
fn lookup(&self, name: &str) -> Option<TestMethod> {
match name {
"alpha" => Some(TestMethod::Alpha),
"beta" => Some(TestMethod::Beta),
_ => None,
}
}
fn decode_request_params<'a>(
&self,
_method: TestMethod,
params: Option<&'a RawValue>,
) -> Result<Self::RequestParams<'a>, RouteError<'a>> {
self.decoded.set(self.decoded.get() + 1);
Ok(params)
}
fn decode_notification_params<'a>(
&self,
_method: TestMethod,
params: Option<&'a RawValue>,
) -> Result<Self::NotificationParams<'a>, RouteError<'a>> {
self.decoded.set(self.decoded.get() + 1);
Ok(params)
}
}
const BATCH: &[u8] =
br#"[{"jsonrpc":"2.0","id":1,"method":"alpha"},{"jsonrpc":"2.0","id":2,"method":"beta"}]"#;
#[test]
fn a_request_reports_its_method_through_the_accessor() {
let table = TestTable::new();
let input = br#"{"jsonrpc":"2.0","id":1,"method":"alpha"}"#;
let routed = route_one(single(input), &table).expect("alpha is in the table");
assert_eq!(routed.method(), Some(TestMethod::Alpha));
}
#[test]
fn a_reply_reports_no_method() {
let table = TestTable::new();
let input = br#"{"jsonrpc":"2.0","id":1,"result":null}"#;
let routed = route_one(single(input), &table).expect("a reply routes to no method");
assert_eq!(routed.method(), None, "a reply has no method to name");
}
#[test]
fn an_unroutable_frame_never_decodes_its_params() {
let table = TestTable::new();
let input = br#"{"jsonrpc":"2.0","id":1,"method":"nope","params":{"deep":[1,2,3]}}"#;
let error = route_one(single(input), &table).expect_err("no such method");
assert_eq!(error.code(), IrErrorCode::MethodNotFound);
assert_eq!(table.decoded.get(), 0, "params decoding must not have run");
}
#[test]
fn a_routable_frame_decodes_its_params_exactly_once() {
let table = TestTable::new();
let input = br#"{"jsonrpc":"2.0","id":1,"method":"alpha","params":{"x":1}}"#;
route_one(single(input), &table).expect("alpha is in the table");
assert_eq!(table.decoded.get(), 1);
}
#[test]
fn a_batch_routes_every_element_in_order() {
let table = TestTable::new();
let routed = route_frame(BATCH, &table, &FeatureProfile::all(), &ProfileBatching)
.expect("batching is allowed in this profile");
let methods: Vec<_> = routed.iter().filter_map(Routed::method).collect();
assert_eq!(methods, [TestMethod::Alpha, TestMethod::Beta]);
}
#[test]
fn a_decode_failure_outranks_the_batch_policy() {
let table = TestTable::new();
let error = route_frame(
b"{not json",
&table,
&FeatureProfile::all(),
&ProfileBatching,
)
.expect_err("the frame is malformed");
assert_eq!(error.code(), IrErrorCode::ParseError);
}
#[test]
fn a_single_message_is_unaffected_by_the_batch_policy() {
let input = br#"{"jsonrpc":"2.0","id":1,"method":"alpha"}"#;
let table = TestTable::new();
let routed = route_frame(input, &table, &FeatureProfile::empty(), &DenyBatching)
.expect("a single message is not a batch");
assert_eq!(
routed.len(),
1,
"DenyBatching must not refuse a single message"
);
let table = TestTable::new();
let routed = route_frame(input, &table, &FeatureProfile::empty(), &AllowBatching)
.expect("a single message is not a batch");
assert_eq!(
routed.len(),
1,
"AllowBatching must not refuse a single message"
);
}
#[test]
fn the_profile_decides_whether_a_batch_is_allowed() {
let table = TestTable::new();
let error = route_frame(BATCH, &table, &FeatureProfile::empty(), &ProfileBatching)
.expect_err("this profile has no batching");
assert_eq!(
error.code(),
IrErrorCode::InvalidRequest,
"an unacceptable batch is `-32600`, not a code of its own"
);
}
#[test]
fn a_policy_that_ignores_the_profile_overrides_it_both_ways() {
let table = TestTable::new();
assert!(
route_frame(BATCH, &table, &FeatureProfile::empty(), &AllowBatching).is_ok(),
"AllowBatching must allow a batch the profile refuses"
);
let table = TestTable::new();
assert!(
route_frame(BATCH, &table, &FeatureProfile::all(), &DenyBatching).is_err(),
"DenyBatching must refuse a batch the profile allows"
);
}
#[test]
fn a_null_id_is_a_malformed_frame_not_a_notification() {
let input = br#"{"jsonrpc":"2.0","id":null,"method":"alpha"}"#;
let error = crate::domain::jsonrpc::decode(input).expect_err("a null id is not a valid id");
assert_eq!(error.code(), IrErrorCode::InvalidRequest);
assert!(
error.to_string().contains("number or a string"),
"and says why: {error}"
);
}
#[test]
fn a_message_carrying_neither_method_nor_reply_is_refused_before_routing() {
let input = br#"{"jsonrpc":"2.0","id":1}"#;
let error = crate::domain::jsonrpc::decode(input)
.expect_err("a message must be a request, a notification, or a reply");
assert_eq!(error.code(), IrErrorCode::InvalidRequest);
assert!(
error.to_string().contains("method or a reply"),
"and says which half is missing: {error}"
);
}
#[test]
fn a_null_result_is_a_result() -> Result<(), TestError> {
let table = TestTable::new();
let input = br#"{"jsonrpc":"2.0","id":1,"result":null}"#;
match route_one(single(input), &table) {
Ok(Routed::Reply { id, payload }) => {
assert_eq!(id, WireId::Number(1));
assert_matches!(payload, ReplyPayload::Result(raw) if raw.get() == "null");
}
other => return Err(format!("expected a reply, got {other:?}").into()),
}
Ok(())
}
#[test]
fn an_error_reply_keeps_its_code_and_message() -> Result<(), TestError> {
let table = TestTable::new();
let input = br#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"nope"}}"#;
match route_one(single(input), &table) {
Ok(Routed::Reply { id, payload }) => {
assert_eq!(id, WireId::Number(1));
assert_matches!(
payload,
ReplyPayload::Error { code, message }
if code == -32601 && message == "nope"
);
}
other => return Err(format!("expected a reply, got {other:?}").into()),
}
Ok(())
}
fn single(input: &[u8]) -> crate::domain::jsonrpc::Envelope<'_> {
match crate::domain::jsonrpc::decode(input) {
Ok(crate::domain::jsonrpc::Frame::Single(envelope)) => envelope,
Ok(crate::domain::jsonrpc::Frame::Batch(_)) => {
panic!("these tests each pass exactly one message")
}
Err(error) => panic!("these tests each pass well-formed JSON: {error}"),
}
}
}