Files
sausage/interface/src/client.rs
T
surma c728429d04 sdk, agent: review fixes (T18R)
- The serve loop ends the process after EOF or exit_after_response,
  also with background tasks or listeners.
- One schema generator per direction for the whole document, so types
  with the same name get distinct names.
- The agent gives the complete error (code, message, data) to the
  model, writes SKIP only after a successful request, caps tool output
  at 64 KiB, writes each history line in one write, and skips a damaged
  line.
- Endpoints skip blank and non-RPC lines. client_rs does not panic.
- #[must_use] on builders and the lock guard; #[schemars] attributes
  and duplicate hooks are compile errors; raw identifiers; -32000 for
  local SDK errors; x-sausage-params only for methods with params.
2026-10-23 02:38:35 +01:00

203 lines
8.6 KiB
Rust

//! Generate Rust clients from OpenRPC documents with typify's Draft 7 converter.
use quote::quote;
use serde_json::{Value, json};
/// Generate a formatted private client module for a provider's document.
pub fn client_rs(name: &str, doc: &Value) -> Result<String, String> {
fn local_refs(value: &mut Value) {
match value {
Value::Object(map) => {
if let Some(Value::String(r)) = map.get_mut("$ref") {
*r = r.replace("#/components/schemas/", "#/definitions/");
}
for v in map.values_mut() {
local_refs(v);
}
}
Value::Array(a) => {
for v in a {
local_refs(v);
}
}
_ => {}
}
}
let module = syn::parse_str::<syn::Ident>(&name.replace('-', "_"))
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{}", name.replace('-', "_"))))
.map_err(|e| e.to_string())?;
let mut space = typify::TypeSpace::default();
let mut defs = doc["components"]["schemas"]
.as_object()
.cloned()
.unwrap_or_default();
for schema in defs.values_mut() {
local_refs(schema);
}
let defs: Vec<(String, schemars08::schema::Schema)> = defs
.into_iter()
.map(|(n, v)| serde_json::from_value(v).map(|v| (n, v)))
.collect::<Result<_, _>>()
.map_err(|e| e.to_string())?;
space.add_ref_types(defs).map_err(|e| e.to_string())?;
fn methods(
space: &mut typify::TypeSpace,
provider: &str,
methods: &Value,
endpoint: bool,
) -> Result<Vec<proc_macro2::TokenStream>, String> {
let mut result = vec![];
for method in methods.as_array().into_iter().flatten() {
let wire = method["name"].as_str().ok_or("method name missing")?;
let ident = syn::parse_str::<syn::Ident>(wire)
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{wire}")))
.map_err(|e| e.to_string())?;
let mut rs = method["result"]["schema"].clone();
local_refs(&mut rs);
let empty = method["params"].as_array().is_none_or(Vec::is_empty);
let p = if empty {
None
} else {
let mut properties = serde_json::Map::new();
let mut required = vec![];
for param in method["params"].as_array().into_iter().flatten() {
let name = param["name"].as_str().ok_or("parameter name missing")?;
properties.insert(name.into(), param["schema"].clone());
if param["required"] == true {
required.push(name);
}
}
let mut ps = method.get("x-sausage-params").cloned().unwrap_or_else(|| {
let title = wire.split('_').filter(|s| !s.is_empty()).map(|s| {
let mut chars = s.chars();
chars.next().unwrap().to_uppercase().to_string() + chars.as_str()
}).collect::<String>();
json!({"title":format!("{title}Params"),"type":"object","properties":properties,"required":required})
});
local_refs(&mut ps);
Some(
space
.add_type(&serde_json::from_value(ps).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?,
)
};
let r = space
.add_type(&serde_json::from_value(rs).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let p = p
.map(|p| {
space
.get_type(&p)
.map(|p| p.ident())
.map_err(|e| e.to_string())
})
.transpose()?;
let r = space.get_type(&r).map_err(|e| e.to_string())?.ident();
let docs = method
.get("description")
.or_else(|| method.get("summary"))
.and_then(Value::as_str)
.unwrap_or("");
let arg = if empty { quote!() } else { quote!(params:#p) };
let params = if empty {
quote!(::sausage_plugin::serde_json::json!({}))
} else {
quote!(params)
};
let target = format!("{provider}.{wire}");
if endpoint {
let arg = if empty { quote!() } else { quote!(, #arg) };
result.push(quote!(#[doc=#docs] #[warn(dead_code)] pub fn #ident(&self #arg)->::sausage_plugin::EndpointCall<#r>{self.0.call(#wire,#params)}));
} else {
result.push(quote!(#[doc=#docs] #[warn(dead_code)] pub fn #ident(#arg)->::sausage_plugin::TypedCall<#r>{::sausage_plugin::TypedCall::new(::sausage_plugin::call(#target,#params))}));
}
}
Ok(result)
}
let functions = methods(&mut space, name, &doc["methods"], false)?;
let mut endpoints = vec![];
for (name, description) in doc["x-sausage-endpoints"].as_object().into_iter().flatten() {
let ident = syn::parse_str::<syn::Ident>(name)
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{name}")))
.map_err(|e| e.to_string())?;
let functions = methods(&mut space, "", &description["methods"], true)?;
endpoints.push(quote!{pub mod #ident {use super::*;pub struct Client(::sausage_plugin::Endpoint);pub async fn connect(path:impl AsRef<::std::path::Path>)->::sausage_plugin::Result<Client>{let endpoint=::sausage_plugin::connect(path).await?;endpoint.require_interface(#name)?;Ok(Client(endpoint))} impl Client {#(#functions)*}}});
}
let types = space.to_stream().to_string();
let mut file = syn::parse_file(&types).map_err(|e| e.to_string())?;
fn serde_attrs(items: &mut [syn::Item]) {
for item in items {
match item {
syn::Item::Struct(s) => s
.attrs
.push(syn::parse_quote!(#[serde(crate="::sausage_plugin::serde")])),
syn::Item::Enum(e) => e
.attrs
.push(syn::parse_quote!(#[serde(crate="::sausage_plugin::serde")])),
syn::Item::Mod(m) => {
if let Some((_, items)) = &mut m.content {
serde_attrs(items)
}
}
_ => {}
}
}
}
// Only data types have serde derives. ConversionError is a support type.
for item in &mut file.items {
if let syn::Item::Mod(m) = item
&& m.ident == "error"
{
continue;
}
serde_attrs(std::slice::from_mut(item));
}
let uses_error = file
.items
.iter()
.filter(|item| !matches!(item, syn::Item::Mod(m) if m.ident == "error"))
.any(|item| {
quote!(#item)
.to_string()
.contains("error :: ConversionError")
});
if !uses_error {
file.items
.retain(|item| !matches!(item, syn::Item::Mod(m) if m.ident == "error"));
}
let types = &file.items;
let mut file =
syn::parse2::<syn::File>(quote!(mod #module {#(#types)* #(#functions)* #(#endpoints)*}))
.map_err(|e| e.to_string())?;
struct Reexports;
impl syn::visit_mut::VisitMut for Reexports {
fn visit_path_mut(&mut self, path: &mut syn::Path) {
syn::visit_mut::visit_path_mut(self, path);
if path.leading_colon.is_some()
&& path
.segments
.first()
.is_some_and(|s| s.ident == "serde" || s.ident == "serde_json")
{
path.segments.insert(0, syn::parse_quote!(sausage_plugin));
}
}
fn visit_attribute_mut(&mut self, attr: &mut syn::Attribute) {
if attr.path().is_ident("derive") {
let mut paths = attr
.parse_args_with(
syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
)
.expect("derive paths");
for path in &mut paths {
self.visit_path_mut(path);
}
*attr = syn::parse_quote!(#[derive(#paths)]);
} else {
syn::visit_mut::visit_attribute_mut(self, attr);
}
}
}
syn::visit_mut::VisitMut::visit_file_mut(&mut Reexports, &mut file);
Ok(prettyplease::unparse(&file))
}