diff --git a/.gitignore b/.gitignore index 192c6c5..2d0690a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ -/target +target/ /.cargo-home /world /result + +# Lock files of test and example crates: they resolve against the workspace lock. +**/tests/**/Cargo.lock +/examples/*/Cargo.lock diff --git a/Cargo.lock b/Cargo.lock index ae216f5..3fa281a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.92" @@ -205,6 +211,18 @@ dependencies = [ "winx", ] +[[package]] +name = "cap-std" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + [[package]] name = "cc" version = "1.8.0" @@ -623,6 +641,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -673,15 +712,6 @@ 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" @@ -690,6 +720,7 @@ checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -712,12 +743,49 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "futures-sink" version = "0.3.34" @@ -739,6 +807,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -805,6 +874,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "h2" version = "0.4.20" @@ -829,6 +904,9 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] [[package]] name = "hashbrown" @@ -841,6 +919,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -1056,23 +1143,12 @@ dependencies = [ ] [[package]] -name = "inotify" -version = "0.11.5" +name = "instant" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" 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", + "cfg-if", ] [[package]] @@ -1165,26 +1241,6 @@ 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" @@ -1218,6 +1274,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1279,7 +1346,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", - "log", "wasi", "windows-sys 0.61.2", ] @@ -1290,33 +1356,6 @@ 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" @@ -1350,6 +1389,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1625,6 +1670,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.21", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -1713,15 +1783,6 @@ 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" @@ -1732,11 +1793,13 @@ dependencies = [ "http", "http-body-util", "include_dir", - "notify", + "rusqlite", "serde", "serde_json", + "sha2", "tokio", "toml_edit", + "uuid", "wasmtime", "wasmtime-wasi", "wasmtime-wasi-http", @@ -1756,16 +1819,39 @@ dependencies = [ name = "sausage-plugin" version = "0.1.0" dependencies = [ + "futures", + "sausage-interface", "sausage-plugin-macros", + "schemars", + "serde", + "serde_json", + "trybuild", + "wstd", ] [[package]] name = "sausage-plugin-macros" version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "sausage-vfs" version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "cap-std", + "proptest", + "rusqlite", + "tempfile", + "tokio", + "wasmtime", + "wasmtime-wasi", +] [[package]] name = "schemars" @@ -1929,6 +2015,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1986,13 +2084,19 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "target-tuple" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876fef147edbcbddc8ac5cbbba92c7b86519e314e86638596c09673b2ed01e7f" + [[package]] name = "tempfile" version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "fastrand", + "fastrand 2.5.0", "getrandom 0.4.3", "once_cell", "rustix", @@ -2199,6 +2303,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62db9c92d704393fbf2132041720cc80b689f2d3f28521015c2ac866223c11b8" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-tuple", + "termcolor", + "toml", +] + [[package]] name = "typenum" version = "1.20.1" @@ -2259,10 +2378,17 @@ version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2279,14 +2405,10 @@ dependencies = [ ] [[package]] -name = "walkdir" -version = "2.5.0" +name = "waker-fn" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" [[package]] name = "want" @@ -2303,6 +2425,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -3033,6 +3164,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags", +] + [[package]] name = "wit-component" version = "0.258.3" @@ -3089,6 +3229,38 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "wstd" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b52936db10a79bb724dadd1c2c3aac958e8229dcb1f1c7f2b7044ca9fc6a3a" +dependencies = [ + "anyhow", + "async-task", + "bytes", + "futures-lite", + "http", + "http-body", + "http-body-util", + "itoa", + "pin-project-lite", + "serde", + "serde_json", + "slab", + "wasip2", + "wstd-macro", +] + +[[package]] +name = "wstd-macro" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153db9b65508bf6c2efe26617169840c03671ba5719a35868db7682a5261f7d4" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/interface/src/document.rs b/interface/src/document.rs new file mode 100644 index 0000000..7efc887 --- /dev/null +++ b/interface/src/document.rs @@ -0,0 +1,35 @@ +//! Shared access to OpenRPC documents and their Sausage extensions. +use serde_json::{Value, json}; + +/// An OpenRPC document, including JSON Schema and extension fields. +#[derive(Clone, Debug, PartialEq)] +pub struct Document(pub Value); + +impl Document { + /// Create an empty main interface. + pub fn new(title: &str, version: &str) -> Self { + Self( + json!({"openrpc":"1.2.6", "info":{"title":title,"version":version}, + "methods":[], "components":{"schemas":{}}, + "x-sausage-endpoints":{}, "x-sausage-imports":{}}), + ) + } + + /// Read the method descriptions. + pub fn methods(&self) -> &[Value] { + self.0["methods"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + } + + /// Read a named schema. + pub fn schema(&self, name: &str) -> Option<&Value> { + self.0.get("components")?.get("schemas")?.get(name) + } + + /// Consume the document as a JSON value. + pub fn into_value(self) -> Value { + self.0 + } +} diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 23832e3..0b93d27 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -2,3 +2,5 @@ //! (`compat`), and the generator of client code for `import!`. pub mod compat; +pub mod document; +pub use document::Document; diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index 2cde9ab..630895e 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -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" diff --git a/sdk/macros/Cargo.toml b/sdk/macros/Cargo.toml index 1567106..629383e 100644 --- a/sdk/macros/Cargo.toml +++ b/sdk/macros/Cargo.toml @@ -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" diff --git a/sdk/macros/src/lib.rs b/sdk/macros/src/lib.rs index 1fa50a5..c8e03f4 100644 --- a/sdk/macros/src/lib.rs +++ b/sdk/macros/src/lib.rs @@ -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::>() + .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 { + 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::>(), + 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() {} #(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 { + let mut grace = None; + let parser = syn::meta::parser(|m| { + if m.path.is_ident("grace_seconds") { + grace = Some(m.value()?.parse::()?); + Ok(()) + } else if m.path.is_ident("endpoints") { + let content; + syn::parenthesized!(content in m.input); + let _ = content.parse::()?; + 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::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")); + }; + let Some(syn::GenericArgument::Type(t)) = a.args.first() else { + return Err(syn::Error::new(a.span(), "expected Result")); + }; + quote!(#t) + } + _ => return Err(syn::Error::new(t.span(), "RPC methods return Result")), + }, + _ => return Err(syn::Error::new(f.span(), "RPC methods return Result")), + }; + let doc = docs(&f.attrs); + arms.push(quote!(#wire => { fn params_type() {} 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>+'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() +} diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs index eccbf7b..10cf8bd 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -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 = std::result::Result; + +/// 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, +} +impl Error { + pub fn new(code: i64, message: impl Into) -> 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 for Error { + fn from(e: std::io::Error) -> Self { + Self::new(-32603, e.to_string()) + } +} +impl From 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 SchemaType for Vec {} +impl SchemaType for Option {} + +/// 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>; + fn stop(&self) -> LocalBoxFuture<'_, ()> { + Box::pin(async {}) + } +} + +/// Context of an incoming call. +#[derive(Default)] +pub struct Ctx { + exit: std::cell::Cell, +} +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; +struct Pending { + tx: oneshot::Sender>, + notify: Option, + unlock: Option>, +} +struct Lease { + path: String, + held: std::cell::Cell, +} +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, + #[cfg(target_os = "wasi")] + tasks: usize, + #[cfg(target_os = "wasi")] + wake: Option, + #[cfg(target_os = "wasi")] + exit: bool, +} +thread_local! { static STATE: RefCell = 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, + unlock: Option>, +} +pub fn call(method: impl Into, 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; + 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) -> 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, +} +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; + 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, 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: F) -> F::Output { + wstd::runtime::block_on(f) +} +#[cfg(not(target_os = "wasi"))] +pub fn block_on(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: 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::(&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: F) -> F::Output { + block_on(f) +} + +/// Register a task that keeps the process alive. +#[cfg(target_os = "wasi")] +pub fn background(f: impl Future + '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 + '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(value: T) { + block_on(serve(value)); +} +#[cfg(not(target_os = "wasi"))] +pub fn run(_value: T) { + panic!("run requires WASI stdin/stdout"); +} + +#[cfg(target_os = "wasi")] +async fn serve(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::(&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(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::

