vespera_macro 0.4.0

Procedural macros for Vespera - automatic route discovery and OpenAPI generation
Documentation
use std::collections::{HashMap, HashSet};

use syn::Type;
use vespera_core::schema::{Schema, SchemaRef};

use crate::{
    parser::schema::is_primitive_type,
    schema_macro::type_utils::is_primitive_like as utils_is_primitive_like,
};

pub(super) fn is_primitive_or_like(ty: &Type) -> bool {
    is_primitive_type(ty) || utils_is_primitive_like(ty)
}

pub(super) fn convert_to_inline_schema(field_schema: SchemaRef, is_optional: bool) -> SchemaRef {
    match field_schema {
        SchemaRef::Inline(mut schema) => {
            if is_optional {
                schema.nullable = Some(true);
            }
            SchemaRef::Inline(schema)
        }
        SchemaRef::Ref(r) if is_optional => SchemaRef::Inline(Box::new(Schema {
            ref_path: Some(r.ref_path),
            schema_type: None,
            nullable: Some(true),
            ..Default::default()
        })),
        SchemaRef::Ref(r) => SchemaRef::Ref(r),
    }
}

pub(super) fn is_known_type(
    ty: &Type,
    known_schemas: &HashSet<&str>,
    struct_definitions: &HashMap<&str, &str>,
) -> bool {
    if is_primitive_type(ty) {
        return true;
    }

    if let Type::Path(type_path) = ty {
        // A segment-less path names no known type: report `false` rather than panic.
        let Some(segment) = type_path.path.segments.last() else {
            return false;
        };
        let ident_str = segment.ident.to_string();
        if struct_definitions.contains_key(ident_str.as_str())
            || known_schemas.contains(ident_str.as_str())
        {
            return true;
        }

        if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
            match ident_str.as_str() {
                "Vec" | "HashSet" | "BTreeSet" | "Option" => {
                    if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() {
                        return is_known_type(inner_ty, known_schemas, struct_definitions);
                    }
                }
                _ => {}
            }
        }
    }

    false
}

#[cfg(test)]
mod tests {
    use std::collections::{HashMap, HashSet};

    use rstest::rstest;
    use syn::Type;
    use vespera_core::schema::{Reference, Schema, SchemaRef, SchemaType};

    use super::*;
    use crate::schema_macro::type_utils::is_map_type as utils_is_map_type;

    #[rstest]
    #[case("String", true)]
    #[case("i32", true)]
    #[case("Vec<String>", true)]
    #[case("Option<bool>", true)]
    #[case("CustomType", false)]
    fn primitive_like(#[case] type_str: &str, #[case] expected: bool) {
        let ty: Type = syn::parse_str(type_str).unwrap();
        assert_eq!(is_primitive_or_like(&ty), expected);
    }

    #[rstest]
    #[case("HashMap<String, String>", true)]
    #[case("BTreeMap<String, String>", true)]
    #[case("String", false)]
    #[case("Vec<i32>", false)]
    fn map_type(#[case] type_str: &str, #[case] expected: bool) {
        let ty: Type = syn::parse_str(type_str).unwrap();
        assert_eq!(utils_is_map_type(&ty), expected);
    }

    #[rstest]
    #[case("i32", HashSet::new(), HashMap::new(), true)]
    #[case("User", HashSet::new(), {
        let mut map = HashMap::new();
        map.insert("User", "pub struct User { id: i32 }");
        map
    }, true)]
    #[case("Product", {
        let mut set = HashSet::new();
        set.insert("Product");
        set
    }, HashMap::new(), true)]
    #[case("Vec<i32>", HashSet::new(), HashMap::new(), true)]
    #[case("Option<String>", HashSet::new(), HashMap::new(), true)]
    #[case("UnknownType", HashSet::new(), HashMap::new(), false)]
    fn known_type(
        #[case] type_str: &str,
        #[case] known_schemas: HashSet<&str>,
        #[case] struct_definitions: HashMap<&str, &str>,
        #[case] expected: bool,
    ) {
        let ty: Type = syn::parse_str(type_str).unwrap();
        assert_eq!(
            is_known_type(&ty, &known_schemas, &struct_definitions),
            expected
        );
    }

    #[test]
    fn known_type_empty_segments() {
        use syn::punctuated::Punctuated;

        let ty = Type::Path(syn::TypePath {
            attrs: Vec::new(),
            qself: None,
            path: syn::Path {
                leading_colon: None,
                segments: Punctuated::new(),
            },
        });
        assert!(!is_known_type(&ty, &HashSet::new(), &HashMap::new()));
    }

    #[test]
    fn known_type_non_path_type_is_false() {
        let ty: Type = syn::parse_str("(i32, String)").unwrap();
        assert!(!is_known_type(&ty, &HashSet::new(), &HashMap::new()));
    }

    #[test]
    fn known_type_non_vec_option_generic() {
        let known_schemas = HashSet::new();
        let struct_definitions = HashMap::new();
        let ty: Type = syn::parse_str("Box<i32>").unwrap();
        assert!(!is_known_type(&ty, &known_schemas, &struct_definitions));
        let ty: Type = syn::parse_str("Result<i32, String>").unwrap();
        assert!(!is_known_type(&ty, &known_schemas, &struct_definitions));
    }

    #[test]
    fn convert_to_inline_schema_inline() {
        let schema = SchemaRef::Inline(Box::new(Schema::string()));
        let result = convert_to_inline_schema(schema, false);
        let SchemaRef::Inline(s) = result else {
            panic!("Expected Inline")
        };
        assert_eq!(s.schema_type, Some(SchemaType::String));
        assert!(s.nullable.is_none());
    }

    #[test]
    fn convert_to_inline_schema_inline_optional() {
        let schema = SchemaRef::Inline(Box::new(Schema::string()));
        let result = convert_to_inline_schema(schema, true);
        let SchemaRef::Inline(s) = result else {
            panic!("Expected Inline")
        };
        assert_eq!(s.schema_type, Some(SchemaType::String));
        assert_eq!(s.nullable, Some(true));
    }

    #[test]
    fn convert_to_inline_schema_ref_optional_preserves_ref_path() {
        let schema = SchemaRef::Ref(Reference {
            ref_path: "#/components/schemas/User".to_string(),
        });
        let result = convert_to_inline_schema(schema, true);
        let SchemaRef::Inline(s) = result else {
            panic!("Expected Inline wrapper")
        };
        assert_eq!(s.ref_path, Some("#/components/schemas/User".to_string()));
        assert_eq!(s.nullable, Some(true));
        assert_eq!(s.schema_type, None);
    }

    #[test]
    fn convert_to_inline_schema_ref_required_passes_through() {
        let schema = SchemaRef::Ref(Reference::schema("SomeType"));
        let result = convert_to_inline_schema(schema, false);
        let SchemaRef::Ref(r) = result else {
            panic!("Expected $ref")
        };
        assert_eq!(r.ref_path, "#/components/schemas/SomeType");
    }

    #[test]
    fn convert_to_inline_schema_ref_optional_wraps_nullable() {
        let schema = SchemaRef::Ref(Reference::schema("User"));
        let result = convert_to_inline_schema(schema, true);
        let SchemaRef::Inline(s) = result else {
            panic!("Expected Inline wrapper")
        };
        assert_eq!(s.ref_path, Some("#/components/schemas/User".to_string()));
        assert_eq!(s.nullable, Some(true));
        assert_eq!(s.schema_type, None);
    }
}