diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs index 27da88c4..9601301a 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs @@ -15,6 +15,7 @@ namespace Asp.Versioning.OpenApi.Transformers; /// public class XmlComments { + private const int MaxInheritDocDepth = 8; private readonly ConcurrentDictionary members = new(); /// @@ -182,8 +183,109 @@ public virtual string GetResponseDescription( MemberInfo member, string statusCo /// /// The member to get the information for. /// The representing the matching member element or null. - protected virtual XElement? GetMember( MemberInfo member ) => - GetMemberById( XmlCommentsProvider.GetDocumentationMemberId( member ) ); + protected virtual XElement? GetMember( MemberInfo member ) => ResolveMember( member, depth: 0 ); + + private XElement? ResolveMember( MemberInfo member, int depth ) + { + var element = GetMemberById( XmlCommentsProvider.GetDocumentationMemberId( member ) ); + + // The C# compiler writes verbatim into the XML file; following it is the consumer's + // responsibility. Resolve it so members documented on a base type or an implemented interface still + // surface their summary, remarks, parameters, and so on. + if ( depth < MaxInheritDocDepth && element?.Element( "inheritdoc" ) is { } inheritdoc ) + { + if ( ResolveInheritDoc( member, inheritdoc, depth ) is { } inherited ) + { + return inherited; + } + } + + return element; + } + + private XElement? ResolveInheritDoc( MemberInfo member, XElement inheritdoc, int depth ) + { + // Explicit source: + if ( inheritdoc.Attribute( "cref" )?.Value is { Length: > 0 } cref + && GetMemberById( cref ) is { } referenced + && referenced.Element( "inheritdoc" ) is null ) + { + return referenced; + } + + // Implicit source: the same member on a base type, then on an implemented interface + foreach ( var inheritedMember in GetInheritedMembers( member ) ) + { + if ( ResolveMember( inheritedMember, depth + 1 ) is { } resolved + && resolved.Element( "inheritdoc" ) is null ) + { + return resolved; + } + } + + return null; + } + + [UnconditionalSuppressMessage( "ILLink", "IL2070" )] + private static IEnumerable GetInheritedMembers( MemberInfo member ) + { + // on a type inherits from its base type and then its implemented interfaces + if ( member is Type type ) + { + if ( type.BaseType is { } baseType && baseType != typeof( object ) ) + { + yield return baseType; + } + + foreach ( var contract in type.GetInterfaces() ) + { + yield return contract; + } + + yield break; + } + + if ( member.DeclaringType is not { } declaringType ) + { + yield break; + } + + // Base class first (overrides and hidden members), then implemented interfaces + if ( declaringType.BaseType is { } baseDeclaringType + && baseDeclaringType != typeof( object ) + && FindMatchingMember( baseDeclaringType, member ) is { } baseMember ) + { + yield return baseMember; + } + + foreach ( var contract in declaringType.GetInterfaces() ) + { + if ( FindMatchingMember( contract, member ) is { } interfaceMember ) + { + yield return interfaceMember; + } + } + } + + [UnconditionalSuppressMessage( "ILLink", "IL2070" )] + private static MemberInfo? FindMatchingMember( Type type, MemberInfo member ) + { + const BindingFlags Flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + + switch ( member ) + { + case PropertyInfo property: + return type.GetProperty( property.Name, Flags ); + case MethodInfo method: + var parameterTypes = method.GetParameters().Select( parameter => parameter.ParameterType ).ToArray(); + return type.GetMethod( method.Name, Flags, binder: null, parameterTypes, modifiers: null ) + ?? type.GetMethods( Flags ).FirstOrDefault( + candidate => candidate.Name == method.Name + && candidate.GetParameters().Length == parameterTypes.Length ); + default: + return type.GetMember( member.Name, Flags ).FirstOrDefault(); + } + } private static XElement? FindMember( XDocument xml, string key ) => xml.Descendants( "member" ).FirstOrDefault( member => member.Attribute( "name" )?.Value == key ); diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsProvider.cs index 54180aa4..955576c4 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsProvider.cs @@ -30,7 +30,9 @@ private static StringBuilder GetTypeName( StringBuilder builder, Type type ) if ( i >= 0 ) { - name = name[..i] + "``" + type.GetGenericArguments().Length; + // A generic type uses a single backtick for its arity (e.g. List`1); the double backtick form + // is reserved for generic methods and generic parameters. + name = name[..i] + "`" + type.GetGenericArguments().Length; } return builder.Append( name.Replace( '+', '.' ) ); @@ -73,7 +75,10 @@ private static StringBuilder GetParameterTypeName( StringBuilder builder, Type t { if ( type.IsGenericParameter ) { - return builder.Append( "``" ).Append( type.GenericParameterPosition ); + // A type generic parameter uses a single backtick (e.g. `0), while a method generic parameter + // uses a double backtick (e.g. ``0). + var prefix = type.DeclaringMethod is null ? "`" : "``"; + return builder.Append( prefix ).Append( type.GenericParameterPosition ); } if ( type.IsArray ) diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/OpenApiDocumentDescriptionOptionsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/OpenApiDocumentDescriptionOptionsTest.cs index e85d4136..18dc2f02 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/OpenApiDocumentDescriptionOptionsTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/OpenApiDocumentDescriptionOptionsTest.cs @@ -3,6 +3,7 @@ namespace Asp.Versioning.OpenApi; +[AssumeCulture( "en-US" )] public class OpenApiDocumentDescriptionOptionsTest { [Fact] diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/DateRange.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/DateRange.cs new file mode 100644 index 00000000..a037b696 --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/DateRange.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.OpenApi.Simulators; + +/// +/// Represents a range of dates. +/// +public sealed class DateRange : RangeBase +{ +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/IRange.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/IRange.cs new file mode 100644 index 00000000..b5cde463 --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/IRange.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.OpenApi.Simulators; + +/// +/// Represents a range of values. +/// +/// The type of value in the range. +public interface IRange where T : struct +{ + /// + /// Gets or sets the lower bound of the range. + /// + T? Lower { get; set; } + + /// + /// Gets or sets the upper bound of the range. + /// + T? Upper { get; set; } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/RangeBase.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/RangeBase.cs new file mode 100644 index 00000000..a0edbd79 --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/RangeBase.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.OpenApi.Simulators; + +/// +/// Represents the base implementation of a range of values. +/// +/// The type of value in the range. +public abstract class RangeBase : IRange where T : struct +{ + /// + public T? Lower { get; set; } + + /// + public T? Upper { get; set; } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsTest.cs index c7b6374d..040911b4 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsTest.cs @@ -175,4 +175,32 @@ public void example_property_should_be_retrieved_from_nested_model() // assert example.Should().Be( "user@example.com" ); } + + [Fact] + public void summary_should_be_retrieved_for_generic_type_property() + { + // arrange + var comments = XmlComments.FromFile( FilePath.XmlCommentFile ); + var property = typeof( IRange ).GetProperty( nameof( IRange.Lower ) ); + + // act + var summary = comments.GetSummary( property ); + + // assert + summary.Should().Be( "Gets or sets the lower bound of the range." ); + } + + [Fact] + public void summary_should_be_inherited_from_interface_via_inheritdoc() + { + // arrange + var comments = XmlComments.FromFile( FilePath.XmlCommentFile ); + var property = typeof( DateRange ).GetProperty( nameof( DateRange.Upper ) ); + + // act + var summary = comments.GetSummary( property ); + + // assert + summary.Should().Be( "Gets or sets the upper bound of the range." ); + } } \ No newline at end of file