From 32e7c8f7852ffa25bdfe1df7ba57c131b88abf7c Mon Sep 17 00:00:00 2001 From: Yingxin Jiang Date: Mon, 5 Dec 2022 17:30:17 +0000 Subject: [PATCH 1/2] Add support for associating XML Comments with custom tags defined using TagsAttribute. Also includes all tags in root tags object, not just ones with XML Comments. --- .../SwaggerGenOptionsExtensions.cs | 8 +- .../XmlComments/XmlCommentsDocumentFilter.cs | 67 ++++++++++---- ...akeControllerWithCustomTagAndXmlSummary.cs | 17 ++++ .../Fixtures/FakeControllerWithXmlComments.cs | 5 +- .../XmlCommentsDocumentFilterTests.cs | 88 ++++++++++++++++++- 5 files changed, 159 insertions(+), 26 deletions(-) create mode 100644 test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/FakeControllerWithCustomTagAndXmlSummary.cs diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/DependencyInjection/SwaggerGenOptionsExtensions.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/DependencyInjection/SwaggerGenOptionsExtensions.cs index 2239084555..76fc3e7527 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/DependencyInjection/SwaggerGenOptionsExtensions.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/DependencyInjection/SwaggerGenOptionsExtensions.cs @@ -422,7 +422,9 @@ public static void DocumentFilter( /// A factory method that returns XML Comments as an XPathDocument /// /// 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. /// public static void IncludeXmlComments( this SwaggerGenOptions swaggerGenOptions, @@ -446,7 +448,9 @@ public static void IncludeXmlComments( /// An absolute path to the file that contains XML Comments /// /// 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. /// public static void IncludeXmlComments( this SwaggerGenOptions swaggerGenOptions, diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/XmlComments/XmlCommentsDocumentFilter.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/XmlComments/XmlCommentsDocumentFilter.cs index 2ce004eebc..b0d68076c8 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/XmlComments/XmlCommentsDocumentFilter.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/XmlComments/XmlCommentsDocumentFilter.cs @@ -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 { @@ -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(group.Key, group.First().ControllerTypeInfo.AsType())); + .Select(group => new KeyValuePair(group.Key, GetControllerInfo(group))); - foreach (var nameAndType in controllerNamesAndTypes) + swaggerDoc.Tags ??= new List(); + 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(); - - 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 + { + public Type ControllerType { get; set; } + public string CustomTagName { get; set; } + } + + private static ControllerInfo GetControllerInfo(IGrouping group) + { + var controllerInfo = new ControllerInfo + { + ControllerType = group.First().ControllerTypeInfo.AsType() + }; + +#if NET6_0_OR_GREATER + controllerInfo.CustomTagName = + group.First().MethodInfo.DeclaringType?.GetCustomAttribute()?.Tags[0]; +#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; } } } diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/FakeControllerWithCustomTagAndXmlSummary.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/FakeControllerWithCustomTagAndXmlSummary.cs new file mode 100644 index 0000000000..48d8169290 --- /dev/null +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/FakeControllerWithCustomTagAndXmlSummary.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Http; + +namespace Swashbuckle.AspNetCore.SwaggerGen.Test +{ + /// + /// Summary for FakeControllerWithCustomTag + /// + [Tags("fake controller custom tag")] + public class FakeControllerWithCustomTag + { + public void ActionAny() + { } + + public void ActionAnother() + { } + } +} \ No newline at end of file diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/FakeControllerWithXmlComments.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/FakeControllerWithXmlComments.cs index 6d9cd92377..35bb478928 100644 --- a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/FakeControllerWithXmlComments.cs +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/FakeControllerWithXmlComments.cs @@ -1,7 +1,4 @@ -using Swashbuckle.AspNetCore.TestSupport; -using System; - -namespace Swashbuckle.AspNetCore.SwaggerGen.Test +namespace Swashbuckle.AspNetCore.SwaggerGen.Test { /// /// Summary for FakeControllerWithXmlComments diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/XmlComments/XmlCommentsDocumentFilterTests.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/XmlComments/XmlCommentsDocumentFilterTests.cs index 4f3b28a3ad..1613eec2d6 100644 --- a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/XmlComments/XmlCommentsDocumentFilterTests.cs +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/XmlComments/XmlCommentsDocumentFilterTests.cs @@ -23,7 +23,8 @@ public void Apply_SetsTagDescription_FromControllerSummaryTags() ActionDescriptor = new ControllerActionDescriptor { ControllerTypeInfo = typeof(FakeControllerWithXmlComments).GetTypeInfo(), - ControllerName = nameof(FakeControllerWithXmlComments) + ControllerName = nameof(FakeControllerWithXmlComments), + MethodInfo = typeof(FakeControllerWithXmlComments).GetMethod(nameof(FakeControllerWithXmlComments.ActionWithParamTags))! } }, new ApiDescription @@ -31,7 +32,8 @@ public void Apply_SetsTagDescription_FromControllerSummaryTags() ActionDescriptor = new ControllerActionDescriptor { ControllerTypeInfo = typeof(FakeControllerWithXmlComments).GetTypeInfo(), - ControllerName = nameof(FakeControllerWithXmlComments) + ControllerName = nameof(FakeControllerWithXmlComments), + MethodInfo = typeof(FakeControllerWithXmlComments).GetMethod(nameof(FakeControllerWithXmlComments.ActionWithParamTags))! } } }, @@ -41,9 +43,91 @@ public void Apply_SetsTagDescription_FromControllerSummaryTags() 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); + 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")) From 51e095f0c7c4d3bd55e0509b1fe897c315f3eaf0 Mon Sep 17 00:00:00 2001 From: Yingxin Jiang Date: Tue, 6 Dec 2022 14:11:56 +0000 Subject: [PATCH 2/2] Fix build by adding deconstruct extension for older .NET frameworks --- .../XmlComments/KeyValuePairExtensions.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/Swashbuckle.AspNetCore.SwaggerGen/XmlComments/KeyValuePairExtensions.cs diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/XmlComments/KeyValuePairExtensions.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/XmlComments/KeyValuePairExtensions.cs new file mode 100644 index 0000000000..1cf1205fae --- /dev/null +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/XmlComments/KeyValuePairExtensions.cs @@ -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(this KeyValuePair kvp, out TKey key, out TValue value) + { + key = kvp.Key; + value = kvp.Value; + } + } +} \ No newline at end of file