Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ namespace Asp.Versioning.OpenApi.Transformers;
/// </summary>
public class XmlComments
{
private const int MaxInheritDocDepth = 8;
private readonly ConcurrentDictionary<string, XElement?> members = new();

/// <summary>
Expand Down Expand Up @@ -182,8 +183,109 @@ public virtual string GetResponseDescription( MemberInfo member, string statusCo
/// </summary>
/// <param name="member">The member to get the information for.</param>
/// <returns>The <see cref="XElement"/> representing the matching <c>member</c> element or <c>null</c>.</returns>
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 <inheritdoc /> 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: <inheritdoc cref="..." />
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<MemberInfo> GetInheritedMembers( MemberInfo member )
{
// <inheritdoc /> 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 );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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( '+', '.' ) );
Expand Down Expand Up @@ -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 )
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace Asp.Versioning.OpenApi;

[AssumeCulture( "en-US" )]
public class OpenApiDocumentDescriptionOptionsTest
{
[Fact]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.

namespace Asp.Versioning.OpenApi.Simulators;

/// <summary>
/// Represents a range of dates.
/// </summary>
public sealed class DateRange : RangeBase<DateOnly>
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.

namespace Asp.Versioning.OpenApi.Simulators;

/// <summary>
/// Represents a range of values.
/// </summary>
/// <typeparam name="T">The type of value in the range.</typeparam>
public interface IRange<T> where T : struct
{
/// <summary>
/// Gets or sets the lower bound of the range.
/// </summary>
T? Lower { get; set; }

/// <summary>
/// Gets or sets the upper bound of the range.
/// </summary>
T? Upper { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.

namespace Asp.Versioning.OpenApi.Simulators;

/// <summary>
/// Represents the base implementation of a range of values.
/// </summary>
/// <typeparam name="T">The type of value in the range.</typeparam>
public abstract class RangeBase<T> : IRange<T> where T : struct
{
/// <inheritdoc />
public T? Lower { get; set; }

/// <inheritdoc />
public T? Upper { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<DateOnly> ).GetProperty( nameof( IRange<DateOnly>.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." );
}
}
Loading