diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs index f09cd4a6f3..1febf730f7 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs @@ -16,6 +16,11 @@ public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDep { private static readonly Regex WhitespaceRegex = new(@"\s+", RegexOptions.Compiled); + // Matches , , , + private static readonly Regex XmlRefTagRegex = new( + @"<(see|paramref|typeparamref)\s+(cref|name|langword)=""([TMFPE]:)?(?[^""]+)""\s*/?>", + RegexOptions.Compiled); + private readonly ConcurrentDictionary> _xmlDocCache = new(); public virtual async Task GetSummaryAsync(Type type) @@ -117,13 +122,39 @@ public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDep return null; } - var text = element.Value; - if (string.IsNullOrWhiteSpace(text)) + // Convert to string first so we can process inline XML tags like + var raw = element.ToString(); + + // Strip the outer element tags (e.g. ...) + var start = raw.IndexOf('>') + 1; + var end = raw.LastIndexOf('<'); + if (start >= end) + { + return null; + } + + var inner = raw[start..end]; + + // Replace with the short name "Bar" + // Replace with "null" + // Replace and with the name + inner = XmlRefTagRegex.Replace(inner, m => + { + var display = m.Groups["display"].Value; + // For cref values like "T:Foo.Bar.Baz", return only "Baz" + var dot = display.LastIndexOf('.'); + return dot >= 0 ? display[(dot + 1)..] : display; + }); + + // Strip any remaining XML tags (e.g. , , , , etc.) + inner = Regex.Replace(inner, @"<[^>]+>", string.Empty); + + if (string.IsNullOrWhiteSpace(inner)) { return null; } - return WhitespaceRegex.Replace(text.Trim(), " "); + return WhitespaceRegex.Replace(inner.Trim(), " "); } private static string GetMemberNameForType(Type type) diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProviderTests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProviderTests.cs new file mode 100644 index 0000000000..01b5f6b060 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProviderTests.cs @@ -0,0 +1,213 @@ +#nullable enable +using System; +using System.Reflection; +using System.Threading.Tasks; +using System.Xml.Linq; +using Shouldly; +using Volo.Abp.DependencyInjection; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; + +public class XmlDocumentationProviderTests +{ + // A stub type so we can construct member-name keys that the provider can look up. + private class StubType { } + + private static XmlDocumentationProvider CreateProvider(string xmlDocBody) + { + var xml = $@" + + + {xmlDocBody} + +"; + return new FakeXmlDocumentationProvider(xml); + } + + private static string StubTypeMemberName(string elementName, string xmlContent) + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + return $@" + {xmlContent} + "; + } + + // Tests for CleanXmlText via GetSummaryAsync(Type) + + [Fact] + public async Task GetSummary_Returns_PlainText() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@"A simple summary."); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBe("A simple summary."); + } + + [Fact] + public async Task GetSummary_Expands_SeeCref_To_ShortTypeName() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@"Returns a value."); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBe("Returns a String value."); + } + + [Fact] + public async Task GetSummary_Expands_SeeCref_NestedType() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@"See for details."); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBe("See List`1 for details."); + } + + [Fact] + public async Task GetSummary_Expands_SeeLangword() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@"Returns when not found."); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBe("Returns null when not found."); + } + + [Fact] + public async Task GetSummary_Strips_CodeTag() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@"Use DoSomething() to start."); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBe("Use DoSomething() to start."); + } + + [Fact] + public async Task GetSummary_Strips_ParaTag() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@"First paragraph."); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBe("First paragraph."); + } + + [Fact] + public async Task GetSummary_Collapses_Whitespace() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@" + Multiple + spaces here. + "); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBe("Multiple spaces here."); + } + + [Fact] + public async Task GetSummary_Returns_Null_When_Member_Not_Found() + { + var provider = CreateProvider(string.Empty); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBeNull(); + } + + [Fact] + public async Task GetSummary_Returns_Null_When_Summary_Is_Empty() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@" "); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBeNull(); + } + + [Fact] + public async Task GetSummary_Expands_Paramref() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@"Use the parameter."); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBe("Use the input parameter."); + } + + [Fact] + public async Task GetSummary_Expands_Typeparamref() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@"Returns instance."); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBe("Returns T instance."); + } + + [Fact] + public async Task GetSummary_Expands_Mixed_Tags() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@"Returns or if not found."); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBe("Returns String or null if key not found."); + } + + [Fact] + public async Task GetRemarks_Returns_Null_When_No_Remarks_Element() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@"Only summary."); + + var result = await provider.GetRemarksAsync(typeof(StubType)); + + result.ShouldBeNull(); + } + + /// + /// A fake provider that loads XML from an in-memory string instead of the file system. + /// + [DisableConventionalRegistration] + private sealed class FakeXmlDocumentationProvider : XmlDocumentationProvider + { + private readonly XDocument _document; + + public FakeXmlDocumentationProvider(string xml) + { + _document = XDocument.Parse(xml); + } + + protected override Task LoadXmlDocumentationFromDiskAsync(Assembly assembly) + { + return Task.FromResult(_document); + } + } +}