()).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::()).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> = 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::>>>() + .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 {} diff --git a/sdk/tests/plugins/probe/Cargo.toml b/sdk/tests/plugins/probe/Cargo.toml new file mode 100644 index 0000000..df95a6c --- /dev/null +++ b/sdk/tests/plugins/probe/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "sdk-probe" +version = "0.1.0" +edition = "2024" +[workspace] +[dependencies] +sausage-plugin = { path = "../../.." } diff --git a/sdk/tests/plugins/probe/src/main.rs b/sdk/tests/plugins/probe/src/main.rs new file mode 100644 index 0000000..668f281 --- /dev/null +++ b/sdk/tests/plugins/probe/src/main.rs @@ -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 { + Ok(p.a + p.b) + } + /// Return a deliberate error. + fn fail(&self) -> Result { + Err(sausage_plugin::Error::new(-32010, "deliberate")) + } + /// Exercise calls, notifications, and advisory locks. + async fn exercise(&self, ctx: &Ctx) -> Result { + 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 { + { + 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 { + Ok(p.previous) + } + /// Wait until EOF, then run the stop hook. + async fn wait(&self) -> Result { + call("other.wait", json!({})).await?; + Ok("done".into()) + } + /// Keep the process alive with a background call. + fn start(&self) -> Result { + 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>, + b: impl std::future::IntoFuture>, +) -> (Result, Result) { + 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) +} diff --git a/sdk/tests/sdk.rs b/sdk/tests/sdk.rs new file mode 100644 index 0000000..0b4952e --- /dev/null +++ b/sdk/tests/sdk.rs @@ -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 { + Ok(Choice::First) + } + /// Round-trip the defaulted object. + fn defaults(&self, p: Defaulted) -> Result { + Ok(p) + } + /// Add integers. + /// + /// Return their sum. + fn add(&self, p: Add) -> Result { + 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"); +} diff --git a/sdk/tests/ui/enum.rs b/sdk/tests/ui/enum.rs new file mode 100644 index 0000000..d7f2c01 --- /dev/null +++ b/sdk/tests/ui/enum.rs @@ -0,0 +1,8 @@ +//! Reject data-carrying enums. +/// An invalid enum. +#[sausage_plugin::schema] +enum Bad { + /// A payload variant. + Value(String), +} +fn main() {} diff --git a/sdk/tests/ui/enum.stderr b/sdk/tests/ui/enum.stderr new file mode 100644 index 0000000..3054232 --- /dev/null +++ b/sdk/tests/ui/enum.stderr @@ -0,0 +1,5 @@ +error: schema enums require unit variants + --> tests/ui/enum.rs:5:5 + | +5 | /// A payload variant. + | ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/sdk/tests/ui/field_docs.rs b/sdk/tests/ui/field_docs.rs new file mode 100644 index 0000000..694d02d --- /dev/null +++ b/sdk/tests/ui/field_docs.rs @@ -0,0 +1,5 @@ +//! Require field documentation. +/// A documented type. +#[sausage_plugin::schema] +struct Bad { value: String } +fn main() {} diff --git a/sdk/tests/ui/field_docs.stderr b/sdk/tests/ui/field_docs.stderr new file mode 100644 index 0000000..b2614b0 --- /dev/null +++ b/sdk/tests/ui/field_docs.stderr @@ -0,0 +1,5 @@ +error: a doc comment is required + --> tests/ui/field_docs.rs:4:14 + | +4 | struct Bad { value: String } + | ^^^^^ diff --git a/sdk/tests/ui/method_docs.rs b/sdk/tests/ui/method_docs.rs new file mode 100644 index 0000000..74987c1 --- /dev/null +++ b/sdk/tests/ui/method_docs.rs @@ -0,0 +1,5 @@ +//! Require method documentation. +struct Bad; +#[sausage_plugin::rpc] +impl Bad { fn bad(&self)->sausage_plugin::Result<()> {Ok(())} } +fn main() {} diff --git a/sdk/tests/ui/method_docs.stderr b/sdk/tests/ui/method_docs.stderr new file mode 100644 index 0000000..9087506 --- /dev/null +++ b/sdk/tests/ui/method_docs.stderr @@ -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(())} } + | ^^ diff --git a/sdk/tests/ui/missing_docs.rs b/sdk/tests/ui/missing_docs.rs new file mode 100644 index 0000000..84033f3 --- /dev/null +++ b/sdk/tests/ui/missing_docs.rs @@ -0,0 +1,4 @@ +//! Reject undocumented schemas. +#[sausage_plugin::schema] +struct Bad { value: String } +fn main() {} diff --git a/sdk/tests/ui/missing_docs.stderr b/sdk/tests/ui/missing_docs.stderr new file mode 100644 index 0000000..0815c52 --- /dev/null +++ b/sdk/tests/ui/missing_docs.stderr @@ -0,0 +1,5 @@ +error: a doc comment is required + --> tests/ui/missing_docs.rs:3:8 + | +3 | struct Bad { value: String } + | ^^^ diff --git a/sdk/tests/ui/params.rs b/sdk/tests/ui/params.rs new file mode 100644 index 0000000..d8a766e --- /dev/null +++ b/sdk/tests/ui/params.rs @@ -0,0 +1,8 @@ +//! Reject primitive params. +struct Bad; +#[sausage_plugin::rpc] +impl Bad { + /// Invalid params. + fn bad(&self, value:String)->sausage_plugin::Result {Ok(value)} +} +fn main() {} diff --git a/sdk/tests/ui/params.stderr b/sdk/tests/ui/params.stderr new file mode 100644 index 0000000..24ff651 --- /dev/null +++ b/sdk/tests/ui/params.stderr @@ -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 {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(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 {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) diff --git a/sdk/tests/ui/serde.rs b/sdk/tests/ui/serde.rs new file mode 100644 index 0000000..a7a8f30 --- /dev/null +++ b/sdk/tests/ui/serde.rs @@ -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() {} diff --git a/sdk/tests/ui/serde.stderr b/sdk/tests/ui/serde.stderr new file mode 100644 index 0000000..464f17e --- /dev/null +++ b/sdk/tests/ui/serde.stderr @@ -0,0 +1,5 @@ +error: serde attribute is outside the schema subset + --> tests/ui/serde.rs:6:13 + | +6 | #[serde(flatten)] + | ^^^^^^^ diff --git a/sdk/tests/ui/signature.rs b/sdk/tests/ui/signature.rs new file mode 100644 index 0000000..4aed616 --- /dev/null +++ b/sdk/tests/ui/signature.rs @@ -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() {} diff --git a/sdk/tests/ui/signature.stderr b/sdk/tests/ui/signature.stderr new file mode 100644 index 0000000..0dbe7b6 --- /dev/null +++ b/sdk/tests/ui/signature.stderr @@ -0,0 +1,5 @@ +error: RPC methods require &self + --> tests/ui/signature.rs:6:5 + | +6 | fn bad(&mut self) -> sausage_plugin::Result<()> {Ok(())} + | ^^ diff --git a/sdk/tests/ui/subset.rs b/sdk/tests/ui/subset.rs new file mode 100644 index 0000000..667464a --- /dev/null +++ b/sdk/tests/ui/subset.rs @@ -0,0 +1,8 @@ +//! Reject unsupported field types. +/// An invalid schema. +#[sausage_plugin::schema] +struct Bad { + /// An unsupported dictionary. + value: std::collections::HashMap, +} +fn main() {} diff --git a/sdk/tests/ui/subset.stderr b/sdk/tests/ui/subset.stderr new file mode 100644 index 0000000..1d10145 --- /dev/null +++ b/sdk/tests/ui/subset.stderr @@ -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, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `SchemaType` is not implemented for `HashMap` + | + = help: the following other types implement trait `SchemaType`: + () + Bad + Vec + 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) diff --git a/sdk/tests/ui/tuple.rs b/sdk/tests/ui/tuple.rs new file mode 100644 index 0000000..c88b464 --- /dev/null +++ b/sdk/tests/ui/tuple.rs @@ -0,0 +1,5 @@ +//! Reject tuple params. +/// A tuple is not a named object. +#[sausage_plugin::schema] +struct Bad(String); +fn main() {} diff --git a/sdk/tests/ui/tuple.stderr b/sdk/tests/ui/tuple.stderr new file mode 100644 index 0000000..b27c3d3 --- /dev/null +++ b/sdk/tests/ui/tuple.stderr @@ -0,0 +1,5 @@ +error: schema structs require named fields + --> tests/ui/tuple.rs:2:1 + | +2 | /// A tuple is not a named object. + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/sdk/tests/wasi.rs b/sdk/tests/wasi.rs new file mode 100644 index 0000000..b75c1f3 --- /dev/null +++ b/sdk/tests/wasi.rs @@ -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, + output: BufReader, +} +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::(&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(); +}