Browse Source

feat: Enhance XmlDocumentationProvider with XML tag processing and add unit tests

pull/25022/head
maliming 6 months ago
parent
commit
88b3b5d6e3
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 37
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs
  2. 213
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProviderTests.cs

37
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 <see cref="T:Foo.Bar"/>, <see langword="null"/>, <paramref name="x"/>, <typeparamref name="T"/>
private static readonly Regex XmlRefTagRegex = new(
@"<(see|paramref|typeparamref)\s+(cref|name|langword)=""([TMFPE]:)?(?<display>[^""]+)""\s*/?>",
RegexOptions.Compiled);
private readonly ConcurrentDictionary<Assembly, Task<XDocument?>> _xmlDocCache = new();
public virtual async Task<string?> 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 <see cref="..."/>
var raw = element.ToString();
// Strip the outer element tags (e.g. <summary>...</summary>)
var start = raw.IndexOf('>') + 1;
var end = raw.LastIndexOf('<');
if (start >= end)
{
return null;
}
var inner = raw[start..end];
// Replace <see cref="T:Foo.Bar"/> with the short name "Bar"
// Replace <see langword="null"/> with "null"
// Replace <paramref name="x"/> and <typeparamref name="T"/> 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. <c>, <code>, <para>, <b>, 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)

213
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 = $@"<?xml version=""1.0""?>
<doc>
<members>
{xmlDocBody}
</members>
</doc>";
return new FakeXmlDocumentationProvider(xml);
}
private static string StubTypeMemberName(string elementName, string xmlContent)
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
return $@"<member name=""{elementName}:{typeName}"">
{xmlContent}
</member>";
}
// Tests for CleanXmlText via GetSummaryAsync(Type)
[Fact]
public async Task GetSummary_Returns_PlainText()
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
var provider = CreateProvider(
$@"<member name=""T:{typeName}""><summary>A simple summary.</summary></member>");
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(
$@"<member name=""T:{typeName}""><summary>Returns a <see cref=""T:System.String"" /> value.</summary></member>");
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(
$@"<member name=""T:{typeName}""><summary>See <see cref=""T:System.Collections.Generic.List`1"" /> for details.</summary></member>");
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(
$@"<member name=""T:{typeName}""><summary>Returns <see langword=""null"" /> when not found.</summary></member>");
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(
$@"<member name=""T:{typeName}""><summary>Use <c>DoSomething()</c> to start.</summary></member>");
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(
$@"<member name=""T:{typeName}""><summary><para>First paragraph.</para></summary></member>");
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(
$@"<member name=""T:{typeName}""><summary>
Multiple
spaces here.
</summary></member>");
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(
$@"<member name=""T:{typeName}""><summary> </summary></member>");
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(
$@"<member name=""T:{typeName}""><summary>Use the <paramref name=""input"" /> parameter.</summary></member>");
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(
$@"<member name=""T:{typeName}""><summary>Returns <typeparamref name=""T"" /> instance.</summary></member>");
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(
$@"<member name=""T:{typeName}""><summary>Returns <see cref=""T:System.String"" /> or <see langword=""null"" /> if <paramref name=""key"" /> not found.</summary></member>");
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(
$@"<member name=""T:{typeName}""><summary>Only summary.</summary></member>");
var result = await provider.GetRemarksAsync(typeof(StubType));
result.ShouldBeNull();
}
/// <summary>
/// A fake provider that loads XML from an in-memory string instead of the file system.
/// </summary>
[DisableConventionalRegistration]
private sealed class FakeXmlDocumentationProvider : XmlDocumentationProvider
{
private readonly XDocument _document;
public FakeXmlDocumentationProvider(string xml)
{
_document = XDocument.Parse(xml);
}
protected override Task<XDocument?> LoadXmlDocumentationFromDiskAsync(Assembly assembly)
{
return Task.FromResult<XDocument?>(_document);
}
}
}
Loading…
Cancel
Save