From 778abc8aaaa1b191ba97ae9f17f2e8db983c3629 Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 9 Mar 2026 12:15:07 +0800 Subject: [PATCH] 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); + } +}