Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for associating XML Comments with custom tags defined using TagsAttribute #2565

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -422,7 +422,9 @@ public static void SupportNonNullableReferenceTypes(this SwaggerGenOptions swagg
/// <param name="xmlDocFactory">A factory method that returns XML Comments as an XPathDocument</param>
/// <param name="includeControllerXmlComments">
/// Flag to indicate if controller XML comments (i.e. summary) should be used to assign Tag descriptions.
/// Don't set this flag if you're customizing the default tag for operations via TagActionsBy.
/// If customizing the default tag for operations via TagsAttribute, only the first Tag per controller will be
/// associated with the description.
/// However, don't set this flag if you're customizing the default tag for operations via TagActionsBy.
Comment on lines +425 to +427
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make TagsAttribute and TagActionsBy <see .... /> references in these two comments.

/// </param>
public static void IncludeXmlComments(
this SwaggerGenOptions swaggerGenOptions,
Expand All @@ -446,7 +448,9 @@ public static void SupportNonNullableReferenceTypes(this SwaggerGenOptions swagg
/// <param name="filePath">An absolute path to the file that contains XML Comments</param>
/// <param name="includeControllerXmlComments">
/// Flag to indicate if controller XML comments (i.e. summary) should be used to assign Tag descriptions.
/// Don't set this flag if you're customizing the default tag for operations via TagActionsBy.
/// If customizing the default tag for operations via TagsAttribute, only the first Tag per controller will be
/// associated with the description.
/// However, don't set this flag if you're customizing the default tag for operations via TagActionsBy.
/// </param>
public static void IncludeXmlComments(
this SwaggerGenOptions swaggerGenOptions,
Expand Down
@@ -0,0 +1,14 @@
using System.Collections.Generic;

namespace Swashbuckle.AspNetCore.SwaggerGen
{
public static class KeyValuePairExtensions
{
// Explicit deconstruct required for older .NET frameworks
public static void Deconstruct<TKey, TValue>(this KeyValuePair<TKey, TValue> kvp, out TKey key, out TValue value)
{
key = kvp.Key;
value = kvp.Value;
}
}
Comment on lines +5 to +13
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to only add this polyfill for the TFMs that need it. Also, can it be internal?

}
Expand Up @@ -4,6 +4,8 @@
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.OpenApi.Models;
using System;
using System.Reflection;
using Microsoft.AspNetCore.Http;

namespace Swashbuckle.AspNetCore.SwaggerGen
{
Expand All @@ -21,34 +23,63 @@ public XmlCommentsDocumentFilter(XPathDocument xmlDoc)

public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
// Collect (unique) controller names and types in a dictionary
var controllerNamesAndTypes = context.ApiDescriptions
// Collect (unique) controller names, types and custom tags (defined by the first TagsAttribute value) in a dictionary
var controllers = context.ApiDescriptions
.Select(apiDesc => apiDesc.ActionDescriptor as ControllerActionDescriptor)
.Where(actionDesc => actionDesc != null)
.GroupBy(actionDesc => actionDesc.ControllerName)
.Select(group => new KeyValuePair<string, Type>(group.Key, group.First().ControllerTypeInfo.AsType()));
.Select(group => new KeyValuePair<string, ControllerInfo>(group.Key, GetControllerInfo(group)));

foreach (var nameAndType in controllerNamesAndTypes)
swaggerDoc.Tags ??= new List<OpenApiTag>();
foreach (var (controllerName, controllerInfo) in controllers)
{
var memberName = XmlCommentsNodeNameHelper.GetMemberNameForType(nameAndType.Value);
var memberName = XmlCommentsNodeNameHelper.GetMemberNameForType(controllerInfo.ControllerType);
var typeNode = _xmlNavigator.SelectSingleNode(string.Format(MemberXPath, memberName));

if (typeNode != null)
var description = GetXmlDescriptionOrNull(typeNode);

swaggerDoc.Tags.Add(new OpenApiTag
{
var summaryNode = typeNode.SelectSingleNode(SummaryTag);
if (summaryNode != null)
{
if (swaggerDoc.Tags == null)
swaggerDoc.Tags = new List<OpenApiTag>();

swaggerDoc.Tags.Add(new OpenApiTag
{
Name = nameAndType.Key,
Description = XmlCommentsTextHelper.Humanize(summaryNode.InnerXml)
});
}
Name = controllerInfo.CustomTagName ?? controllerName,
Description = description
});
}
swaggerDoc.Tags = swaggerDoc.Tags.OrderBy(x => x.Name).ToList();
}

private class ControllerInfo
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private class ControllerInfo
private sealed class ControllerInfo

{
public Type ControllerType { get; set; }
public string CustomTagName { get; set; }
}

private static ControllerInfo GetControllerInfo(IGrouping<string, ControllerActionDescriptor> group)
{
var controllerInfo = new ControllerInfo
{
ControllerType = group.First().ControllerTypeInfo.AsType()
};

#if NET6_0_OR_GREATER
controllerInfo.CustomTagName =
group.First().MethodInfo.DeclaringType?.GetCustomAttribute<TagsAttribute>()?.Tags[0];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it'll throw an exception if Tags is empty or null.

#endif

return controllerInfo;
}

private static string GetXmlDescriptionOrNull(XPathNavigator typeNode)
{
if (typeNode != null)
{
var summaryNode = typeNode.SelectSingleNode(SummaryTag);
if (summaryNode != null)
{
return XmlCommentsTextHelper.Humanize(summaryNode.InnerXml);
}
}

return null;
}
}
}
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Http;

namespace Swashbuckle.AspNetCore.SwaggerGen.Test
{
/// <summary>
/// Summary for FakeControllerWithCustomTag
/// </summary>
[Tags("fake controller custom tag")]
public class FakeControllerWithCustomTag
{
public void ActionAny()
{ }

public void ActionAnother()
{ }
}
}
@@ -1,7 +1,4 @@
using Swashbuckle.AspNetCore.TestSupport;
using System;

namespace Swashbuckle.AspNetCore.SwaggerGen.Test
namespace Swashbuckle.AspNetCore.SwaggerGen.Test
{
/// <summary>
/// Summary for FakeControllerWithXmlComments
Expand Down
Expand Up @@ -23,15 +23,17 @@
ActionDescriptor = new ControllerActionDescriptor
{
ControllerTypeInfo = typeof(FakeControllerWithXmlComments).GetTypeInfo(),
ControllerName = nameof(FakeControllerWithXmlComments)
ControllerName = nameof(FakeControllerWithXmlComments),
MethodInfo = typeof(FakeControllerWithXmlComments).GetMethod(nameof(FakeControllerWithXmlComments.ActionWithParamTags))!
}
},
new ApiDescription
{
ActionDescriptor = new ControllerActionDescriptor
{
ControllerTypeInfo = typeof(FakeControllerWithXmlComments).GetTypeInfo(),
ControllerName = nameof(FakeControllerWithXmlComments)
ControllerName = nameof(FakeControllerWithXmlComments),
MethodInfo = typeof(FakeControllerWithXmlComments).GetMethod(nameof(FakeControllerWithXmlComments.ActionWithParamTags))!
}
}
},
Expand All @@ -41,9 +43,91 @@
Subject().Apply(document, filterContext);

var tag = Assert.Single(document.Tags);
Assert.Equal("FakeControllerWithXmlComments", tag.Name);
Assert.Equal("Summary for FakeControllerWithXmlComments", tag.Description);
}

