sausage-plugin part 2: import! with typed clients (sausage_interface::client_rs), TCP endpoints with rpc.endpoint, examples

Part 1 fixes: complete stdout writes, buffered stdin, Rust type names
in schemas, enum variant docs, -32603 for serde errors in handlers,
crate docs and modules. End-to-end check of a generated consumer through
a fake core.
This commit is contained in:
2026-10-23 00:35:45 +01:00
parent ae1cb4cc99
commit 8d3c163992
42 changed files with 3020 additions and 856 deletions
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "sdk-consumer"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
sausage-plugin = { path = "../../.." }
@@ -0,0 +1,268 @@
{
"components": {
"schemas": {
"Add": {
"description": "The values to add.",
"properties": {
"a": {
"description": "The first number.",
"format": "int32",
"type": "integer"
},
"b": {
"description": "The second number.",
"format": "int32",
"type": "integer"
}
},
"required": [
"a",
"b"
],
"type": "object"
},
"Echo": {
"description": "A large payload.",
"properties": {
"text": {
"description": "The text to return unchanged.",
"type": "string"
}
},
"required": [
"text"
],
"type": "object"
},
"Empty": {
"description": "Empty parameters for methods without arguments.",
"type": "object"
}
}
},
"info": {
"title": "sdk-probe",
"version": "0.1.0"
},
"methods": [
{
"description": "Publish a control endpoint until EOF.",
"name": "serve",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "null"
}
},
"summary": "Publish a control endpoint until EOF.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
},
{
"description": "Add two numbers.\n\nThe result is their sum.",
"name": "add",
"paramStructure": "by-name",
"params": [
{
"description": "The first number.",
"name": "a",
"required": true,
"schema": {
"description": "The first number.",
"format": "int32",
"type": "integer"
}
},
{
"description": "The second number.",
"name": "b",
"required": true,
"schema": {
"description": "The second number.",
"format": "int32",
"type": "integer"
}
}
],
"result": {
"name": "result",
"schema": {
"format": "int32",
"type": "integer"
}
},
"summary": "Add two numbers.",
"x-sausage-params": {
"$ref": "#/components/schemas/Add"
}
},
{
"description": "Echo a payload larger than a host I/O buffer.",
"name": "echo",
"paramStructure": "by-name",
"params": [
{
"description": "The text to return unchanged.",
"name": "text",
"required": true,
"schema": {
"description": "The text to return unchanged.",
"type": "string"
}
}
],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Echo a payload larger than a host I/O buffer.",
"x-sausage-params": {
"$ref": "#/components/schemas/Echo"
}
},
{
"description": "Return an internal serialization error.",
"name": "serde_error",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Return an internal serialization error.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
},
{
"description": "Return a deliberate error.",
"name": "fail",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Return a deliberate error.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
},
{
"description": "Exercise calls, notifications, and advisory locks.",
"name": "exercise",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Exercise calls, notifications, and advisory locks.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
},
{
"description": "Drop an acquisition before the core grants its lock.",
"name": "late",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Drop an acquisition before the core grants its lock.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
},
{
"description": "Wait until EOF, then run the stop hook.",
"name": "wait",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Wait until EOF, then run the stop hook.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
},
{
"description": "Keep the process alive with a background call.",
"name": "start",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Keep the process alive with a background call.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
}
],
"openrpc": "1.2.6",
"x-sausage-endpoints": {
"control": {
"methods": [
{
"description": "Report progress and return a stable counter.",
"name": "stats",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"format": "uint64",
"minimum": 0,
"type": "integer"
}
},
"summary": "Report progress and return a stable counter.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
},
{
"description": "End the endpoint process after the response.",
"name": "stop",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "null"
}
},
"summary": "End the endpoint process after the response.",
"x-sausage-params": {
"$ref": "#/components/schemas/Empty"
}
}
]
}
},
"x-sausage-grace": 12,
"x-sausage-imports": {}
}
+25
View File
@@ -0,0 +1,25 @@
//! Consumer of the probe's actual discovered interface.
use sausage_plugin::{Result, rpc};
sausage_plugin::import!("probe");
struct Consumer;
#[rpc]
impl Consumer {
/// Route an imported call through the core.
async fn run(&self) -> Result<i32> {
Ok(probe::add(probe::Add { a: 2, b: 3 })
.detach()
.on_notify(|name, _| eprintln!("typed progress: {name}"))
.await? as i32)
}
/// Connect to the provider's endpoint and receive its notification.
async fn endpoint(&self) -> Result<u64> {
let control = probe::control::connect("/endpoint").await?;
control
.stats()
.on_notify(|name, _| eprintln!("endpoint notification: {name}"))
.await
}
}
fn main() {
sausage_plugin::run(Consumer)
}
+33 -1
View File
@@ -16,14 +16,32 @@ struct Retired {
/// The previous hash.
previous: String,
}
#[rpc(grace_seconds = 12)]
/// A large payload.
#[schema]
struct Echo {
/// The text to return unchanged.
text: String,
}
#[rpc(grace_seconds = 12,endpoints(control=Control))]
impl Probe {
/// Publish a control endpoint until EOF.
async fn serve(&self) -> Result<()> {
sausage_plugin::listen("/endpoint", std::rc::Rc::new(Control)).await
}
/// Add two numbers.
///
/// The result is their sum.
fn add(&self, p: Add) -> Result<i32> {
Ok(p.a + p.b)
}
/// Echo a payload larger than a host I/O buffer.
fn echo(&self, p: Echo) -> Result<String> {
Ok(p.text)
}
/// Return an internal serialization error.
fn serde_error(&self) -> Result<String> {
Ok(sausage_plugin::serde_json::from_str::<String>("invalid")?)
}
/// Return a deliberate error.
fn fail(&self) -> Result<String> {
Err(sausage_plugin::Error::new(-32010, "deliberate"))
@@ -109,6 +127,20 @@ async fn join(
})
.await
}
struct Control;
#[rpc]
impl Control {
/// Report progress and return a stable counter.
fn stats(&self, ctx: &Ctx) -> Result<u64> {
ctx.notify("endpoint-progress", json!({"value":7}))?;
Ok(7)
}
/// End the endpoint process after the response.
fn stop(&self, ctx: &Ctx) -> Result<()> {
ctx.exit_after_response();
Ok(())
}
}
fn main() {
sausage_plugin::run(Probe)
}
+34 -4
View File
@@ -25,8 +25,12 @@ enum Choice {
Second,
}
struct Plugin;
#[rpc]
#[rpc(endpoints(control=Plugin))]
impl Plugin {
/// Return the same named object without a contract change.
fn identity(&self, p: Add) -> Result<Add> {
Ok(p)
}
/// Choose the first value.
fn choice(&self) -> Result<Choice> {
Ok(Choice::First)
@@ -45,6 +49,23 @@ impl Plugin {
#[test]
fn discovery_and_dispatch() {
let doc = Plugin.discover();
assert!(
doc["x-sausage-endpoints"]["control"]["methods"]
.as_array()
.unwrap()
.iter()
.any(|m| m["name"] == "add")
);
assert!(doc["components"]["schemas"].get("AddResult").is_none());
assert_eq!(
doc["methods"]
.as_array()
.unwrap()
.iter()
.find(|m| m["name"] == "identity")
.unwrap()["result"]["schema"]["$ref"],
"#/components/schemas/Add"
);
assert!(
doc["components"]["schemas"]["Defaulted"]
.get("required")
@@ -55,8 +76,8 @@ fn discovery_and_dispatch() {
json!(["value"])
);
assert_eq!(
doc["components"]["schemas"]["ChoiceResult"]["enum"],
json!(["First", "Second"])
doc["components"]["schemas"]["Choice"]["oneOf"][0]["description"],
json!("The first choice.")
);
let add = doc["methods"]
.as_array()
@@ -76,7 +97,16 @@ fn discovery_and_dispatch() {
.unwrap(),
5
);
assert!(sausage_plugin::block_on(Plugin.dispatch("add", json!({}), &Ctx::default())).is_err());
assert_eq!(
sausage_plugin::block_on(Plugin.dispatch("add", json!({}), &Ctx::default()))
.unwrap_err()
.code,
-32602
);
let internal: sausage_plugin::Error = sausage_plugin::serde_json::from_str::<i32>("bad")
.unwrap_err()
.into();
assert_eq!(internal.code, -32603);
}
#[sausage_plugin::test]
async fn missing_core_fails_without_wait() {
+4 -4
View File
@@ -5,12 +5,12 @@ error[E0277]: RPC params must be a #[schema] struct with named fields
| ^^^^^^ the trait `Params` is not implemented for `std::string::String`
|
help: the trait `Params` is implemented for `sausage_plugin::Empty`
--> src/lib.rs
--> src/discovery.rs
|
| impl Params for Empty {}
| ^^^^^^^^^^^^^^^^^^^^^
note: required by a bound in `method`
--> src/lib.rs
note: required by a bound in `sausage_plugin::method`
--> src/discovery.rs
|
| pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str) {
| ^^^^^^ required by this bound in `method`
@@ -22,7 +22,7 @@ error[E0277]: RPC params must be a #[schema] struct with named fields
| ^^^^^^ the trait `Params` is not implemented for `std::string::String`
|
help: the trait `Params` is implemented for `sausage_plugin::Empty`
--> src/lib.rs
--> src/discovery.rs
|
| impl Params for Empty {}
| ^^^^^^^^^^^^^^^^^^^^^
+101 -3
View File
@@ -34,7 +34,16 @@ impl Process {
}
fn start(wasm: &Path, dir: &Path) -> Process {
let mut child = Command::new("wasmtime")
.args(["run", "--dir"])
.args([
"run",
"-S",
"inherit-network=y",
"--env",
"SAUSAGE_PID=probe-00000000-0000-7000-8000-000000000000",
"--env",
"SAUSAGE_PLUGIN=probe",
"--dir",
])
.arg(format!("{}::/", dir.display()))
.arg(wasm)
.stdin(Stdio::piped())
@@ -72,7 +81,12 @@ fn protocol() {
let d = p.read();
assert_eq!(d["result"]["x-sausage-grace"], 12);
assert_eq!(
d["result"]["methods"][0]["description"],
d["result"]["methods"]
.as_array()
.unwrap()
.iter()
.find(|m| m["name"] == "add")
.unwrap()["description"],
"Add two numbers.\n\nThe result is their sum."
);
assert!(p.child.wait().unwrap().success());
@@ -81,6 +95,18 @@ fn protocol() {
assert_eq!(p.read()["result"], 6);
assert!(p.child.wait().unwrap().success());
let mut p = start(&wasm, &dir);
p.request("echo", json!({"text":"x".repeat(70_000)}));
assert_eq!(p.read()["result"], "x".repeat(70_000));
assert!(p.child.wait().unwrap().success());
let mut p = start(&wasm, &dir);
p.request("echo", json!({"text":5}));
assert_eq!(p.read()["error"]["code"], -32602);
assert!(p.child.wait().unwrap().success());
let mut p = start(&wasm, &dir);
p.request("serde_error", json!({}));
assert_eq!(p.read()["error"]["code"], -32603);
assert!(p.child.wait().unwrap().success());
let mut p = start(&wasm, &dir);
p.request("fail", json!({}));
assert_eq!(p.read()["error"]["code"], -32010);
assert!(p.child.wait().unwrap().success());
@@ -107,7 +133,7 @@ fn protocol() {
assert!(p.child.wait().unwrap().success());
let mut err = String::new();
std::io::Read::read_to_string(p.child.stderr.as_mut().unwrap(), &mut err).unwrap();
assert!(err.contains("unknown ID"));
assert!(err.contains("unknown ID 9876"));
assert!(err.contains("notification: tick"));
let mut p = start(&wasm, &dir);
p.request("rpc.retired", json!({"previous":"hash"}));
@@ -143,5 +169,77 @@ fn protocol() {
assert_eq!(outgoing["method"], "other.background");
p.answer(&outgoing, json!({}));
assert!(p.child.wait().unwrap().success());
let provider_doc = &d["result"];
let imports = root.join("tests/plugins/consumer/imports");
std::fs::create_dir_all(&imports).unwrap();
std::fs::write(
imports.join("probe.json"),
sausage_plugin::serde_json::to_vec_pretty(provider_doc).unwrap(),
)
.unwrap();
let generated = sausage_interface::client_rs("probe", provider_doc).unwrap();
std::fs::write(target.join("client.rs"), generated).unwrap();
assert!(
Command::new("cargo")
.env("CARGO_TARGET_DIR", &target)
.args(["build", "--offline", "--manifest-path"])
.arg(root.join("tests/plugins/consumer/Cargo.toml"))
.args(["--target", "wasm32-wasip2"])
.status()
.unwrap()
.success()
);
let consumer = target.join("wasm32-wasip2/debug/sdk-consumer.wasm");
let mut client = start(&consumer, &dir);
client.request("rpc.discover", json!({}));
let consumer_doc = client.read()["result"].clone();
assert!(client.child.wait().unwrap().success());
let imported = &consumer_doc["x-sausage-imports"]["probe"];
assert!(sausage_interface::compat::check(imported, provider_doc).is_empty());
let mut broken = provider_doc.clone();
broken["methods"]
.as_array_mut()
.unwrap()
.retain(|m| m["name"] != "add");
assert!(!sausage_interface::compat::check(imported, &broken).is_empty());
let mut client = start(&consumer, &dir);
client.request("run", json!({}));
let outgoing = client.read();
assert_eq!(outgoing["method"], "core.call");
assert_eq!(outgoing["params"]["method"], "probe.add");
let mut provider = start(&wasm, &dir);
provider.request("add", outgoing["params"]["params"].clone());
let reply = provider.read();
assert!(provider.child.wait().unwrap().success());
client.send(json!({"jsonrpc":"2.0","method":"core.notify","params":{"call":outgoing["id"],"name":"typed","params":{}}}));
client.answer(&outgoing, reply["result"].clone());
assert_eq!(client.read()["result"], 5);
assert!(client.child.wait().unwrap().success());
let mut provider = start(&wasm, &dir);
provider.request("serve", json!({}));
assert_eq!(provider.read()["result"], Value::Null);
let mut client = start(&consumer, &dir);
client.request("endpoint", json!({}));
assert_eq!(client.read()["result"], 7);
assert!(client.child.wait().unwrap().success());
let mut stderr = String::new();
std::io::Read::read_to_string(client.child.stderr.as_mut().unwrap(), &mut stderr).unwrap();
assert!(stderr.contains("endpoint notification: endpoint-progress"));
let path = dir.join("endpoint");
let original = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, original.replace("probe-00000000", "probe-11111111")).unwrap();
let mut client = start(&consumer, &dir);
client.request("endpoint", json!({}));
assert!(
client.read()["error"]["message"]
.as_str()
.unwrap()
.contains("identity mismatch")
);
assert!(client.child.wait().unwrap().success());
std::fs::write(path, original).unwrap();
drop(provider.input.take());
assert_eq!(provider.read()["params"]["name"], "stopped");
assert!(provider.child.wait().unwrap().success());
std::fs::remove_dir_all(dir).unwrap();
}