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:
+19
-2
@@ -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(¶ms));
|
||||||
}
|
}
|
||||||
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(¶ms))
|
||||||
} 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
@@ -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!(
|
||||||
|
h.call("core.call", json!({"method":"probe.echo", "params":params}))
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let object = json!({"text":"hello"});
|
||||||
|
assert_eq!(call(&h, "probe.echo", object.clone()).await, object);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
h.call("probe.echo", json!([])).await.unwrap_err()["code"],
|
call(
|
||||||
-32602
|
&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() {
|
||||||
|
|||||||
@@ -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),
|
||||||
_ => {
|
_ => {
|
||||||
|
|||||||
@@ -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(¶ms));} match method {#(#arms,)* _=>Err(::sausage_plugin::Error::new(-32601,"method not found"))}})}
|
||||||
#stop
|
#stop
|
||||||
}})
|
}})
|
||||||
})();
|
})();
|
||||||
|
|||||||
+1
-1
@@ -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| {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
Reference in New Issue
Block a user