[Fact]
public void Apply_SetsCustomTagNameAndDescription_FromControllerAttributesAndSummaryTags()
{
var document = new OpenApiDocument();
var filterContext = new DocumentFilterContext(
new[]
{
new ApiDescription
{
ActionDescriptor = new ControllerActionDescriptor
{
ControllerTypeInfo = typeof(FakeControllerWithCustomTag).GetTypeInfo(),
ControllerName = nameof(FakeControllerWithCustomTag),
MethodInfo = typeof(FakeControllerWithCustomTag).GetMethod(nameof(FakeControllerWithCustomTag.ActionAny))!
}
},
new ApiDescription
{
ActionDescriptor = new ControllerActionDescriptor
{
ControllerTypeInfo = typeof(FakeControllerWithCustomTag).GetTypeInfo(),
ControllerName = nameof(FakeControllerWithCustomTag),
MethodInfo = typeof(FakeControllerWithCustomTag).GetMethod(nameof(FakeControllerWithCustomTag.ActionAnother))!
}
}
},
null,
null);

Subject().Apply(document, filterContext);

Assert.Equal(1, document.Tags.Count);

Check failure on line 81 in test/Swashbuckle.AspNetCore.SwaggerGen.Test/XmlComments/XmlCommentsDocumentFilterTests.cs

View workflow job for this annotation

GitHub Actions / code-ql

Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013)

