sausage-plugin part 2: import! with typed clients (sausage_interface::client_rs), TCP endpoints with rpc.endpoint, examples
Part 1 fixes: complete stdout writes, buffered stdin, Rust type names in schemas, enum variant docs, -32603 for serde errors in handlers, crate docs and modules. End-to-end check of a generated consumer through a fake core.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
//! Outgoing core calls and notification callbacks.
|
||||
use super::*;
|
||||
/// An outgoing request builder.
|
||||
pub struct Call {
|
||||
method: String,
|
||||
params: Value,
|
||||
detach: bool,
|
||||
notify: Option<Notify>,
|
||||
pub(super) unlock: Option<std::rc::Rc<Lease>>,
|
||||
}
|
||||
/// Build an outgoing call with object parameters.
|
||||
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 {
|
||||
/// Detach the child process from its caller.
|
||||
pub fn detach(mut self) -> Self {
|
||||
self.detach = true;
|
||||
self
|
||||
}
|
||||
/// Receive progress notifications before the result arrives.
|
||||
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")))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Schema contracts and OpenRPC document generation.
|
||||
use crate::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// 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> {}
|
||||
|
||||
/// Create an OpenRPC document builder for generated interfaces.
|
||||
#[doc(hidden)]
|
||||
pub fn document(title: &str, version: &str) -> Value {
|
||||
let mut doc = sausage_interface::Document::new(title, version).into_value();
|
||||
doc["x-sdk-input"] = json!({});
|
||||
doc["x-sdk-output"] = json!({});
|
||||
doc
|
||||
}
|
||||
|
||||
/// Add a method with independent deserialize and serialize contracts.
|
||||
#[doc(hidden)]
|
||||
pub fn method<P: Params, R: SchemaType>(doc: &mut Value, name: &str, docs: &str) {
|
||||
fn generator(serialize: bool) -> schemars::SchemaGenerator {
|
||||
let mut settings = schemars::generate::SchemaSettings::draft07();
|
||||
settings.definitions_path = if serialize {
|
||||
"/sdk-output"
|
||||
} else {
|
||||
"/sdk-input"
|
||||
}
|
||||
.into();
|
||||
if serialize {
|
||||
settings.for_serialize()
|
||||
} else {
|
||||
settings.for_deserialize()
|
||||
}
|
||||
.into_generator()
|
||||
}
|
||||
let mut input = generator(false);
|
||||
let params_ref = serde_json::to_value(input.subschema_for::<P>()).unwrap();
|
||||
let definitions = input.take_definitions(true);
|
||||
let schema = &definitions[P::schema_name().as_ref()];
|
||||
let params:Vec<_>=schema["properties"].as_object().into_iter().flatten().map(|(name,field)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"description": field.get("description").cloned().unwrap_or(Value::Null),
|
||||
"required": schema["required"].as_array().is_some_and(|required|required.contains(&json!(name))),
|
||||
"schema": field,
|
||||
})
|
||||
}).collect();
|
||||
doc["x-sdk-input"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(definitions);
|
||||
let mut output = generator(true);
|
||||
let result_schema = serde_json::to_value(output.subschema_for::<R>()).unwrap();
|
||||
doc["x-sdk-output"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(output.take_definitions(true));
|
||||
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":result_schema},
|
||||
"x-sausage-params":params_ref,
|
||||
}));
|
||||
}
|
||||
|
||||
/// Resolve shared schema names after all methods contribute their contracts.
|
||||
#[doc(hidden)]
|
||||
pub fn finish_document(mut doc: Value) -> Value {
|
||||
let input = doc.as_object_mut().unwrap().remove("x-sdk-input").unwrap();
|
||||
let output = doc.as_object_mut().unwrap().remove("x-sdk-output").unwrap();
|
||||
fn refs(value: &mut Value, names: &std::collections::HashMap<String, String>) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
if let Some(Value::String(reference)) = map.get_mut("$ref") {
|
||||
if let Some(name) = reference.strip_prefix("#/sdk-input/") {
|
||||
*reference = format!("#/components/schemas/{name}");
|
||||
} else if let Some(name) = reference.strip_prefix("#/sdk-output/") {
|
||||
*reference = format!(
|
||||
"#/components/schemas/{}",
|
||||
names.get(name).map(String::as_str).unwrap_or(name)
|
||||
);
|
||||
}
|
||||
}
|
||||
for child in map.values_mut() {
|
||||
refs(child, names);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for child in values {
|
||||
refs(child, names);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut names = std::collections::HashMap::new();
|
||||
for (name, schema) in output.as_object().unwrap() {
|
||||
if let Some(other) = input.get(name) {
|
||||
let mut a = schema.clone();
|
||||
let mut b = other.clone();
|
||||
refs(&mut a, &names);
|
||||
refs(&mut b, &names);
|
||||
if a != b {
|
||||
names.insert(name.clone(), format!("{name}Result"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Propagate differences through references to nested contracts.
|
||||
loop {
|
||||
let before = names.len();
|
||||
for (name, schema) in output.as_object().unwrap() {
|
||||
if let Some(other) = input.get(name) {
|
||||
let mut a = schema.clone();
|
||||
let mut b = other.clone();
|
||||
refs(&mut a, &names);
|
||||
refs(&mut b, &names);
|
||||
if a != b {
|
||||
names.insert(name.clone(), format!("{name}Result"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if names.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
doc["components"]["schemas"] = input;
|
||||
for (name, schema) in output.as_object().unwrap() {
|
||||
let name = names.get(name).unwrap_or(name);
|
||||
doc["components"]["schemas"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert(name.clone(), schema.clone());
|
||||
}
|
||||
refs(&mut doc, &names);
|
||||
for import in inventory::iter::<crate::Import> {
|
||||
doc["x-sausage-imports"][import.name] =
|
||||
serde_json::from_str(import.document).expect("validated import document");
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
/// Empty parameters for methods without arguments.
|
||||
#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct Empty {}
|
||||
impl SchemaType for Empty {}
|
||||
impl Params for Empty {}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! TCP endpoint publication, verified connections, and per-request notifications.
|
||||
use crate::*;
|
||||
type Notify = Box<dyn FnMut(&str, Value)>;
|
||||
use serde_json::Value;
|
||||
#[cfg(target_os = "wasi")]
|
||||
use serde_json::json;
|
||||
use std::{future::IntoFuture, marker::PhantomData, path::Path, rc::Rc};
|
||||
|
||||
/// A verified endpoint address and identity.
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(not(target_os = "wasi"), allow(dead_code))]
|
||||
pub struct Endpoint {
|
||||
address: String,
|
||||
owner: String,
|
||||
interface: String,
|
||||
}
|
||||
impl Endpoint {
|
||||
/// Require the interface expected by a generated client.
|
||||
pub fn require_interface(&self, name: &str) -> Result<()> {
|
||||
if self.interface == name {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::new(-32002, "endpoint interface mismatch"))
|
||||
}
|
||||
}
|
||||
/// Build a typed endpoint request.
|
||||
pub fn call<T>(&self, method: &str, params: impl serde::Serialize) -> EndpointCall<T> {
|
||||
EndpointCall {
|
||||
endpoint: self.clone(),
|
||||
method: method.into(),
|
||||
params: serde_json::to_value(params).expect("serializable params"),
|
||||
notify: None,
|
||||
result: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A lazy endpoint request with optional progress callbacks.
|
||||
pub struct EndpointCall<T> {
|
||||
endpoint: Endpoint,
|
||||
method: String,
|
||||
params: Value,
|
||||
notify: Option<Notify>,
|
||||
result: PhantomData<T>,
|
||||
}
|
||||
impl<T> EndpointCall<T> {
|
||||
/// Receive progress from this endpoint handler.
|
||||
pub fn on_notify(mut self, f: impl FnMut(&str, Value) + 'static) -> Self {
|
||||
self.notify = Some(Box::new(f));
|
||||
self
|
||||
}
|
||||
}
|
||||
impl<T: serde::de::DeserializeOwned + 'static> IntoFuture for EndpointCall<T> {
|
||||
type Output = Result<T>;
|
||||
type IntoFuture = futures::future::LocalBoxFuture<'static, Result<T>>;
|
||||
fn into_future(self) -> Self::IntoFuture {
|
||||
Box::pin(async move {
|
||||
Ok(serde_json::from_value(
|
||||
endpoint_request(self.endpoint, &self.method, self.params, self.notify).await?,
|
||||
)?)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a declared interface and keep its listener alive until stdin EOF.
|
||||
#[cfg(target_os = "wasi")]
|
||||
pub async fn listen<T: Interface>(path: impl AsRef<Path>, value: Rc<T>) -> Result<()> {
|
||||
use wstd::iter::AsyncIterator;
|
||||
let name = crate::transport::endpoint_name::<T>()?;
|
||||
let listener = wstd::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
write_atomic(
|
||||
path,
|
||||
format!("tcp:{} {} {}\n", listener.local_addr()?, pid(), name),
|
||||
)?;
|
||||
crate::transport::endpoint_task(async move {
|
||||
let mut incoming = listener.incoming();
|
||||
while let Some(Ok(stream)) = incoming.next().await {
|
||||
let value = value.clone();
|
||||
let name = name.clone();
|
||||
crate::transport::endpoint_task(async move {
|
||||
let _ = serve_connection(stream, value, name).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
/// Publish an endpoint. Native programs require the WASI runtime.
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
pub async fn listen<T: Interface>(_path: impl AsRef<Path>, _value: Rc<T>) -> Result<()> {
|
||||
crate::transport::endpoint_name::<T>()?;
|
||||
Err(Error::new(-32002, "endpoints require WASI"))
|
||||
}
|
||||
|
||||
/// Read an endpoint file and verify its owner before returning a client.
|
||||
#[cfg(target_os = "wasi")]
|
||||
pub async fn connect(path: impl AsRef<Path>) -> Result<Endpoint> {
|
||||
let mut attempts = 0;
|
||||
let contents = loop {
|
||||
match std::fs::read_to_string(path.as_ref()) {
|
||||
Ok(s) => break s,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound && attempts < 20 => {
|
||||
attempts += 1;
|
||||
wstd::task::sleep(wstd::time::Duration::from_millis(25)).await;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
};
|
||||
let words: Vec<_> = contents.split_whitespace().collect();
|
||||
if words.len() != 3 {
|
||||
return Err(Error::new(-32602, "invalid endpoint file"));
|
||||
}
|
||||
let endpoint = Endpoint {
|
||||
address: words[0]
|
||||
.strip_prefix("tcp:")
|
||||
.ok_or_else(|| Error::new(-32602, "invalid endpoint address"))?
|
||||
.into(),
|
||||
owner: words[1].into(),
|
||||
interface: words[2].into(),
|
||||
};
|
||||
let _ = handshake(&endpoint).await?;
|
||||
Ok(endpoint)
|
||||
}
|
||||
/// Connect to an endpoint. Native programs require the WASI runtime.
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
pub async fn connect(_path: impl AsRef<Path>) -> Result<Endpoint> {
|
||||
Err(Error::new(-32002, "endpoints require WASI"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "wasi")]
|
||||
async fn send(stream: &mut impl wstd::io::AsyncWrite, value: Value) -> Result<()> {
|
||||
stream.write_all(format!("{value}\n").as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "wasi")]
|
||||
async fn handshake(endpoint: &Endpoint) -> Result<wstd::net::TcpStream> {
|
||||
let mut stream = wstd::net::TcpStream::connect(endpoint.address.as_str()).await?;
|
||||
send(
|
||||
&mut stream,
|
||||
json!({"jsonrpc":"2.0","id":0,"method":"rpc.endpoint","params":{}}),
|
||||
)
|
||||
.await?;
|
||||
let bytes = crate::lines::Lines::default()
|
||||
.next(&mut stream)
|
||||
.await?
|
||||
.ok_or_else(|| Error::new(-32002, "endpoint closed"))?;
|
||||
let reply: Value = serde_json::from_slice(&bytes)?;
|
||||
let result = &reply["result"];
|
||||
let plugin = endpoint
|
||||
.owner
|
||||
.len()
|
||||
.checked_sub(37)
|
||||
.and_then(|end| endpoint.owner.get(..end));
|
||||
if result["owner"] != endpoint.owner
|
||||
|| result["interface"] != endpoint.interface
|
||||
|| result["plugin"].as_str() != plugin
|
||||
{
|
||||
return Err(Error::new(-32002, "endpoint identity mismatch"));
|
||||
}
|
||||
Ok(stream)
|
||||
}
|
||||
#[cfg(target_os = "wasi")]
|
||||
async fn endpoint_request(
|
||||
endpoint: Endpoint,
|
||||
method: &str,
|
||||
params: Value,
|
||||
mut notify: Option<Notify>,
|
||||
) -> Result<Value> {
|
||||
let mut stream = handshake(&endpoint).await?;
|
||||
let mut lines = crate::lines::Lines::default();
|
||||
send(
|
||||
&mut stream,
|
||||
json!({"jsonrpc":"2.0","id":1,"method":method,"params":params}),
|
||||
)
|
||||
.await?;
|
||||
while let Some(bytes) = lines.next(&mut stream).await? {
|
||||
let value: Value = serde_json::from_slice(&bytes)?;
|
||||
if value["method"] == "core.notify" {
|
||||
if let Some(f) = &mut notify {
|
||||
f(
|
||||
value["params"]["name"].as_str().unwrap_or_default(),
|
||||
value["params"]["params"].clone(),
|
||||
);
|
||||
}
|
||||
} else if value["id"] == 1 {
|
||||
if let Some(error) = value.get("error") {
|
||||
return Err(serde_json::from_value(error.clone())?);
|
||||
}
|
||||
return Ok(value["result"].clone());
|
||||
}
|
||||
}
|
||||
Err(Error::new(-32002, "endpoint closed before response"))
|
||||
}
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
async fn endpoint_request(
|
||||
_endpoint: Endpoint,
|
||||
_method: &str,
|
||||
_params: Value,
|
||||
_notify: Option<Notify>,
|
||||
) -> Result<Value> {
|
||||
Err(Error::new(-32002, "endpoints require WASI"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "wasi")]
|
||||
async fn serve_connection<T: Interface>(
|
||||
stream: wstd::net::TcpStream,
|
||||
value: Rc<T>,
|
||||
name: String,
|
||||
) -> Result<()> {
|
||||
use futures::{FutureExt, StreamExt};
|
||||
let (mut reader, mut writer) = stream.split();
|
||||
let mut lines = crate::lines::Lines::default();
|
||||
while let Some(bytes) = lines.next(&mut reader).await? {
|
||||
let request: Value = serde_json::from_slice(&bytes)?;
|
||||
if request["jsonrpc"] != "2.0" {
|
||||
continue;
|
||||
}
|
||||
let method = request["method"].as_str().unwrap_or_default();
|
||||
let (tx, mut rx) = futures::channel::mpsc::unbounded();
|
||||
let ctx = Ctx {
|
||||
sink: Some(tx),
|
||||
..Ctx::default()
|
||||
};
|
||||
let handler=async {
|
||||
if method=="rpc.endpoint" {Ok(json!({"owner":pid(),"plugin":std::env::var("SAUSAGE_PLUGIN").unwrap_or_default(),"interface":name}))}
|
||||
else {value.dispatch(method,request.get("params").cloned().unwrap_or(json!({})),&ctx).await}
|
||||
}.fuse();
|
||||
futures::pin_mut!(handler);
|
||||
let result = loop {
|
||||
futures::select! {result=handler=>break result,notification=rx.next().fuse()=>{if let Some(notification)=notification {send(&mut writer,notification).await?;}}}
|
||||
};
|
||||
while let Ok(notification) = rx.try_recv() {
|
||||
send(&mut writer, notification).await?;
|
||||
}
|
||||
if let Some(id) = request.get("id") {
|
||||
let response = match result {
|
||||
Ok(result) => json!({"jsonrpc":"2.0","id":id,"result":result}),
|
||||
Err(error) => json!({"jsonrpc":"2.0","id":id,"error":error}),
|
||||
};
|
||||
send(&mut writer, response).await?;
|
||||
}
|
||||
if ctx.exit.get() {
|
||||
crate::transport::exit();
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! JSON-RPC failures and conversions from local errors.
|
||||
use serde_json::Value;
|
||||
/// An SDK operation that can fail.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// A JSON-RPC failure.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Error {
|
||||
/// The JSON-RPC error code.
|
||||
pub code: i64,
|
||||
/// The diagnostic message.
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
/// Optional structured error details.
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
impl Error {
|
||||
/// Construct an error without structured details.
|
||||
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(-32603, e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Process identity and atomic state-file helpers.
|
||||
use crate::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
/// Return the process ID from `SAUSAGE_PID`.
|
||||
pub fn pid() -> String {
|
||||
std::env::var("SAUSAGE_PID").unwrap_or_default()
|
||||
}
|
||||
/// Return the persistent state directory of this plugin.
|
||||
pub fn state_dir() -> PathBuf {
|
||||
PathBuf::from("/state").join(std::env::var("SAUSAGE_PLUGIN").unwrap_or_default())
|
||||
}
|
||||
/// Replace a file atomically after a complete temporary write.
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Typed outgoing calls and statically registered imported interfaces.
|
||||
use crate::{Call, Result};
|
||||
use serde_json::Value;
|
||||
use std::{future::IntoFuture, marker::PhantomData};
|
||||
|
||||
/// An imported provider document registered by `import!`.
|
||||
#[doc(hidden)]
|
||||
pub struct Import {
|
||||
/// The provider name.
|
||||
pub name: &'static str,
|
||||
/// Its complete OpenRPC document.
|
||||
pub document: &'static str,
|
||||
}
|
||||
inventory::collect!(Import);
|
||||
|
||||
/// An outgoing call whose result decodes to a generated Rust type.
|
||||
pub struct TypedCall<T> {
|
||||
call: Call,
|
||||
result: PhantomData<T>,
|
||||
}
|
||||
impl<T> TypedCall<T> {
|
||||
/// Wrap an untyped call without sending it yet.
|
||||
pub fn new(call: Call) -> Self {
|
||||
Self {
|
||||
call,
|
||||
result: PhantomData,
|
||||
}
|
||||
}
|
||||
/// Detach the child process from its caller.
|
||||
pub fn detach(mut self) -> Self {
|
||||
self.call = self.call.detach();
|
||||
self
|
||||
}
|
||||
/// Receive progress notifications while the call runs.
|
||||
pub fn on_notify(mut self, f: impl FnMut(&str, Value) + 'static) -> Self {
|
||||
self.call = self.call.on_notify(f);
|
||||
self
|
||||
}
|
||||
}
|
||||
impl<T: serde::de::DeserializeOwned + 'static> IntoFuture for TypedCall<T> {
|
||||
type Output = Result<T>;
|
||||
type IntoFuture = futures::future::LocalBoxFuture<'static, Result<T>>;
|
||||
fn into_future(self) -> Self::IntoFuture {
|
||||
Box::pin(async move { Ok(serde_json::from_value(self.call.await?)?) })
|
||||
}
|
||||
}
|
||||
+64
-632
@@ -1,633 +1,65 @@
|
||||
//! 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");
|
||||
}
|
||||
|
||||
//! Write single-threaded WASI plugins for Sausage.
|
||||
//!
|
||||
//! Each core call starts a process. Keep its initial value cheap to construct.
|
||||
//! Declare documented parameter and result types with [`macro@schema`].
|
||||
//! Declare methods with [`macro@rpc`], then serve them with [`run`].
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use sausage_plugin::{rpc, schema, Result};
|
||||
//! struct Calculator;
|
||||
//! /// The numbers to add.
|
||||
//! #[schema]
|
||||
//! struct AddParams {
|
||||
//! /// The first number.
|
||||
//! a: i32,
|
||||
//! /// The second number.
|
||||
//! b: i32,
|
||||
//! }
|
||||
//! #[rpc]
|
||||
//! impl Calculator {
|
||||
//! /// Add two numbers.
|
||||
//! fn add(&self, p: AddParams) -> Result<i32> { Ok(p.a + p.b) }
|
||||
//! }
|
||||
//! fn main() { sausage_plugin::run(Calculator); }
|
||||
//! ```
|
||||
//!
|
||||
//! Use [`call`] for outgoing calls. Await its builder for the JSON result.
|
||||
//! Use `detach()` to detach the child and `on_notify(f)` to receive progress.
|
||||
//! Use [`Ctx::notify`] to report progress to your caller.
|
||||
//! Use [`Ctx::exit_after_response`] to end the process after the response.
|
||||
//!
|
||||
//! [`background`] keeps the process alive while a task runs.
|
||||
//! At stdin EOF, pending calls fail and the optional [`macro@on_stop`] hook runs.
|
||||
//! The stop hook can write files and send notifications, but cannot make calls.
|
||||
//! [`macro@on_retired`] handles `rpc.retired` after an upgrade.
|
||||
//!
|
||||
//! Use [`lock`] before shared state changes. A guard releases its lock on drop.
|
||||
//! `try_now()` returns a guard whose `held()` method reports success.
|
||||
//! `keep()` retains a lock until process exit.
|
||||
//! [`state_dir`], [`write_atomic`], and [`pid`] provide filesystem and identity helpers.
|
||||
//! Use [`macro@test`] for async tests. Native tests fail clearly without a core transport.
|
||||
//! [`macro@import`] reads `imports/<provider>.json` and generates a private typed client.
|
||||
//! Its functions return [`TypedCall`] builders with progress and detach options.
|
||||
//! Declare endpoint types with `#[rpc(endpoints(control = Control))]`.
|
||||
//! [`listen`] publishes a declared interface at an endpoint file.
|
||||
//! [`connect`] verifies its owner before it returns an [`Endpoint`].
|
||||
//! Endpoint calls report progress through [`EndpointCall::on_notify`].
|
||||
//! Log with `eprintln!`. Stdout belongs to JSON-RPC.
|
||||
#![warn(missing_docs)]
|
||||
mod discovery;
|
||||
mod endpoints;
|
||||
mod error;
|
||||
mod helpers;
|
||||
mod imports;
|
||||
mod lines;
|
||||
mod transport;
|
||||
pub use discovery::*;
|
||||
pub use endpoints::*;
|
||||
pub use error::*;
|
||||
pub use helpers::*;
|
||||
pub use imports::*;
|
||||
#[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 {}
|
||||
pub use inventory;
|
||||
pub use sausage_plugin_macros::{import, on_retired, on_stop, rpc, schema, test};
|
||||
pub use transport::*;
|
||||
pub use {schemars, serde, serde_json};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Buffered newline framing shared by stdin and endpoint connections.
|
||||
#[cfg(target_os = "wasi")]
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Lines {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
#[cfg(target_os = "wasi")]
|
||||
impl Lines {
|
||||
pub(crate) async fn next(
|
||||
&mut self,
|
||||
input: &mut impl wstd::io::AsyncRead,
|
||||
) -> std::io::Result<Option<Vec<u8>>> {
|
||||
loop {
|
||||
if let Some(end) = self.bytes.iter().position(|b| *b == b'\n') {
|
||||
return Ok(Some(self.bytes.drain(..=end).collect()));
|
||||
}
|
||||
let mut buffer = [0; 8192];
|
||||
let count = input.read(&mut buffer).await?;
|
||||
if count == 0 {
|
||||
return Ok(if self.bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(std::mem::take(&mut self.bytes))
|
||||
});
|
||||
}
|
||||
self.bytes.extend_from_slice(&buffer[..count]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Advisory lock acquisition and guard lifetime.
|
||||
use super::*;
|
||||
/// An advisory lock acquisition.
|
||||
pub struct Lock {
|
||||
path: String,
|
||||
wait: bool,
|
||||
}
|
||||
/// Acquire an advisory lock on a path.
|
||||
pub fn lock(path: impl AsRef<Path>) -> Lock {
|
||||
Lock {
|
||||
path: path.as_ref().to_string_lossy().into_owned(),
|
||||
wait: true,
|
||||
}
|
||||
}
|
||||
impl Lock {
|
||||
/// Return immediately, without waiting for a held 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 {
|
||||
/// Hold this lock until process exit.
|
||||
pub fn keep(mut self) {
|
||||
self.path.take();
|
||||
}
|
||||
/// Report whether this guard holds the lock.
|
||||
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
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Stdin routing, stdout framing, and process lifetime.
|
||||
#[cfg(target_os = "wasi")]
|
||||
use crate::lines::Lines;
|
||||
use crate::*;
|
||||
use futures::{channel::oneshot, future::LocalBoxFuture};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::HashMap,
|
||||
future::{Future, IntoFuture},
|
||||
io::Write,
|
||||
path::Path,
|
||||
};
|
||||
#[path = "calls.rs"]
|
||||
mod calls;
|
||||
#[path = "locks.rs"]
|
||||
mod locks;
|
||||
pub use calls::*;
|
||||
pub use locks::*;
|
||||
/// Generated main-interface dispatch.
|
||||
pub trait Interface: 'static {
|
||||
/// Describe this interface without constructing its value.
|
||||
fn describe() -> Value
|
||||
where
|
||||
Self: Sized;
|
||||
/// Describe only this type's methods, without endpoint declarations.
|
||||
fn methods_document() -> Value
|
||||
where
|
||||
Self: Sized;
|
||||
/// List the declared endpoint types and their names.
|
||||
fn endpoint_types(&self) -> Vec<(std::any::TypeId, String)> {
|
||||
vec![]
|
||||
}
|
||||
/// Describe this interface as OpenRPC.
|
||||
fn discover(&self) -> Value;
|
||||
/// Dispatch a request to a generated handler.
|
||||
fn dispatch<'a>(
|
||||
&'a self,
|
||||
method: &'a str,
|
||||
params: Value,
|
||||
ctx: &'a Ctx,
|
||||
) -> LocalBoxFuture<'a, Result<Value>>;
|
||||
/// Run the EOF hook.
|
||||
fn stop(&self) -> LocalBoxFuture<'_, ()> {
|
||||
Box::pin(async {})
|
||||
}
|
||||
}
|
||||
|
||||
/// Context of an incoming call.
|
||||
#[derive(Default)]
|
||||
pub struct Ctx {
|
||||
pub(crate) exit: std::cell::Cell<bool>,
|
||||
pub(crate) sink: Option<futures::channel::mpsc::UnboundedSender<Value>>,
|
||||
}
|
||||
impl Ctx {
|
||||
/// Send progress to the caller of this request.
|
||||
pub fn notify(&self, name: &str, params: impl serde::Serialize) -> Result<()> {
|
||||
let params = json!({"name":name,"params":params});
|
||||
if let Some(sink) = &self.sink {
|
||||
sink.unbounded_send(json!({"jsonrpc":"2.0","method":"core.notify","params":params}))
|
||||
.map_err(|_| Error::new(-32002, "endpoint closed"))
|
||||
} else {
|
||||
notify("core.notify", params)
|
||||
}
|
||||
}
|
||||
/// End the process after this request receives its response.
|
||||
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,
|
||||
endpoints: HashMap<std::any::TypeId, String>,
|
||||
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,
|
||||
#[cfg(target_os = "wasi")]
|
||||
listeners: HashMap<u64, futures::future::AbortHandle>,
|
||||
}
|
||||
thread_local! { static STATE: RefCell<State> = RefCell::new(State::default()); }
|
||||
pub(crate) fn endpoint_name<T: 'static>() -> Result<String> {
|
||||
STATE
|
||||
.with(|s| {
|
||||
s.borrow()
|
||||
.endpoints
|
||||
.get(&std::any::TypeId::of::<T>())
|
||||
.cloned()
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
Error::new(
|
||||
-32602,
|
||||
"endpoint type is not declared by the main interface",
|
||||
)
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "wasi")]
|
||||
pub(crate) fn endpoint_task(future: impl Future<Output = ()> + 'static) {
|
||||
let (handle, registration) = futures::future::AbortHandle::new_pair();
|
||||
let id = STATE.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.next += 1;
|
||||
let id = s.next;
|
||||
s.listeners.insert(id, handle);
|
||||
id
|
||||
});
|
||||
background(async move {
|
||||
let _ = futures::future::Abortable::new(future, registration).await;
|
||||
STATE.with(|s| s.borrow_mut().listeners.remove(&id));
|
||||
});
|
||||
}
|
||||
#[cfg(target_os = "wasi")]
|
||||
pub(crate) fn exit() {
|
||||
STATE.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.exit = true;
|
||||
if let Some(w) = s.wake.take() {
|
||||
w.wake();
|
||||
}
|
||||
});
|
||||
}
|
||||
fn send(value: Value) -> Result<()> {
|
||||
let bytes = format!("\n{value}\n").into_bytes();
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
stdout.write_all(&bytes)?;
|
||||
stdout.flush()?;
|
||||
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}))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "wasi")]
|
||||
/// Execute a future with the SDK runtime.
|
||||
pub fn block_on<F: Future>(f: F) -> F::Output {
|
||||
wstd::runtime::block_on(f)
|
||||
}
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
/// Execute a future with the SDK runtime.
|
||||
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")]
|
||||
/// Execute an async test with the available core transport.
|
||||
pub fn test_run<F: Future>(f: F) -> F::Output {
|
||||
block_on(async {
|
||||
STATE.with(|s| s.borrow_mut().active = true);
|
||||
let reader = wstd::runtime::spawn(async {
|
||||
let mut input = wstd::io::stdin();
|
||||
let mut lines = Lines::default();
|
||||
while let Ok(Some(line)) = lines.next(&mut input).await {
|
||||
if let Ok(v) = serde_json::from_slice::<Value>(&line)
|
||||
&& v["jsonrpc"] == "2.0"
|
||||
{
|
||||
receive(v);
|
||||
}
|
||||
}
|
||||
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"))]
|
||||
/// Execute an async test with the available core transport.
|
||||
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")]
|
||||
/// Keep this process alive while a future runs.
|
||||
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"))]
|
||||
/// Keep this process alive while a future runs.
|
||||
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 {}", value["id"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve the main interface until its work ends or stdin reaches EOF.
|
||||
#[cfg(target_os = "wasi")]
|
||||
/// Serve the main interface over stdin and stdout.
|
||||
pub fn run<T: Interface>(value: T) {
|
||||
block_on(serve(value));
|
||||
}
|
||||
#[cfg(not(target_os = "wasi"))]
|
||||
/// Serve the main interface over stdin and stdout.
|
||||
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};
|
||||
STATE.with(|s| {
|
||||
*s.borrow_mut() = State {
|
||||
active: true,
|
||||
endpoints: value.endpoint_types().into_iter().collect(),
|
||||
..State::default()
|
||||
}
|
||||
});
|
||||
let value = Rc::new(value);
|
||||
let mut input = wstd::io::stdin();
|
||||
let mut lines = Lines::default();
|
||||
let mut started = false;
|
||||
loop {
|
||||
let read = lines.next(&mut input);
|
||||
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();
|
||||
for (_, listener) in s.listeners.drain() {
|
||||
listener.abort();
|
||||
}
|
||||
});
|
||||
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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user