sdk: readable clients (no Empty wrapper, no unused error module), documented examples with tests

This commit is contained in:
2026-10-23 01:05:19 +01:00
parent edc2d1a63b
commit 0516158883
15 changed files with 426 additions and 81 deletions
+1 -8
View File
@@ -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",
+1 -8
View File
@@ -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",
+2 -12
View File
@@ -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."
}
]
}
+41
View File
@@ -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/<provider>/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<usize> {
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<u64> {
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
);
}
}
+42 -1
View File
@@ -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<Vec<String>> {
match std::fs::read(state_dir().join("notes.json")) {
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
@@ -38,6 +42,43 @@ fn load() -> Result<Vec<String>> {
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());
}
}
+44
View File
@@ -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<BTreeMap<String, String>>,
}
/// 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")
);
}
}
+22
View File
@@ -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/<pid>/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<u64>,
}
#[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<Control>) {
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<Control>) {
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());
}
}