diff --git a/utoipa-gen/Cargo.toml b/utoipa-gen/Cargo.toml index 96328ac8..335d95cc 100644 --- a/utoipa-gen/Cargo.toml +++ b/utoipa-gen/Cargo.toml @@ -22,6 +22,7 @@ include = [ proc-macro = true [dependencies] +desynt = { git = "https://github.com/jayvdb/desynt" } utoipa-config = { version = "0.1", path = "../utoipa-config", optional = true } once_cell = { version = "1.19.0", optional = true } proc-macro2 = "1.0" diff --git a/utoipa-gen/src/component.rs b/utoipa-gen/src/component.rs index 90bce51d..dfc3920c 100644 --- a/utoipa-gen/src/component.rs +++ b/utoipa-gen/src/component.rs @@ -145,48 +145,55 @@ impl<'p> SynPathExt for &'p Path { .expect("syn::Path must have at least one segment"); let mut segment = last_segment.clone(); - if let PathArguments::AngleBracketed(anglebracketed_args) = &last_segment.arguments { - let args = anglebracketed_args.args.iter().try_fold( - Punctuated::::new(), - |mut args, generic_arg| { - match generic_arg { - GenericArgument::Type(ty) => { - let type_tree = TypeTree::from_type(ty)?; - let alias_type = type_tree.get_alias_type()?; - let alias_type_tree = - alias_type.as_ref().map(TypeTree::from_type).transpose()?; - let type_tree = alias_type_tree.unwrap_or(type_tree); - - let path = type_tree - .path - .as_ref() - .expect("TypeTree must have a path") - .as_ref(); - - if let Some(default_type) = PrimitiveType::new(path) { - args.push(GenericArgument::Type(default_type.ty.clone())); - } else { - let inner = path.rewrite_path()?; - args.push(GenericArgument::Type(syn::Type::Path( - syn::parse_quote!(#inner), - ))) + let args = + if let PathArguments::AngleBracketed(anglebracketed_args) = &last_segment.arguments { + anglebracketed_args.args.iter().try_fold( + Punctuated::::new(), + |mut args, generic_arg| { + match generic_arg { + GenericArgument::Type(ty) => { + let type_tree = TypeTree::from_type(ty)?; + let alias_type = type_tree.get_alias_type()?; + let alias_type_tree = + alias_type.as_ref().map(TypeTree::from_type).transpose()?; + let type_tree = alias_type_tree.unwrap_or(type_tree); + + let path = type_tree + .path + .as_ref() + .expect("TypeTree must have a path") + .as_ref(); + + if let Some(default_type) = PrimitiveType::new(path) { + args.push(GenericArgument::Type(default_type.ty.clone())); + } else { + let inner = path.rewrite_path()?; + args.push(GenericArgument::Type(syn::Type::Path( + syn::parse_quote!(#inner), + ))) + } } + other => args.push(other.clone()), } - other => args.push(other.clone()), - } - - Result::<_, Diagnostics>::Ok(args) - }, - )?; - let angle_bracket_args = AngleBracketedGenericArguments { - args, - lt_token: anglebracketed_args.lt_token, - gt_token: anglebracketed_args.gt_token, - colon2_token: anglebracketed_args.colon2_token, + Result::<_, Diagnostics>::Ok(args) + }, + )? + } else { + Punctuated::new() }; - segment.arguments = PathArguments::AngleBracketed(angle_bracket_args); + if !args.is_empty() { + if let PathArguments::AngleBracketed(anglebracketed_args) = &last_segment.arguments { + let angle_bracket_args = AngleBracketedGenericArguments { + args, + lt_token: anglebracketed_args.lt_token, + gt_token: anglebracketed_args.gt_token, + colon2_token: anglebracketed_args.colon2_token, + }; + + segment.arguments = PathArguments::AngleBracketed(angle_bracket_args); + } } let segment_ident = &segment.ident; @@ -412,7 +419,7 @@ impl TypeTree<'_> { } fn convert<'t>(path: &'t Path, last_segment: &'t PathSegment) -> TypeTree<'t> { - let generic_type = Self::get_generic_type(last_segment); + let generic_type = Self::get_generic_type(path, last_segment); let schema_type = SchemaType { path: Cow::Borrowed(path), nullable: matches!(generic_type, Some(GenericType::Option)), @@ -434,12 +441,16 @@ impl TypeTree<'_> { } // TODO should we recognize unknown generic types with `GenericType::Unknown` instead of `None`? - fn get_generic_type(segment: &PathSegment) -> Option { + fn get_generic_type(path: &Path, segment: &PathSegment) -> Option { if segment.arguments.is_empty() { return None; } - match &*segment.ident.to_string() { + // Try to resolve the full path to a canonical type name using desynt::PathResolver + // Only recognize standard library types - if resolution fails, it's not a stdlib type + let canonical_name = crate::resolve_path_to_canonical(path)?; + + match canonical_name { "HashMap" | "Map" | "BTreeMap" => Some(GenericType::Map), #[cfg(feature = "indexmap")] "IndexMap" => Some(GenericType::Map), @@ -568,7 +579,9 @@ impl TypeTree<'_> { .as_ref() .and_then(|path| path.segments.iter().last()) .and_then(|last_segment| { - crate::CONFIG.aliases.get(&*last_segment.ident.to_string()) + crate::CONFIG + .aliases + .get(&*last_segment.ident.strip_raw().to_string()) }) .map_try(|alias| syn::parse_str::(alias.as_ref())) .map_err(|error| Diagnostics::new(error.to_string())) diff --git a/utoipa-gen/src/component/features/attributes.rs b/utoipa-gen/src/component/features/attributes.rs index cb343b2f..c0db4d54 100644 --- a/utoipa-gen/src/component/features/attributes.rs +++ b/utoipa-gen/src/component/features/attributes.rs @@ -1,5 +1,6 @@ use std::mem; +use desynt::StripRaw; use proc_macro2::{Ident, TokenStream}; use quote::ToTokens; use syn::parse::ParseStream; @@ -497,7 +498,7 @@ impl_feature! { impl ValueType { /// Create [`TypeTree`] from current [`syn::Type`]. - pub fn as_type_tree(&self) -> Result { + pub fn as_type_tree(&self) -> Result, Diagnostics> { TypeTree::from_type(&self.0) } } @@ -682,7 +683,7 @@ impl As { .path .segments .iter() - .map(|segment| segment.ident.to_string()) + .map(|segment| segment.ident.strip_raw().to_string()) .collect::>() .join(".") } diff --git a/utoipa-gen/src/component/features/validators.rs b/utoipa-gen/src/component/features/validators.rs index f0ba2fe1..355d7e1f 100644 --- a/utoipa-gen/src/component/features/validators.rs +++ b/utoipa-gen/src/component/features/validators.rs @@ -31,6 +31,7 @@ impl Validator for IsString<'_> { } } +#[allow(dead_code)] pub struct IsInteger<'a>(&'a SchemaType<'a>); impl Validator for IsInteger<'_> { diff --git a/utoipa-gen/src/component/into_params.rs b/utoipa-gen/src/component/into_params.rs index 3871cd80..fff6a9c4 100644 --- a/utoipa-gen/src/component/into_params.rs +++ b/utoipa-gen/src/component/into_params.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; +use desynt::StripRaw; use proc_macro2::TokenStream; use quote::{quote, quote_spanned, ToTokens}; use syn::{ @@ -323,19 +324,15 @@ impl Param { let mut tokens = TokenStream::new(); let field_serde_params = &field_serde_params; let ident = &field.ident; - let mut name = &*ident + let name = &*ident .as_ref() - .map(|ident| ident.to_string()) + .map(|ident| ident.strip_raw().to_string()) .or_else(|| container_attributes.name.cloned()) .ok_or_else(|| Diagnostics::with_span(field.span(), "No name specified for unnamed field.") .help("Try adding #[into_params(names(...))] container attribute to specify the name for this field") )?; - if name.starts_with("r#") { - name = &name[2..]; - } - let (schema_features, mut param_features) = Param::resolve_field_features(field_features, &container_attributes) .map_err(Diagnostics::from)?; diff --git a/utoipa-gen/src/component/schema.rs b/utoipa-gen/src/component/schema.rs index 9f09bc2b..c8766484 100644 --- a/utoipa-gen/src/component/schema.rs +++ b/utoipa-gen/src/component/schema.rs @@ -1,5 +1,6 @@ use std::borrow::{Borrow, Cow}; +use desynt::StripRaw; use proc_macro2::{Ident, TokenStream}; use quote::{quote, quote_spanned, ToTokens}; use syn::{ @@ -347,11 +348,8 @@ impl NamedStructSchema { let mut fields_vec = fields .iter() .filter_map(|field| { - let mut field_name = Cow::Owned(field.ident.as_ref().unwrap().to_string()); - - if Borrow::::borrow(&field_name).starts_with("r#") { - field_name = Cow::Owned(field_name[2..].to_string()); - } + let field_ident = field.ident.as_ref().unwrap(); + let field_name: Cow<'_, str> = Cow::Owned(field_ident.strip_raw().to_string()); let field_rules = serde::parse_value(&field.attrs); let field_rules = match field_rules { @@ -500,11 +498,11 @@ impl NamedStructSchema { Some(flattened_map_field) => { return Err(Diagnostics::with_span( fields.span(), - format!("The structure `{}` contains multiple flattened map fields.", root.ident)) + format!("The structure `{}` contains multiple flattened map fields.", root.ident.strip_raw())) .note( format!("first flattened map field was declared here as `{}`", - flattened_map_field.ident.as_ref().unwrap())) - .note(format!("second flattened map field was declared here as `{}`", field.ident.as_ref().unwrap())) + flattened_map_field.ident.as_ref().unwrap().strip_raw())) + .note(format!("second flattened map field was declared here as `{}`", field.ident.as_ref().unwrap().strip_raw())) ); } } diff --git a/utoipa-gen/src/component/schema/enums.rs b/utoipa-gen/src/component/schema/enums.rs index 224925ce..5f56bf62 100644 --- a/utoipa-gen/src/component/schema/enums.rs +++ b/utoipa-gen/src/component/schema/enums.rs @@ -1,5 +1,6 @@ use std::{borrow::Cow, ops::Deref}; +use desynt::StripRaw; use proc_macro2::TokenStream; use quote::{quote, ToTokens}; use syn::{punctuated::Punctuated, spanned::Spanned, token::Comma, Fields, TypePath, Variant}; @@ -103,7 +104,7 @@ impl<'e> PlainEnum<'e> { .collect::, Diagnostics>>()? .into_iter() .map(|(variant, variant_rules, mut variant_features)| { - let name = &*variant.ident.to_string(); + let name = &*variant.ident.strip_raw().to_string(); let renamed = super::rename_enum_variant( name, &mut variant_features, @@ -398,7 +399,7 @@ impl MixedEnumContent { mut variant_features: Vec, ) -> Result { let mut tokens = TokenStream::new(); - let name = variant.ident.to_string(); + let name = variant.ident.strip_raw().to_string(); // TODO support `description = ...` attribute via Feature::Description // let description = // pop_feature!(variant_features => Feature::Description(_) as Option); diff --git a/utoipa-gen/src/ext.rs b/utoipa-gen/src/ext.rs index 4d1eda1c..004133de 100644 --- a/utoipa-gen/src/ext.rs +++ b/utoipa-gen/src/ext.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; +use desynt::StripRaw; use proc_macro2::TokenStream; use quote::ToTokens; use syn::spanned::Spanned; @@ -182,7 +183,7 @@ fn get_actual_body_type<'t>(ty: &'t TypeTree<'t>) -> Option<&'t TypeTree<'t>> { .expect("RequestBody TypeTree must have syn::Path") .segments .iter() - .find_map(|segment| match &*segment.ident.to_string() { + .find_map(|segment| match &*segment.ident.strip_raw().to_string() { "Json" => Some( ty.children .as_deref() @@ -288,7 +289,7 @@ pub trait ArgumentResolver { _: &'_ Punctuated, _: Option>, _: String, - ) -> Result { + ) -> Result, Diagnostics> { Ok((None, None, None)) } } diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index d1c799d0..82447806 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -69,6 +69,20 @@ use self::{ static CONFIG: once_cell::sync::Lazy = once_cell::sync::Lazy::new(utoipa_config::Config::read_from_file); +// Global PathResolver using desynt's type groups for normalizing type paths +static PATH_RESOLVER: std::sync::OnceLock< + desynt::PathResolver>, +> = std::sync::OnceLock::new(); + +fn get_path_resolver() -> &'static desynt::PathResolver> { + PATH_RESOLVER.get_or_init(|| desynt::PathResolver::with_all_groups()) +} + +/// Resolve a type path to its canonical name using desynt::PathResolver +pub(crate) fn resolve_path_to_canonical(path: &syn::Path) -> Option<&'static str> { + get_path_resolver().resolve(path) +} + #[proc_macro_derive(ToSchema, attributes(schema))] /// Generate reusable OpenAPI schema to be used /// together with [`OpenApi`][openapi_derive]. diff --git a/utoipa-gen/src/openapi.rs b/utoipa-gen/src/openapi.rs index 8ba84abf..2d0ce8d8 100644 --- a/utoipa-gen/src/openapi.rs +++ b/utoipa-gen/src/openapi.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; +use desynt::StripRaw; use proc_macro2::Ident; use syn::{ bracketed, parenthesized, @@ -70,7 +71,7 @@ impl<'o> OpenApiAttr<'o> { } } -pub fn parse_openapi_attrs(attrs: &[Attribute]) -> Result, Error> { +pub fn parse_openapi_attrs(attrs: &[Attribute]) -> Result>, Error> { attrs .iter() .filter(|attribute| attribute.path().is_ident("openapi")) @@ -440,7 +441,7 @@ impl OpenApi<'_> { .segments .iter() .take(nest_api.path.segments.len() - 1) - .map(|segment| segment.ident.to_string()) + .map(|segment| segment.ident.strip_raw().to_string()) .collect::>() .join("::"); let tags = &item.tags.iter().collect::>(); @@ -696,7 +697,7 @@ fn impl_paths(handler_paths: Option<&Punctuated>) -> Paths { let segments = handler.path.segments.iter().collect::>(); let handler_config_name = segments .iter() - .map(|segment| segment.ident.to_string()) + .map(|segment| segment.ident.strip_raw().to_string()) .collect::>() .join("_"); let handler_fn = &segments.last().unwrap().ident; @@ -706,7 +707,7 @@ fn impl_paths(handler_paths: Option<&Punctuated>) -> Paths { let tag = segments .iter() .take(segments.len() - 1) - .map(|part| part.ident.to_string()) + .map(|part| part.ident.strip_raw().to_string()) .collect::>() .join("::"); @@ -759,7 +760,7 @@ fn impl_paths(handler_paths: Option<&Punctuated>) -> Paths { let segments = handler.path.segments.iter().collect::>(); let handler_config_name = segments .iter() - .map(|segment| segment.ident.to_string()) + .map(|segment| segment.ident.strip_raw().to_string()) .collect::>() .join("_"); let handler_ident_config = format_ident!("{}_config", handler_config_name); diff --git a/utoipa-gen/src/path/media_type.rs b/utoipa-gen/src/path/media_type.rs index ecdf7214..ba9093ff 100644 --- a/utoipa-gen/src/path/media_type.rs +++ b/utoipa-gen/src/path/media_type.rs @@ -229,7 +229,7 @@ impl Default for Schema<'_> { } impl Schema<'_> { - pub fn get_type_tree(&self) -> Result>>, Diagnostics> { + pub fn get_type_tree(&self) -> Result>>, Diagnostics> { match self { Self::Default(def) => def.get_type_tree(), Self::Ext(ext) => ext.get_type_tree(), @@ -417,7 +417,7 @@ pub struct ParsedType<'i> { impl ParsedType<'_> { /// Get's the underlying [`syn::Type`] as [`TypeTree`]. - fn to_type_tree(&self) -> Result { + fn to_type_tree(&self) -> Result, Diagnostics> { TypeTree::from_type(&self.ty) } } diff --git a/utoipa-gen/src/path/response.rs b/utoipa-gen/src/path/response.rs index 06f9a089..a0eb0102 100644 --- a/utoipa-gen/src/path/response.rs +++ b/utoipa-gen/src/path/response.rs @@ -673,12 +673,12 @@ struct ResponseStatus(TokenStream2); impl Parse for ResponseStatus { fn parse(input: ParseStream) -> syn::Result { - fn parse_lit_int(input: ParseStream) -> syn::Result> { + fn parse_lit_int(input: ParseStream<'_>) -> syn::Result> { input.parse::()?.base10_parse().map(Cow::Owned) } - fn parse_lit_str_status_range(input: ParseStream) -> syn::Result> { - const VALID_STATUS_RANGES: [&str; 6] = ["default", "1XX", "2XX", "3XX", "4XX", "5XX"]; + fn parse_lit_str_status_range(input: ParseStream<'_>) -> syn::Result> { + const VALID_STATUS_RANGES: [&str; 5] = ["1XX", "2XX", "3XX", "4XX", "5XX"]; input .parse::() diff --git a/utoipa-gen/src/path/response/derive.rs b/utoipa-gen/src/path/response/derive.rs index 05e3e250..a0c68bcd 100644 --- a/utoipa-gen/src/path/response/derive.rs +++ b/utoipa-gen/src/path/response/derive.rs @@ -678,7 +678,7 @@ impl<'r> EnumResponse<'r> { Ok(Self(response_value.into())) } - fn parse_variant_attributes(variant: &Variant) -> Result { + fn parse_variant_attributes(variant: &Variant) -> Result, Diagnostics> { let variant_derive_response_value = DeriveToResponseValue::from_attributes(variant.attrs.as_slice())?; // named enum variant should not have field attributes diff --git a/utoipa-gen/src/schema_type.rs b/utoipa-gen/src/schema_type.rs index e5d0b19c..5a118667 100644 --- a/utoipa-gen/src/schema_type.rs +++ b/utoipa-gen/src/schema_type.rs @@ -1,3 +1,4 @@ +use desynt::StripRaw; use proc_macro2::TokenStream; use quote::{quote, ToTokens}; use syn::spanned::Spanned; @@ -68,7 +69,7 @@ impl SchemaType<'_> { Some(segment) => segment, None => return false, }; - let name = &*last_segment.ident.to_string(); + let name = &*last_segment.ident.strip_raw().to_string(); #[cfg(not(any( feature = "chrono", @@ -233,7 +234,7 @@ impl ToTokensDiagnostics for SchemaType<'_> { "schema type should have at least one segment in the path", ) })?; - let name = &*last_segment.ident.to_string(); + let name = &*last_segment.ident.strip_raw().to_string(); fn schema_type_tokens( tokens: &mut TokenStream, @@ -373,7 +374,7 @@ impl KnownFormat { "type should have at least one segment in the path", ) })?; - let name = &*last_segment.ident.to_string(); + let name = &*last_segment.ident.strip_raw().to_string(); let variant = match name { #[cfg(feature = "non_strict_integers")] @@ -688,7 +689,7 @@ impl PrimitiveType { ) }); - let name = &*last_segment.ident.to_string(); + let name = &*last_segment.ident.strip_raw().to_string(); let ty: syn::Type = match name { "String" | "str" | "char" => syn::parse_quote!(#path), diff --git a/utoipa-gen/tests/raw_identifier.rs b/utoipa-gen/tests/raw_identifier.rs new file mode 100644 index 00000000..df82fba1 --- /dev/null +++ b/utoipa-gen/tests/raw_identifier.rs @@ -0,0 +1,511 @@ +#![expect( + dead_code, + reason = "Test structs with raw identifier fields are used only for schema generation" +)] + +use utoipa::{IntoParams, PartialSchema, ToSchema}; + +mod common; + +/// Test that demonstrates raw identifiers in struct fields are properly handled +#[test] +fn struct_with_raw_identifier_fields() { + let schema = { + #[derive(ToSchema)] + struct TestStruct { + r#type: String, + r#match: i32, + normal_field: bool, + } + + serde_json::to_value(::schema()).unwrap() + }; + + // The schema should contain clean field names without r# prefix + assert_value! { schema=> + "properties.type.type" = r#""string""#, "type field should be string type" + "properties.match.type" = r#""integer""#, "match field should be integer type" + "properties.normal_field.type" = r#""boolean""#, "normal_field should be boolean type" + }; + + // Verify that the property keys are clean (no r# prefix) + let properties = schema + .pointer("/properties") + .expect("Should have properties"); + let property_names: Vec = properties + .as_object() + .expect("Properties should be object") + .keys() + .cloned() + .collect(); + + println!("SUCCESS: Field names are clean: {:?}", property_names); + + assert!( + property_names.contains(&"type".to_string()), + "Should contain 'type' property" + ); + assert!( + property_names.contains(&"match".to_string()), + "Should contain 'match' property" + ); + assert!( + property_names.contains(&"normal_field".to_string()), + "Should contain 'normal_field' property" + ); + + // Assert that raw identifiers are NOT present + assert!( + !property_names.contains(&"r#type".to_string()), + "Should NOT contain 'r#type' property" + ); + assert!( + !property_names.contains(&"r#match".to_string()), + "Should NOT contain 'r#match' property" + ); +} + +/// Test that demonstrates raw identifiers in IntoParams are properly handled +#[test] +fn into_params_with_raw_identifiers() { + #[derive(IntoParams)] + struct QueryParams { + r#type: Option, + r#match: Option, + limit: Option, + } + + let params = QueryParams::into_params(|| None); + + // Check that parameter names are clean (without r# prefix) + let param_names: Vec<&str> = params.iter().map(|p| p.name.as_str()).collect(); + + println!("Parameter names: {:?}", param_names); + + assert!( + param_names.contains(&"type"), + "Should contain 'type' parameter, got: {:?}", + param_names + ); + assert!( + param_names.contains(&"match"), + "Should contain 'match' parameter, got: {:?}", + param_names + ); + assert!( + param_names.contains(&"limit"), + "Should contain 'limit' parameter, got: {:?}", + param_names + ); + + // Assert that raw identifiers are NOT present + assert!( + !param_names.contains(&"r#type"), + "Should NOT contain 'r#type' parameter" + ); + assert!( + !param_names.contains(&"r#match"), + "Should NOT contain 'r#match' parameter" + ); + + println!("SUCCESS: Parameter names are clean: {:?}", param_names); +} + +/// Test error messages and display formatting +#[test] +fn clean_names_in_debug_output() { + let schema = { + #[derive(ToSchema)] + struct TestStruct { + r#type: String, + r#async: bool, + } + + serde_json::to_value(::schema()).unwrap() + }; + + let debug_output = format!("{:?}", schema); + + // The debug output should contain clean field names + assert!( + debug_output.contains("\"type\""), + "Debug output should contain clean 'type' field name" + ); + assert!( + debug_output.contains("\"async\""), + "Debug output should contain clean 'async' field name" + ); + + println!("SUCCESS: Debug output contains clean field names"); + println!( + "Sample debug output: {}", + debug_output.chars().take(200).collect::() + ); +} + +/// Test that demonstrates raw identifiers work with primitive type aliases +#[test] +fn primitive_type_aliases_with_raw_identifiers() { + // This test demonstrates what happens when people use raw identifiers + // for field names with primitive types - the more common real-world scenario + let schema = { + #[derive(ToSchema)] + #[allow(non_snake_case)] + struct PrimitiveTestStruct { + // These field names use raw identifiers but the types are normal primitives + r#i8: i8, + r#u8: u8, + r#i16: i16, + r#u16: u16, + r#i32: i32, + r#u32: u32, + r#i64: i64, + r#f32: f32, + r#f64: f64, + r#bool: bool, + r#String: String, + } + + serde_json::to_value(::schema()).unwrap() + }; + + // Verify that all field names are clean (no r# prefix) + let properties = schema + .pointer("/properties") + .expect("Should have properties"); + let property_names: Vec = properties + .as_object() + .expect("Properties should be object") + .keys() + .cloned() + .collect(); + + println!("Primitive type field names: {:?}", property_names); + + // Check that all expected field names are present and clean + let expected_fields = [ + "i8", "u8", "i16", "u16", "i32", "u32", "i64", "f32", "f64", "bool", "String", + ]; + + for field in expected_fields { + assert!( + property_names.contains(&field.to_string()), + "Should contain '{}' field", + field + ); + } + + // Ensure raw identifier prefixes are NOT present + for field in expected_fields { + let raw_field = format!("r#{}", field); + assert!( + !property_names.contains(&raw_field), + "Should NOT contain '{}' field", + raw_field + ); + } + + // Verify basic types are correctly mapped in the schema (without worrying about specific formats) + assert_value! { schema=> + "properties.i8.type" = r#""integer""#, "i8 field should be integer type" + "properties.u8.type" = r#""integer""#, "u8 field should be integer type" + "properties.String.type" = r#""string""#, "String field should be string type" + "properties.bool.type" = r#""boolean""#, "bool field should be boolean type" + "properties.f32.type" = r#""number""#, "f32 field should be number type" + }; + + println!("SUCCESS: All primitive type fields with raw identifier names work correctly - r#i8, r#bool, etc. are all cleaned!"); +} + +/// Test that demonstrates raw identifiers work with stdlib generic type names +/// +/// This tests the case where someone uses raw identifiers on the actual stdlib types +/// themselves (e.g., r#Option, r#Vec), not type aliases. This is the real-world scenario +/// where desynt helps - it strips the r# prefix from type names like r#Option so that +/// utoipa can recognize them as GenericType::Option. +#[test] +fn generic_types_with_raw_identifiers() { + let schema = { + #[derive(ToSchema)] + #[allow(non_camel_case_types)] + struct GenericTestStruct { + // Using raw identifiers directly on stdlib generic types + // This is what desynt helps with - stripping r# from type names + optional_field: r#Option, + list_field: r#Vec, + map_field: std::collections::r#HashMap, + normal_option: Option, + } + + serde_json::to_value(::schema()).unwrap() + }; + + // Verify that all field names are present + let properties = schema + .pointer("/properties") + .expect("Should have properties"); + let property_names: Vec = properties + .as_object() + .expect("Properties should be object") + .keys() + .cloned() + .collect(); + + println!("Generic type field names: {:?}", property_names); + + let expected_fields = ["optional_field", "list_field", "map_field", "normal_option"]; + + for field in expected_fields { + assert!( + property_names.contains(&field.to_string()), + "Should contain '{}' field", + field + ); + } + + // Verify the types are correctly recognized and nullable fields are handled + // Both optional fields should not be in required since they're Option types + if let Some(required) = schema.pointer("/required") { + let required_array = required.as_array().expect("Required should be array"); + let required_names: Vec<&str> = required_array.iter().filter_map(|v| v.as_str()).collect(); + + println!("Required fields: {:?}", required_names); + + // optional_field and normal_option should NOT be required (they're Option types) + // This tests that r#Option is properly recognized as GenericType::Option + assert!( + !required_names.contains(&"optional_field"), + "optional_field should not be required (it's r#Option which should be recognized as Option)" + ); + assert!( + !required_names.contains(&"normal_option"), + "normal_option should not be required (it's an Option)" + ); + + // list_field and map_field SHOULD be required (they're not Option) + assert!( + required_names.contains(&"list_field"), + "list_field should be required" + ); + assert!( + required_names.contains(&"map_field"), + "map_field should be required" + ); + } + + // Verify list_field is recognized as an array (tests that r#Vec is recognized as Vec) + assert_value! { schema=> + "properties.list_field.type" = r#""array""#, "list_field should be array type (r#Vec recognized as Vec)" + "properties.list_field.items.type" = r#""integer""#, "list_field items should be integer" + "properties.map_field.type" = r#""object""#, "map_field should be object type (r#HashMap recognized as HashMap)" + }; + + println!("SUCCESS: Generic types with raw identifiers work correctly - r#Option, r#Vec, r#HashMap are properly recognized!"); +} + +/// Test that demonstrates whether utoipa recognizes fully qualified type paths +/// +/// This tests whether types like `std::string::String`, `std::vec::Vec`, `std::option::Option` +/// are recognized the same as their short forms. This is important for understanding how +/// desynt::PathResolver should be used. +#[test] +fn fully_qualified_type_paths() { + let schema = { + #[derive(ToSchema)] + struct FullyQualifiedTestStruct { + // Using fully qualified paths to see if utoipa recognizes them + string_field: std::string::String, + vec_field: std::vec::Vec, + option_field: std::option::Option, + hashmap_field: std::collections::HashMap, + + // Compare with short forms + short_string: String, + short_vec: Vec, + short_option: Option, + } + + serde_json::to_value(::schema()).unwrap() + }; + + // Verify that all field names are present + let properties = schema + .pointer("/properties") + .expect("Should have properties"); + let property_names: Vec = properties + .as_object() + .expect("Properties should be object") + .keys() + .cloned() + .collect(); + + println!("Fully qualified type field names: {:?}", property_names); + + // Check required fields - Option types should NOT be required + if let Some(required) = schema.pointer("/required") { + let required_array = required.as_array().expect("Required should be array"); + let required_names: Vec<&str> = required_array.iter().filter_map(|v| v.as_str()).collect(); + + println!("Required fields: {:?}", required_names); + + // This is the key test: does std::option::Option get recognized as Option? + if required_names.contains(&"option_field") { + panic!( + "FAILED: option_field (std::option::Option) is marked as required! \ + This means utoipa is NOT recognizing fully qualified paths. \ + It only looks at the last segment 'Option' but std::option::Option has last segment 'Option' in a module path." + ); + } + + println!( + "SUCCESS: option_field is NOT required, suggesting std::option::Option is recognized" + ); + + // short_option should definitely not be required + assert!( + !required_names.contains(&"short_option"), + "short_option should not be required" + ); + + // Non-option fields SHOULD be required + assert!( + required_names.contains(&"string_field"), + "string_field should be required" + ); + assert!( + required_names.contains(&"vec_field"), + "vec_field should be required" + ); + assert!( + required_names.contains(&"hashmap_field"), + "hashmap_field should be required" + ); + } else { + panic!("Schema should have required fields"); + } + + // Verify that string_field is recognized as string type (not object) + assert_value! { schema=> + "properties.string_field.type" = r#""string""#, "std::string::String should be recognized as string type" + "properties.short_string.type" = r#""string""#, "String should be recognized as string type" + }; + + // Verify that vec_field is recognized as array type (not object) + assert_value! { schema=> + "properties.vec_field.type" = r#""array""#, "std::vec::Vec should be recognized as array type" + "properties.vec_field.items.type" = r#""integer""#, "vec_field items should be integer" + "properties.short_vec.type" = r#""array""#, "Vec should be recognized as array type" + "properties.short_vec.items.type" = r#""integer""#, "short_vec items should be integer" + }; + + // Verify that hashmap is recognized as object with additionalProperties + assert_value! { schema=> + "properties.hashmap_field.type" = r#""object""#, "std::collections::HashMap should be recognized as object type" + }; + + println!("SUCCESS: Fully qualified type paths are properly recognized!"); +} + +/// Test with type aliases to demonstrate where path resolution is needed +/// +/// This tests a realistic scenario where someone creates type aliases with the same +/// names as stdlib types but in different modules. Without proper path resolution, +/// utoipa might incorrectly treat these as the stdlib types. +#[test] +fn type_alias_path_resolution() { + // Create type aliases in a local module with same names as stdlib types + mod custom { + use utoipa::ToSchema; + + // Custom types with similar names to stdlib types + #[derive(ToSchema)] + pub struct MyOption { + pub value: Option, + } + + #[derive(ToSchema)] + pub struct MyVec { + pub items: Vec, + } + } + + let schema = { + #[derive(ToSchema)] + struct PathResolutionTest { + // This is the stdlib Option - should NOT be required (nullable) + std_option: std::option::Option, + + // This is a custom type - SHOULD be required + custom_option: custom::MyOption, + + // This is the stdlib Vec - should be array type + std_vec: std::vec::Vec, + + // This is a custom type - should have $ref + custom_vec: custom::MyVec, + } + + serde_json::to_value(::schema()).unwrap() + }; + + println!("Schema: {}", serde_json::to_string_pretty(&schema).unwrap()); + + // Check required fields + if let Some(required) = schema.pointer("/required") { + let required_array = required.as_array().expect("Required should be array"); + let required_names: Vec<&str> = required_array.iter().filter_map(|v| v.as_str()).collect(); + + println!("Required fields: {:?}", required_names); + + // std::option::Option should NOT be required (it's nullable) + assert!( + !required_names.contains(&"std_option"), + "std_option (std::option::Option) should not be required" + ); + + // custom::Option SHOULD be required because it's not the stdlib Option type + assert!( + required_names.contains(&"custom_option"), + "custom_option should be required - it's a custom type, not std::option::Option" + ); + + // Both Vec types should be required + assert!( + required_names.contains(&"std_vec"), + "std_vec should be required" + ); + assert!( + required_names.contains(&"custom_vec"), + "custom_vec should be required" + ); + } + + // Check if std_vec is treated as array + if let Some(std_vec_type) = schema.pointer("/properties/std_vec/type") { + println!("std_vec type: {}", std_vec_type); + assert_eq!( + std_vec_type.as_str(), + Some("array"), + "std::vec::Vec should be array type" + ); + } + + // Check if custom types have $ref (not inline like arrays or options) + if let Some(custom_option_ref) = schema.pointer("/properties/custom_option/$ref") { + println!("custom_option has $ref: {}", custom_option_ref); + } else { + panic!("custom_option should have a $ref since it's a custom type, not an inline type"); + } + + if let Some(custom_vec_ref) = schema.pointer("/properties/custom_vec/$ref") { + println!("custom_vec has $ref: {}", custom_vec_ref); + } else { + panic!("custom_vec should have a $ref since it's a custom type, not an inline array"); + } + + println!("\nSUCCESS: desynt::PathResolver correctly distinguishes:"); + println!( + " - std::option::Option (nullable, not required) vs custom types (required with $ref)" + ); + println!(" - std::vec::Vec (inline array type) vs custom types ($ref to custom schema)"); +}