use heck::{ToSnakeCase, ToUpperCamelCase};
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
const RUST_KEYWORDS: &[&str] = &[
"as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
"false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
"ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type",
"unsafe", "use", "where", "while", "yield", "abstract", "become", "box", "do", "final",
"macro", "override", "priv", "try", "typeof", "unsized", "virtual",
];
pub fn escape_rust_keyword(name: &str) -> proc_macro2::Ident {
if name == "self" || name == "Self" {
format_ident!("{}_", name)
} else if RUST_KEYWORDS.contains(&name) {
format_ident!("r#{}", name)
} else {
format_ident!("{}", name)
}
}
#[allow(clippy::only_used_in_recursion)]
pub fn schema_to_rust_type(schema: &Value, schemas: &HashMap<String, Value>) -> TokenStream {
if let Some(any_of) = schema.get("anyOf").and_then(|a| a.as_array()) {
let non_null: Vec<&Value> = any_of
.iter()
.filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
.collect();
let has_null = any_of
.iter()
.any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
if non_null.len() == 1 && has_null {
let inner = schema_to_rust_type(non_null[0], schemas);
return quote! { Option<#inner> };
}
if non_null.len() == 1 {
return schema_to_rust_type(non_null[0], schemas);
}
return quote! { ::openapi_contract::serde_json::Value };
}
if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
let type_name = ref_path.rsplit('/').next().unwrap_or(ref_path);
let ident = format_ident!("{}", type_name);
let nullable = schema
.get("nullable")
.and_then(|n| n.as_bool())
.unwrap_or(false);
if nullable {
return quote! { Option<#ident> };
}
return quote! { #ident };
}
let nullable = schema
.get("nullable")
.and_then(|n| n.as_bool())
.unwrap_or(false);
let base = match schema.get("type").and_then(|t| t.as_str()) {
Some("string") => quote! { String },
Some("integer") => quote! { i64 },
Some("number") => quote! { f64 },
Some("boolean") => quote! { bool },
Some("array") => {
let items = schema.get("items").unwrap_or(&Value::Null);
let inner = schema_to_rust_type(items, schemas);
quote! { Vec<#inner> }
}
Some("object") | None => quote! { ::openapi_contract::serde_json::Value },
_ => quote! { ::openapi_contract::serde_json::Value },
};
if nullable {
quote! { Option<#base> }
} else {
base
}
}
pub fn generate_struct(
name: &str,
schema: &Value,
schemas: &HashMap<String, Value>,
) -> TokenStream {
let struct_ident = format_ident!("{}", name);
let required: HashSet<String> = schema
.get("required")
.and_then(|r| r.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let properties = match schema.get("properties").and_then(|p| p.as_object()) {
Some(props) => props,
None => {
return quote! {
#[derive(Debug, Clone, ::openapi_contract::serde::Serialize, ::openapi_contract::serde::Deserialize)]
pub struct #struct_ident {}
};
}
};
let mut fields = Vec::new();
for (prop_name, prop_schema) in properties {
let field_name_snake = prop_name.to_snake_case();
let field_ident = escape_rust_keyword(&field_name_snake);
let is_required = required.contains(prop_name);
let is_nullable = prop_schema
.get("nullable")
.and_then(|n| n.as_bool())
.unwrap_or(false);
let is_anyof_nullable = prop_schema
.get("anyOf")
.and_then(|a| a.as_array())
.map(|arr| {
arr.iter()
.any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"))
})
.unwrap_or(false);
let mut field_type = schema_to_rust_type(prop_schema, schemas);
if !is_required && !is_nullable && !is_anyof_nullable {
field_type = quote! { Option<#field_type> };
}
let serde_attr = if &field_name_snake != prop_name {
quote! { #[serde(rename = #prop_name)] }
} else {
quote! {}
};
let default_attr = if !is_required {
quote! { #[serde(default)] }
} else {
quote! {}
};
fields.push(quote! {
#serde_attr
#default_attr
pub #field_ident: #field_type
});
}
quote! {
#[derive(Debug, Clone, ::openapi_contract::serde::Serialize, ::openapi_contract::serde::Deserialize)]
pub struct #struct_ident {
#(#fields),*
}
}
}
pub fn generate_enum(name: &str, schema: &Value) -> Option<TokenStream> {
let values = schema.get("enum")?.as_array()?;
let enum_ident = format_ident!("{}", name);
let variants: Vec<TokenStream> = values
.iter()
.filter_map(|v| v.as_str())
.map(|val| {
let variant_name = val.to_upper_camel_case();
let variant_ident = format_ident!("{}", variant_name);
quote! {
#[serde(rename = #val)]
#variant_ident
}
})
.collect();
Some(quote! {
#[derive(Debug, Clone, PartialEq, Eq, ::openapi_contract::serde::Serialize, ::openapi_contract::serde::Deserialize)]
pub enum #enum_ident {
#(#variants),*
}
})
}
pub fn generate_all_types(schemas: &HashMap<String, Value>) -> TokenStream {
let mut output = Vec::new();
let mut sorted: Vec<_> = schemas.iter().collect();
sorted.sort_by_key(|(k, _)| (*k).clone());
for (name, schema) in sorted {
let schema_type = schema.get("type").and_then(|t| t.as_str());
if schema_type == Some("string") && schema.get("enum").is_some() {
if let Some(enum_ts) = generate_enum(name, schema) {
output.push(enum_ts);
}
} else if schema_type == Some("object") || schema.get("properties").is_some() {
output.push(generate_struct(name, schema, schemas));
} else {
let ident = format_ident!("{}", name);
let inner = schema_to_rust_type(schema, schemas);
output.push(quote! {
pub type #ident = #inner;
});
}
}
quote! { #(#output)* }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_rust_keywords() {
assert_eq!(escape_rust_keyword("type").to_string(), "r#type");
assert_eq!(escape_rust_keyword("self").to_string(), "self_");
assert_eq!(escape_rust_keyword("Self").to_string(), "Self_");
assert_eq!(escape_rust_keyword("name").to_string(), "name");
}
#[test]
fn schema_to_rust_type_primitives() {
let s = &HashMap::new();
assert_eq!(
schema_to_rust_type(&serde_json::json!({"type": "string"}), s).to_string(),
"String"
);
assert_eq!(
schema_to_rust_type(&serde_json::json!({"type": "integer"}), s).to_string(),
"i64"
);
assert_eq!(
schema_to_rust_type(&serde_json::json!({"type": "number"}), s).to_string(),
"f64"
);
assert_eq!(
schema_to_rust_type(&serde_json::json!({"type": "boolean"}), s).to_string(),
"bool"
);
assert_eq!(
schema_to_rust_type(
&serde_json::json!({"type": "array", "items": {"type": "string"}}),
s
)
.to_string(),
"Vec < String >"
);
assert_eq!(
schema_to_rust_type(&serde_json::json!({"type": "string", "enum": ["a"]}), s)
.to_string(),
"String"
);
}
#[test]
fn schema_to_rust_type_refs_and_nullable() {
let s = &HashMap::new();
assert_eq!(
schema_to_rust_type(&serde_json::json!({"$ref": "#/components/schemas/Team"}), s)
.to_string(),
"Team"
);
assert_eq!(
schema_to_rust_type(
&serde_json::json!({"$ref": "#/components/schemas/Team", "nullable": true}),
s
)
.to_string(),
"Option < Team >"
);
assert_eq!(
schema_to_rust_type(&serde_json::json!({"type": "string", "nullable": true}), s)
.to_string(),
"Option < String >"
);
}
#[test]
fn schema_to_rust_type_anyof() {
let s = &HashMap::new();
let ts = schema_to_rust_type(
&serde_json::json!({"anyOf": [{"$ref": "#/components/schemas/Team"}, {"type": "null"}]}),
s,
);
assert_eq!(ts.to_string(), "Option < Team >");
assert_eq!(
schema_to_rust_type(&serde_json::json!({"anyOf": [{"type": "integer"}]}), s)
.to_string(),
"i64"
);
assert!(
schema_to_rust_type(
&serde_json::json!({"anyOf": [{"type": "string"}, {"type": "integer"}]}),
s
)
.to_string()
.contains("Value")
);
}
#[test]
fn schema_to_rust_type_fallbacks() {
let s = &HashMap::new();
for schema in [
serde_json::json!({"type": "object"}),
serde_json::json!({}),
serde_json::json!({"type": "customThing"}),
serde_json::json!({"type": "object", "properties": {"x": {"type": "integer"}}}),
] {
assert!(
schema_to_rust_type(&schema, s)
.to_string()
.contains("Value")
);
}
}
#[test]
fn generate_struct_variants() {
let s = &HashMap::new();
let code = generate_struct("User", &serde_json::json!({
"type": "object", "required": ["id"],
"properties": { "id": {"type": "string"}, "name": {"type": "string", "nullable": true} }
}), s).to_string();
assert!(
code.contains("pub struct User")
&& code.contains("pub id : String")
&& code.contains("Option < String >")
);
let code = generate_struct("M", &serde_json::json!({
"type": "object", "required": ["userId"], "properties": { "userId": {"type": "string"} }
}), s).to_string();
assert!(code.contains("user_id") && code.contains(r#"rename = "userId""#));
let code = generate_struct(
"T",
&serde_json::json!({
"type": "object", "required": ["name"], "properties": { "name": {"type": "string"} }
}),
s,
)
.to_string();
assert!(code.contains("pub name : String") && !code.contains("rename"));
let code = generate_struct(
"I",
&serde_json::json!({
"type": "object", "properties": { "type": {"type": "string"} }
}),
s,
)
.to_string();
assert!(code.contains("r#type"));
assert!(
generate_struct("Empty", &serde_json::json!({"type": "object"}), s)
.to_string()
.contains("pub struct Empty")
);
let code = generate_struct(
"T",
&serde_json::json!({
"type": "object", "required": [], "properties": { "score": {"type": "integer"} }
}),
s,
)
.to_string();
assert!(code.contains("Option < i64 >"));
}
#[test]
fn generate_enum_and_all_types() {
let code = generate_enum(
"Status",
&serde_json::json!({"type": "string", "enum": ["active", "archived"]}),
)
.unwrap()
.to_string();
assert!(
code.contains("pub enum Status")
&& code.contains("Active")
&& code.contains("Archived")
);
assert!(generate_enum("X", &serde_json::json!({"type": "string"})).is_none());
let mut schemas = HashMap::new();
schemas.insert(
"Role".into(),
serde_json::json!({"type": "string", "enum": ["admin", "member"]}),
);
schemas.insert("User".into(), serde_json::json!({"type": "object", "required": ["id"], "properties": {"id": {"type": "string"}}}));
schemas.insert("MyInt".into(), serde_json::json!({"type": "integer"}));
let code = generate_all_types(&schemas).to_string();
assert!(
code.contains("pub enum Role")
&& code.contains("pub struct User")
&& code.contains("pub type MyInt = i64")
);
}
}