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 JsonSchemaArgs {
title: Option<String>,
draft: Option<String>,
}
impl Parse for JsonSchemaArgs {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let mut args = JsonSchemaArgs::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());
}
"draft" => {
let lit: syn::LitStr = input.parse()?;
args.draft = Some(lit.value());
}
other => {
const VALID: &[&str] = &["title", "draft"];
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, draft"
),
));
}
}
if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
}
}
Ok(args)
}
}
pub(crate) fn expand_jsonschema(
args: JsonSchemaArgs,
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 draft = args
.draft
.unwrap_or_else(|| "http://json-schema.org/draft-07/schema#".to_string());
let definitions: Vec<String> = methods
.iter()
.flat_map(generate_schema_definitions)
.collect();
let definitions_json = definitions.join(",\n");
let maybe_impl = if crate::is_protocol_impl_emitter(&impl_block, "jsonschema") {
quote! { #impl_block }
} else {
quote! {}
};
Ok(quote! {
#maybe_impl
impl #impl_generics #self_ty #where_clause {
pub fn json_schema() -> ::server_less::serde_json::Value {
let defs_str = concat!("{", #definitions_json, "}");
let definitions: ::server_less::serde_json::Value =
::server_less::serde_json::from_str(defs_str).unwrap_or_default();
::server_less::serde_json::json!({
"$schema": #draft,
"title": #title,
"definitions": definitions
})
}
pub fn json_schema_string() -> String {
::server_less::serde_json::to_string_pretty(&Self::json_schema())
.unwrap_or_else(|_| "{}".to_string())
}
pub fn write_json_schema(path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
std::fs::write(path, Self::json_schema_string())
}
}
})
}
fn generate_schema_definitions(method: &MethodInfo) -> Vec<String> {
let method_name = method.name_str();
let request_name = format!("{}Request", capitalize(&method_name));
let response_name = format!("{}Response", capitalize(&method_name));
let request_props: Vec<String> = method.params.iter().map(generate_property).collect();
let required_fields: Vec<String> = method
.params
.iter()
.filter(|p| !p.is_optional)
.map(|p| format!("\"{}\"", p.name_str().to_lower_camel_case()))
.collect();
let request_schema = if request_props.is_empty() {
format!(
r#"
"{}": {{
"type": "object",
"properties": {{}},
"additionalProperties": false
}}"#,
request_name
)
} else {
format!(
r#"
"{}": {{
"type": "object",
"properties": {{
{}
}},
"required": [{}],
"additionalProperties": false
}}"#,
request_name,
request_props.join(",\n "),
required_fields.join(", ")
)
};
let ret = &method.return_info;
let response_schema = if ret.is_unit {
format!(
r#"
"{}": {{
"type": "object",
"properties": {{}},
"additionalProperties": false
}}"#,
response_name
)
} else {
let result_schema = get_type_schema(&ret.ty);
format!(
r#"
"{}": {{
"type": "object",
"properties": {{
"result": {}
}},
"required": ["result"],
"additionalProperties": false
}}"#,
response_name, result_schema
)
};
vec![request_schema, response_schema]
}
fn generate_property(param: &ParamInfo) -> String {
let name = param.name_str().to_lower_camel_case();
let schema = get_type_schema(&Some(param.ty.clone()));
format!(r#""{}": {}"#, name, schema)
}
fn get_type_schema(ty: &Option<syn::Type>) -> String {
let Some(ty) = ty else {
return r#"{"type": "null"}"#.to_string();
};
get_type_schema_ty(ty)
}
fn get_type_schema_ty(ty: &syn::Type) -> String {
if let Some(ok) = unwrap_result_ok_type(ty) {
return get_type_schema_ty(ok);
}
if let Some(inner) = unwrap_option_type(ty) {
let inner_schema = get_type_schema_ty(inner);
return format!(r#"{{"anyOf": [{{"type": "null"}}, {}]}}"#, inner_schema);
}
if let Some(inner) = unwrap_vec_type(ty) {
let inner_schema = get_type_schema_ty(inner);
return format!(r#"{{"type": "array", "items": {}}}"#, inner_schema);
}
let type_str = quote!(#ty).to_string();
if type_str.contains("HashMap") || type_str.contains("BTreeMap") {
r#"{"type": "object", "additionalProperties": true}"#.to_string()
} else 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")
{
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()
}
}
fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().chain(c).collect(),
}
}