Check failure on line 81 in test/Swashbuckle.AspNetCore.SwaggerGen.Test/XmlComments/XmlCommentsDocumentFilterTests.cs

View workflow job for this annotation

GitHub Actions / code-ql

Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013)

Check failure on line 81 in test/Swashbuckle.AspNetCore.SwaggerGen.Test/XmlComments/XmlCommentsDocumentFilterTests.cs

View workflow job for this annotation

GitHub Actions / code-ql

Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013)

Check failure on line 81 in test/Swashbuckle.AspNetCore.SwaggerGen.Test/XmlComments/XmlCommentsDocumentFilterTests.cs

View workflow job for this annotation

GitHub Actions / code-ql

Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013)

Check failure on line 81 in test/Swashbuckle.AspNetCore.SwaggerGen.Test/XmlComments/XmlCommentsDocumentFilterTests.cs

View workflow job for this annotation

GitHub Actions / windows-latest

Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013)

Check failure on line 81 in test/Swashbuckle.AspNetCore.SwaggerGen.Test/XmlComments/XmlCommentsDocumentFilterTests.cs

View workflow job for this annotation

GitHub Actions / windows-latest

Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013)
Assert.Equal("fake controller custom tag", document.Tags[0].Name);
Assert.Equal("Summary for FakeControllerWithCustomTag", document.Tags[0].Description);
}

[Fact]
public void Apply_SetsTagNameWithNoDescription_ForControllerWithoutSummaryTags()
{
var document = new OpenApiDocument();
var filterContext = new DocumentFilterContext(
new[]
{
new ApiDescription
{
ActionDescriptor = new ControllerActionDescriptor
{
ControllerTypeInfo = typeof(FakeController).GetTypeInfo(),
ControllerName = nameof(FakeController),
MethodInfo = typeof(FakeController).GetMethod(nameof(FakeController.ActionWithParameter))!
}
},
new ApiDescription
{
ActionDescriptor = new ControllerActionDescriptor
{
ControllerTypeInfo = typeof(FakeControllerWithCustomTag).GetTypeInfo(),
ControllerName = nameof(FakeControllerWithCustomTag),
MethodInfo = typeof(FakeControllerWithCustomTag).GetMethod(nameof(FakeControllerWithCustomTag.ActionAny))!
}
}
},
null,
null);

Subject().Apply(document, filterContext);

Assert.Equal(2, document.Tags.Count);
Assert.Collection(document.Tags,
tag1 =>
{
Assert.Equal("fake controller custom tag", tag1.Name);
Assert.Equal("Summary for FakeControllerWithCustomTag", tag1.Description);
},
tag2 =>
{
Assert.Equal("FakeController", tag2.Name);
Assert.Null(tag2.Description);
});
}

private static XmlCommentsDocumentFilter Subject()
{
using (var xmlComments = File.OpenText($"{typeof(FakeControllerWithXmlComments).Assembly.GetName().Name}.xml"))
Expand Down