use crate::app::extract_app_meta;
use crate::server_attrs::{has_server_hidden, has_server_skip, validate_server_attrs};
use heck::ToLowerCamelCase;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use server_less_parse::{
MethodInfo, ParamInfo, extract_methods, get_impl_name, unwrap_option_type, unwrap_result_ok_type,
unwrap_vec_type,
};
use syn::{ItemImpl, Token, parse::Parse};
#[derive(Default)]
pub(crate) struct OpenRpcArgs {
title: Option<String>,
version: Option<String>,
}
impl Parse for OpenRpcArgs {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let mut args = OpenRpcArgs::default();
while !input.is_empty() {
let ident: syn::Ident = input.parse()?;
input.parse::<Token![=]>()?;
match ident.to_string().as_str() {
"name" | "title" => {
let lit: syn::LitStr = input.parse()?;
args.title = Some(lit.value());
}
"version" => {
let lit: syn::LitStr = input.parse()?;
args.version = Some(lit.value());
}
other => {
const VALID: &[&str] = &["title", "version"];
let suggestion = crate::did_you_mean(other, VALID)
.map(|s| format!(" — did you mean `{s}`?"))
.unwrap_or_default();
return Err(syn::Error::new(
ident.span(),
format!(
"unknown argument `{other}`{suggestion}. Valid arguments: name, version"
),
));
}
}
if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
}
}
Ok(args)
}
}
pub(crate) fn expand_openrpc(args: OpenRpcArgs, mut impl_block: ItemImpl) -> syn::Result<TokenStream2> {
crate::reject_generic_impl(&impl_block)?;
let app_meta = extract_app_meta(&mut impl_block.attrs);
let struct_name = get_impl_name(&impl_block)?;
let (impl_generics, _ty_generics, where_clause) = impl_block.generics.split_for_impl();
let self_ty = &impl_block.self_ty;
let struct_name_str = struct_name.to_string();
let all_methods = extract_methods(&impl_block)?;
for m in &all_methods {
validate_server_attrs(m)?;
}
let methods: Vec<_> = all_methods
.into_iter()
.filter(|m| !has_server_skip(m) && !has_server_hidden(m))
.collect();
let title = args
.title
.or(app_meta.name)
.unwrap_or_else(|| struct_name_str.clone());
let version = args
.version
.or_else(|| app_meta.version.into_explicit())
.unwrap_or_else(|| "1.0.0".to_string());
let description = app_meta.description;
let homepage = app_meta.homepage;
let method_specs: Vec<String> = methods.iter().map(generate_method_spec).collect();
let methods_json = method_specs.join(",\n");
let description_field = match &description {
Some(desc) => quote! {
if let Some(__obj) = __info.as_object_mut() {
__obj.insert("description".to_string(), ::server_less::serde_json::json!(#desc));
}
},
None => quote! {},
};
let homepage_field = match &homepage {
Some(url) => quote! {
if let Some(__obj) = __info.as_object_mut() {
__obj.insert("contact".to_string(), ::server_less::serde_json::json!({"url": #url}));
}
},
None => quote! {},
};
let maybe_impl = if crate::is_protocol_impl_emitter(&impl_block, "openrpc") {
quote! { #impl_block }
} else {
quote! {}
};
Ok(quote! {
#maybe_impl
impl #impl_generics #self_ty #where_clause {
pub fn openrpc_spec() -> ::server_less::serde_json::Value {
let mut __info = ::server_less::serde_json::json!({
"title": #title,
"version": #version
});
#description_field
#homepage_field
::server_less::serde_json::json!({
"openrpc": "1.0.0",
"info": __info,
"methods": Self::openrpc_methods()
})
}
fn openrpc_methods() -> Vec<::server_less::serde_json::Value> {
let methods_str = concat!("[", #methods_json, "]");
::server_less::serde_json::from_str(methods_str).unwrap_or_default()
}
pub fn openrpc_json() -> String {
::server_less::serde_json::to_string_pretty(&Self::openrpc_spec())
.unwrap_or_else(|_| "{}".to_string())
}
pub fn write_openrpc(path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
std::fs::write(path, Self::openrpc_json())
}
}
})
}
fn generate_method_spec(method: &MethodInfo) -> String {
let name = method.name_str().to_lower_camel_case();
let description = method.docs.clone().unwrap_or_default();
let params: Vec<String> = method.params.iter().map(generate_param_spec).collect();
let result_schema = get_json_schema(&method.return_info.ty);
format!(
r#"{{
"name": "{}",
"description": "{}",
"params": [{}],
"result": {{
"name": "result",
"schema": {}
}}
}}"#,
name,
description.replace('"', "\\\""),
params.join(", "),
result_schema
)
}
fn generate_param_spec(param: &ParamInfo) -> String {
let name = param.name_str().to_lower_camel_case();
let description = param
.help_text
.as_deref()
.unwrap_or("")
.replace('"', "\\\"");
let schema = get_json_schema(&Some(param.ty.clone()));
let required = !param.is_optional;
format!(
r#"{{
"name": "{}",
"description": "{}",
"required": {},
"schema": {}
}}"#,
name, description, required, schema
)
}
fn get_json_schema(ty: &Option<syn::Type>) -> String {
let Some(ty) = ty else {
return r#"{"type": "null"}"#.to_string();
};
get_json_schema_ty(ty)
}
fn get_json_schema_ty(ty: &syn::Type) -> String {
if let Some(ok) = unwrap_result_ok_type(ty) {
return get_json_schema_ty(ok);
}
if let Some(inner) = unwrap_option_type(ty) {
let inner_schema = get_json_schema_ty(inner);
return format!(r#"{{"anyOf": [{{"type": "null"}}, {}]}}"#, inner_schema);
}
if let Some(inner) = unwrap_vec_type(ty) {
let inner_schema = get_json_schema_ty(inner);
return format!(r#"{{"type": "array", "items": {}}}"#, inner_schema);
}
let type_str = quote!(#ty).to_string();
if type_str.contains("String") || type_str.contains("str") {
r#"{"type": "string"}"#.to_string()
} else if type_str.contains("i8")
|| type_str.contains("i16")
|| type_str.contains("i32")
|| type_str.contains("i64")
|| type_str.contains("u8")
|| type_str.contains("u16")
|| type_str.contains("u32")
|| type_str.contains("u64")
|| type_str.contains("isize")
|| type_str.contains("usize")
{
r#"{"type": "integer"}"#.to_string()
} else if type_str.contains("f32") || type_str.contains("f64") {
r#"{"type": "number"}"#.to_string()
} else if type_str.contains("bool") {
r#"{"type": "boolean"}"#.to_string()
} else {
r#"{"type": "object"}"#.to_string()
}
}