From 15e467517ddbacb7d2ba496516edd054b082c5a4 Mon Sep 17 00:00:00 2001 From: Nico Lachmuth Date: Thu, 5 Mar 2026 14:24:38 +0100 Subject: [PATCH 1/5] documentation support for abp api description --- .../ApiExploring/IXmlDocumentationProvider.cs | 21 ++ .../ApiExploring/XmlDocumentationProvider.cs | 175 +++++++++++++++ .../AspNetCoreApiDescriptionModelProvider.cs | 114 ++++++++-- .../Modeling/ActionApiDescriptionModel.cs | 8 + ...pplicationApiDescriptionModelRequestDto.cs | 2 + .../Modeling/ControllerApiDescriptionModel.cs | 12 + .../MethodParameterApiDescriptionModel.cs | 6 + .../Modeling/ParameterApiDescriptionModel.cs | 6 + .../Modeling/PropertyApiDescriptionModel.cs | 6 + .../ReturnValueApiDescriptionModel.cs | 2 + .../Http/Modeling/TypeApiDescriptionModel.cs | 8 + ...iDefinitionController_Description_Tests.cs | 205 ++++++++++++++++++ .../Volo.Abp.TestApp/Volo.Abp.TestApp.csproj | 1 + .../Application/DocumentedAppService.cs | 40 ++++ .../TestApp/Application/Dto/DocumentedDto.cs | 25 +++ .../Application/IDocumentedAppService.cs | 28 +++ 16 files changed, 645 insertions(+), 14 deletions(-) create mode 100644 framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/IXmlDocumentationProvider.cs create mode 100644 framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Description_Tests.cs create mode 100644 framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/DocumentedAppService.cs create mode 100644 framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/Dto/DocumentedDto.cs create mode 100644 framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IDocumentedAppService.cs diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/IXmlDocumentationProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/IXmlDocumentationProvider.cs new file mode 100644 index 0000000000..f305f74c6e --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/IXmlDocumentationProvider.cs @@ -0,0 +1,21 @@ +using System; +using System.Reflection; + +namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; + +public interface IXmlDocumentationProvider +{ + string? GetSummary(Type type); + + string? GetRemarks(Type type); + + string? GetSummary(MethodInfo method); + + string? GetRemarks(MethodInfo method); + + string? GetReturns(MethodInfo method); + + string? GetParameterSummary(MethodInfo method, string parameterName); + + string? GetSummary(PropertyInfo property); +} 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 new file mode 100644 index 0000000000..40cb546cc5 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using System.Xml.XPath; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; + +public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDependency +{ + private readonly ConcurrentDictionary _xmlDocCache = new(); + + public string? GetSummary(Type type) + { + var memberName = GetMemberNameForType(type); + return GetDocumentationElement(type.Assembly, memberName, "summary"); + } + + public string? GetRemarks(Type type) + { + var memberName = GetMemberNameForType(type); + return GetDocumentationElement(type.Assembly, memberName, "remarks"); + } + + public string? GetSummary(MethodInfo method) + { + var memberName = GetMemberNameForMethod(method); + return GetDocumentationElement(method.DeclaringType!.Assembly, memberName, "summary"); + } + + public string? GetRemarks(MethodInfo method) + { + var memberName = GetMemberNameForMethod(method); + return GetDocumentationElement(method.DeclaringType!.Assembly, memberName, "remarks"); + } + + public string? GetReturns(MethodInfo method) + { + var memberName = GetMemberNameForMethod(method); + return GetDocumentationElement(method.DeclaringType!.Assembly, memberName, "returns"); + } + + public string? GetParameterSummary(MethodInfo method, string parameterName) + { + var memberName = GetMemberNameForMethod(method); + var doc = LoadXmlDocumentation(method.DeclaringType!.Assembly); + if (doc == null) + { + return null; + } + + var memberNode = doc.XPathSelectElement($"//member[@name='{memberName}']"); + var paramNode = memberNode?.XPathSelectElement($"param[@name='{parameterName}']"); + return CleanXmlText(paramNode); + } + + public string? GetSummary(PropertyInfo property) + { + var memberName = GetMemberNameForProperty(property); + return GetDocumentationElement(property.DeclaringType!.Assembly, memberName, "summary"); + } + + private string? GetDocumentationElement(Assembly assembly, string memberName, string elementName) + { + var doc = LoadXmlDocumentation(assembly); + if (doc == null) + { + return null; + } + + var memberNode = doc.XPathSelectElement($"//member[@name='{memberName}']"); + var element = memberNode?.Element(elementName); + return CleanXmlText(element); + } + + private XDocument? LoadXmlDocumentation(Assembly assembly) + { + return _xmlDocCache.GetOrAdd(assembly, static asm => + { + if (string.IsNullOrEmpty(asm.Location)) + { + return null; + } + + var xmlFilePath = Path.ChangeExtension(asm.Location, ".xml"); + if (!File.Exists(xmlFilePath)) + { + return null; + } + + try + { + return XDocument.Load(xmlFilePath); + } + catch + { + return null; + } + }); + } + + private static string? CleanXmlText(XElement? element) + { + if (element == null) + { + return null; + } + + var text = element.Value; + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + return Regex.Replace(text.Trim(), @"\s+", " "); + } + + private static string GetMemberNameForType(Type type) + { + return $"T:{GetTypeFullName(type)}"; + } + + private static string GetMemberNameForMethod(MethodInfo method) + { + var typeName = GetTypeFullName(method.DeclaringType!); + var parameters = method.GetParameters(); + if (parameters.Length == 0) + { + return $"M:{typeName}.{method.Name}"; + } + + var paramTypes = string.Join(",", + parameters.Select(p => GetParameterTypeName(p.ParameterType))); + return $"M:{typeName}.{method.Name}({paramTypes})"; + } + + private static string GetMemberNameForProperty(PropertyInfo property) + { + var typeName = GetTypeFullName(property.DeclaringType!); + return $"P:{typeName}.{property.Name}"; + } + + private static string GetTypeFullName(Type type) + { + return type.FullName?.Replace('+', '.') ?? type.Name; + } + + private static string GetParameterTypeName(Type type) + { + if (type.IsGenericType) + { + var genericDef = type.GetGenericTypeDefinition(); + var defName = genericDef.FullName!; + defName = defName[..defName.IndexOf('`')]; + var args = string.Join(",", type.GetGenericArguments().Select(GetParameterTypeName)); + return $"{defName}{{{args}}}"; + } + + if (type.IsArray) + { + return GetParameterTypeName(type.GetElementType()!) + "[]"; + } + + if (type.IsByRef) + { + return GetParameterTypeName(type.GetElementType()!) + "@"; + } + + return type.FullName ?? type.Name; + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs index 2df5dea048..4dfb7b5e08 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Reflection; using Asp.Versioning; @@ -12,6 +14,7 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Volo.Abp.AspNetCore.Mvc.ApiExploring; using Volo.Abp.AspNetCore.Mvc.Conventions; using Volo.Abp.AspNetCore.Mvc.Utils; using Volo.Abp.DependencyInjection; @@ -29,17 +32,20 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide private readonly IApiDescriptionGroupCollectionProvider _descriptionProvider; private readonly AbpAspNetCoreMvcOptions _abpAspNetCoreMvcOptions; private readonly AbpApiDescriptionModelOptions _modelOptions; + private readonly IXmlDocumentationProvider _xmlDocProvider; public AspNetCoreApiDescriptionModelProvider( IOptions options, IApiDescriptionGroupCollectionProvider descriptionProvider, IOptions abpAspNetCoreMvcOptions, - IOptions modelOptions) + IOptions modelOptions, + IXmlDocumentationProvider xmlDocProvider) { _options = options.Value; _descriptionProvider = descriptionProvider; _abpAspNetCoreMvcOptions = abpAspNetCoreMvcOptions.Value; _modelOptions = modelOptions.Value; + _xmlDocProvider = xmlDocProvider; Logger = NullLogger.Instance; } @@ -161,10 +167,21 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide if (input.IncludeTypes) { - AddCustomTypesToModel(applicationModel, method); + AddCustomTypesToModel(applicationModel, method, input.IncludeDescriptions); } AddParameterDescriptionsToModel(actionModel, method, apiDescription); + + if (input.IncludeDescriptions) + { + if (controllerModel.Summary == null && controllerModel.Description == null && controllerModel.DisplayName == null) + { + PopulateControllerDescriptions(controllerModel, controllerType); + } + + PopulateActionDescriptions(actionModel, method); + PopulateParameterDescriptions(actionModel, method); + } } private static List GetSupportedVersions(Type controllerType, MethodInfo method, @@ -191,18 +208,18 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide return supportedVersions.Select(v => v.ToString()).Distinct().ToList(); } - private void AddCustomTypesToModel(ApplicationApiDescriptionModel applicationModel, MethodInfo method) + private void AddCustomTypesToModel(ApplicationApiDescriptionModel applicationModel, MethodInfo method, bool includeDescriptions) { foreach (var parameterInfo in method.GetParameters()) { - AddCustomTypesToModel(applicationModel, parameterInfo.ParameterType); + AddCustomTypesToModel(applicationModel, parameterInfo.ParameterType, includeDescriptions); } - AddCustomTypesToModel(applicationModel, method.ReturnType); + AddCustomTypesToModel(applicationModel, method.ReturnType, includeDescriptions); } - private static void AddCustomTypesToModel(ApplicationApiDescriptionModel applicationModel, - Type? type) + private void AddCustomTypesToModel(ApplicationApiDescriptionModel applicationModel, + Type? type, bool includeDescriptions) { if (type == null) { @@ -229,14 +246,14 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide if (TypeHelper.IsDictionary(type, out var keyType, out var valueType)) { - AddCustomTypesToModel(applicationModel, keyType); - AddCustomTypesToModel(applicationModel, valueType); + AddCustomTypesToModel(applicationModel, keyType, includeDescriptions); + AddCustomTypesToModel(applicationModel, valueType, includeDescriptions); return; } if (TypeHelper.IsEnumerable(type, out var itemType)) { - AddCustomTypesToModel(applicationModel, itemType); + AddCustomTypesToModel(applicationModel, itemType, includeDescriptions); return; } @@ -244,11 +261,11 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide { var genericTypeDefinition = type.GetGenericTypeDefinition(); - AddCustomTypesToModel(applicationModel, genericTypeDefinition); + AddCustomTypesToModel(applicationModel, genericTypeDefinition, includeDescriptions); foreach (var genericArgument in type.GetGenericArguments()) { - AddCustomTypesToModel(applicationModel, genericArgument); + AddCustomTypesToModel(applicationModel, genericArgument, includeDescriptions); } return; @@ -262,11 +279,16 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide applicationModel.Types[typeName] = TypeApiDescriptionModel.Create(type); - AddCustomTypesToModel(applicationModel, type.BaseType); + if (includeDescriptions) + { + PopulateTypeDescriptions(applicationModel.Types[typeName], type); + } + + AddCustomTypesToModel(applicationModel, type.BaseType, includeDescriptions); foreach (var propertyInfo in type.GetProperties().Where(p => p.DeclaringType == type)) { - AddCustomTypesToModel(applicationModel, propertyInfo.PropertyType); + AddCustomTypesToModel(applicationModel, propertyInfo.PropertyType, includeDescriptions); } } @@ -414,4 +436,68 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide return null; } + + private void PopulateControllerDescriptions(ControllerApiDescriptionModel controllerModel, Type controllerType) + { + controllerModel.Summary = _xmlDocProvider.GetSummary(controllerType); + controllerModel.Remarks = _xmlDocProvider.GetRemarks(controllerType); + controllerModel.Description = controllerType.GetCustomAttribute()?.Description; + controllerModel.DisplayName = controllerType.GetCustomAttribute()?.Name; + } + + private void PopulateActionDescriptions(ActionApiDescriptionModel actionModel, MethodInfo method) + { + actionModel.Summary = _xmlDocProvider.GetSummary(method); + actionModel.Remarks = _xmlDocProvider.GetRemarks(method); + actionModel.Description = method.GetCustomAttribute()?.Description; + actionModel.DisplayName = method.GetCustomAttribute()?.Name; + actionModel.ReturnValue.Summary = _xmlDocProvider.GetReturns(method); + } + + private void PopulateParameterDescriptions(ActionApiDescriptionModel actionModel, MethodInfo method) + { + foreach (var param in actionModel.ParametersOnMethod) + { + var paramInfo = method.GetParameters().FirstOrDefault(p => p.Name == param.Name); + if (paramInfo == null) + { + continue; + } + + param.Summary = _xmlDocProvider.GetParameterSummary(method, param.Name); + param.Description = paramInfo.GetCustomAttribute()?.Description; + param.DisplayName = paramInfo.GetCustomAttribute()?.Name; + } + + foreach (var param in actionModel.Parameters) + { + param.Summary = _xmlDocProvider.GetParameterSummary(method, param.NameOnMethod); + } + } + + private void PopulateTypeDescriptions(TypeApiDescriptionModel typeModel, Type type) + { + typeModel.Summary = _xmlDocProvider.GetSummary(type); + typeModel.Remarks = _xmlDocProvider.GetRemarks(type); + typeModel.Description = type.GetCustomAttribute()?.Description; + typeModel.DisplayName = type.GetCustomAttribute()?.Name; + + if (typeModel.Properties == null) + { + return; + } + + foreach (var propModel in typeModel.Properties) + { + var propInfo = type.GetProperty(propModel.Name, BindingFlags.Instance | BindingFlags.Public); + if (propInfo == null) + { + continue; + } + + propModel.Summary = _xmlDocProvider.GetSummary(propInfo); + propModel.Description = propInfo.GetCustomAttribute()?.Description; + propModel.DisplayName = propInfo.GetCustomAttribute()?.Name; + } + } } diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs index 83bacddd8c..7650e40f88 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs @@ -32,6 +32,14 @@ public class ActionApiDescriptionModel public string? ImplementFrom { get; set; } + public string? Summary { get; set; } + + public string? Remarks { get; set; } + + public string? Description { get; set; } + + public string? DisplayName { get; set; } + public ActionApiDescriptionModel() { diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ApplicationApiDescriptionModelRequestDto.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ApplicationApiDescriptionModelRequestDto.cs index 7f178c47e4..b70355daf5 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ApplicationApiDescriptionModelRequestDto.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ApplicationApiDescriptionModelRequestDto.cs @@ -3,4 +3,6 @@ public class ApplicationApiDescriptionModelRequestDto { public bool IncludeTypes { get; set; } + + public bool IncludeDescriptions { get; set; } } diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ControllerApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ControllerApiDescriptionModel.cs index 40188c4b93..e27459d7e7 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ControllerApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ControllerApiDescriptionModel.cs @@ -19,6 +19,14 @@ public class ControllerApiDescriptionModel public string Type { get; set; } = default!; + public string? Summary { get; set; } + + public string? Remarks { get; set; } + + public string? Description { get; set; } + + public string? DisplayName { get; set; } + public List Interfaces { get; set; } = default!; public Dictionary Actions { get; set; } = default!; @@ -66,6 +74,10 @@ public class ControllerApiDescriptionModel Type = Type, Interfaces = Interfaces, ControllerName = ControllerName, + Summary = Summary, + Remarks = Remarks, + Description = Description, + DisplayName = DisplayName, Actions = new Dictionary() }; diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/MethodParameterApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/MethodParameterApiDescriptionModel.cs index c3ff20b897..fb5c47245c 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/MethodParameterApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/MethodParameterApiDescriptionModel.cs @@ -19,6 +19,12 @@ public class MethodParameterApiDescriptionModel public object? DefaultValue { get; set; } + public string? Summary { get; set; } + + public string? Description { get; set; } + + public string? DisplayName { get; set; } + public MethodParameterApiDescriptionModel() { diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ParameterApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ParameterApiDescriptionModel.cs index a863d1bfad..7bcac1510c 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ParameterApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ParameterApiDescriptionModel.cs @@ -26,6 +26,12 @@ public class ParameterApiDescriptionModel public string? DescriptorName { get; set; } + public string? Summary { get; set; } + + public string? Description { get; set; } + + public string? DisplayName { get; set; } + public ParameterApiDescriptionModel() { diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs index ed604793b0..d0bf430546 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs @@ -32,6 +32,12 @@ public class PropertyApiDescriptionModel public bool IsNullable { get; set; } + public string? Summary { get; set; } + + public string? Description { get; set; } + + public string? DisplayName { get; set; } + public static PropertyApiDescriptionModel Create(PropertyInfo propertyInfo) { var customAttributes = propertyInfo.GetCustomAttributes(true); diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs index e5a7e120a8..e77d2f7fea 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs @@ -11,6 +11,8 @@ public class ReturnValueApiDescriptionModel public string TypeSimple { get; set; } = default!; + public string? Summary { get; set; } + public ReturnValueApiDescriptionModel() { diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs index d1733577e5..703c5a8583 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs @@ -20,6 +20,14 @@ public class TypeApiDescriptionModel public PropertyApiDescriptionModel[]? Properties { get; set; } + public string? Summary { get; set; } + + public string? Remarks { get; set; } + + public string? Description { get; set; } + + public string? DisplayName { get; set; } + public TypeApiDescriptionModel() { diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Description_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Description_Tests.cs new file mode 100644 index 0000000000..e751e01cb1 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Description_Tests.cs @@ -0,0 +1,205 @@ +using System.Linq; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Http.Modeling; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; + +public class AbpApiDefinitionController_Description_Tests : AspNetCoreMvcTestBase +{ + [Fact] + public async Task Default_Should_Not_Include_Descriptions() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition"); + + var controller = GetDocumentedController(model); + controller.Summary.ShouldBeNull(); + controller.Remarks.ShouldBeNull(); + controller.Description.ShouldBeNull(); + controller.DisplayName.ShouldBeNull(); + } + + [Fact] + public async Task IncludeDescriptions_Should_Populate_Controller_Summary() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var controller = GetDocumentedController(model); + controller.Summary.ShouldNotBeNullOrEmpty(); + controller.Summary.ShouldContain("documented application service"); + } + + [Fact] + public async Task IncludeDescriptions_Should_Populate_Controller_Remarks() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var controller = GetDocumentedController(model); + controller.Remarks.ShouldNotBeNullOrEmpty(); + controller.Remarks.ShouldContain("integration tests"); + } + + [Fact] + public async Task IncludeDescriptions_Should_Populate_Controller_Description_Attribute() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var controller = GetDocumentedController(model); + controller.Description.ShouldBe("Documented service description from attribute"); + } + + [Fact] + public async Task IncludeDescriptions_Should_Populate_Controller_DisplayName_Attribute() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var controller = GetDocumentedController(model); + controller.DisplayName.ShouldBe("Documented Service"); + } + + [Fact] + public async Task IncludeDescriptions_Should_Populate_Action_Descriptions() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var controller = GetDocumentedController(model); + var action = GetAction(controller, "GetGreeting"); + + action.Summary.ShouldNotBeNullOrEmpty(); + action.Summary.ShouldContain("greeting message"); + action.Remarks.ShouldBeNull(); + action.Description.ShouldBe("Get greeting description from attribute"); + action.DisplayName.ShouldBe("Get Greeting"); + } + + [Fact] + public async Task IncludeDescriptions_Should_Populate_ReturnValue_Summary() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var controller = GetDocumentedController(model); + var action = GetAction(controller, "GetGreeting"); + + action.ReturnValue.Summary.ShouldNotBeNullOrEmpty(); + action.ReturnValue.Summary.ShouldContain("personalized greeting"); + } + + [Fact] + public async Task IncludeDescriptions_Should_Populate_ParameterOnMethod_Summary() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var controller = GetDocumentedController(model); + var action = GetAction(controller, "GetGreeting"); + + var param = action.ParametersOnMethod.FirstOrDefault(p => p.Name == "name"); + param.ShouldNotBeNull(); + param.Summary.ShouldNotBeNullOrEmpty(); + param.Summary.ShouldContain("name of the person"); + } + + [Fact] + public async Task IncludeDescriptions_Should_Populate_Parameter_Summary() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var controller = GetDocumentedController(model); + var action = GetAction(controller, "GetGreeting"); + + var param = action.Parameters.FirstOrDefault(p => p.NameOnMethod == "name"); + param.ShouldNotBeNull(); + param.Summary.ShouldNotBeNullOrEmpty(); + param.Summary.ShouldContain("name of the person"); + } + + [Fact] + public async Task IncludeDescriptions_With_IncludeTypes_Should_Populate_Type_Descriptions() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true&includeTypes=true"); + + model.Types.ShouldNotBeEmpty(); + + var documentedDtoType = model.Types.FirstOrDefault(t => t.Key.Contains("DocumentedDto")); + documentedDtoType.Value.ShouldNotBeNull(); + documentedDtoType.Value.Summary.ShouldNotBeNullOrEmpty(); + documentedDtoType.Value.Summary.ShouldContain("documented DTO"); + documentedDtoType.Value.Description.ShouldBe("Documented DTO description from attribute"); + documentedDtoType.Value.DisplayName.ShouldBe("Documented DTO"); + } + + [Fact] + public async Task IncludeDescriptions_With_IncludeTypes_Should_Populate_Property_Descriptions() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true&includeTypes=true"); + + var documentedDtoType = model.Types.FirstOrDefault(t => t.Key.Contains("DocumentedDto")); + documentedDtoType.Value.ShouldNotBeNull(); + documentedDtoType.Value.Properties.ShouldNotBeNull(); + + var nameProp = documentedDtoType.Value.Properties!.FirstOrDefault(p => p.Name == "Name"); + nameProp.ShouldNotBeNull(); + nameProp.Summary.ShouldNotBeNullOrEmpty(); + nameProp.Summary.ShouldContain("name of the documented item"); + nameProp.Description.ShouldBe("Name description from attribute"); + nameProp.DisplayName.ShouldBe("Item Name"); + + var valueProp = documentedDtoType.Value.Properties!.FirstOrDefault(p => p.Name == "Value"); + valueProp.ShouldNotBeNull(); + valueProp.Summary.ShouldNotBeNullOrEmpty(); + valueProp.Description.ShouldBe("Value description from attribute"); + valueProp.DisplayName.ShouldBeNull(); + } + + [Fact] + public async Task Default_Should_Not_Include_Type_Descriptions() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeTypes=true"); + + var documentedDtoType = model.Types.FirstOrDefault(t => t.Key.Contains("DocumentedDto")); + documentedDtoType.Value.ShouldNotBeNull(); + documentedDtoType.Value.Summary.ShouldBeNull(); + documentedDtoType.Value.Description.ShouldBeNull(); + } + + [Fact] + public async Task Action_Without_Descriptions_Should_Have_Null_Properties() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var peopleController = model.Modules.Values + .SelectMany(m => m.Controllers.Values) + .First(c => c.ControllerName == "People"); + + var action = peopleController.Actions.Values.First(a => a.Name == "GetPhones"); + action.Summary.ShouldBeNull(); + action.Description.ShouldBeNull(); + action.DisplayName.ShouldBeNull(); + } + + private static ControllerApiDescriptionModel GetDocumentedController(ApplicationApiDescriptionModel model) + { + return model.Modules.Values + .SelectMany(m => m.Controllers.Values) + .First(c => c.ControllerName == "Documented"); + } + + private static ActionApiDescriptionModel GetAction(ControllerApiDescriptionModel controller, string actionName) + { + return controller.Actions.Values + .First(a => a.Name == actionName + "Async" || a.Name == actionName); + } +} diff --git a/framework/test/Volo.Abp.TestApp/Volo.Abp.TestApp.csproj b/framework/test/Volo.Abp.TestApp/Volo.Abp.TestApp.csproj index ae0b3923a3..d5c3970ddc 100644 --- a/framework/test/Volo.Abp.TestApp/Volo.Abp.TestApp.csproj +++ b/framework/test/Volo.Abp.TestApp/Volo.Abp.TestApp.csproj @@ -6,6 +6,7 @@ net10.0 true + true diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/DocumentedAppService.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/DocumentedAppService.cs new file mode 100644 index 0000000000..477de737e0 --- /dev/null +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/DocumentedAppService.cs @@ -0,0 +1,40 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; +using Volo.Abp.TestApp.Application.Dto; + +namespace Volo.Abp.TestApp.Application; + +/// +/// A documented application service for testing API descriptions. +/// +/// +/// This service is used in integration tests to verify XML doc extraction. +/// +[Description("Documented service description from attribute")] +[Display(Name = "Documented Service")] +public class DocumentedAppService : ApplicationService, IDocumentedAppService +{ + /// + /// Gets a greeting message for the specified name. + /// + /// The name of the person to greet. + /// A personalized greeting message. + [Description("Get greeting description from attribute")] + [Display(Name = "Get Greeting")] + public Task GetGreetingAsync(string name) + { + return Task.FromResult($"Hello, {name}!"); + } + + /// + /// Creates a documented item. + /// + /// The input for creating a documented item. + /// The created documented item. + public Task CreateAsync(DocumentedDto input) + { + return Task.FromResult(input); + } +} diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/Dto/DocumentedDto.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/Dto/DocumentedDto.cs new file mode 100644 index 0000000000..56119f7939 --- /dev/null +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/Dto/DocumentedDto.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Volo.Abp.TestApp.Application.Dto; + +/// +/// A documented DTO for testing type and property descriptions. +/// +[Description("Documented DTO description from attribute")] +[Display(Name = "Documented DTO")] +public class DocumentedDto +{ + /// + /// The name of the documented item. + /// + [Description("Name description from attribute")] + [Display(Name = "Item Name")] + public string Name { get; set; } = default!; + + /// + /// The value of the documented item. + /// + [Description("Value description from attribute")] + public int Value { get; set; } +} diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IDocumentedAppService.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IDocumentedAppService.cs new file mode 100644 index 0000000000..2477c2d601 --- /dev/null +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IDocumentedAppService.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using Volo.Abp.Application.Services; +using Volo.Abp.TestApp.Application.Dto; + +namespace Volo.Abp.TestApp.Application; + +/// +/// A documented application service for testing API descriptions. +/// +/// +/// This service is used in integration tests to verify XML doc extraction. +/// +public interface IDocumentedAppService : IApplicationService +{ + /// + /// Gets a greeting message for the specified name. + /// + /// The name of the person to greet. + /// A personalized greeting message. + Task GetGreetingAsync(string name); + + /// + /// Creates a documented item. + /// + /// The input for creating a documented item. + /// The created documented item. + Task CreateAsync(DocumentedDto input); +} From 778abc8aaaa1b191ba97ae9f17f2e8db983c3629 Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 9 Mar 2026 12:15:07 +0800 Subject: [PATCH 2/5] feat: Refactor API documentation handling to support asynchronous operations --- .../AbpApiDefinitionController.cs | 7 +- .../ApiExploring/IXmlDocumentationProvider.cs | 15 +- .../ApiExploring/XmlDocumentationProvider.cs | 87 ++--- .../AspNetCoreApiDescriptionModelProvider.cs | 159 +++++++-- .../Modeling/IApiDescriptionModelProvider.cs | 5 + ...iDefinitionController_Description_Tests.cs | 301 ++++++++++++++++-- .../Application/DocumentedAppService.cs | 26 +- .../Application/IDocumentedAppService.cs | 10 + .../IInterfaceOnlyDocumentedAppService.cs | 20 ++ .../InterfaceOnlyDocumentedAppService.cs | 12 + 10 files changed, 528 insertions(+), 114 deletions(-) create mode 100644 framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IInterfaceOnlyDocumentedAppService.cs create mode 100644 framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/InterfaceOnlyDocumentedAppService.cs diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController.cs index 10f7e28811..413bddcc3a 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; using Volo.Abp.Http.Modeling; namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; @@ -16,8 +17,8 @@ public class AbpApiDefinitionController : AbpController, IRemoteService } [HttpGet] - public virtual ApplicationApiDescriptionModel Get(ApplicationApiDescriptionModelRequestDto model) + public virtual async Task Get(ApplicationApiDescriptionModelRequestDto model) { - return ModelProvider.CreateApiModel(model); + return await ModelProvider.CreateApiModelAsync(model); } } diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/IXmlDocumentationProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/IXmlDocumentationProvider.cs index f305f74c6e..fdc1138c27 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/IXmlDocumentationProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/IXmlDocumentationProvider.cs @@ -1,21 +1,22 @@ using System; using System.Reflection; +using System.Threading.Tasks; namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; public interface IXmlDocumentationProvider { - string? GetSummary(Type type); + Task GetSummaryAsync(Type type); - string? GetRemarks(Type type); + Task GetRemarksAsync(Type type); - string? GetSummary(MethodInfo method); + Task GetSummaryAsync(MethodInfo method); - string? GetRemarks(MethodInfo method); + Task GetRemarksAsync(MethodInfo method); - string? GetReturns(MethodInfo method); + Task GetReturnsAsync(MethodInfo method); - string? GetParameterSummary(MethodInfo method, string parameterName); + Task GetParameterSummaryAsync(MethodInfo method, string parameterName); - string? GetSummary(PropertyInfo property); + Task GetSummaryAsync(PropertyInfo property); } 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 40cb546cc5..f09cd4a6f3 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 @@ -4,6 +4,8 @@ using System.IO; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; using System.Xml.Linq; using System.Xml.XPath; using Volo.Abp.DependencyInjection; @@ -12,42 +14,44 @@ namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDependency { - private readonly ConcurrentDictionary _xmlDocCache = new(); + private static readonly Regex WhitespaceRegex = new(@"\s+", RegexOptions.Compiled); - public string? GetSummary(Type type) + private readonly ConcurrentDictionary> _xmlDocCache = new(); + + public virtual async Task GetSummaryAsync(Type type) { var memberName = GetMemberNameForType(type); - return GetDocumentationElement(type.Assembly, memberName, "summary"); + return await GetDocumentationElementAsync(type.Assembly, memberName, "summary"); } - public string? GetRemarks(Type type) + public virtual async Task GetRemarksAsync(Type type) { var memberName = GetMemberNameForType(type); - return GetDocumentationElement(type.Assembly, memberName, "remarks"); + return await GetDocumentationElementAsync(type.Assembly, memberName, "remarks"); } - public string? GetSummary(MethodInfo method) + public virtual async Task GetSummaryAsync(MethodInfo method) { var memberName = GetMemberNameForMethod(method); - return GetDocumentationElement(method.DeclaringType!.Assembly, memberName, "summary"); + return await GetDocumentationElementAsync(method.DeclaringType!.Assembly, memberName, "summary"); } - public string? GetRemarks(MethodInfo method) + public virtual async Task GetRemarksAsync(MethodInfo method) { var memberName = GetMemberNameForMethod(method); - return GetDocumentationElement(method.DeclaringType!.Assembly, memberName, "remarks"); + return await GetDocumentationElementAsync(method.DeclaringType!.Assembly, memberName, "remarks"); } - public string? GetReturns(MethodInfo method) + public virtual async Task GetReturnsAsync(MethodInfo method) { var memberName = GetMemberNameForMethod(method); - return GetDocumentationElement(method.DeclaringType!.Assembly, memberName, "returns"); + return await GetDocumentationElementAsync(method.DeclaringType!.Assembly, memberName, "returns"); } - public string? GetParameterSummary(MethodInfo method, string parameterName) + public virtual async Task GetParameterSummaryAsync(MethodInfo method, string parameterName) { var memberName = GetMemberNameForMethod(method); - var doc = LoadXmlDocumentation(method.DeclaringType!.Assembly); + var doc = await LoadXmlDocumentationAsync(method.DeclaringType!.Assembly); if (doc == null) { return null; @@ -58,15 +62,15 @@ public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDep return CleanXmlText(paramNode); } - public string? GetSummary(PropertyInfo property) + public virtual async Task GetSummaryAsync(PropertyInfo property) { var memberName = GetMemberNameForProperty(property); - return GetDocumentationElement(property.DeclaringType!.Assembly, memberName, "summary"); + return await GetDocumentationElementAsync(property.DeclaringType!.Assembly, memberName, "summary"); } - private string? GetDocumentationElement(Assembly assembly, string memberName, string elementName) + protected virtual async Task GetDocumentationElementAsync(Assembly assembly, string memberName, string elementName) { - var doc = LoadXmlDocumentation(assembly); + var doc = await LoadXmlDocumentationAsync(assembly); if (doc == null) { return null; @@ -77,30 +81,33 @@ public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDep return CleanXmlText(element); } - private XDocument? LoadXmlDocumentation(Assembly assembly) + protected virtual Task LoadXmlDocumentationAsync(Assembly assembly) + { + return _xmlDocCache.GetOrAdd(assembly, LoadXmlDocumentationFromDiskAsync); + } + + protected virtual async Task LoadXmlDocumentationFromDiskAsync(Assembly assembly) { - return _xmlDocCache.GetOrAdd(assembly, static asm => + if (string.IsNullOrEmpty(assembly.Location)) + { + return null; + } + + var xmlFilePath = Path.ChangeExtension(assembly.Location, ".xml"); + if (!File.Exists(xmlFilePath)) { - if (string.IsNullOrEmpty(asm.Location)) - { - return null; - } - - var xmlFilePath = Path.ChangeExtension(asm.Location, ".xml"); - if (!File.Exists(xmlFilePath)) - { - return null; - } - - try - { - return XDocument.Load(xmlFilePath); - } - catch - { - return null; - } - }); + return null; + } + + try + { + await using var stream = new FileStream(xmlFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true); + return await XDocument.LoadAsync(stream, LoadOptions.None, CancellationToken.None); + } + catch + { + return null; + } } private static string? CleanXmlText(XElement? element) @@ -116,7 +123,7 @@ public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDep return null; } - return Regex.Replace(text.Trim(), @"\s+", " "); + return WhitespaceRegex.Replace(text.Trim(), " "); } private static string GetMemberNameForType(Type type) diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs index 4dfb7b5e08..da42052821 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Reflection; +using System.Threading.Tasks; using Asp.Versioning; using JetBrains.Annotations; using Microsoft.AspNetCore.Authorization; @@ -51,10 +52,16 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide } public ApplicationApiDescriptionModel CreateApiModel(ApplicationApiDescriptionModelRequestDto input) + { + return AsyncHelper.RunSync(() => CreateApiModelAsync(input)); + } + + public virtual async Task CreateApiModelAsync(ApplicationApiDescriptionModelRequestDto input) { //TODO: Can cache the model? var model = ApplicationApiDescriptionModel.Create(); + var populatedControllers = new HashSet(); foreach (var descriptionGroupItem in _descriptionProvider.ApiDescriptionGroups.Items) { @@ -65,7 +72,7 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide continue; } - AddApiDescriptionToModel(apiDescription, model, input); + await AddApiDescriptionToModelAsync(apiDescription, model, input, populatedControllers); } } @@ -86,10 +93,11 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide return model; } - private void AddApiDescriptionToModel( + private async Task AddApiDescriptionToModelAsync( ApiDescription apiDescription, ApplicationApiDescriptionModel applicationModel, - ApplicationApiDescriptionModelRequestDto input) + ApplicationApiDescriptionModelRequestDto input, + HashSet populatedControllers) { var controllerType = apiDescription .ActionDescriptor @@ -167,20 +175,20 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide if (input.IncludeTypes) { - AddCustomTypesToModel(applicationModel, method, input.IncludeDescriptions); + await AddCustomTypesToModelAsync(applicationModel, method, input.IncludeDescriptions); } AddParameterDescriptionsToModel(actionModel, method, apiDescription); if (input.IncludeDescriptions) { - if (controllerModel.Summary == null && controllerModel.Description == null && controllerModel.DisplayName == null) + if (populatedControllers.Add(controllerModel)) { - PopulateControllerDescriptions(controllerModel, controllerType); + await PopulateControllerDescriptionsAsync(controllerModel, controllerType); } - PopulateActionDescriptions(actionModel, method); - PopulateParameterDescriptions(actionModel, method); + await PopulateActionDescriptionsAsync(actionModel, method); + await PopulateParameterDescriptionsAsync(actionModel, method); } } @@ -208,17 +216,17 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide return supportedVersions.Select(v => v.ToString()).Distinct().ToList(); } - private void AddCustomTypesToModel(ApplicationApiDescriptionModel applicationModel, MethodInfo method, bool includeDescriptions) + private async Task AddCustomTypesToModelAsync(ApplicationApiDescriptionModel applicationModel, MethodInfo method, bool includeDescriptions) { foreach (var parameterInfo in method.GetParameters()) { - AddCustomTypesToModel(applicationModel, parameterInfo.ParameterType, includeDescriptions); + await AddCustomTypesToModelAsync(applicationModel, parameterInfo.ParameterType, includeDescriptions); } - AddCustomTypesToModel(applicationModel, method.ReturnType, includeDescriptions); + await AddCustomTypesToModelAsync(applicationModel, method.ReturnType, includeDescriptions); } - private void AddCustomTypesToModel(ApplicationApiDescriptionModel applicationModel, + private async Task AddCustomTypesToModelAsync(ApplicationApiDescriptionModel applicationModel, Type? type, bool includeDescriptions) { if (type == null) @@ -246,14 +254,14 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide if (TypeHelper.IsDictionary(type, out var keyType, out var valueType)) { - AddCustomTypesToModel(applicationModel, keyType, includeDescriptions); - AddCustomTypesToModel(applicationModel, valueType, includeDescriptions); + await AddCustomTypesToModelAsync(applicationModel, keyType, includeDescriptions); + await AddCustomTypesToModelAsync(applicationModel, valueType, includeDescriptions); return; } if (TypeHelper.IsEnumerable(type, out var itemType)) { - AddCustomTypesToModel(applicationModel, itemType, includeDescriptions); + await AddCustomTypesToModelAsync(applicationModel, itemType, includeDescriptions); return; } @@ -261,11 +269,11 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide { var genericTypeDefinition = type.GetGenericTypeDefinition(); - AddCustomTypesToModel(applicationModel, genericTypeDefinition, includeDescriptions); + await AddCustomTypesToModelAsync(applicationModel, genericTypeDefinition, includeDescriptions); foreach (var genericArgument in type.GetGenericArguments()) { - AddCustomTypesToModel(applicationModel, genericArgument, includeDescriptions); + await AddCustomTypesToModelAsync(applicationModel, genericArgument, includeDescriptions); } return; @@ -281,14 +289,14 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide if (includeDescriptions) { - PopulateTypeDescriptions(applicationModel.Types[typeName], type); + await PopulateTypeDescriptionsAsync(applicationModel.Types[typeName], type); } - AddCustomTypesToModel(applicationModel, type.BaseType, includeDescriptions); + await AddCustomTypesToModelAsync(applicationModel, type.BaseType, includeDescriptions); foreach (var propertyInfo in type.GetProperties().Where(p => p.DeclaringType == type)) { - AddCustomTypesToModel(applicationModel, propertyInfo.PropertyType, includeDescriptions); + await AddCustomTypesToModelAsync(applicationModel, propertyInfo.PropertyType, includeDescriptions); } } @@ -437,48 +445,129 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide return null; } - private void PopulateControllerDescriptions(ControllerApiDescriptionModel controllerModel, Type controllerType) + protected virtual async Task PopulateControllerDescriptionsAsync(ControllerApiDescriptionModel controllerModel, Type controllerType) { - controllerModel.Summary = _xmlDocProvider.GetSummary(controllerType); - controllerModel.Remarks = _xmlDocProvider.GetRemarks(controllerType); + controllerModel.Summary = await _xmlDocProvider.GetSummaryAsync(controllerType); + controllerModel.Remarks = await _xmlDocProvider.GetRemarksAsync(controllerType); + + if (controllerModel.Summary == null && controllerModel.Remarks == null) + { + foreach (var interfaceType in GetDirectInterfaces(controllerType)) + { + controllerModel.Summary = await _xmlDocProvider.GetSummaryAsync(interfaceType); + controllerModel.Remarks = await _xmlDocProvider.GetRemarksAsync(interfaceType); + if (controllerModel.Summary != null || controllerModel.Remarks != null) + { + break; + } + } + } + controllerModel.Description = controllerType.GetCustomAttribute()?.Description; controllerModel.DisplayName = controllerType.GetCustomAttribute()?.Name; } - private void PopulateActionDescriptions(ActionApiDescriptionModel actionModel, MethodInfo method) + protected virtual async Task PopulateActionDescriptionsAsync(ActionApiDescriptionModel actionModel, MethodInfo method) { - actionModel.Summary = _xmlDocProvider.GetSummary(method); - actionModel.Remarks = _xmlDocProvider.GetRemarks(method); + actionModel.Summary = await _xmlDocProvider.GetSummaryAsync(method); + actionModel.Remarks = await _xmlDocProvider.GetRemarksAsync(method); + + if (actionModel.Summary == null && actionModel.Remarks == null) + { + var interfaceMethod = GetInterfaceMethod(method); + if (interfaceMethod != null) + { + actionModel.Summary = await _xmlDocProvider.GetSummaryAsync(interfaceMethod); + actionModel.Remarks = await _xmlDocProvider.GetRemarksAsync(interfaceMethod); + } + } + actionModel.Description = method.GetCustomAttribute()?.Description; actionModel.DisplayName = method.GetCustomAttribute()?.Name; - actionModel.ReturnValue.Summary = _xmlDocProvider.GetReturns(method); + + actionModel.ReturnValue.Summary = await _xmlDocProvider.GetReturnsAsync(method); + if (actionModel.ReturnValue.Summary == null) + { + var interfaceMethod = GetInterfaceMethod(method); + if (interfaceMethod != null) + { + actionModel.ReturnValue.Summary = await _xmlDocProvider.GetReturnsAsync(interfaceMethod); + } + } } - private void PopulateParameterDescriptions(ActionApiDescriptionModel actionModel, MethodInfo method) + protected virtual async Task PopulateParameterDescriptionsAsync(ActionApiDescriptionModel actionModel, MethodInfo method) { + var interfaceMethod = GetInterfaceMethod(method); + var methodParameters = method.GetParameters(); + foreach (var param in actionModel.ParametersOnMethod) { - var paramInfo = method.GetParameters().FirstOrDefault(p => p.Name == param.Name); + var paramInfo = methodParameters.FirstOrDefault(p => p.Name == param.Name); if (paramInfo == null) { continue; } - param.Summary = _xmlDocProvider.GetParameterSummary(method, param.Name); + param.Summary = await _xmlDocProvider.GetParameterSummaryAsync(method, param.Name); + if (param.Summary == null && interfaceMethod != null) + { + param.Summary = await _xmlDocProvider.GetParameterSummaryAsync(interfaceMethod, param.Name); + } + param.Description = paramInfo.GetCustomAttribute()?.Description; param.DisplayName = paramInfo.GetCustomAttribute()?.Name; } foreach (var param in actionModel.Parameters) { - param.Summary = _xmlDocProvider.GetParameterSummary(method, param.NameOnMethod); + param.Summary = await _xmlDocProvider.GetParameterSummaryAsync(method, param.NameOnMethod); + if (param.Summary == null && interfaceMethod != null) + { + param.Summary = await _xmlDocProvider.GetParameterSummaryAsync(interfaceMethod, param.NameOnMethod); + } + + var paramInfo = methodParameters.FirstOrDefault(p => p.Name == param.NameOnMethod); + if (paramInfo != null) + { + param.Description = paramInfo.GetCustomAttribute()?.Description; + param.DisplayName = paramInfo.GetCustomAttribute()?.Name; + } } } - private void PopulateTypeDescriptions(TypeApiDescriptionModel typeModel, Type type) + private static MethodInfo? GetInterfaceMethod(MethodInfo method) + { + var declaringType = method.DeclaringType; + if (declaringType == null || declaringType.IsInterface) + { + return null; + } + + foreach (var interfaceType in GetDirectInterfaces(declaringType)) + { + var interfaceMethod = interfaceType.GetMethods() + .FirstOrDefault(m => m.ToString() == method.ToString()); + if (interfaceMethod != null) + { + return interfaceMethod; + } + } + + return null; + } + + private static IEnumerable GetDirectInterfaces(Type type) + { + var allInterfaces = type.GetInterfaces(); + var baseInterfaces = type.BaseType?.GetInterfaces() ?? Type.EmptyTypes; + return allInterfaces.Except(baseInterfaces); + } + + protected virtual async Task PopulateTypeDescriptionsAsync(TypeApiDescriptionModel typeModel, Type type) { - typeModel.Summary = _xmlDocProvider.GetSummary(type); - typeModel.Remarks = _xmlDocProvider.GetRemarks(type); + typeModel.Summary = await _xmlDocProvider.GetSummaryAsync(type); + typeModel.Remarks = await _xmlDocProvider.GetRemarksAsync(type); typeModel.Description = type.GetCustomAttribute()?.Description; typeModel.DisplayName = type.GetCustomAttribute()?.Name; @@ -495,7 +584,7 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide continue; } - propModel.Summary = _xmlDocProvider.GetSummary(propInfo); + propModel.Summary = await _xmlDocProvider.GetSummaryAsync(propInfo); propModel.Description = propInfo.GetCustomAttribute()?.Description; propModel.DisplayName = propInfo.GetCustomAttribute()?.Name; } diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IApiDescriptionModelProvider.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IApiDescriptionModelProvider.cs index 145f3ad175..b985a2df76 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IApiDescriptionModelProvider.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IApiDescriptionModelProvider.cs @@ -1,6 +1,11 @@ +using System.Threading.Tasks; + namespace Volo.Abp.Http.Modeling; public interface IApiDescriptionModelProvider { ApplicationApiDescriptionModel CreateApiModel(ApplicationApiDescriptionModelRequestDto input); + + Task CreateApiModelAsync(ApplicationApiDescriptionModelRequestDto input) + => Task.FromResult(CreateApiModel(input)); } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Description_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Description_Tests.cs index e751e01cb1..09baed4aa6 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Description_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Description_Tests.cs @@ -9,7 +9,7 @@ namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; public class AbpApiDefinitionController_Description_Tests : AspNetCoreMvcTestBase { [Fact] - public async Task Default_Should_Not_Include_Descriptions() + public async Task Default_Should_Not_Include_Controller_Descriptions() { var model = await GetResponseAsObjectAsync( "/api/abp/api-definition"); @@ -21,6 +21,55 @@ public class AbpApiDefinitionController_Description_Tests : AspNetCoreMvcTestBas controller.DisplayName.ShouldBeNull(); } + [Fact] + public async Task Default_Should_Not_Include_Action_Descriptions() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition"); + + var controller = GetDocumentedController(model); + var action = GetAction(controller, "GetGreeting"); + action.Summary.ShouldBeNull(); + action.Remarks.ShouldBeNull(); + action.Description.ShouldBeNull(); + action.DisplayName.ShouldBeNull(); + action.ReturnValue.Summary.ShouldBeNull(); + } + + [Fact] + public async Task Default_Should_Not_Include_Parameter_Descriptions() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition"); + + var controller = GetDocumentedController(model); + var action = GetAction(controller, "GetGreeting"); + + var methodParam = action.ParametersOnMethod.FirstOrDefault(p => p.Name == "name"); + methodParam.ShouldNotBeNull(); + methodParam.Summary.ShouldBeNull(); + methodParam.Description.ShouldBeNull(); + methodParam.DisplayName.ShouldBeNull(); + + var httpParam = action.Parameters.FirstOrDefault(p => p.NameOnMethod == "name"); + httpParam.ShouldNotBeNull(); + httpParam.Summary.ShouldBeNull(); + } + + [Fact] + public async Task Default_Should_Not_Include_Type_Descriptions() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeTypes=true"); + + var documentedDtoType = model.Types.FirstOrDefault(t => t.Key.Contains("DocumentedDto")); + documentedDtoType.Value.ShouldNotBeNull(); + documentedDtoType.Value.Summary.ShouldBeNull(); + documentedDtoType.Value.Remarks.ShouldBeNull(); + documentedDtoType.Value.Description.ShouldBeNull(); + documentedDtoType.Value.DisplayName.ShouldBeNull(); + } + [Fact] public async Task IncludeDescriptions_Should_Populate_Controller_Summary() { @@ -64,18 +113,56 @@ public class AbpApiDefinitionController_Description_Tests : AspNetCoreMvcTestBas } [Fact] - public async Task IncludeDescriptions_Should_Populate_Action_Descriptions() + public async Task Controller_Descriptions_Should_Be_Populated_Only_Once_For_Multiple_Actions() { var model = await GetResponseAsObjectAsync( "/api/abp/api-definition?includeDescriptions=true"); var controller = GetDocumentedController(model); - var action = GetAction(controller, "GetGreeting"); + controller.Actions.Count.ShouldBeGreaterThan(1); + controller.Summary.ShouldNotBeNullOrEmpty(); + controller.Summary.ShouldContain("documented application service"); + } + + [Fact] + public async Task IncludeDescriptions_Should_Populate_Action_Summary() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var action = GetAction(GetDocumentedController(model), "GetGreeting"); action.Summary.ShouldNotBeNullOrEmpty(); action.Summary.ShouldContain("greeting message"); + } + + [Fact] + public async Task IncludeDescriptions_Should_Leave_Action_Remarks_Null_When_Not_Documented() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var action = GetAction(GetDocumentedController(model), "GetGreeting"); action.Remarks.ShouldBeNull(); + } + + [Fact] + public async Task IncludeDescriptions_Should_Populate_Action_Description_Attribute() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var action = GetAction(GetDocumentedController(model), "GetGreeting"); action.Description.ShouldBe("Get greeting description from attribute"); + } + + [Fact] + public async Task IncludeDescriptions_Should_Populate_Action_DisplayName_Attribute() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var action = GetAction(GetDocumentedController(model), "GetGreeting"); action.DisplayName.ShouldBe("Get Greeting"); } @@ -85,22 +172,32 @@ public class AbpApiDefinitionController_Description_Tests : AspNetCoreMvcTestBas var model = await GetResponseAsObjectAsync( "/api/abp/api-definition?includeDescriptions=true"); - var controller = GetDocumentedController(model); - var action = GetAction(controller, "GetGreeting"); - + var action = GetAction(GetDocumentedController(model), "GetGreeting"); action.ReturnValue.Summary.ShouldNotBeNullOrEmpty(); action.ReturnValue.Summary.ShouldContain("personalized greeting"); } [Fact] - public async Task IncludeDescriptions_Should_Populate_ParameterOnMethod_Summary() + public async Task Undocumented_Action_Should_Have_Null_Descriptions() { var model = await GetResponseAsObjectAsync( "/api/abp/api-definition?includeDescriptions=true"); - var controller = GetDocumentedController(model); - var action = GetAction(controller, "GetGreeting"); + var action = GetAction(GetDocumentedController(model), "Delete"); + action.Summary.ShouldBeNull(); + action.Remarks.ShouldBeNull(); + action.Description.ShouldBeNull(); + action.DisplayName.ShouldBeNull(); + action.ReturnValue.Summary.ShouldBeNull(); + } + + [Fact] + public async Task IncludeDescriptions_Should_Populate_ParameterOnMethod_Summary() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + var action = GetAction(GetDocumentedController(model), "GetGreeting"); var param = action.ParametersOnMethod.FirstOrDefault(p => p.Name == "name"); param.ShouldNotBeNull(); param.Summary.ShouldNotBeNullOrEmpty(); @@ -108,14 +205,41 @@ public class AbpApiDefinitionController_Description_Tests : AspNetCoreMvcTestBas } [Fact] - public async Task IncludeDescriptions_Should_Populate_Parameter_Summary() + public async Task IncludeDescriptions_Should_Populate_ParameterOnMethod_Description_And_DisplayName_From_Attribute() { var model = await GetResponseAsObjectAsync( "/api/abp/api-definition?includeDescriptions=true"); - var controller = GetDocumentedController(model); - var action = GetAction(controller, "GetGreeting"); + var action = GetAction(GetDocumentedController(model), "Search"); + var param = action.ParametersOnMethod.FirstOrDefault(p => p.Name == "query"); + param.ShouldNotBeNull(); + param.Summary.ShouldNotBeNullOrEmpty(); + param.Summary.ShouldContain("search query"); + param.Description.ShouldBe("Query param description from attribute"); + param.DisplayName.ShouldBe("Search Query"); + } + + [Fact] + public async Task IncludeDescriptions_Should_Leave_Parameter_Attributes_Null_When_Not_Annotated() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var action = GetAction(GetDocumentedController(model), "Search"); + var param = action.ParametersOnMethod.FirstOrDefault(p => p.Name == "maxResults"); + param.ShouldNotBeNull(); + param.Summary.ShouldNotBeNullOrEmpty(); + param.Description.ShouldBeNull(); + param.DisplayName.ShouldBeNull(); + } + + [Fact] + public async Task IncludeDescriptions_Should_Populate_Parameter_Summary() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + var action = GetAction(GetDocumentedController(model), "GetGreeting"); var param = action.Parameters.FirstOrDefault(p => p.NameOnMethod == "name"); param.ShouldNotBeNull(); param.Summary.ShouldNotBeNullOrEmpty(); @@ -123,23 +247,58 @@ public class AbpApiDefinitionController_Description_Tests : AspNetCoreMvcTestBas } [Fact] - public async Task IncludeDescriptions_With_IncludeTypes_Should_Populate_Type_Descriptions() + public async Task IncludeDescriptions_Should_Populate_Parameter_Description_And_DisplayName_From_Attribute() { var model = await GetResponseAsObjectAsync( - "/api/abp/api-definition?includeDescriptions=true&includeTypes=true"); + "/api/abp/api-definition?includeDescriptions=true"); + + var action = GetAction(GetDocumentedController(model), "Search"); + var param = action.Parameters.FirstOrDefault(p => p.NameOnMethod == "query"); + param.ShouldNotBeNull(); + param.Summary.ShouldNotBeNullOrEmpty(); + param.Description.ShouldBe("Query param description from attribute"); + param.DisplayName.ShouldBe("Search Query"); + } - model.Types.ShouldNotBeEmpty(); + [Fact] + public async Task IncludeDescriptions_Should_Leave_Parameter_Attributes_Null_When_Not_Annotated_Http() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var action = GetAction(GetDocumentedController(model), "Search"); + var param = action.Parameters.FirstOrDefault(p => p.NameOnMethod == "maxResults"); + param.ShouldNotBeNull(); + param.Description.ShouldBeNull(); + param.DisplayName.ShouldBeNull(); + } + + [Fact] + public async Task IncludeDescriptions_With_IncludeTypes_Should_Populate_Type_Summary() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true&includeTypes=true"); var documentedDtoType = model.Types.FirstOrDefault(t => t.Key.Contains("DocumentedDto")); documentedDtoType.Value.ShouldNotBeNull(); documentedDtoType.Value.Summary.ShouldNotBeNullOrEmpty(); documentedDtoType.Value.Summary.ShouldContain("documented DTO"); + } + + [Fact] + public async Task IncludeDescriptions_With_IncludeTypes_Should_Populate_Type_Description_And_DisplayName() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true&includeTypes=true"); + + var documentedDtoType = model.Types.FirstOrDefault(t => t.Key.Contains("DocumentedDto")); + documentedDtoType.Value.ShouldNotBeNull(); documentedDtoType.Value.Description.ShouldBe("Documented DTO description from attribute"); documentedDtoType.Value.DisplayName.ShouldBe("Documented DTO"); } [Fact] - public async Task IncludeDescriptions_With_IncludeTypes_Should_Populate_Property_Descriptions() + public async Task IncludeDescriptions_With_IncludeTypes_Should_Populate_Property_Summary() { var model = await GetResponseAsObjectAsync( "/api/abp/api-definition?includeDescriptions=true&includeTypes=true"); @@ -152,8 +311,31 @@ public class AbpApiDefinitionController_Description_Tests : AspNetCoreMvcTestBas nameProp.ShouldNotBeNull(); nameProp.Summary.ShouldNotBeNullOrEmpty(); nameProp.Summary.ShouldContain("name of the documented item"); + } + + [Fact] + public async Task IncludeDescriptions_With_IncludeTypes_Should_Populate_Property_Description_And_DisplayName() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true&includeTypes=true"); + + var documentedDtoType = model.Types.FirstOrDefault(t => t.Key.Contains("DocumentedDto")); + documentedDtoType.Value.ShouldNotBeNull(); + + var nameProp = documentedDtoType.Value.Properties!.FirstOrDefault(p => p.Name == "Name"); + nameProp.ShouldNotBeNull(); nameProp.Description.ShouldBe("Name description from attribute"); nameProp.DisplayName.ShouldBe("Item Name"); + } + + [Fact] + public async Task IncludeDescriptions_With_IncludeTypes_Should_Leave_Property_DisplayName_Null_When_Not_Set() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true&includeTypes=true"); + + var documentedDtoType = model.Types.FirstOrDefault(t => t.Key.Contains("DocumentedDto")); + documentedDtoType.Value.ShouldNotBeNull(); var valueProp = documentedDtoType.Value.Properties!.FirstOrDefault(p => p.Name == "Value"); valueProp.ShouldNotBeNull(); @@ -163,7 +345,7 @@ public class AbpApiDefinitionController_Description_Tests : AspNetCoreMvcTestBas } [Fact] - public async Task Default_Should_Not_Include_Type_Descriptions() + public async Task IncludeTypes_Without_IncludeDescriptions_Should_Not_Populate_Type_Descriptions() { var model = await GetResponseAsObjectAsync( "/api/abp/api-definition?includeTypes=true"); @@ -171,23 +353,85 @@ public class AbpApiDefinitionController_Description_Tests : AspNetCoreMvcTestBas var documentedDtoType = model.Types.FirstOrDefault(t => t.Key.Contains("DocumentedDto")); documentedDtoType.Value.ShouldNotBeNull(); documentedDtoType.Value.Summary.ShouldBeNull(); + documentedDtoType.Value.Remarks.ShouldBeNull(); documentedDtoType.Value.Description.ShouldBeNull(); + documentedDtoType.Value.DisplayName.ShouldBeNull(); + + if (documentedDtoType.Value.Properties != null) + { + foreach (var prop in documentedDtoType.Value.Properties) + { + prop.Summary.ShouldBeNull(); + prop.Description.ShouldBeNull(); + prop.DisplayName.ShouldBeNull(); + } + } } [Fact] - public async Task Action_Without_Descriptions_Should_Have_Null_Properties() + public async Task IncludeDescriptions_Should_Fallback_To_Interface_For_Controller_Summary() { var model = await GetResponseAsObjectAsync( "/api/abp/api-definition?includeDescriptions=true"); - var peopleController = model.Modules.Values - .SelectMany(m => m.Controllers.Values) - .First(c => c.ControllerName == "People"); + var controller = GetInterfaceOnlyController(model); + controller.Summary.ShouldNotBeNullOrEmpty(); + controller.Summary.ShouldContain("documented only on the interface"); + } - var action = peopleController.Actions.Values.First(a => a.Name == "GetPhones"); - action.Summary.ShouldBeNull(); - action.Description.ShouldBeNull(); - action.DisplayName.ShouldBeNull(); + [Fact] + public async Task IncludeDescriptions_Should_Fallback_To_Interface_For_Controller_Remarks() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var controller = GetInterfaceOnlyController(model); + controller.Remarks.ShouldNotBeNullOrEmpty(); + controller.Remarks.ShouldContain("resolved from the interface"); + } + + [Fact] + public async Task IncludeDescriptions_Should_Fallback_To_Interface_For_Action_Summary() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var controller = GetInterfaceOnlyController(model); + var action = GetAction(controller, "GetMessage"); + action.Summary.ShouldNotBeNullOrEmpty(); + action.Summary.ShouldContain("documented only on the interface"); + } + + [Fact] + public async Task IncludeDescriptions_Should_Fallback_To_Interface_For_Action_ReturnValue_Summary() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var controller = GetInterfaceOnlyController(model); + var action = GetAction(controller, "GetMessage"); + action.ReturnValue.Summary.ShouldNotBeNullOrEmpty(); + action.ReturnValue.Summary.ShouldContain("resolved message"); + } + + [Fact] + public async Task IncludeDescriptions_Should_Fallback_To_Interface_For_Parameter_Summary() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var controller = GetInterfaceOnlyController(model); + var action = GetAction(controller, "GetMessage"); + + var methodParam = action.ParametersOnMethod.FirstOrDefault(p => p.Name == "key"); + methodParam.ShouldNotBeNull(); + methodParam.Summary.ShouldNotBeNullOrEmpty(); + methodParam.Summary.ShouldContain("message key"); + + var httpParam = action.Parameters.FirstOrDefault(p => p.NameOnMethod == "key"); + httpParam.ShouldNotBeNull(); + httpParam.Summary.ShouldNotBeNullOrEmpty(); + httpParam.Summary.ShouldContain("message key"); } private static ControllerApiDescriptionModel GetDocumentedController(ApplicationApiDescriptionModel model) @@ -197,6 +441,13 @@ public class AbpApiDefinitionController_Description_Tests : AspNetCoreMvcTestBas .First(c => c.ControllerName == "Documented"); } + private static ControllerApiDescriptionModel GetInterfaceOnlyController(ApplicationApiDescriptionModel model) + { + return model.Modules.Values + .SelectMany(m => m.Controllers.Values) + .First(c => c.ControllerName == "InterfaceOnlyDocumented"); + } + private static ActionApiDescriptionModel GetAction(ControllerApiDescriptionModel controller, string actionName) { return controller.Actions.Values diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/DocumentedAppService.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/DocumentedAppService.cs index 477de737e0..32dd58feb1 100644 --- a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/DocumentedAppService.cs +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/DocumentedAppService.cs @@ -23,9 +23,9 @@ public class DocumentedAppService : ApplicationService, IDocumentedAppService /// A personalized greeting message. [Description("Get greeting description from attribute")] [Display(Name = "Get Greeting")] - public Task GetGreetingAsync(string name) + public async Task GetGreetingAsync(string name) { - return Task.FromResult($"Hello, {name}!"); + return await Task.FromResult($"Hello, {name}!"); } /// @@ -33,8 +33,26 @@ public class DocumentedAppService : ApplicationService, IDocumentedAppService /// /// The input for creating a documented item. /// The created documented item. - public Task CreateAsync(DocumentedDto input) + public async Task CreateAsync(DocumentedDto input) { - return Task.FromResult(input); + return await Task.FromResult(input); + } + + /// + /// Searches for items matching the query. + /// + /// The search query string. + /// The maximum number of results to return. + /// A list of matching item names. + public async Task SearchAsync( + [Description("Query param description from attribute")] [Display(Name = "Search Query")] string query, + int maxResults) + { + return await Task.FromResult($"Results for {query}"); + } + + public async Task DeleteAsync(int id) + { + await Task.CompletedTask; } } diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IDocumentedAppService.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IDocumentedAppService.cs index 2477c2d601..e99c0e6b21 100644 --- a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IDocumentedAppService.cs +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IDocumentedAppService.cs @@ -25,4 +25,14 @@ public interface IDocumentedAppService : IApplicationService /// The input for creating a documented item. /// The created documented item. Task CreateAsync(DocumentedDto input); + + /// + /// Searches for items matching the query. + /// + /// The search query string. + /// The maximum number of results to return. + /// A list of matching item names. + Task SearchAsync(string query, int maxResults); + + Task DeleteAsync(int id); } diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IInterfaceOnlyDocumentedAppService.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IInterfaceOnlyDocumentedAppService.cs new file mode 100644 index 0000000000..d752fd0df9 --- /dev/null +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IInterfaceOnlyDocumentedAppService.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Volo.Abp.TestApp.Application; + +/// +/// A service documented only on the interface to test XML doc fallback. +/// +/// +/// Used to verify that documentation is resolved from the interface when the implementation has none. +/// +public interface IInterfaceOnlyDocumentedAppService : IApplicationService +{ + /// + /// Gets a message documented only on the interface. + /// + /// The message key. + /// The resolved message. + Task GetMessageAsync(string key); +} diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/InterfaceOnlyDocumentedAppService.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/InterfaceOnlyDocumentedAppService.cs new file mode 100644 index 0000000000..d9bc53981c --- /dev/null +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/InterfaceOnlyDocumentedAppService.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Volo.Abp.TestApp.Application; + +public class InterfaceOnlyDocumentedAppService : ApplicationService, IInterfaceOnlyDocumentedAppService +{ + public async Task GetMessageAsync(string key) + { + return await Task.FromResult(key); + } +} From 88b3b5d6e3bc34648017af7210ec4a188c89cd36 Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 9 Mar 2026 12:40:16 +0800 Subject: [PATCH 3/5] feat: Enhance XmlDocumentationProvider with XML tag processing and add unit tests --- .../ApiExploring/XmlDocumentationProvider.cs | 37 ++- .../XmlDocumentationProviderTests.cs | 213 ++++++++++++++++++ 2 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProviderTests.cs 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); + } + } +} From 60ff0218ec6f588c70ba2433be1ae84d2999e6ad Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 9 Mar 2026 12:57:50 +0800 Subject: [PATCH 4/5] feat: Refactor API scripting and model provider to support asynchronous operations --- .../Mvc/ApiExploring/AbpApiDefinitionController.cs | 2 +- .../Mvc/AspNetCoreApiDescriptionModelProvider.cs | 5 ----- .../AbpServiceProxyScriptController.cs | 7 ++++--- .../Http/Modeling/IApiDescriptionModelProvider.cs | 5 +---- .../Abp/Http/ProxyScripting/IProxyScriptManager.cs | 4 +++- .../Http/ProxyScripting/IProxyScriptManagerCache.cs | 2 ++ .../Abp/Http/ProxyScripting/ProxyScriptManager.cs | 13 +++++++------ .../Http/ProxyScripting/ProxyScriptManagerCache.cs | 5 +++++ 8 files changed, 23 insertions(+), 20 deletions(-) diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController.cs index 413bddcc3a..1cf47ddb64 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController.cs @@ -1,4 +1,4 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Volo.Abp.Http.Modeling; diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs index da42052821..7960b9319a 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs @@ -51,11 +51,6 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide Logger = NullLogger.Instance; } - public ApplicationApiDescriptionModel CreateApiModel(ApplicationApiDescriptionModelRequestDto input) - { - return AsyncHelper.RunSync(() => CreateApiModelAsync(input)); - } - public virtual async Task CreateApiModelAsync(ApplicationApiDescriptionModelRequestDto input) { //TODO: Can cache the model? diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ProxyScripting/AbpServiceProxyScriptController.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ProxyScripting/AbpServiceProxyScriptController.cs index ef7196fb0c..49881e103d 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ProxyScripting/AbpServiceProxyScriptController.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ProxyScripting/AbpServiceProxyScriptController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Volo.Abp.Auditing; using Volo.Abp.Http; @@ -29,11 +30,11 @@ public class AbpServiceProxyScriptController : AbpController [HttpGet] [Produces(MimeTypes.Application.Javascript, MimeTypes.Text.Plain)] - public virtual ActionResult GetAll(ServiceProxyGenerationModel model) + public virtual async Task GetAll(ServiceProxyGenerationModel model) { model.Normalize(); - var script = ProxyScriptManager.GetScript(model.CreateOptions()); + var script = await ProxyScriptManager.GetScriptAsync(model.CreateOptions()); return Content( Options.MinifyGeneratedScript == true diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IApiDescriptionModelProvider.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IApiDescriptionModelProvider.cs index b985a2df76..107e68008f 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IApiDescriptionModelProvider.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IApiDescriptionModelProvider.cs @@ -4,8 +4,5 @@ namespace Volo.Abp.Http.Modeling; public interface IApiDescriptionModelProvider { - ApplicationApiDescriptionModel CreateApiModel(ApplicationApiDescriptionModelRequestDto input); - - Task CreateApiModelAsync(ApplicationApiDescriptionModelRequestDto input) - => Task.FromResult(CreateApiModel(input)); + Task CreateApiModelAsync(ApplicationApiDescriptionModelRequestDto input); } diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManager.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManager.cs index a39f96e35a..193936d1a3 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManager.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManager.cs @@ -1,6 +1,8 @@ +using System.Threading.Tasks; + namespace Volo.Abp.Http.ProxyScripting; public interface IProxyScriptManager { - string GetScript(ProxyScriptingModel scriptingModel); + Task GetScriptAsync(ProxyScriptingModel scriptingModel); } diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManagerCache.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManagerCache.cs index e08a608905..74149251e0 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManagerCache.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManagerCache.cs @@ -6,5 +6,7 @@ public interface IProxyScriptManagerCache { string GetOrAdd(string key, Func factory); + bool TryGet(string key, out string? value); + void Set(string key, string value); } diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManager.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManager.cs index c8fd93c220..11bb25a1fe 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManager.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Volo.Abp.DependencyInjection; @@ -32,23 +33,23 @@ public class ProxyScriptManager : IProxyScriptManager, ITransientDependency _options = options.Value; } - public string GetScript(ProxyScriptingModel scriptingModel) + public async Task GetScriptAsync(ProxyScriptingModel scriptingModel) { var cacheKey = CreateCacheKey(scriptingModel); - if (scriptingModel.UseCache) + if (scriptingModel.UseCache && _cache.TryGet(cacheKey, out var cached)) { - return _cache.GetOrAdd(cacheKey, () => CreateScript(scriptingModel)); + return cached!; } - var script = CreateScript(scriptingModel); + var script = await CreateScriptAsync(scriptingModel); _cache.Set(cacheKey, script); return script; } - private string CreateScript(ProxyScriptingModel scriptingModel) + private async Task CreateScriptAsync(ProxyScriptingModel scriptingModel) { - var apiModel = _modelProvider.CreateApiModel(new ApplicationApiDescriptionModelRequestDto { IncludeTypes = false }); + var apiModel = await _modelProvider.CreateApiModelAsync(new ApplicationApiDescriptionModelRequestDto { IncludeTypes = false }); if (scriptingModel.IsPartialRequest()) { diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManagerCache.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManagerCache.cs index 9bfe12e86f..67c1b39d7f 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManagerCache.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManagerCache.cs @@ -19,6 +19,11 @@ public class ProxyScriptManagerCache : IProxyScriptManagerCache, ISingletonDepen return _cache.GetOrAdd(key, factory); } + public bool TryGet(string key, out string? value) + { + return _cache.TryGetValue(key, out value); + } + public void Set(string key, string value) { _cache[key] = value; From 68ac1cee606f9ed338f755240fcbb5d19f5d730e Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 9 Mar 2026 13:55:26 +0800 Subject: [PATCH 5/5] feat: Enhance XmlDocumentationProvider and related classes for improved XML documentation handling and caching --- .../ApiExploring/XmlDocumentationProvider.cs | 26 ++- .../AspNetCoreApiDescriptionModelProvider.cs | 70 +++--- .../Modeling/ControllerApiDescriptionModel.cs | 4 + .../IProxyScriptManagerCache.cs | 9 +- .../Http/ProxyScripting/ProxyScriptManager.cs | 8 +- .../ProxyScripting/ProxyScriptManagerCache.cs | 34 +-- ...iDefinitionController_Description_Tests.cs | 80 +++++++ .../XmlDocumentationProviderTests.cs | 214 ++++++++++++++++++ 8 files changed, 384 insertions(+), 61 deletions(-) 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 1febf730f7..538a79a611 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 @@ -8,20 +8,32 @@ using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; using System.Xml.XPath; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Volo.Abp.DependencyInjection; namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDependency { + public ILogger Logger { get; set; } + + public XmlDocumentationProvider() + { + Logger = NullLogger.Instance; + } + private static readonly Regex WhitespaceRegex = new(@"\s+", RegexOptions.Compiled); + // Matches any remaining XML tags like , , , , etc. + private static readonly Regex XmlTagRegex = new(@"<[^>]+>", 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(); + private readonly ConcurrentDictionary>> _xmlDocCache = new(); public virtual async Task GetSummaryAsync(Type type) { @@ -88,7 +100,12 @@ public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDep protected virtual Task LoadXmlDocumentationAsync(Assembly assembly) { - return _xmlDocCache.GetOrAdd(assembly, LoadXmlDocumentationFromDiskAsync); + return _xmlDocCache.GetOrAdd( + assembly, + asm => new Lazy>( + () => LoadXmlDocumentationFromDiskAsync(asm), + LazyThreadSafetyMode.ExecutionAndPublication) + ).Value; } protected virtual async Task LoadXmlDocumentationFromDiskAsync(Assembly assembly) @@ -109,8 +126,9 @@ public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDep await using var stream = new FileStream(xmlFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true); return await XDocument.LoadAsync(stream, LoadOptions.None, CancellationToken.None); } - catch + catch (Exception ex) { + Logger.LogWarning(ex, "Failed to load XML documentation from {XmlFilePath}.", xmlFilePath); return null; } } @@ -147,7 +165,7 @@ public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDep }); // Strip any remaining XML tags (e.g. , , , , etc.) - inner = Regex.Replace(inner, @"<[^>]+>", string.Empty); + inner = XmlTagRegex.Replace(inner, string.Empty); if (string.IsNullOrWhiteSpace(inner)) { diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs index 7960b9319a..464981cb3c 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs @@ -148,10 +148,21 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide var implementFrom = controllerType.FullName; - var interfaceType = controllerType.GetInterfaces().FirstOrDefault(i => i.GetMethods().Any(x => x.ToString() == method.ToString())); - if (interfaceType != null) + foreach (var iface in controllerType.GetInterfaces()) { - implementFrom = TypeHelper.GetFullNameHandlingNullableAndGenerics(interfaceType); + try + { + var map = controllerType.GetInterfaceMap(iface); + if (Array.IndexOf(map.TargetMethods, method) >= 0) + { + implementFrom = TypeHelper.GetFullNameHandlingNullableAndGenerics(iface); + break; + } + } + catch (ArgumentException) + { + // GetInterfaceMap is not supported for some generic interface edge cases + } } var actionModel = controllerModel.AddAction( @@ -182,8 +193,9 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide await PopulateControllerDescriptionsAsync(controllerModel, controllerType); } - await PopulateActionDescriptionsAsync(actionModel, method); - await PopulateParameterDescriptionsAsync(actionModel, method); + var interfaceMethod = GetInterfaceMethod(method); + await PopulateActionDescriptionsAsync(actionModel, method, interfaceMethod); + await PopulateParameterDescriptionsAsync(actionModel, method, interfaceMethod); } } @@ -447,7 +459,7 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide if (controllerModel.Summary == null && controllerModel.Remarks == null) { - foreach (var interfaceType in GetDirectInterfaces(controllerType)) + foreach (var interfaceType in GetDirectInterfaces(controllerType).Where(i => !_modelOptions.IgnoredInterfaces.Contains(i))) { controllerModel.Summary = await _xmlDocProvider.GetSummaryAsync(interfaceType); controllerModel.Remarks = await _xmlDocProvider.GetRemarksAsync(interfaceType); @@ -462,38 +474,29 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide controllerModel.DisplayName = controllerType.GetCustomAttribute()?.Name; } - protected virtual async Task PopulateActionDescriptionsAsync(ActionApiDescriptionModel actionModel, MethodInfo method) + protected virtual async Task PopulateActionDescriptionsAsync(ActionApiDescriptionModel actionModel, MethodInfo method, MethodInfo? interfaceMethod) { actionModel.Summary = await _xmlDocProvider.GetSummaryAsync(method); actionModel.Remarks = await _xmlDocProvider.GetRemarksAsync(method); - if (actionModel.Summary == null && actionModel.Remarks == null) + if (actionModel.Summary == null && actionModel.Remarks == null && interfaceMethod != null) { - var interfaceMethod = GetInterfaceMethod(method); - if (interfaceMethod != null) - { - actionModel.Summary = await _xmlDocProvider.GetSummaryAsync(interfaceMethod); - actionModel.Remarks = await _xmlDocProvider.GetRemarksAsync(interfaceMethod); - } + actionModel.Summary = await _xmlDocProvider.GetSummaryAsync(interfaceMethod); + actionModel.Remarks = await _xmlDocProvider.GetRemarksAsync(interfaceMethod); } actionModel.Description = method.GetCustomAttribute()?.Description; actionModel.DisplayName = method.GetCustomAttribute()?.Name; actionModel.ReturnValue.Summary = await _xmlDocProvider.GetReturnsAsync(method); - if (actionModel.ReturnValue.Summary == null) + if (actionModel.ReturnValue.Summary == null && interfaceMethod != null) { - var interfaceMethod = GetInterfaceMethod(method); - if (interfaceMethod != null) - { - actionModel.ReturnValue.Summary = await _xmlDocProvider.GetReturnsAsync(interfaceMethod); - } + actionModel.ReturnValue.Summary = await _xmlDocProvider.GetReturnsAsync(interfaceMethod); } } - protected virtual async Task PopulateParameterDescriptionsAsync(ActionApiDescriptionModel actionModel, MethodInfo method) + protected virtual async Task PopulateParameterDescriptionsAsync(ActionApiDescriptionModel actionModel, MethodInfo method, MethodInfo? interfaceMethod) { - var interfaceMethod = GetInterfaceMethod(method); var methodParameters = method.GetParameters(); foreach (var param in actionModel.ParametersOnMethod) @@ -516,6 +519,13 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide foreach (var param in actionModel.Parameters) { + // Skip expanded properties from complex types - their descriptions + // should come from type-level documentation (PopulateTypeDescriptionsAsync) + if (!string.IsNullOrEmpty(param.DescriptorName) && param.Name != param.NameOnMethod) + { + continue; + } + param.Summary = await _xmlDocProvider.GetParameterSummaryAsync(method, param.NameOnMethod); if (param.Summary == null && interfaceMethod != null) { @@ -531,7 +541,7 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide } } - private static MethodInfo? GetInterfaceMethod(MethodInfo method) + private MethodInfo? GetInterfaceMethod(MethodInfo method) { var declaringType = method.DeclaringType; if (declaringType == null || declaringType.IsInterface) @@ -539,13 +549,15 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide return null; } - foreach (var interfaceType in GetDirectInterfaces(declaringType)) + foreach (var interfaceType in GetDirectInterfaces(declaringType).Where(i => !_modelOptions.IgnoredInterfaces.Contains(i))) { - var interfaceMethod = interfaceType.GetMethods() - .FirstOrDefault(m => m.ToString() == method.ToString()); - if (interfaceMethod != null) + var map = declaringType.GetInterfaceMap(interfaceType); + for (var i = 0; i < map.TargetMethods.Length; i++) { - return interfaceMethod; + if (map.TargetMethods[i] == method) + { + return map.InterfaceMethods[i]; + } } } @@ -573,7 +585,7 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide foreach (var propModel in typeModel.Properties) { - var propInfo = type.GetProperty(propModel.Name, BindingFlags.Instance | BindingFlags.Public); + var propInfo = type.GetProperty(propModel.Name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly); if (propInfo == null) { continue; diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ControllerApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ControllerApiDescriptionModel.cs index e27459d7e7..04daecc72f 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ControllerApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ControllerApiDescriptionModel.cs @@ -74,6 +74,10 @@ public class ControllerApiDescriptionModel Type = Type, Interfaces = Interfaces, ControllerName = ControllerName, + ControllerGroupName = ControllerGroupName, + IsRemoteService = IsRemoteService, + IsIntegrationService = IsIntegrationService, + ApiVersion = ApiVersion, Summary = Summary, Remarks = Remarks, Description = Description, diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManagerCache.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManagerCache.cs index 74149251e0..10d812e3ce 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManagerCache.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManagerCache.cs @@ -1,12 +1,9 @@ -using System; +using System; +using System.Threading.Tasks; namespace Volo.Abp.Http.ProxyScripting; public interface IProxyScriptManagerCache { - string GetOrAdd(string key, Func factory); - - bool TryGet(string key, out string? value); - - void Set(string key, string value); + Task GetOrAddAsync(string key, Func> factory); } diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManager.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManager.cs index 11bb25a1fe..178637b7ea 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManager.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManager.cs @@ -37,14 +37,12 @@ public class ProxyScriptManager : IProxyScriptManager, ITransientDependency { var cacheKey = CreateCacheKey(scriptingModel); - if (scriptingModel.UseCache && _cache.TryGet(cacheKey, out var cached)) + if (scriptingModel.UseCache) { - return cached!; + return await _cache.GetOrAddAsync(cacheKey, () => CreateScriptAsync(scriptingModel)); } - var script = await CreateScriptAsync(scriptingModel); - _cache.Set(cacheKey, script); - return script; + return await CreateScriptAsync(scriptingModel); } private async Task CreateScriptAsync(ProxyScriptingModel scriptingModel) diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManagerCache.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManagerCache.cs index 67c1b39d7f..95bb999c9d 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManagerCache.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManagerCache.cs @@ -1,31 +1,31 @@ -using System; +using System; using System.Collections.Concurrent; -using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using Volo.Abp.DependencyInjection; namespace Volo.Abp.Http.ProxyScripting; public class ProxyScriptManagerCache : IProxyScriptManagerCache, ISingletonDependency { - private readonly ConcurrentDictionary _cache; + private readonly ConcurrentDictionary _cache = new(); + private readonly ConcurrentDictionary>> _asyncCache = new(); - public ProxyScriptManagerCache() + public async Task GetOrAddAsync(string key, Func> factory) { - _cache = new ConcurrentDictionary(); - } + if (_cache.TryGetValue(key, out var cached)) + { + return cached; + } - public string GetOrAdd(string key, Func factory) - { - return _cache.GetOrAdd(key, factory); - } + var result = await _asyncCache.GetOrAdd( + key, + _ => new Lazy>(factory, LazyThreadSafetyMode.ExecutionAndPublication) + ).Value; - public bool TryGet(string key, out string? value) - { - return _cache.TryGetValue(key, out value); - } + _cache[key] = result; + _asyncCache.TryRemove(key, out _); - public void Set(string key, string value) - { - _cache[key] = value; + return result; } } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Description_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Description_Tests.cs index 09baed4aa6..9308026cd8 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Description_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Description_Tests.cs @@ -434,6 +434,86 @@ public class AbpApiDefinitionController_Description_Tests : AspNetCoreMvcTestBas httpParam.Summary.ShouldContain("message key"); } + [Fact] + public async Task IncludeDescriptions_Should_Not_Apply_Container_Param_Summary_To_Expanded_Properties() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition?includeDescriptions=true"); + + var controller = GetDocumentedController(model); + var action = GetAction(controller, "Create"); + + // Expanded properties from DocumentedDto should not have the container parameter's summary + var expandedParams = action.Parameters + .Where(p => !string.IsNullOrEmpty(p.DescriptorName) && p.Name != p.NameOnMethod) + .ToList(); + + foreach (var param in expandedParams) + { + param.Summary.ShouldBeNull(); + param.Description.ShouldBeNull(); + param.DisplayName.ShouldBeNull(); + } + } + + [Fact] + public async Task Action_ImplementFrom_Should_Point_To_Implemented_Interface() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition"); + + var controller = GetDocumentedController(model); + var action = GetAction(controller, "GetGreeting"); + + action.ImplementFrom.ShouldNotBeNullOrEmpty(); + action.ImplementFrom.ShouldContain("IDocumentedAppService"); + action.ImplementFrom.ShouldNotContain("DocumentedAppService."); + } + + [Fact] + public async Task Action_ImplementFrom_Should_Point_To_Interface_When_Only_Documented_On_Interface() + { + var model = await GetResponseAsObjectAsync( + "/api/abp/api-definition"); + + var controller = GetInterfaceOnlyController(model); + var action = GetAction(controller, "GetMessage"); + + action.ImplementFrom.ShouldNotBeNullOrEmpty(); + action.ImplementFrom.ShouldContain("IInterfaceOnlyDocumentedAppService"); + action.ImplementFrom.ShouldNotContain("InterfaceOnlyDocumentedAppService."); + } + + [Fact] + public void CreateSubModel_Should_Preserve_All_Controller_Properties() + { + var controller = ControllerApiDescriptionModel.Create( + "TestController", + "TestGroup", + isRemoteService: true, + isIntegrationService: false, + apiVersion: "1.0", + typeof(AbpApiDefinitionController_Description_Tests)); + + controller.Summary = "Test summary"; + controller.Remarks = "Test remarks"; + controller.Description = "Test description"; + controller.DisplayName = "Test display name"; + + var subModel = controller.CreateSubModel(null); + + subModel.ControllerName.ShouldBe("TestController"); + subModel.ControllerGroupName.ShouldBe("TestGroup"); + subModel.IsRemoteService.ShouldBeTrue(); + subModel.IsIntegrationService.ShouldBeFalse(); + subModel.ApiVersion.ShouldBe("1.0"); + subModel.Summary.ShouldBe("Test summary"); + subModel.Remarks.ShouldBe("Test remarks"); + subModel.Description.ShouldBe("Test description"); + subModel.DisplayName.ShouldBe("Test display name"); + subModel.Type.ShouldBe(controller.Type); + } + private static ControllerApiDescriptionModel GetDocumentedController(ApplicationApiDescriptionModel model) { return model.Modules.Values 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 index 01b5f6b060..6f6c34ba53 100644 --- 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 @@ -192,6 +192,220 @@ public class XmlDocumentationProviderTests result.ShouldBeNull(); } + [Fact] + public async Task GetRemarks_Returns_Remarks_Content() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@"Summary text.Some remarks here."); + + var result = await provider.GetRemarksAsync(typeof(StubType)); + + result.ShouldBe("Some remarks here."); + } + + [Fact] + public async Task GetSummary_Returns_Null_For_SelfClosing_Summary_Tag() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@""); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBeNull(); + } + + [Fact] + public async Task GetSummary_Expands_SeeCref_Without_TypePrefix() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@"See for details."); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBe("See DoWork for details."); + } + + [Fact] + public async Task GetSummary_Expands_SeeCref_Property() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@"See property."); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBe("See Name property."); + } + + [Fact] + public async Task GetSummary_Strips_Multiple_Xml_Tags() + { + var typeName = typeof(StubType).FullName!.Replace('+', '.'); + var provider = CreateProvider( + $@"First. code bold end."); + + var result = await provider.GetSummaryAsync(typeof(StubType)); + + result.ShouldBe("First. code bold end."); + } + + // Tests for GetSummaryAsync(MethodInfo) and GetReturnsAsync(MethodInfo) + + private class StubService + { + public string GetValue(string key, int count) => key; + public string NoParams() => string.Empty; + public string Name { get; set; } = default!; + } + + [Fact] + public async Task GetSummary_For_Method_Returns_Summary() + { + var typeName = typeof(StubService).FullName!.Replace('+', '.'); + var method = typeof(StubService).GetMethod(nameof(StubService.GetValue))!; + var provider = CreateProvider( + $@"Gets a value by key."); + + var result = await provider.GetSummaryAsync(method); + + result.ShouldBe("Gets a value by key."); + } + + [Fact] + public async Task GetSummary_For_Method_Without_Parameters_Returns_Summary() + { + var typeName = typeof(StubService).FullName!.Replace('+', '.'); + var method = typeof(StubService).GetMethod(nameof(StubService.NoParams))!; + var provider = CreateProvider( + $@"No params method."); + + var result = await provider.GetSummaryAsync(method); + + result.ShouldBe("No params method."); + } + + [Fact] + public async Task GetReturns_For_Method_Returns_Content() + { + var typeName = typeof(StubService).FullName!.Replace('+', '.'); + var method = typeof(StubService).GetMethod(nameof(StubService.GetValue))!; + var provider = CreateProvider( + $@"The resolved value."); + + var result = await provider.GetReturnsAsync(method); + + result.ShouldBe("The resolved value."); + } + + [Fact] + public async Task GetReturns_Returns_Null_When_No_Returns_Element() + { + var typeName = typeof(StubService).FullName!.Replace('+', '.'); + var method = typeof(StubService).GetMethod(nameof(StubService.GetValue))!; + var provider = CreateProvider( + $@"Gets a value."); + + var result = await provider.GetReturnsAsync(method); + + result.ShouldBeNull(); + } + + [Fact] + public async Task GetParameterSummary_Returns_Content() + { + var typeName = typeof(StubService).FullName!.Replace('+', '.'); + var method = typeof(StubService).GetMethod(nameof(StubService.GetValue))!; + var provider = CreateProvider( + $@"The lookup key.Max results."); + + var result = await provider.GetParameterSummaryAsync(method, "key"); + result.ShouldBe("The lookup key."); + + var result2 = await provider.GetParameterSummaryAsync(method, "count"); + result2.ShouldBe("Max results."); + } + + [Fact] + public async Task GetParameterSummary_Returns_Null_When_Param_Not_Found() + { + var typeName = typeof(StubService).FullName!.Replace('+', '.'); + var method = typeof(StubService).GetMethod(nameof(StubService.GetValue))!; + var provider = CreateProvider( + $@"The key."); + + var result = await provider.GetParameterSummaryAsync(method, "nonExistent"); + + result.ShouldBeNull(); + } + + [Fact] + public async Task GetParameterSummary_Returns_Null_When_Member_Not_Found() + { + var method = typeof(StubService).GetMethod(nameof(StubService.GetValue))!; + var provider = CreateProvider(string.Empty); + + var result = await provider.GetParameterSummaryAsync(method, "key"); + + result.ShouldBeNull(); + } + + // Tests for GetSummaryAsync(PropertyInfo) + + [Fact] + public async Task GetSummary_For_Property_Returns_Summary() + { + var typeName = typeof(StubService).FullName!.Replace('+', '.'); + var property = typeof(StubService).GetProperty(nameof(StubService.Name))!; + var provider = CreateProvider( + $@"The name property."); + + var result = await provider.GetSummaryAsync(property); + + result.ShouldBe("The name property."); + } + + [Fact] + public async Task GetSummary_For_Property_Returns_Null_When_Not_Found() + { + var property = typeof(StubService).GetProperty(nameof(StubService.Name))!; + var provider = CreateProvider(string.Empty); + + var result = await provider.GetSummaryAsync(property); + + result.ShouldBeNull(); + } + + // Tests for GetRemarksAsync(MethodInfo) + + [Fact] + public async Task GetRemarks_For_Method_Returns_Content() + { + var typeName = typeof(StubService).FullName!.Replace('+', '.'); + var method = typeof(StubService).GetMethod(nameof(StubService.GetValue))!; + var provider = CreateProvider( + $@"Implementation note."); + + var result = await provider.GetRemarksAsync(method); + + result.ShouldBe("Implementation note."); + } + + [Fact] + public async Task GetRemarks_For_Method_Returns_Null_When_Not_Found() + { + var typeName = typeof(StubService).FullName!.Replace('+', '.'); + var method = typeof(StubService).GetMethod(nameof(StubService.GetValue))!; + var provider = CreateProvider( + $@"Summary only."); + + var result = await provider.GetRemarksAsync(method); + + result.ShouldBeNull(); + } + /// /// A fake provider that loads XML from an in-memory string instead of the file system. ///