use std::collections::{BTreeMap, HashMap, HashSet};
use syn::{Fields, Type};
use vespera_core::schema::{Schema, SchemaRef, SchemaType};
use super::{
serde_attrs::{
extract_doc_comment, extract_field_rename, extract_flatten, extract_rename_all,
extract_skip, rename_field, strip_raw_prefix,
},
type_schema::parse_type_to_schema_ref,
};
#[allow(clippy::too_many_lines)]
pub fn parse_struct_to_schema(
struct_item: &syn::ItemStruct,
known_schemas: &HashSet<String>,
struct_definitions: &HashMap<String, String>,
) -> Schema {
let mut properties = BTreeMap::new();
let mut required = Vec::new();
let mut flattened_refs: Vec<SchemaRef> = Vec::new();
let struct_description = extract_doc_comment(&struct_item.attrs);
let rename_all = extract_rename_all(&struct_item.attrs);
match &struct_item.fields {
Fields::Named(fields_named) => {
for field in &fields_named.named {
if extract_skip(&field.attrs) {
continue;
}
if extract_flatten(&field.attrs) {
let field_type = &field.ty;
let schema_ref =
parse_type_to_schema_ref(field_type, known_schemas, struct_definitions);
flattened_refs.push(schema_ref);
continue;
}
let rust_field_name = field.ident.as_ref().map_or_else(
|| "unknown".to_string(),
|i| strip_raw_prefix(&i.to_string()).to_string(),
);
let field_name = extract_field_rename(&field.attrs)
.unwrap_or_else(|| rename_field(&rust_field_name, rename_all.as_deref()));
let field_type = &field.ty;
let mut schema_ref =
parse_type_to_schema_ref(field_type, known_schemas, struct_definitions);
if let Some(doc) = extract_doc_comment(&field.attrs) {
match &mut schema_ref {
SchemaRef::Inline(schema) => {
schema.description = Some(doc);
}
SchemaRef::Ref(_) => {
let ref_schema = std::mem::replace(
&mut schema_ref,
SchemaRef::Inline(Box::new(Schema::object())),
);
if let SchemaRef::Ref(reference) = ref_schema {
schema_ref = SchemaRef::Inline(Box::new(Schema {
description: Some(doc),
all_of: Some(vec![SchemaRef::Ref(reference)]),
..Default::default()
}));
}
}
}
}
let is_optional = matches!(
field_type,
Type::Path(type_path)
if type_path
.path
.segments
.first()
.is_some_and(|s| s.ident == "Option")
);
if !is_optional {
required.push(field_name.clone());
}
properties.insert(field_name, schema_ref);
}
}
Fields::Unnamed(_) | Fields::Unit => {
}
}
if flattened_refs.is_empty() {
Schema {
schema_type: Some(SchemaType::Object),
description: struct_description,
properties: if properties.is_empty() {
None
} else {
Some(properties)
},
required: if required.is_empty() {
None
} else {
Some(required)
},
..Schema::object()
}
} else {
let inline_schema = Schema {
schema_type: Some(SchemaType::Object),
properties: if properties.is_empty() {
None
} else {
Some(properties)
},
required: if required.is_empty() {
None
} else {
Some(required)
},
..Schema::object()
};
let mut all_of = vec![SchemaRef::Inline(Box::new(inline_schema))];
all_of.extend(flattened_refs);
Schema {
description: struct_description,
all_of: Some(all_of),
..Default::default()
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[test]
fn test_parse_struct_to_schema_required_optional() {
let struct_item: syn::ItemStruct = syn::parse_str(
r"
struct User {
id: i32,
name: Option<String>,
}
",
)
.unwrap();
let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new());
let props = schema.properties.as_ref().unwrap();
assert!(props.contains_key("id"));
assert!(props.contains_key("name"));
assert!(
schema
.required
.as_ref()
.unwrap()
.contains(&"id".to_string())
);
assert!(
!schema
.required
.as_ref()
.unwrap()
.contains(&"name".to_string())
);
}
#[test]
fn test_parse_struct_to_schema_rename_all_and_field_rename() {
let struct_item: syn::ItemStruct = syn::parse_str(
r#"
#[serde(rename_all = "camelCase")]
struct Profile {
#[serde(rename = "id")]
user_id: i32,
display_name: Option<String>,
}
"#,
)
.unwrap();
let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new());
let props = schema.properties.as_ref().expect("props missing");
assert!(props.contains_key("id")); assert!(props.contains_key("displayName")); let required = schema.required.as_ref().expect("required missing");
assert!(required.contains(&"id".to_string()));
assert!(!required.contains(&"displayName".to_string())); }
#[rstest]
#[case("struct Wrapper(i32);")]
#[case("struct Empty;")]
fn test_parse_struct_to_schema_tuple_and_unit_structs(#[case] struct_src: &str) {
let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap();
let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new());
assert!(schema.properties.is_none());
assert!(schema.required.is_none());
}
#[test]
fn test_parse_struct_to_schema_with_skip_field() {
let struct_item: syn::ItemStruct = syn::parse_str(
r"
struct User {
id: i32,
#[serde(skip)]
internal_data: String,
name: String,
}
",
)
.unwrap();
let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new());
let props = schema.properties.as_ref().unwrap();
assert!(props.contains_key("id"));
assert!(props.contains_key("name"));
assert!(!props.contains_key("internal_data")); }
#[test]
fn test_parse_struct_to_schema_with_default_fields() {
let struct_item: syn::ItemStruct = syn::parse_str(
r#"
struct Config {
required_field: i32,
#[serde(default)]
with_default: String,
#[serde(skip_serializing_if = "Option::is_none")]
maybe_skip: Option<i32>,
}
"#,
)
.unwrap();
let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new());
let props = schema.properties.as_ref().unwrap();
assert!(props.contains_key("required_field"));
assert!(props.contains_key("with_default"));
assert!(props.contains_key("maybe_skip"));
let required = schema.required.as_ref().unwrap();
assert!(required.contains(&"required_field".to_string()));
assert!(required.contains(&"with_default".to_string()));
assert!(!required.contains(&"maybe_skip".to_string()));
}
#[test]
fn test_parse_struct_to_schema_with_description() {
let struct_src = r"
/// User struct description
struct User {
/// User ID
id: i32,
/// User name
name: String,
}
";
let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap();
let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new());
assert_eq!(
schema.description,
Some("User struct description".to_string())
);
let props = schema.properties.unwrap();
if let SchemaRef::Inline(id_schema) = props.get("id").unwrap() {
assert_eq!(id_schema.description, Some("User ID".to_string()));
}
if let SchemaRef::Inline(name_schema) = props.get("name").unwrap() {
assert_eq!(name_schema.description, Some("User name".to_string()));
}
}
#[test]
fn test_parse_struct_to_schema_field_with_ref_and_description() {
let struct_src = r"
struct Container {
/// The user reference
user: User,
}
";
let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap();
let mut struct_defs = HashMap::new();
struct_defs.insert("User".to_string(), "struct User { id: i32 }".to_string());
let mut known = HashSet::new();
known.insert("User".to_string());
let schema = parse_struct_to_schema(&struct_item, &known, &struct_defs);
let props = schema.properties.unwrap();
if let SchemaRef::Inline(user_schema) = props.get("user").unwrap() {
assert_eq!(
user_schema.description,
Some("The user reference".to_string())
);
assert!(user_schema.all_of.is_some());
}
}
#[test]
fn test_parse_struct_to_schema_with_flatten() {
let struct_item: syn::ItemStruct = syn::parse_str(
r"
struct UserListRequest {
filter: String,
#[serde(flatten)]
pagination: Pagination,
}
",
)
.unwrap();
let mut struct_defs = HashMap::new();
struct_defs.insert(
"Pagination".to_string(),
"struct Pagination { page: i32 }".to_string(),
);
let mut known = HashSet::new();
known.insert("Pagination".to_string());
let schema = parse_struct_to_schema(&struct_item, &known, &struct_defs);
assert!(
schema.all_of.is_some(),
"Schema should have allOf for flatten"
);
let all_of = schema.all_of.as_ref().unwrap();
assert_eq!(all_of.len(), 2, "allOf should have 2 elements");
if let SchemaRef::Inline(obj_schema) = &all_of[0] {
let props = obj_schema.properties.as_ref().unwrap();
assert!(props.contains_key("filter"), "Should have filter property");
assert!(
!props.contains_key("pagination"),
"Should NOT have pagination property"
);
} else {
panic!("First allOf element should be inline schema");
}
if let SchemaRef::Ref(reference) = &all_of[1] {
assert_eq!(reference.ref_path, "#/components/schemas/Pagination");
} else {
panic!("Second allOf element should be $ref");
}
}
#[test]
fn test_parse_struct_to_schema_with_multiple_flatten() {
let struct_item: syn::ItemStruct = syn::parse_str(
r"
struct Combined {
name: String,
#[serde(flatten)]
pagination: Pagination,
#[serde(flatten)]
metadata: Metadata,
}
",
)
.unwrap();
let mut struct_defs = HashMap::new();
struct_defs.insert("Pagination".to_string(), "struct Pagination {}".to_string());
struct_defs.insert("Metadata".to_string(), "struct Metadata {}".to_string());
let mut known = HashSet::new();
known.insert("Pagination".to_string());
known.insert("Metadata".to_string());
let schema = parse_struct_to_schema(&struct_item, &known, &struct_defs);
assert!(schema.all_of.is_some());
let all_of = schema.all_of.as_ref().unwrap();
assert_eq!(
all_of.len(),
3,
"allOf should have 3 elements (1 inline + 2 refs)"
);
}
#[test]
fn test_parse_struct_to_schema_no_flatten() {
let struct_item: syn::ItemStruct = syn::parse_str(
r"
struct Simple {
name: String,
age: i32,
}
",
)
.unwrap();
let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new());
assert!(
schema.all_of.is_none(),
"Simple struct should not have allOf"
);
assert!(schema.properties.is_some());
}
}