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:
@@ -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": {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user