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:
@@ -4,8 +4,18 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Sausage plugin interfaces: OpenRPC documents, the compatibility check, and client code generation."
|
||||
|
||||
[features]
|
||||
default = ["codegen"]
|
||||
codegen = ["dep:typify", "dep:schemars08", "dep:syn", "dep:quote", "dep:prettyplease", "dep:proc-macro2"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
typify = { version = "0.6", optional = true }
|
||||
schemars08 = { package = "schemars", version = "0.8", optional = true }
|
||||
syn = { version = "2", features = ["full", "visit-mut"], optional = true }
|
||||
quote = { version = "1", optional = true }
|
||||
prettyplease = { version = "0.2", optional = true }
|
||||
proc-macro2 = { version = "1", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Generate Rust clients from OpenRPC documents with typify's Draft 7 converter.
|
||||
use quote::{format_ident, 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('-', "_")).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).map_err(|e| e.to_string())?;
|
||||
let mut ps=method.get("x-sausage-params").cloned().unwrap_or_else(|| {
|
||||
let mut properties=serde_json::Map::new();let mut required=vec![];
|
||||
for p in method["params"].as_array().into_iter().flatten(){let n=p["name"].as_str().unwrap();properties.insert(n.into(),p["schema"].clone());if p["required"]==true{required.push(n);}}
|
||||
json!({"title":format!("{}Params",wire.split('_').map(|s|{let mut c=s.chars();c.next().unwrap().to_uppercase().to_string()+c.as_str()}).collect::<String>()),"type":"object","properties":properties,"required":required})
|
||||
});
|
||||
local_refs(&mut ps);
|
||||
let mut rs = method["result"]["schema"].clone();
|
||||
local_refs(&mut rs);
|
||||
let p = 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 = space.get_type(&p).map_err(|e| e.to_string())?.ident();
|
||||
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 empty = method["params"].as_array().is_none_or(Vec::is_empty);
|
||||
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 = format_ident!("{}", name);
|
||||
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 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))
|
||||
}
|
||||
@@ -4,3 +4,7 @@
|
||||
pub mod compat;
|
||||
pub mod document;
|
||||
pub use document::Document;
|
||||
#[cfg(feature="codegen")]
|
||||
mod client;
|
||||
#[cfg(feature="codegen")]
|
||||
pub use client::client_rs;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Check the shared generator against a real example's discovered document.
|
||||
#[test]
|
||||
fn notes_client_preserves_names_and_docs() {
|
||||
let document =
|
||||
serde_json::from_str(include_str!("../../examples/consumer/imports/notes.json")).unwrap();
|
||||
let code = sausage_interface::client_rs("notes", &document).unwrap();
|
||||
assert!(code.contains("pub struct AddParams"));
|
||||
assert!(code.contains("pub struct Added"));
|
||||
assert!(!code.contains("AddedResult"));
|
||||
assert!(code.contains("The complete note text"));
|
||||
assert!(code.contains("pub fn add(params: AddParams)"));
|
||||
syn::parse_file(&code).unwrap();
|
||||
let target = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../target/sdk");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
std::fs::write(target.join("notes-client.rs"), code).unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user