sausage-plugin part 1: run, #[rpc], #[schema], rpc.discover, calls, locks, Ctx, hooks

Checkpoint. Known issues, fixed in part 2: short writes on stdout,
byte-wise stdin reads, Result suffix on all result schemas, lost enum
variant docs, serde errors as invalid params, missing item docs.
This commit is contained in:
2026-10-23 00:05:37 +01:00
parent 4e41061ed4
commit 01712e745a
30 changed files with 1722 additions and 93 deletions
+11
View File
@@ -6,3 +6,14 @@ description = "SDK for Sausage plugins."
[dependencies]
sausage-plugin-macros = { version = "0.1", path = "macros" }
sausage-interface = { path = "../interface" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "1"
futures = "0.3"
[target.'cfg(target_os = "wasi")'.dependencies]
wstd = "0.6"
[dev-dependencies]
trybuild = "1"
+3
View File
@@ -8,3 +8,6 @@ description = "Macros of the Sausage plugin SDK. Use them through `sausage-plugi
proc-macro = true
[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"
+251 -1
View File
@@ -1 +1,251 @@
//! Macros of the Sausage plugin SDK. Use them through `sausage-plugin`.
//! Attribute macros for documented schemas and plugin dispatch.
use proc_macro::TokenStream;
use quote::quote;
use syn::{
Attribute, Fields, FnArg, ImplItem, Item, ItemImpl, ReturnType, Type, parse_macro_input,
spanned::Spanned,
};
fn docs(attrs: &[Attribute]) -> String {
attrs
.iter()
.filter_map(|a| {
if a.path().is_ident("doc")
&& let syn::Meta::NameValue(n) = &a.meta
&& let syn::Expr::Lit(e) = &n.value
&& let syn::Lit::Str(s) = &e.lit
{
return Some(s.value().trim().to_string());
}
None
})
.collect::<Vec<_>>()
.join("\n")
}
fn documented(attrs: &[Attribute], span: proc_macro2::Span) -> syn::Result<()> {
if docs(attrs).is_empty() {
Err(syn::Error::new(span, "a doc comment is required"))
} else {
Ok(())
}
}
fn serde_subset(attrs: &[Attribute]) -> syn::Result<()> {
for a in attrs {
if a.path().is_ident("serde") {
a.parse_nested_meta(|m| {
if !["rename", "rename_all", "default", "skip_serializing_if"]
.iter()
.any(|s| m.path.is_ident(s))
{
return Err(m.error("serde attribute is outside the schema subset"));
}
if m.input.peek(syn::Token![=]) {
let _: syn::Expr = m.value()?.parse()?;
}
Ok(())
})?;
}
}
Ok(())
}
#[proc_macro_attribute]
pub fn schema(_: TokenStream, input: TokenStream) -> TokenStream {
let item = parse_macro_input!(input as Item);
let result = (|| -> syn::Result<proc_macro2::TokenStream> {
let (name, attrs, fields, is_params) = match &item {
Item::Struct(s) => {
if !matches!(s.fields, Fields::Named(_)) {
return Err(syn::Error::new(
s.span(),
"schema structs require named fields",
));
}
(
&s.ident,
&s.attrs,
s.fields.iter().collect::<Vec<_>>(),
true,
)
}
Item::Enum(e) => {
for v in &e.variants {
if !matches!(v.fields, Fields::Unit) {
return Err(syn::Error::new(
v.span(),
"schema enums require unit variants",
));
}
serde_subset(&v.attrs)?;
}
(&e.ident, &e.attrs, vec![], false)
}
_ => {
return Err(syn::Error::new(
item.span(),
"schema requires a struct or unit enum",
));
}
};
documented(attrs, name.span())?;
serde_subset(attrs)?;
let mut types = vec![];
for f in fields {
documented(&f.attrs, f.span())?;
serde_subset(&f.attrs)?;
types.push(&f.ty);
}
let params = if is_params {
quote!(impl ::sausage_plugin::Params for #name {})
} else {
quote!()
};
Ok(quote! {
#[derive(::sausage_plugin::serde::Serialize,::sausage_plugin::serde::Deserialize,::sausage_plugin::schemars::JsonSchema)]
#[serde(crate="::sausage_plugin::serde")]
#[schemars(crate="::sausage_plugin::schemars")]
#item
impl ::sausage_plugin::SchemaType for #name {}
#params
const _: () = { fn check() { fn supported<T: ::sausage_plugin::SchemaType>() {} #(supported::<#types>();)* } };
})
})();
result.unwrap_or_else(syn::Error::into_compile_error).into()
}
#[proc_macro_attribute]
pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
let mut item = parse_macro_input!(input as ItemImpl);
let args = proc_macro2::TokenStream::from(args);
let result = (|| -> syn::Result<proc_macro2::TokenStream> {
let mut grace = None;
let parser = syn::meta::parser(|m| {
if m.path.is_ident("grace_seconds") {
grace = Some(m.value()?.parse::<syn::LitInt>()?);
Ok(())
} else if m.path.is_ident("endpoints") {
let content;
syn::parenthesized!(content in m.input);
let _ = content.parse::<proc_macro2::TokenStream>()?;
Ok(())
} else {
Err(m.error("unknown RPC option"))
}
});
syn::parse::Parser::parse2(parser, args)?;
let ty = &item.self_ty;
let mut arms = vec![];
let mut entries = vec![];
let mut stop = quote!();
for member in &mut item.items {
let ImplItem::Fn(f) = member else { continue };
let retired = f.attrs.iter().any(|a| {
a.path()
.segments
.last()
.is_some_and(|s| s.ident == "on_retired")
});
let stopping = f.attrs.iter().any(|a| {
a.path()
.segments
.last()
.is_some_and(|s| s.ident == "on_stop")
});
f.attrs.retain(|a| {
!a.path()
.segments
.last()
.is_some_and(|s| s.ident == "on_retired" || s.ident == "on_stop")
});
documented(&f.attrs, f.span())?;
if f.sig.inputs.is_empty()
|| !matches!(f.sig.inputs.first(),Some(FnArg::Receiver(r)) if r.reference.is_some() && r.mutability.is_none())
{
return Err(syn::Error::new(f.sig.span(), "RPC methods require &self"));
}
let name = &f.sig.ident;
let mut args = vec![];
let mut param = None;
for arg in f.sig.inputs.iter().skip(1) {
let FnArg::Typed(p) = arg else { unreachable!() };
let is_ctx = matches!(p.ty.as_ref(),Type::Reference(r) if matches!(r.elem.as_ref(),Type::Path(t) if t.path.segments.last().is_some_and(|s|s.ident=="Ctx")));
if is_ctx {
if !args.is_empty() {
return Err(syn::Error::new(p.span(), "ctx must follow &self"));
}
args.push(quote!(ctx));
} else {
if param.is_some() {
return Err(syn::Error::new(
p.span(),
"RPC methods take at most one params struct",
));
}
param = Some(p.ty.as_ref());
args.push(quote!(::sausage_plugin::serde_json::from_value(params)?));
}
}
let await_ = f.sig.asyncness.map(|_| quote!(.await));
if stopping {
if !args.is_empty() {
return Err(syn::Error::new(f.span(), "stop hook takes only &self"));
}
stop = quote!(fn stop(&self)->::std::pin::Pin<Box<dyn ::std::future::Future<Output=()> + '_>> {Box::pin(async move {let _=self.#name() #await_;})});
continue;
}
let wire = if retired {
"rpc.retired".to_string()
} else {
name.to_string()
};
let p = param
.map(|t| quote!(#t))
.unwrap_or(quote!(::sausage_plugin::Empty));
let output = match &f.sig.output {
ReturnType::Type(_, t) => match t.as_ref() {
Type::Path(t)
if t.path.segments.last().is_some_and(|s| s.ident == "Result") =>
{
let syn::PathArguments::AngleBracketed(a) =
&t.path.segments.last().unwrap().arguments
else {
return Err(syn::Error::new(t.span(), "expected Result<T>"));
};
let Some(syn::GenericArgument::Type(t)) = a.args.first() else {
return Err(syn::Error::new(a.span(), "expected Result<T>"));
};
quote!(#t)
}
_ => return Err(syn::Error::new(t.span(), "RPC methods return Result<T>")),
},
_ => return Err(syn::Error::new(f.span(), "RPC methods return Result<T>")),
};
let doc = docs(&f.attrs);
arms.push(quote!(#wire => { fn params_type<T: ::sausage_plugin::Params>() {} params_type::<#p>(); let result=self.#name(#(#args),*) #await_ ?; Ok(::sausage_plugin::serde_json::to_value(result)?) }));
if !retired {
entries.push(quote!(::sausage_plugin::method::<#p,#output>(&mut doc,#wire,#doc);));
}
}
let grace =
grace.map(|n| quote!(doc["x-sausage-grace"]=::sausage_plugin::serde_json::json!(#n);));
Ok(quote! {#item impl ::sausage_plugin::Interface for #ty {
fn discover(&self)->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); #(#entries)* #grace doc}
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"))}})}
#stop
}})
})();
result.unwrap_or_else(syn::Error::into_compile_error).into()
}
#[proc_macro_attribute]
pub fn on_retired(_: TokenStream, input: TokenStream) -> TokenStream {
input
}
#[proc_macro_attribute]
pub fn on_stop(_: TokenStream, input: TokenStream) -> TokenStream {
input
}
#[proc_macro_attribute]
pub fn test(_: TokenStream, input: TokenStream) -> TokenStream {
let mut f = parse_macro_input!(input as syn::ItemFn);
f.sig.asyncness = None;
let body = f.block;
f.block = Box::new(syn::parse_quote!({::sausage_plugin::test_run(async #body)}));
quote!(#[::core::prelude::v1::test] #f).into()
}
+633 -1
View File
@@ -1 +1,633 @@
//! SDK for Sausage plugins.
//! JSON-RPC transport, lifetime, and helpers for single-threaded Sausage plugins.
use futures::{channel::oneshot, future::LocalBoxFuture};
pub use sausage_plugin_macros::{on_retired, on_stop, rpc, schema, test};
use serde_json::{Value, json};
use std::{
cell::RefCell,
collections::HashMap,
future::{Future, IntoFuture},
io::Write,
path::{Path, PathBuf},
};
pub use {schemars, serde, serde_json};
pub type Result<T> = std::result::Result<T, Error>;
/// A JSON-RPC failure.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Error {
pub code: i64,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
impl Error {
pub fn new(code: i64, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
data: None,
}
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for Error {}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Self::new(-32603, e.to_string())
}
}
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Self {
Self::new(-32602, e.to_string())
}
}
/// Types within the supported schema subset.
#[diagnostic::on_unimplemented(message = "this type is outside the Sausage schema subset")]
pub trait SchemaType: schemars::JsonSchema {}
/// Named parameter objects.
#[diagnostic::on_unimplemented(message = "RPC params must be a #[schema] struct with named fields")]
pub trait Params: SchemaType {}
macro_rules! primitive { ($($t:ty),*) => { $(impl SchemaType for $t {})* }; }
primitive!(
String,
bool,
u8,
u16,
u32,
u64,
usize,
i8,
i16,
i32,
i64,
isize,
f32,
f64,
()
);
impl<T: SchemaType> SchemaType for Vec<T> {}
impl<T: SchemaType> SchemaType for Option<T> {}
/// Generated main-interface dispatch.
pub trait Interface: 'static {
fn discover(&self) -> Value;
fn dispatch<'a>(
&'a self,
method: &'a str,
params: Value,
ctx: &'a Ctx,
) -> LocalBoxFuture<'a, Result<Value>>;
fn stop(&self) -> LocalBoxFuture<'_, ()> {
Box::pin(async {})
}
}
/// Context of an incoming call.
#[derive(Default)]
pub struct Ctx {
exit: std::cell::Cell<bool>,
}
impl Ctx {
pub fn notify(&self, name: &str, params: impl serde::Serialize) -> Result<()> {
notify("core.notify", json!({"name":name,"params":params}))
}
pub fn exit_after_response(&self) {
self.exit.set(true);
}
}
type Notify = Box<dyn FnMut(&str, Value)>;
struct Pending {
tx: oneshot::Sender<Result<Value>>,
notify: Option<Notify>,
unlock: Option<std::rc::Rc<Lease>>,
}
struct Lease {
path: String,
held: std::cell::Cell<bool>,
}
impl Drop for Lease {
fn drop(&mut self) {
if self.held.get() {
let _ = notify("core.unlock", json!({"path":self.path}));
}
}
}
#[derive(Default)]
struct State {
active: bool,
stopping: bool,
next: u64,
pending: HashMap<u64, Pending>,
#[cfg(target_os = "wasi")]
tasks: usize,
#[cfg(target_os = "wasi")]
wake: Option<std::task::Waker>,
#[cfg(target_os = "wasi")]
exit: bool,
}
thread_local! { static STATE: RefCell<State> = RefCell::new(State::default()); }
fn send(value: Value) -> Result<()> {
let bytes = format!("\n{value}\n").into_bytes();
let written = std::io::stdout().write(&bytes)?;
if written != bytes.len() {
return Err(Error::new(-32603, "short protocol write"));
}
Ok(())
}
fn notify(method: &str, params: Value) -> Result<()> {
if !STATE.with(|s| s.borrow().active) {
return Err(Error::new(-32002, "no core transport"));
}
send(json!({"jsonrpc":"2.0","method":method,"params":params}))
}
/// An outgoing request builder.
pub struct Call {
method: String,
params: Value,
detach: bool,
notify: Option<Notify>,
unlock: Option<std::rc::Rc<Lease>>,
}
pub fn call(method: impl Into<String>, params: impl serde::Serialize) -> Call {
Call {
method: method.into(),
params: serde_json::to_value(params).expect("serializable params"),
detach: false,
notify: None,
unlock: None,
}
}
impl Call {
pub fn detach(mut self) -> Self {
self.detach = true;
self
}
pub fn on_notify(mut self, f: impl FnMut(&str, Value) + 'static) -> Self {
self.notify = Some(Box::new(f));
self
}
}
impl IntoFuture for Call {
type Output = Result<Value>;
type IntoFuture = LocalBoxFuture<'static, Self::Output>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
if !self.params.is_object() {
return Err(Error::new(-32602, "params must be an object"));
}
let (tx, rx) = oneshot::channel();
let id = STATE.with(|s| {
let mut s = s.borrow_mut();
if !s.active || s.stopping {
return Err(Error::new(
-32002,
"no core transport (or stdin reached EOF)",
));
}
s.next += 1;
let id = s.next;
s.pending.insert(
id,
Pending {
tx,
notify: self.notify,
unlock: self.unlock,
},
);
Ok(id)
})?;
let (method, params) = if self.detach {
(
"core.call".to_string(),
json!({"method":self.method,"params":self.params,"detach":true}),
)
} else {
(self.method, self.params)
};
if let Err(e) = send(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})) {
STATE.with(|s| s.borrow_mut().pending.remove(&id));
return Err(e);
}
rx.await
.unwrap_or_else(|_| Err(Error::new(-32002, "core transport closed")))
})
}
}
/// An advisory lock acquisition.
pub struct Lock {
path: String,
wait: bool,
}
pub fn lock(path: impl AsRef<Path>) -> Lock {
Lock {
path: path.as_ref().to_string_lossy().into_owned(),
wait: true,
}
}
impl Lock {
pub fn try_now(mut self) -> Self {
self.wait = false;
self
}
}
/// Releases its lock on drop, unless kept until process exit.
pub struct Guard {
path: Option<String>,
}
impl Guard {
pub fn keep(mut self) {
self.path.take();
}
pub fn held(&self) -> bool {
self.path.is_some()
}
}
impl Drop for Guard {
fn drop(&mut self) {
if let Some(path) = self.path.take() {
let _ = notify("core.unlock", json!({"path":path}));
}
}
}
impl IntoFuture for Lock {
type Output = Result<Guard>;
type IntoFuture = LocalBoxFuture<'static, Self::Output>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
let lease = std::rc::Rc::new(Lease {
path: self.path.clone(),
held: std::cell::Cell::new(false),
});
let mut c = call("core.lock", json!({"path":self.path,"wait":self.wait}));
c.unlock = Some(lease.clone());
let result = c.await?;
lease.held.set(false);
Ok(Guard {
path: if result["held"] == true {
Some(self.path)
} else {
None
},
})
})
}
}
pub fn pid() -> String {
std::env::var("SAUSAGE_PID").unwrap_or_default()
}
pub fn state_dir() -> PathBuf {
PathBuf::from("/state").join(std::env::var("SAUSAGE_PLUGIN").unwrap_or_default())
}
pub fn write_atomic(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> Result<()> {
let path = path.as_ref();
let temp = path.with_extension(format!("tmp-{}", pid()));
std::fs::write(&temp, bytes)?;
std::fs::rename(temp, path)?;
Ok(())
}
#[cfg(target_os = "wasi")]
pub fn block_on<F: Future>(f: F) -> F::Output {
wstd::runtime::block_on(f)
}
#[cfg(not(target_os = "wasi"))]
pub fn block_on<F: Future>(f: F) -> F::Output {
futures::executor::block_on(f)
}
/// Run an SDK test with a core transport when the WASI runner supplies one.
#[cfg(target_os = "wasi")]
pub fn test_run<F: Future>(f: F) -> F::Output {
block_on(async {
use wstd::io::AsyncRead;
STATE.with(|s| s.borrow_mut().active = true);
let reader = wstd::runtime::spawn(async {
let mut input = wstd::io::stdin();
let mut line = Vec::new();
loop {
let mut byte = [0];
if !matches!(input.read(&mut byte).await, Ok(1)) {
break;
}
if byte[0] == b'\n' {
if let Ok(v) = serde_json::from_slice::<Value>(&line) {
if v["jsonrpc"] == "2.0" {
receive(v);
}
}
line.clear();
} else {
line.push(byte[0]);
}
}
STATE.with(|s| {
let mut s = s.borrow_mut();
s.stopping = true;
s.pending.clear();
});
});
let result = f.await;
drop(reader);
STATE.with(|s| {
let mut s = s.borrow_mut();
s.active = false;
s.pending.clear();
});
result
})
}
#[cfg(not(target_os = "wasi"))]
pub fn test_run<F: Future>(f: F) -> F::Output {
block_on(f)
}
/// Register a task that keeps the process alive.
#[cfg(target_os = "wasi")]
pub fn background(f: impl Future<Output = ()> + 'static) {
STATE.with(|s| s.borrow_mut().tasks += 1);
wstd::runtime::spawn(async move {
f.await;
STATE.with(|s| {
let mut s = s.borrow_mut();
s.tasks -= 1;
if let Some(w) = s.wake.take() {
w.wake();
}
});
})
.detach();
}
#[cfg(not(target_os = "wasi"))]
pub fn background(_f: impl Future<Output = ()> + 'static) {
panic!("background requires the WASI runtime");
}
#[doc(hidden)]
pub fn receive(value: Value) {
if value["method"] == "core.notify" {
if let Some(id) = value["params"]["call"].as_u64() {
let callback = STATE.with(|s| {
s.borrow_mut()
.pending
.get_mut(&id)
.and_then(|p| p.notify.take())
});
if let Some(mut f) = callback {
f(
value["params"]["name"].as_str().unwrap_or_default(),
value["params"]["params"].clone(),
);
STATE.with(|s| {
if let Some(p) = s.borrow_mut().pending.get_mut(&id) {
p.notify = Some(f);
}
});
}
}
return;
}
let pending = value["id"]
.as_u64()
.and_then(|id| STATE.with(|s| s.borrow_mut().pending.remove(&id)));
if let Some(p) = pending {
let result = if value.get("error").is_some() {
serde_json::from_value(value["error"].clone()).map_or_else(|e| Err(e.into()), Err)
} else {
Ok(value["result"].clone())
};
if let Some(lease) = &p.unlock {
lease
.held
.set(result.as_ref().is_ok_and(|v| v["held"] == true));
}
let _ = p.tx.send(result);
} else {
eprintln!("note: response with unknown ID");
}
}
/// Serve the main interface until its work ends or stdin reaches EOF.
#[cfg(target_os = "wasi")]
pub fn run<T: Interface>(value: T) {
block_on(serve(value));
}
#[cfg(not(target_os = "wasi"))]
pub fn run<T: Interface>(_value: T) {
panic!("run requires WASI stdin/stdout");
}
#[cfg(target_os = "wasi")]
async fn serve<T: Interface>(value: T) {
use std::{rc::Rc, task::Poll};
use wstd::io::AsyncRead;
STATE.with(|s| {
*s.borrow_mut() = State {
active: true,
..State::default()
}
});
let value = Rc::new(value);
let mut input = wstd::io::stdin();
let mut line = Vec::new();
let mut started = false;
loop {
let read = async {
loop {
let mut byte = [0];
if input.read(&mut byte).await? == 0 {
return Ok::<_, std::io::Error>(None);
}
if byte[0] == b'\n' {
return Ok(Some(std::mem::take(&mut line)));
}
line.push(byte[0]);
}
};
let idle = futures::future::poll_fn(|cx| {
STATE.with(|s| {
let mut s = s.borrow_mut();
s.wake = Some(cx.waker().clone());
if started && (s.tasks == 0 || s.exit) {
Poll::Ready(())
} else {
Poll::Pending
}
})
});
futures::pin_mut!(read, idle);
let bytes = match futures::future::select(read, idle).await {
futures::future::Either::Left((Ok(Some(b)), _)) => b,
futures::future::Either::Left((_, _)) => {
STATE.with(|s| {
let mut s = s.borrow_mut();
s.stopping = true;
s.pending.clear();
});
value.stop().await;
break;
}
futures::future::Either::Right(_) => break,
};
let Ok(msg) = serde_json::from_slice::<Value>(&bytes) else {
continue;
};
if msg["jsonrpc"] != "2.0" {
continue;
}
if let Some(method) = msg["method"].as_str() {
if method == "core.notify" {
receive(msg);
continue;
}
started = true;
let value = value.clone();
let method = method.to_owned();
background(async move {
let ctx = Ctx::default();
let result = if method == "rpc.discover" {
Ok(value.discover())
} else {
value
.dispatch(
&method,
msg.get("params").cloned().unwrap_or(json!({})),
&ctx,
)
.await
};
if let Some(id) = msg.get("id") {
let response = match result {
Ok(v) => json!({"jsonrpc":"2.0","id":id,"result":v}),
Err(e) => json!({"jsonrpc":"2.0","id":id,"error":e}),
};
let _ = send(response);
}
if ctx.exit.get() {
STATE.with(|s| s.borrow_mut().exit = true);
}
});
} else {
receive(msg);
}
}
STATE.with(|s| {
let mut s = s.borrow_mut();
s.active = false;
s.pending.clear();
});
}
/// Create a shared OpenRPC document.
pub fn document(title: &str, version: &str) -> Value {
sausage_interface::Document::new(title, version).into_value()
}
/// Build OpenRPC entries with separate deserialize and serialize contracts.
pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str) {
fn generator(serialize: bool) -> schemars::SchemaGenerator {
let mut s = schemars::generate::SchemaSettings::draft07();
s.definitions_path = "/components/schemas".into();
if serialize {
s.for_serialize()
} else {
s.for_deserialize()
}
.into_generator()
}
let mut p = generator(false);
let pref = serde_json::to_value(p.subschema_for::<P>()).unwrap();
let mut defs = p.take_definitions(true);
for schema in defs.values_mut() {
normalize_enums(schema);
}
let ps = &defs[P::schema_name().as_ref()];
let params:Vec<_>=ps["properties"].as_object().into_iter().flatten().map(|(name,s)| json!({"name":name,"description":s.get("description").cloned().unwrap_or(Value::Null),"required":ps["required"].as_array().is_some_and(|a|a.contains(&json!(name))),"schema":s})).collect();
doc["components"]["schemas"]
.as_object_mut()
.unwrap()
.extend(defs);
let mut r = generator(true);
let mut rs = serde_json::to_value(r.subschema_for::<R>()).unwrap();
fn result_refs(value: &mut Value) {
match value {
Value::Object(map) => {
if let Some(Value::String(reference)) = map.get_mut("$ref")
&& reference.starts_with("#/components/schemas/")
{
reference.push_str("Result");
}
for child in map.values_mut() {
result_refs(child);
}
}
Value::Array(values) => {
for child in values {
result_refs(child);
}
}
_ => {}
}
}
result_refs(&mut rs);
for (name, mut schema) in r.take_definitions(true) {
normalize_enums(&mut schema);
result_refs(&mut schema);
doc["components"]["schemas"]
.as_object_mut()
.unwrap()
.insert(format!("{name}Result"), schema);
}
doc["methods"].as_array_mut().unwrap().push(json!({"name":name,"summary":docs.split("\n\n").next().unwrap_or_default(),"description":docs,"paramStructure":"by-name","params":params,"result":{"name":"result","schema":rs},"x-sausage-params":pref}));
}
// Schemars represents documented unit variants as oneOf/const. Export string enums instead.
fn normalize_enums(value: &mut Value) {
match value {
Value::Object(map) => {
if let Some(variants) = map.get("oneOf").and_then(Value::as_array) {
let values: Option<Vec<Value>> = variants
.iter()
.map(|v| {
if let Some(s) = v.get("const").filter(|v| v.is_string()) {
Some(vec![s.clone()])
} else {
v.get("enum")
.and_then(Value::as_array)
.filter(|a| a.iter().all(Value::is_string))
.cloned()
}
})
.collect::<Option<Vec<Vec<Value>>>>()
.map(|v| v.into_iter().flatten().collect());
if let Some(values) = values {
map.remove("oneOf");
map.insert("type".into(), json!("string"));
map.insert("enum".into(), json!(values));
}
}
for child in map.values_mut() {
normalize_enums(child);
}
}
Value::Array(values) => {
for child in values {
normalize_enums(child);
}
}
_ => {}
}
}
/// Empty parameters for methods without arguments.
#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct Empty {}
impl SchemaType for Empty {}
impl Params for Empty {}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "sdk-probe"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
sausage-plugin = { path = "../../.." }
+114
View File
@@ -0,0 +1,114 @@
//! Probe of SDK transport and lifecycle behavior.
use sausage_plugin::serde_json::{Value, json};
use sausage_plugin::{Ctx, Result, call, lock, rpc, schema};
struct Probe;
/// The values to add.
#[schema]
struct Add {
/// The first number.
a: i32,
/// The second number.
b: i32,
}
/// The retired version.
#[schema]
struct Retired {
/// The previous hash.
previous: String,
}
#[rpc(grace_seconds = 12)]
impl Probe {
/// Add two numbers.
///
/// The result is their sum.
fn add(&self, p: Add) -> Result<i32> {
Ok(p.a + p.b)
}
/// Return a deliberate error.
fn fail(&self) -> Result<String> {
Err(sausage_plugin::Error::new(-32010, "deliberate"))
}
/// Exercise calls, notifications, and advisory locks.
async fn exercise(&self, ctx: &Ctx) -> Result<String> {
let a =
call("other.first", json!({})).on_notify(|name, _| eprintln!("notification: {name}"));
let b = call("other.second", json!({})).detach();
let (a, b) = join(a, b).await;
a?;
b?;
ctx.notify("progress", json!({"done":true}))?;
{
let guard = lock("/state/probe/file").await?;
assert!(guard.held());
}
assert!(!lock("/state/probe/file").try_now().await?.held());
Ok("done".into())
}
/// Drop an acquisition before the core grants its lock.
async fn late(&self) -> Result<String> {
{
let mut future = std::pin::pin!(std::future::IntoFuture::into_future(lock("/late")));
std::future::poll_fn(|cx| {
assert!(future.as_mut().poll(cx).is_pending());
std::task::Poll::Ready(())
})
.await;
}
call("other.barrier", json!({})).await?;
lock("/kept").await?.keep();
Ok("late done".into())
}
/// Mark a retired version.
#[sausage_plugin::on_retired]
fn retired(&self, p: Retired) -> Result<String> {
Ok(p.previous)
}
/// Wait until EOF, then run the stop hook.
async fn wait(&self) -> Result<String> {
call("other.wait", json!({})).await?;
Ok("done".into())
}
/// Keep the process alive with a background call.
fn start(&self) -> Result<String> {
sausage_plugin::background(async {
let _ = call("other.background", json!({})).await;
});
Ok("started".into())
}
/// Save an EOF marker.
#[sausage_plugin::on_stop]
async fn stop(&self) {
std::fs::write("/stopped", b"yes").unwrap();
Ctx::default().notify("stopped", json!({})).unwrap();
assert!(call("other.no", json!({})).await.is_err());
}
}
async fn join(
a: impl std::future::IntoFuture<Output = Result<Value>>,
b: impl std::future::IntoFuture<Output = Result<Value>>,
) -> (Result<Value>, Result<Value>) {
let mut a = std::pin::pin!(a.into_future());
let mut b = std::pin::pin!(b.into_future());
let (mut av, mut bv) = (None, None);
std::future::poll_fn(move |cx| {
if av.is_none() {
if let std::task::Poll::Ready(v) = a.as_mut().poll(cx) {
av = Some(v);
}
}
if bv.is_none() {
if let std::task::Poll::Ready(v) = b.as_mut().poll(cx) {
bv = Some(v);
}
}
if av.is_some() && bv.is_some() {
std::task::Poll::Ready((av.take().unwrap(), bv.take().unwrap()))
} else {
std::task::Poll::Pending
}
})
.await
}
fn main() {
sausage_plugin::run(Probe)
}
+101
View File
@@ -0,0 +1,101 @@
//! Native checks for generated schemas, dispatch, and missing core transports.
use sausage_plugin::serde_json::json;
use sausage_plugin::{Ctx, Interface, Result, rpc, schema};
/// The addition parameters.
#[schema]
struct Add {
/// The first integer.
a: i32,
/// The second integer.
b: i32,
}
/// A type with different serialize and deserialize contracts.
#[schema]
struct Defaulted {
/// A value which the provider defaults.
#[serde(default)]
value: String,
}
/// A documented string enum.
#[schema]
enum Choice {
/// The first choice.
First,
/// The second choice.
Second,
}
struct Plugin;
#[rpc]
impl Plugin {
/// Choose the first value.
fn choice(&self) -> Result<Choice> {
Ok(Choice::First)
}
/// Round-trip the defaulted object.
fn defaults(&self, p: Defaulted) -> Result<Defaulted> {
Ok(p)
}
/// Add integers.
///
/// Return their sum.
fn add(&self, p: Add) -> Result<i32> {
Ok(p.a + p.b)
}
}
#[test]
fn discovery_and_dispatch() {
let doc = Plugin.discover();
assert!(
doc["components"]["schemas"]["Defaulted"]
.get("required")
.is_none()
);
assert_eq!(
doc["components"]["schemas"]["DefaultedResult"]["required"],
json!(["value"])
);
assert_eq!(
doc["components"]["schemas"]["ChoiceResult"]["enum"],
json!(["First", "Second"])
);
let add = doc["methods"]
.as_array()
.unwrap()
.iter()
.find(|m| m["name"] == "add")
.unwrap();
assert_eq!(add["summary"], "Add integers.");
assert_eq!(
doc["components"]["schemas"]["Add"]["description"],
"The addition parameters."
);
assert_eq!(add["params"][0]["description"], "The first integer.");
eprintln!("DISCOVER={doc}");
assert_eq!(
sausage_plugin::block_on(Plugin.dispatch("add", json!({"a":2,"b":3}), &Ctx::default()))
.unwrap(),
5
);
assert!(sausage_plugin::block_on(Plugin.dispatch("add", json!({}), &Ctx::default())).is_err());
}
#[sausage_plugin::test]
async fn missing_core_fails_without_wait() {
assert!(
Ctx::default()
.notify("progress", json!({}))
.unwrap_err()
.message
.contains("no core transport")
);
assert!(
sausage_plugin::call("core.lock", json!({}))
.await
.unwrap_err()
.message
.contains("no core transport")
);
}
#[test]
fn compile_fail() {
trybuild::TestCases::new().compile_fail("tests/ui/*.rs");
}
+8
View File
@@ -0,0 +1,8 @@
//! Reject data-carrying enums.
/// An invalid enum.
#[sausage_plugin::schema]
enum Bad {
/// A payload variant.
Value(String),
}
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: schema enums require unit variants
--> tests/ui/enum.rs:5:5
|
5 | /// A payload variant.
| ^^^^^^^^^^^^^^^^^^^^^^
+5
View File
@@ -0,0 +1,5 @@
//! Require field documentation.
/// A documented type.
#[sausage_plugin::schema]
struct Bad { value: String }
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: a doc comment is required
--> tests/ui/field_docs.rs:4:14
|
4 | struct Bad { value: String }
| ^^^^^
+5
View File
@@ -0,0 +1,5 @@
//! Require method documentation.
struct Bad;
#[sausage_plugin::rpc]
impl Bad { fn bad(&self)->sausage_plugin::Result<()> {Ok(())} }
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: a doc comment is required
--> tests/ui/method_docs.rs:4:12
|
4 | impl Bad { fn bad(&self)->sausage_plugin::Result<()> {Ok(())} }
| ^^
+4
View File
@@ -0,0 +1,4 @@
//! Reject undocumented schemas.
#[sausage_plugin::schema]
struct Bad { value: String }
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: a doc comment is required
--> tests/ui/missing_docs.rs:3:8
|
3 | struct Bad { value: String }
| ^^^
+8
View File
@@ -0,0 +1,8 @@
//! Reject primitive params.
struct Bad;
#[sausage_plugin::rpc]
impl Bad {
/// Invalid params.
fn bad(&self, value:String)->sausage_plugin::Result<String> {Ok(value)}
}
fn main() {}
+34
View File
@@ -0,0 +1,34 @@
error[E0277]: RPC params must be a #[schema] struct with named fields
--> tests/ui/params.rs:6:25
|
6 | fn bad(&self, value:String)->sausage_plugin::Result<String> {Ok(value)}
| ^^^^^^ the trait `Params` is not implemented for `std::string::String`
|
help: the trait `Params` is implemented for `sausage_plugin::Empty`
--> src/lib.rs
|
| impl Params for Empty {}
| ^^^^^^^^^^^^^^^^^^^^^
note: required by a bound in `method`
--> src/lib.rs
|
| pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str) {
| ^^^^^^ required by this bound in `method`
error[E0277]: RPC params must be a #[schema] struct with named fields
--> tests/ui/params.rs:6:25
|
6 | fn bad(&self, value:String)->sausage_plugin::Result<String> {Ok(value)}
| ^^^^^^ the trait `Params` is not implemented for `std::string::String`
|
help: the trait `Params` is implemented for `sausage_plugin::Empty`
--> src/lib.rs
|
| impl Params for Empty {}
| ^^^^^^^^^^^^^^^^^^^^^
note: required by a bound in `params_type`
--> tests/ui/params.rs:3:1
|
3 | #[sausage_plugin::rpc]
| ^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `params_type`
= note: this error originates in the attribute macro `sausage_plugin::rpc` (in Nightly builds, run with -Z macro-backtrace for more info)
+9
View File
@@ -0,0 +1,9 @@
//! Reject serde shapes outside the subset.
/// A schema with flatten.
#[sausage_plugin::schema]
struct Bad {
/// A flattened value.
#[serde(flatten)]
value: String,
}
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: serde attribute is outside the schema subset
--> tests/ui/serde.rs:6:13
|
6 | #[serde(flatten)]
| ^^^^^^^
+8
View File
@@ -0,0 +1,8 @@
//! Reject invalid receiver signatures.
struct Bad;
#[sausage_plugin::rpc]
impl Bad {
/// An invalid receiver.
fn bad(&mut self) -> sausage_plugin::Result<()> {Ok(())}
}
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: RPC methods require &self
--> tests/ui/signature.rs:6:5
|
6 | fn bad(&mut self) -> sausage_plugin::Result<()> {Ok(())}
| ^^
+8
View File
@@ -0,0 +1,8 @@
//! Reject unsupported field types.
/// An invalid schema.
#[sausage_plugin::schema]
struct Bad {
/// An unsupported dictionary.
value: std::collections::HashMap<String,String>,
}
fn main() {}
+22
View File
@@ -0,0 +1,22 @@
error[E0277]: this type is outside the Sausage schema subset
--> tests/ui/subset.rs:6:12
|
6 | value: std::collections::HashMap<String,String>,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `SchemaType` is not implemented for `HashMap<std::string::String, std::string::String>`
|
= help: the following other types implement trait `SchemaType`:
()
Bad
Vec<T>
bool
f32
f64
i16
i32
and $N others
note: required by a bound in `supported`
--> tests/ui/subset.rs:3:1
|
3 | #[sausage_plugin::schema]
| ^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `supported`
= note: this error originates in the attribute macro `sausage_plugin::schema` (in Nightly builds, run with -Z macro-backtrace for more info)
+5
View File
@@ -0,0 +1,5 @@
//! Reject tuple params.
/// A tuple is not a named object.
#[sausage_plugin::schema]
struct Bad(String);
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
error: schema structs require named fields
--> tests/ui/tuple.rs:2:1
|
2 | /// A tuple is not a named object.
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+147
View File
@@ -0,0 +1,147 @@
//! End-to-end protocol checks against a WASI plugin under Wasmtime.
use sausage_plugin::serde_json::{Value, json};
use std::{
io::{BufRead, BufReader, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
};
struct Process {
child: std::process::Child,
input: Option<std::process::ChildStdin>,
output: BufReader<std::process::ChildStdout>,
}
impl Process {
fn send(&mut self, v: Value) {
writeln!(self.input.as_mut().unwrap(), "{v}").unwrap();
}
fn read(&mut self) -> Value {
loop {
let mut s = String::new();
assert_ne!(self.output.read_line(&mut s).unwrap(), 0, "unexpected EOF");
if let Ok(v) = sausage_plugin::serde_json::from_str::<Value>(&s)
&& v["jsonrpc"] == "2.0"
{
return v;
}
}
}
fn request(&mut self, method: &str, params: Value) {
self.send(json!({"jsonrpc":"2.0","id":"probe-1","method":method,"params":params}));
}
fn answer(&mut self, r: &Value, result: Value) {
self.send(json!({"jsonrpc":"2.0","id":r["id"],"result":result}));
}
}
fn start(wasm: &Path, dir: &Path) -> Process {
let mut child = Command::new("wasmtime")
.args(["run", "--dir"])
.arg(format!("{}::/", dir.display()))
.arg(wasm)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let input = child.stdin.take();
let output = BufReader::new(child.stdout.take().unwrap());
Process {
child,
input,
output,
}
}
#[test]
fn protocol() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let target = root.join("../target/sdk/probe");
assert!(
Command::new("cargo")
.env("CARGO_TARGET_DIR", &target)
.args(["build", "--offline", "--manifest-path"])
.arg(root.join("tests/plugins/probe/Cargo.toml"))
.args(["--target", "wasm32-wasip2"])
.status()
.unwrap()
.success()
);
let wasm = target.join("wasm32-wasip2/debug/sdk-probe.wasm");
let dir = std::env::temp_dir().join(format!("sausage-sdk-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut p = start(&wasm, &dir);
p.request("rpc.discover", json!({}));
let d = p.read();
assert_eq!(d["result"]["x-sausage-grace"], 12);
assert_eq!(
d["result"]["methods"][0]["description"],
"Add two numbers.\n\nThe result is their sum."
);
assert!(p.child.wait().unwrap().success());
let mut p = start(&wasm, &dir);
p.request("add", json!({"a":2,"b":4}));
assert_eq!(p.read()["result"], 6);
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());
let mut p = start(&wasm, &dir);
p.request("exercise", json!({}));
let a = p.read();
let b = p.read();
assert_eq!(a["method"], "other.first");
assert_eq!(b["method"], "core.call");
assert_eq!(b["params"]["detach"], true);
p.send(json!({"jsonrpc":"2.0","id":9876,"result":{}}));
p.send(json!({"jsonrpc":"2.0","method":"core.notify","params":{"call":a["id"],"name":"tick","params":{}}}));
p.answer(&b, json!({}));
p.answer(&a, json!({}));
assert_eq!(p.read()["method"], "core.notify");
let grant = p.read();
assert_eq!(grant["method"], "core.lock");
p.answer(&grant, json!({"held":true}));
assert_eq!(p.read()["method"], "core.unlock");
let attempt = p.read();
assert_eq!(attempt["params"]["wait"], false);
p.answer(&attempt, json!({"held":false}));
assert_eq!(p.read()["result"], "done");
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("notification: tick"));
let mut p = start(&wasm, &dir);
p.request("rpc.retired", json!({"previous":"hash"}));
assert_eq!(p.read()["result"], "hash");
assert!(p.child.wait().unwrap().success());
let mut p = start(&wasm, &dir);
p.request("late", json!({}));
let grant = p.read();
let barrier = p.read();
p.answer(&grant, json!({"held":true}));
assert_eq!(p.read()["method"], "core.unlock");
p.answer(&barrier, json!({}));
let kept = p.read();
p.answer(&kept, json!({"held":true}));
assert_eq!(p.read()["result"], "late done");
assert!(p.child.wait().unwrap().success());
let mut p = start(&wasm, &dir);
p.request("wait", json!({}));
assert_eq!(p.read()["method"], "other.wait");
drop(p.input.take());
assert_eq!(p.read()["params"]["name"], "stopped");
assert!(p.child.wait().unwrap().success());
assert_eq!(std::fs::read(dir.join("stopped")).unwrap(), b"yes");
let mut p = start(&wasm, &dir);
p.request("start", json!({}));
let first = p.read();
let second = p.read();
let outgoing = if first["method"] == "other.background" {
first
} else {
second
};
assert_eq!(outgoing["method"], "other.background");
p.answer(&outgoing, json!({}));
assert!(p.child.wait().unwrap().success());
std::fs::remove_dir_all(dir).unwrap();
}