Name the received JSON type in non-object params errors
ci / check (push) Failing after 2m38s

Keep -32602 and existing validation order; tell callers to send the object itself.
Cover host routing, stdout fast paths, SDK calls and generated dispatch.

Card: SAU-317
This commit is contained in:
SAU-317 implementer
2026-10-28 19:16:39 +00:00
parent 9ef73e6bc7
commit c54edea596
7 changed files with 198 additions and 6 deletions
+19 -2
View File
@@ -7,6 +7,23 @@ use serde_json::{Value, json};
use tokio::sync::{mpsc, oneshot, watch}; use tokio::sync::{mpsc, oneshot, watch};
use uuid::Uuid; use uuid::Uuid;
fn params_not_object(params: &Value) -> Value {
let kind = match params {
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Number(_) => "a number",
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Object(_) => "an object",
};
error(
-32602,
&format!(
"params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string."
),
)
}
impl Host { impl Host {
/// Queue an incoming response or notification for its destination. /// Queue an incoming response or notification for its destination.
pub(crate) fn send(&self, pid: &str, value: Value) { pub(crate) fn send(&self, pid: &str, value: Value) {
@@ -69,7 +86,7 @@ impl Host {
let host = self.clone(); let host = self.clone();
Box::pin(async move { Box::pin(async move {
if !params.is_object() { if !params.is_object() {
return Err(error(-32602, "params must be an object")); return Err(params_not_object(&params));
} }
if caller != "console" && caller != "core" { if caller != "console" && caller != "core" {
let state = host.0.state.lock().unwrap(); let state = host.0.state.lock().unwrap();
@@ -238,7 +255,7 @@ impl Host {
// Deliver progress before a following response removes its destination. // Deliver progress before a following response removes its destination.
let params = v.get("params").cloned().unwrap_or(json!({})); let params = v.get("params").cloned().unwrap_or(json!({}));
let result = if !params.is_object() { let result = if !params.is_object() {
Err(error(-32602, "params must be an object")) Err(params_not_object(&params))
} else if method == "core.unlock" { } else if method == "core.unlock" {
if let Some(path) = params["path"].as_str() { if let Some(path) = params["path"].as_str() {
self.unlock(pid, path); self.unlock(pid, path);
+107 -2
View File
@@ -500,14 +500,119 @@ async fn unimplemented_core_methods_return_method_not_found() {
async fn params_must_be_objects() { async fn params_must_be_objects() {
let w = TestWorld::new(); let w = TestWorld::new();
let h = w.start().await; let h = w.start().await;
for (params, kind) in [
(json!("{\"text\":\"hello\"}"), "a string"),
(json!([]), "an array"),
(json!(42), "a number"),
(json!(null), "null"),
(json!(true), "a boolean"),
] {
let expected = json!({
"code": -32602,
"message": format!("params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string.")
});
for method in ["probe.echo", "core.ps"] {
assert_eq!(h.call(method, params.clone()).await.unwrap_err(), expected);
}
assert_eq!( assert_eq!(
h.call("probe.echo", json!([])).await.unwrap_err()["code"], h.call("core.call", json!({"method":"probe.echo", "params":params}))
-32602 .await
.unwrap_err(),
expected
);
}
let object = json!({"text":"hello"});
assert_eq!(call(&h, "probe.echo", object.clone()).await, object);
assert_eq!(
call(
&h,
"core.call",
json!({"method":"probe.echo", "params":object})
)
.await,
object
);
assert_eq!(
call(&h, "core.call", json!({"method":"probe.echo"})).await,
json!({})
); );
h.stop().await; h.stop().await;
} }
/// PROTOCOL 3.1: stdout fast paths reject non-objects without changing defaults.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stdout_params_must_be_objects() {
let w = TestWorld::new();
let h = w.start().await;
let mut notifications = h.subscribe();
for method in ["core.notify", "core.unlock", "probe.echo"] {
for (params, kind) in [
(json!("{}"), "a string"),
(json!([]), "an array"),
(json!(1.5), "a number"),
(json!(null), "null"),
(json!(false), "a boolean"),
] {
let response = call(
&h,
"probe.raw_call",
json!({
"jsonrpc":"2.0", "id":1, "method":method, "params":params
}),
)
.await;
assert_eq!(response["id"], 1);
assert_eq!(
response["error"],
json!({
"code": -32602,
"message": format!("params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string.")
})
);
}
let params = match method {
"core.notify" => json!({"name":"valid", "params":{"n":1}}),
"core.unlock" => json!({"path":"/state/not-held"}),
_ => json!({"n":1}),
};
let response = call(
&h,
"probe.raw_call",
json!({
"jsonrpc":"2.0", "id":1, "method":method, "params":params
}),
)
.await;
assert_eq!(
response["result"],
if method == "probe.echo" {
params
} else {
Value::Null
}
);
assert!(response.get("error").is_none());
let omitted = call(
&h,
"probe.raw_call",
json!({
"jsonrpc":"2.0", "id":1, "method":method
}),
)
.await;
match method {
"core.notify" => {
assert_eq!(omitted["error"]["message"], "name must be a string");
assert_eq!(notifications.try_recv().unwrap()["params"]["name"], "valid");
}
"core.unlock" => assert_eq!(omitted["error"]["message"], "path must be a string"),
_ => assert_eq!(omitted["result"], json!({})),
}
}
h.stop().await;
}
/// PROTOCOL 3.9: exit without answer reports status and last logs. /// PROTOCOL 3.9: exit without answer reports status and last logs.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exit_without_answer_reports_status_and_last_logs() { async fn exit_without_answer_reports_status_and_last_logs() {
+4
View File
@@ -470,6 +470,10 @@ fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
"network" => network(params), "network" => network(params),
"read" => read_file(params), "read" => read_file(params),
"write" => write_file(params), "write" => write_file(params),
"raw_call" => {
send(params.clone());
receive(lines)
}
"call" => outgoing_call(params, lines), "call" => outgoing_call(params, lines),
"lock" => lock(params, lines), "lock" => lock(params, lines),
_ => { _ => {
+1 -1
View File
@@ -270,7 +270,7 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
fn methods_document()->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); Self::add_methods(&mut doc); #grace ::sausage_plugin::finish_document(doc)} fn methods_document()->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); Self::add_methods(&mut doc); #grace ::sausage_plugin::finish_document(doc)}
fn describe()->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); Self::add_methods(&mut doc); #grace #(doc.endpoint::<#types>(#names);)* ::sausage_plugin::finish_document(doc)} fn describe()->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); Self::add_methods(&mut doc); #grace #(doc.endpoint::<#types>(#names);)* ::sausage_plugin::finish_document(doc)}
fn discover(&self)->::sausage_plugin::serde_json::Value {<Self as ::sausage_plugin::Interface>::describe()} fn discover(&self)->::sausage_plugin::serde_json::Value {<Self as ::sausage_plugin::Interface>::describe()}
fn dispatch<'a>(&'a self, method:&'a str, params: ::sausage_plugin::serde_json::Value,ctx:&'a ::sausage_plugin::Ctx)->::std::pin::Pin<Box<dyn ::std::future::Future<Output=::sausage_plugin::Result<::sausage_plugin::serde_json::Value>>+'a>> {Box::pin(async move {if !params.is_object() {return Err(::sausage_plugin::Error::new(-32602,"params must be an object"));} match method {#(#arms,)* _=>Err(::sausage_plugin::Error::new(-32601,"method not found"))}})} fn dispatch<'a>(&'a self, method:&'a str, params: ::sausage_plugin::serde_json::Value,ctx:&'a ::sausage_plugin::Ctx)->::std::pin::Pin<Box<dyn ::std::future::Future<Output=::sausage_plugin::Result<::sausage_plugin::serde_json::Value>>+'a>> {Box::pin(async move {if !params.is_object() {return Err(::sausage_plugin::Error::params_not_object(&params));} match method {#(#arms,)* _=>Err(::sausage_plugin::Error::new(-32601,"method not found"))}})}
#stop #stop
}}) }})
})(); })();
+1 -1
View File
@@ -37,7 +37,7 @@ impl IntoFuture for Call {
fn into_future(self) -> Self::IntoFuture { fn into_future(self) -> Self::IntoFuture {
Box::pin(async move { Box::pin(async move {
if !self.params.is_object() { if !self.params.is_object() {
return Err(Error::new(-32602, "params must be an object")); return Err(Error::params_not_object(&self.params));
} }
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
let id = STATE.with(|s| { let id = STATE.with(|s| {
+19
View File
@@ -15,6 +15,25 @@ pub struct Error {
pub data: Option<Value>, pub data: Option<Value>,
} }
impl Error { impl Error {
/// Build the object-params diagnostic used by calls and generated dispatch.
#[doc(hidden)]
pub fn params_not_object(params: &Value) -> Self {
let kind = match params {
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Number(_) => "a number",
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Object(_) => "an object",
};
Self::new(
-32602,
format!(
"params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string."
),
)
}
/// Construct an error without structured details. /// Construct an error without structured details.
pub fn new(code: i64, message: impl Into<String>) -> Self { pub fn new(code: i64, message: impl Into<String>) -> Self {
Self { Self {
+47
View File
@@ -116,6 +116,53 @@ fn discovery_and_dispatch() {
.into(); .into();
assert_eq!(internal.code, -32603); assert_eq!(internal.code, -32603);
} }
#[sausage_plugin::test]
async fn params_must_be_objects() {
for (params, kind) in [
(json!("{\"a\":2,\"b\":3}"), "a string"),
(json!([]), "an array"),
(json!(42), "a number"),
(json!(null), "null"),
(json!(true), "a boolean"),
] {
let message = format!(
"params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string."
);
let outgoing = sausage_plugin::call("plugin.add", params.clone())
.await
.unwrap_err();
assert_eq!(outgoing.code, -32602);
assert_eq!(outgoing.message, message);
for method in ["add", "choice", "unknown"] {
let incoming = Plugin
.dispatch(method, params.clone(), &Ctx::default())
.await
.unwrap_err();
assert_eq!(incoming.code, -32602);
assert_eq!(incoming.message, message);
}
}
assert_eq!(
Plugin
.dispatch("add", json!({"a":2,"b":3}), &Ctx::default())
.await
.unwrap(),
5
);
assert_eq!(
Plugin
.dispatch("defaults", json!({}), &Ctx::default())
.await
.unwrap(),
json!({"value":""})
);
// An object reaches transport validation rather than the params rejection.
let outgoing = sausage_plugin::call("plugin.add", json!({"a":2,"b":3}))
.await
.unwrap_err();
assert!(outgoing.message.contains("no core transport"));
}
#[sausage_plugin::test] #[sausage_plugin::test]
async fn missing_core_fails_without_wait() { async fn missing_core_fails_without_wait() {
assert!( assert!(