From 05161588831b0348ba400c12c1b0d231fe3d3ac3 Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 23 Oct 2026 01:05:19 +0100 Subject: [PATCH] sdk: readable clients (no Empty wrapper, no unused error module), documented examples with tests --- Cargo.lock | 99 +++++++++++++++++ examples/consumer/imports/notes.json | 9 +- examples/consumer/imports/secrets.json | 9 +- examples/consumer/imports/www.json | 14 +-- examples/consumer/src/main.rs | 41 +++++++ examples/notes/src/main.rs | 43 +++++++- examples/secrets/src/main.rs | 44 ++++++++ examples/www/src/main.rs | 22 ++++ interface/src/client.rs | 36 +++++- interface/tests/client.rs | 16 +++ sdk/src/discovery.rs | 20 ++-- sdk/tests/plugins/consumer/imports/probe.json | 103 +++++++++++------- sdk/tests/plugins/consumer/src/main.rs | 17 +++ sdk/tests/plugins/probe/src/main.rs | 26 +++++ sdk/tests/sdk.rs | 8 ++ 15 files changed, 426 insertions(+), 81 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb5c54b..f91e598 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -724,6 +724,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.34" @@ -1156,6 +1165,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "instant" version = "0.1.13" @@ -1264,6 +1293,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "leb128" version = "0.2.7" @@ -1369,6 +1418,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1379,6 +1429,33 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1826,6 +1903,15 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "sausage" version = "0.1.0" @@ -1836,7 +1922,10 @@ dependencies = [ "http", "http-body-util", "include_dir", + "notify", "rusqlite", + "sausage-interface", + "sausage-vfs", "serde", "serde_json", "sha2", @@ -2558,6 +2647,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.2" diff --git a/examples/consumer/imports/notes.json b/examples/consumer/imports/notes.json index 3a8d6fd..e5129ce 100644 --- a/examples/consumer/imports/notes.json +++ b/examples/consumer/imports/notes.json @@ -28,10 +28,6 @@ "count" ], "type": "object" - }, - "Empty": { - "description": "Empty parameters for methods without arguments.", - "type": "object" } } }, @@ -80,10 +76,7 @@ "type": "array" } }, - "summary": "List all notes, oldest first.", - "x-sausage-params": { - "$ref": "#/components/schemas/Empty" - } + "summary": "List all notes, oldest first." } ], "openrpc": "1.2.6", diff --git a/examples/consumer/imports/secrets.json b/examples/consumer/imports/secrets.json index 72c9814..7239d1f 100644 --- a/examples/consumer/imports/secrets.json +++ b/examples/consumer/imports/secrets.json @@ -1,10 +1,6 @@ { "components": { "schemas": { - "Empty": { - "description": "Empty parameters for methods without arguments.", - "type": "object" - }, "GetParams": { "description": "The name of a secret to read.", "properties": { @@ -54,10 +50,7 @@ "type": "null" } }, - "summary": "Open the in-memory vault and publish its endpoint.", - "x-sausage-params": { - "$ref": "#/components/schemas/Empty" - } + "summary": "Open the in-memory vault and publish its endpoint." } ], "openrpc": "1.2.6", diff --git a/examples/consumer/imports/www.json b/examples/consumer/imports/www.json index 3a50913..a0bb983 100644 --- a/examples/consumer/imports/www.json +++ b/examples/consumer/imports/www.json @@ -1,10 +1,6 @@ { "components": { "schemas": { - "Empty": { - "description": "Empty parameters for methods without arguments.", - "type": "object" - }, "StartParams": { "description": "The server configuration.", "properties": { @@ -75,10 +71,7 @@ "type": "integer" } }, - "summary": "Count the HTTP requests this server answered.", - "x-sausage-params": { - "$ref": "#/components/schemas/Empty" - } + "summary": "Count the HTTP requests this server answered." }, { "description": "End this process after the response reaches the client.", @@ -91,10 +84,7 @@ "type": "null" } }, - "summary": "End this process after the response reaches the client.", - "x-sausage-params": { - "$ref": "#/components/schemas/Empty" - } + "summary": "End this process after the response reaches the client." } ] } diff --git a/examples/consumer/src/main.rs b/examples/consumer/src/main.rs index f0f1401..695589f 100644 --- a/examples/consumer/src/main.rs +++ b/examples/consumer/src/main.rs @@ -1,10 +1,19 @@ //! Use typed calls from discovered provider documents in `imports/`. +//! Generated modules own the wire types, so calls need no manual JSON. +//! The detached HTTP service survives this consumer. Its endpoint refers to that service's process-local state. //! Copy fresh documents from `/proc/core/plugins//discover.json` after an interface changes. use sausage_plugin::{Result, rpc, schema}; sausage_plugin::import!("notes"); sausage_plugin::import!("www"); sausage_plugin::import!("secrets"); +/// Demonstrate typed imports without manual JSON messages. struct Consumer; +/// A note for the imported notes plugin. +#[schema] +struct NoteParams { + /// The text to append. + text: String, +} /// The text and HTTP port for this demonstration. #[schema] struct DemoParams { @@ -15,6 +24,10 @@ struct DemoParams { } #[rpc] impl Consumer { + /// Add a note through the generated import and return its count. + async fn note(&self, p: NoteParams) -> Result { + Ok(notes::add(notes::AddParams { text: p.text }).await?.count as usize) + } /// Add a note, start a server, and inspect its control endpoint. async fn demo(&self, p: DemoParams) -> Result { let added = notes::add(notes::AddParams { text: p.text }).await?; @@ -42,6 +55,34 @@ impl Consumer { .await } } +/// Start the SDK transport for this plugin. fn main() { sausage_plugin::run(Consumer) } +/// Tests demonstrate generated params and calls through the test core. +#[cfg(test)] +mod tests { + use super::*; + /// Generated params retain their wire field names. + #[test] + fn generated_params_use_text() { + let value = sausage_plugin::serde_json::to_value(notes::AddParams { + text: "milk".into(), + }) + .unwrap(); + assert_eq!(value, sausage_plugin::serde_json::json!({"text":"milk"})); + } + /// A typed import reaches the notes plugin through the test core. + #[sausage_plugin::test] + async fn imports_notes() { + assert_eq!( + Consumer + .note(NoteParams { + text: "milk".into() + }) + .await + .unwrap(), + 1 + ); + } +} diff --git a/examples/notes/src/main.rs b/examples/notes/src/main.rs index 83afd78..80ed84e 100644 --- a/examples/notes/src/main.rs +++ b/examples/notes/src/main.rs @@ -1,6 +1,9 @@ -//! Persistent notes. Each request reads the shared file instead of process memory. +//! Persistent notes in `/state/notes/notes.json`. +//! Each call has its own process. The add method locks the file before its read-modify-write operation. +//! Atomic replacement lets readers observe a complete list without a lock. use sausage_plugin::serde_json; use sausage_plugin::{Result, lock, rpc, schema, state_dir, write_atomic}; +/// The stateless notes plugin reads its file on each call. struct Notes; /// Parameters for a new note. #[schema] @@ -31,6 +34,7 @@ impl Notes { load() } } +/// Read the list, or return an empty list when the file does not exist. fn load() -> Result> { match std::fs::read(state_dir().join("notes.json")) { Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), @@ -38,6 +42,43 @@ fn load() -> Result> { Err(e) => Err(e.into()), } } +/// Start the SDK transport for notes. fn main() { sausage_plugin::run(Notes) } + +/// Tests run in fresh worlds under the WASI test runner. +#[cfg(test)] +mod tests { + use super::*; + /// The core lock protects two sequential additions and their counts. + #[sausage_plugin::test] + async fn add_appends_and_counts() { + assert_eq!( + Notes + .add(AddParams { + text: "milk".into() + }) + .await + .unwrap() + .count, + 1 + ); + assert_eq!( + Notes + .add(AddParams { + text: "eggs".into() + }) + .await + .unwrap() + .count, + 2 + ); + assert_eq!(Notes.list().unwrap(), ["milk", "eggs"]); + } + /// A fresh test world has no notes file. + #[test] + fn list_is_empty_without_a_file() { + assert!(Notes.list().unwrap().is_empty()); + } +} diff --git a/examples/secrets/src/main.rs b/examples/secrets/src/main.rs index 8324df9..7370442 100644 --- a/examples/secrets/src/main.rs +++ b/examples/secrets/src/main.rs @@ -2,6 +2,7 @@ //! Start `secrets.serve` as a detached boot service. Its memory lasts until process exit. use sausage_plugin::{Ctx, Result, listen, rpc, schema}; use std::{cell::RefCell, collections::BTreeMap, rc::Rc}; +/// The service entry point publishes one process-local vault. struct Secrets; #[rpc(endpoints(vault=Vault))] impl Secrets { @@ -10,8 +11,10 @@ impl Secrets { listen("/run/secrets", Rc::new(Vault::default())).await } } +/// The endpoint owns secrets in memory, not on disk. #[derive(Default)] struct Vault { + /// Named values disappear when this process ends. entries: RefCell>, } /// The name and value of a secret. @@ -41,6 +44,47 @@ impl Vault { Ok(self.entries.borrow().get(&p.name).cloned()) } } +/// Start the SDK transport for the vault service. fn main() { sausage_plugin::run(Secrets) } +/// Tests verify absent keys and process-local storage. +#[cfg(test)] +mod tests { + use super::*; + /// Missing names return null instead of an RPC error. + #[test] + fn absent_secret() { + assert_eq!( + Vault::default() + .get(GetParams { + name: "missing".into() + }) + .unwrap(), + None + ); + } + /// Store a value and retrieve it through the same process-local vault. + #[sausage_plugin::test] + async fn stores_secret() { + let vault = Vault::default(); + vault + .put( + &Ctx::default(), + PutParams { + name: "token".into(), + value: "example".into(), + }, + ) + .unwrap(); + assert_eq!( + vault + .get(GetParams { + name: "token".into() + }) + .unwrap() + .as_deref(), + Some("example") + ); + } +} diff --git a/examples/www/src/main.rs b/examples/www/src/main.rs index fdff6f8..5aa4bdc 100644 --- a/examples/www/src/main.rs +++ b/examples/www/src/main.rs @@ -1,4 +1,6 @@ //! Serve files from `/state/www` and expose a control endpoint at `/self/control`. +//! Start this process detached or as a service so it outlives its caller. +//! Clients read `/proc//control`. A fresh core call cannot inspect this process's counters. use sausage_plugin::{Ctx, Result, background, listen, pid, rpc, schema, state_dir}; use std::{cell::Cell, rc::Rc}; use wstd::{ @@ -6,6 +8,7 @@ use wstd::{ iter::AsyncIterator, net::TcpListener, }; +/// The HTTP plugin starts a server and returns its process ID. struct Www; /// The server configuration. #[schema] @@ -24,7 +27,9 @@ impl Www { Ok(pid()) } } +/// Shared process-local state for the HTTP task and its endpoint. struct Control { + /// The number of answered requests. hits: Cell, } #[rpc] @@ -39,6 +44,7 @@ impl Control { Ok(()) } } +/// Answer HTTP requests with files and count all responses, including missing files. async fn serve(tcp: TcpListener, control: Rc) { let mut incoming = tcp.incoming(); while let Some(Ok(mut stream)) = incoming.next().await { @@ -70,6 +76,22 @@ async fn serve(tcp: TcpListener, control: Rc) { let _ = stream.flush().await; } } +/// Start the SDK transport for the HTTP plugin. fn main() { sausage_plugin::run(Www) } +/// Tests check local counters and the service entry point. +#[cfg(test)] +mod tests { + use super::*; + /// Control reports its shared counter without a core round trip. + #[test] + fn stats_reads_counter() { + assert_eq!(Control { hits: Cell::new(3) }.stats().unwrap(), 3); + } + /// The listener binds an ephemeral port and publishes its control endpoint. + #[sausage_plugin::test] + async fn starts_on_available_port() { + assert_eq!(Www.start(StartParams { port: 0 }).await.unwrap(), pid()); + } +} diff --git a/interface/src/client.rs b/interface/src/client.rs index 3e12e0d..0d43c5c 100644 --- a/interface/src/client.rs +++ b/interface/src/client.rs @@ -56,20 +56,33 @@ pub fn client_rs(name: &str, doc: &Value) -> Result { local_refs(&mut ps); let mut rs = method["result"]["schema"].clone(); local_refs(&mut rs); - let p = space - .add_type(&serde_json::from_value(ps).map_err(|e| e.to_string())?) - .map_err(|e| e.to_string())?; + let empty = method["params"].as_array().is_none_or(Vec::is_empty); + let p = if empty { + None + } else { + Some( + space + .add_type(&serde_json::from_value(ps).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?, + ) + }; let r = space .add_type(&serde_json::from_value(rs).map_err(|e| e.to_string())?) .map_err(|e| e.to_string())?; - let p = space.get_type(&p).map_err(|e| e.to_string())?.ident(); + let p = p + .map(|p| { + space + .get_type(&p) + .map(|p| p.ident()) + .map_err(|e| e.to_string()) + }) + .transpose()?; let r = space.get_type(&r).map_err(|e| e.to_string())?.ident(); let docs = method .get("description") .or_else(|| method.get("summary")) .and_then(Value::as_str) .unwrap_or(""); - let empty = method["params"].as_array().is_none_or(Vec::is_empty); let arg = if empty { quote!() } else { quote!(params:#p) }; let params = if empty { quote!(::sausage_plugin::serde_json::json!({})) @@ -122,6 +135,19 @@ pub fn client_rs(name: &str, doc: &Value) -> Result { } serde_attrs(std::slice::from_mut(item)); } + let uses_error = file + .items + .iter() + .filter(|item| !matches!(item, syn::Item::Mod(m) if m.ident == "error")) + .any(|item| { + quote!(#item) + .to_string() + .contains("error :: ConversionError") + }); + if !uses_error { + file.items + .retain(|item| !matches!(item, syn::Item::Mod(m) if m.ident == "error")); + } let types = &file.items; let mut file = syn::parse2::(quote!(mod #module {#(#types)* #(#functions)* #(#endpoints)*})) diff --git a/interface/tests/client.rs b/interface/tests/client.rs index 73ba768..fc29765 100644 --- a/interface/tests/client.rs +++ b/interface/tests/client.rs @@ -1,4 +1,18 @@ //! Check the shared generator against a real example's discovered document. + +/// String enums retain the conversion support that their parsers need. +#[test] +fn enum_client_keeps_used_error_support() { + let doc = serde_json::json!({ + "components":{"schemas":{"Mode":{"type":"string","enum":["fast","slow"]}}}, + "methods":[{"name":"mode","params":[],"result":{"schema":{"$ref":"#/components/schemas/Mode"}}}] + }); + let code = sausage_interface::client_rs("settings", &doc).unwrap(); + assert!(code.contains("pub mod error")); + assert!(code.contains("error::ConversionError")); + assert!(!code.contains("ModeParams")); + syn::parse_file(&code).unwrap(); +} #[test] fn notes_client_preserves_names_and_docs() { let document = @@ -7,6 +21,8 @@ fn notes_client_preserves_names_and_docs() { assert!(code.contains("pub struct AddParams")); assert!(code.contains("pub struct Added")); assert!(!code.contains("AddedResult")); + assert!(!code.contains("pub struct Empty")); + assert!(!code.contains("pub mod error")); assert!(code.contains("The complete note text")); assert!(code.contains("pub fn add(params: AddParams)")); syn::parse_file(&code).unwrap(); diff --git a/sdk/src/discovery.rs b/sdk/src/discovery.rs index d95dfe9..7353b46 100644 --- a/sdk/src/discovery.rs +++ b/sdk/src/discovery.rs @@ -68,25 +68,31 @@ pub fn method(doc: &mut Value, name: &str, docs: &str) "schema": field, }) }).collect(); - doc["x-sdk-input"] - .as_object_mut() - .unwrap() - .extend(definitions); + if !params.is_empty() { + doc["x-sdk-input"] + .as_object_mut() + .unwrap() + .extend(definitions); + } let mut output = generator(true); let result_schema = serde_json::to_value(output.subschema_for::()).unwrap(); doc["x-sdk-output"] .as_object_mut() .unwrap() .extend(output.take_definitions(true)); - doc["methods"].as_array_mut().unwrap().push(json!({ + let has_params = !params.is_empty(); + let mut method = json!({ "name":name, "summary":docs.split("\n\n").next().unwrap_or_default(), "description":docs, "paramStructure":"by-name", "params":params, "result":{"name":"result","schema":result_schema}, - "x-sausage-params":params_ref, - })); + }); + if has_params { + method["x-sausage-params"] = params_ref; + } + doc["methods"].as_array_mut().unwrap().push(method); } /// Resolve shared schema names after all methods contribute their contracts. diff --git a/sdk/tests/plugins/consumer/imports/probe.json b/sdk/tests/plugins/consumer/imports/probe.json index d920257..848048c 100644 --- a/sdk/tests/plugins/consumer/imports/probe.json +++ b/sdk/tests/plugins/consumer/imports/probe.json @@ -33,10 +33,6 @@ "text" ], "type": "object" - }, - "Empty": { - "description": "Empty parameters for methods without arguments.", - "type": "object" } } }, @@ -56,10 +52,61 @@ "type": "null" } }, - "summary": "Publish a control endpoint until EOF.", - "x-sausage-params": { - "$ref": "#/components/schemas/Empty" - } + "summary": "Publish a control endpoint until EOF." + }, + { + "description": "Forward detached progress through the real core.", + "name": "relay", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "format": "int32", + "type": "integer" + } + }, + "summary": "Forward detached progress through the real core." + }, + { + "description": "Send progress before the final result.", + "name": "progress", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "format": "int32", + "type": "integer" + } + }, + "summary": "Send progress before the final result." + }, + { + "description": "Start a detached service that survives this caller.", + "name": "launch", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "type": "string" + } + }, + "summary": "Start a detached service that survives this caller." + }, + { + "description": "Keep the process alive until the core closes stdin.", + "name": "service", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "type": "string" + } + }, + "summary": "Keep the process alive until the core closes stdin." }, { "description": "Add two numbers.\n\nThe result is their sum.", @@ -136,10 +183,7 @@ "type": "string" } }, - "summary": "Return an internal serialization error.", - "x-sausage-params": { - "$ref": "#/components/schemas/Empty" - } + "summary": "Return an internal serialization error." }, { "description": "Return a deliberate error.", @@ -152,10 +196,7 @@ "type": "string" } }, - "summary": "Return a deliberate error.", - "x-sausage-params": { - "$ref": "#/components/schemas/Empty" - } + "summary": "Return a deliberate error." }, { "description": "Exercise calls, notifications, and advisory locks.", @@ -168,10 +209,7 @@ "type": "string" } }, - "summary": "Exercise calls, notifications, and advisory locks.", - "x-sausage-params": { - "$ref": "#/components/schemas/Empty" - } + "summary": "Exercise calls, notifications, and advisory locks." }, { "description": "Drop an acquisition before the core grants its lock.", @@ -184,10 +222,7 @@ "type": "string" } }, - "summary": "Drop an acquisition before the core grants its lock.", - "x-sausage-params": { - "$ref": "#/components/schemas/Empty" - } + "summary": "Drop an acquisition before the core grants its lock." }, { "description": "Wait until EOF, then run the stop hook.", @@ -200,10 +235,7 @@ "type": "string" } }, - "summary": "Wait until EOF, then run the stop hook.", - "x-sausage-params": { - "$ref": "#/components/schemas/Empty" - } + "summary": "Wait until EOF, then run the stop hook." }, { "description": "Keep the process alive with a background call.", @@ -216,10 +248,7 @@ "type": "string" } }, - "summary": "Keep the process alive with a background call.", - "x-sausage-params": { - "$ref": "#/components/schemas/Empty" - } + "summary": "Keep the process alive with a background call." } ], "openrpc": "1.2.6", @@ -239,10 +268,7 @@ "type": "integer" } }, - "summary": "Report progress and return a stable counter.", - "x-sausage-params": { - "$ref": "#/components/schemas/Empty" - } + "summary": "Report progress and return a stable counter." }, { "description": "End the endpoint process after the response.", @@ -255,10 +281,7 @@ "type": "null" } }, - "summary": "End the endpoint process after the response.", - "x-sausage-params": { - "$ref": "#/components/schemas/Empty" - } + "summary": "End the endpoint process after the response." } ] } diff --git a/sdk/tests/plugins/consumer/src/main.rs b/sdk/tests/plugins/consumer/src/main.rs index afd8c18..61de0c3 100644 --- a/sdk/tests/plugins/consumer/src/main.rs +++ b/sdk/tests/plugins/consumer/src/main.rs @@ -11,6 +11,23 @@ impl Consumer { .on_notify(|name, _| eprintln!("typed progress: {name}")) .await? as i32) } + /// Connect to a world endpoint from the real core. + async fn world_endpoint(&self) -> Result { + let control = probe::control::connect("/state/control").await?; + let received = std::rc::Rc::new(std::cell::Cell::new(false)); + let progress = received.clone(); + let result = control + .stats() + .on_notify(move |name, value| { + progress.set(name == "endpoint-progress" && value["value"] == 7); + }) + .await?; + assert!( + received.get(), + "the endpoint must deliver progress before its result" + ); + Ok(result) + } /// Connect to the provider's endpoint and receive its notification. async fn endpoint(&self) -> Result { let control = probe::control::connect("/endpoint").await?; diff --git a/sdk/tests/plugins/probe/src/main.rs b/sdk/tests/plugins/probe/src/main.rs index 89e2824..6fdc4b7 100644 --- a/sdk/tests/plugins/probe/src/main.rs +++ b/sdk/tests/plugins/probe/src/main.rs @@ -28,6 +28,32 @@ impl Probe { async fn serve(&self) -> Result<()> { sausage_plugin::listen("/endpoint", std::rc::Rc::new(Control)).await } + /// Forward detached progress through the real core. + async fn relay(&self) -> Result { + call("probe.progress", json!({})) + .detach() + .on_notify(move |name, params| { + Ctx::default().notify(name, params).unwrap(); + }) + .await + .map(|value| value.as_i64().unwrap() as i32) + } + /// Send progress before the final result. + fn progress(&self, ctx: &Ctx) -> Result { + ctx.notify("step", json!({"done":true}))?; + Ok(42) + } + /// Start a detached service that survives this caller. + async fn launch(&self) -> Result { + Ok(sausage_plugin::serde_json::from_value( + call("probe.service", json!({})).detach().await?, + )?) + } + /// Keep the process alive until the core closes stdin. + async fn service(&self) -> Result { + sausage_plugin::listen("/state/control", std::rc::Rc::new(Control)).await?; + Ok(sausage_plugin::pid()) + } /// Add two numbers. /// /// The result is their sum. diff --git a/sdk/tests/sdk.rs b/sdk/tests/sdk.rs index b51ae55..afd8aa2 100644 --- a/sdk/tests/sdk.rs +++ b/sdk/tests/sdk.rs @@ -49,6 +49,14 @@ impl Plugin { #[test] fn discovery_and_dispatch() { let doc = Plugin.discover(); + assert!(doc["components"]["schemas"].get("Empty").is_none()); + let choice = doc["methods"] + .as_array() + .unwrap() + .iter() + .find(|m| m["name"] == "choice") + .unwrap(); + assert!(choice.get("x-sausage-params").is_none()); assert!( doc["x-sausage-endpoints"]["control"]["methods"] .as_array()