Browse Source

Merge pull request #25022 from tntwist/add-documentation-and-description

Add description and documentation support to API Definition endpoint
pull/25062/head
Ma Liming 7 months ago
committed by GitHub
parent
commit
b5ec64964d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController.cs
  2. 22
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/IXmlDocumentationProvider.cs
  3. 231
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs
  4. 224
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs
  5. 7
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ProxyScripting/AbpServiceProxyScriptController.cs
  6. 8
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs
  7. 2
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ApplicationApiDescriptionModelRequestDto.cs
  8. 16
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ControllerApiDescriptionModel.cs
  9. 4
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IApiDescriptionModelProvider.cs
  10. 6
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/MethodParameterApiDescriptionModel.cs
  11. 6
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ParameterApiDescriptionModel.cs
  12. 6
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs
  13. 2
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs
  14. 8
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs
  15. 4
      framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManager.cs
  16. 7
      framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManagerCache.cs
  17. 13
      framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManager.cs
  18. 31
      framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManagerCache.cs
  19. 536
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Description_Tests.cs
  20. 427
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProviderTests.cs
  21. 1
      framework/test/Volo.Abp.TestApp/Volo.Abp.TestApp.csproj
  22. 58
      framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/DocumentedAppService.cs
  23. 25
      framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/Dto/DocumentedDto.cs
  24. 38
      framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IDocumentedAppService.cs
  25. 20
      framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IInterfaceOnlyDocumentedAppService.cs
  26. 12
      framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/InterfaceOnlyDocumentedAppService.cs

7
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; using Volo.Abp.Http.Modeling;
namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; namespace Volo.Abp.AspNetCore.Mvc.ApiExploring;
@ -16,8 +17,8 @@ public class AbpApiDefinitionController : AbpController, IRemoteService
} }
[HttpGet] [HttpGet]
public virtual ApplicationApiDescriptionModel Get(ApplicationApiDescriptionModelRequestDto model) public virtual async Task<ApplicationApiDescriptionModel> Get(ApplicationApiDescriptionModelRequestDto model)
{ {
return ModelProvider.CreateApiModel(model); return await ModelProvider.CreateApiModelAsync(model);
} }
} }

22
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/IXmlDocumentationProvider.cs

@ -0,0 +1,22 @@
using System;
using System.Reflection;
using System.Threading.Tasks;
namespace Volo.Abp.AspNetCore.Mvc.ApiExploring;
public interface IXmlDocumentationProvider
{
Task<string?> GetSummaryAsync(Type type);
Task<string?> GetRemarksAsync(Type type);
Task<string?> GetSummaryAsync(MethodInfo method);
Task<string?> GetRemarksAsync(MethodInfo method);
Task<string?> GetReturnsAsync(MethodInfo method);
Task<string?> GetParameterSummaryAsync(MethodInfo method, string parameterName);
Task<string?> GetSummaryAsync(PropertyInfo property);
}

231
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs

@ -0,0 +1,231 @@
using System;
using System.Collections.Concurrent;
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 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<XmlDocumentationProvider> Logger { get; set; }
public XmlDocumentationProvider()
{
Logger = NullLogger<XmlDocumentationProvider>.Instance;
}
private static readonly Regex WhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
// Matches any remaining XML tags like <c>, <code>, <para>, <b>, etc.
private static readonly Regex XmlTagRegex = new(@"<[^>]+>", RegexOptions.Compiled);
// Matches <see cref="T:Foo.Bar"/>, <see langword="null"/>, <paramref name="x"/>, <typeparamref name="T"/>
private static readonly Regex XmlRefTagRegex = new(
@"<(see|paramref|typeparamref)\s+(cref|name|langword)=""([TMFPE]:)?(?<display>[^""]+)""\s*/?>",
RegexOptions.Compiled);
private readonly ConcurrentDictionary<Assembly, Lazy<Task<XDocument?>>> _xmlDocCache = new();
public virtual async Task<string?> GetSummaryAsync(Type type)
{
var memberName = GetMemberNameForType(type);
return await GetDocumentationElementAsync(type.Assembly, memberName, "summary");
}
public virtual async Task<string?> GetRemarksAsync(Type type)
{
var memberName = GetMemberNameForType(type);
return await GetDocumentationElementAsync(type.Assembly, memberName, "remarks");
}
public virtual async Task<string?> GetSummaryAsync(MethodInfo method)
{
var memberName = GetMemberNameForMethod(method);
return await GetDocumentationElementAsync(method.DeclaringType!.Assembly, memberName, "summary");
}
public virtual async Task<string?> GetRemarksAsync(MethodInfo method)
{
var memberName = GetMemberNameForMethod(method);
return await GetDocumentationElementAsync(method.DeclaringType!.Assembly, memberName, "remarks");
}
public virtual async Task<string?> GetReturnsAsync(MethodInfo method)
{
var memberName = GetMemberNameForMethod(method);
return await GetDocumentationElementAsync(method.DeclaringType!.Assembly, memberName, "returns");
}
public virtual async Task<string?> GetParameterSummaryAsync(MethodInfo method, string parameterName)
{
var memberName = GetMemberNameForMethod(method);
var doc = await LoadXmlDocumentationAsync(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 virtual async Task<string?> GetSummaryAsync(PropertyInfo property)
{
var memberName = GetMemberNameForProperty(property);
return await GetDocumentationElementAsync(property.DeclaringType!.Assembly, memberName, "summary");
}
protected virtual async Task<string?> GetDocumentationElementAsync(Assembly assembly, string memberName, string elementName)
{
var doc = await LoadXmlDocumentationAsync(assembly);
if (doc == null)
{
return null;
}
var memberNode = doc.XPathSelectElement($"//member[@name='{memberName}']");
var element = memberNode?.Element(elementName);
return CleanXmlText(element);
}
protected virtual Task<XDocument?> LoadXmlDocumentationAsync(Assembly assembly)
{
return _xmlDocCache.GetOrAdd(
assembly,
asm => new Lazy<Task<XDocument?>>(
() => LoadXmlDocumentationFromDiskAsync(asm),
LazyThreadSafetyMode.ExecutionAndPublication)
).Value;
}
protected virtual async Task<XDocument?> LoadXmlDocumentationFromDiskAsync(Assembly assembly)
{
if (string.IsNullOrEmpty(assembly.Location))
{
return null;
}
var xmlFilePath = Path.ChangeExtension(assembly.Location, ".xml");
if (!File.Exists(xmlFilePath))
{
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 (Exception ex)
{
Logger.LogWarning(ex, "Failed to load XML documentation from {XmlFilePath}.", xmlFilePath);
return null;
}
}
private static string? CleanXmlText(XElement? element)
{
if (element == null)
{
return null;
}
// Convert to string first so we can process inline XML tags like <see cref="..."/>
var raw = element.ToString();
// Strip the outer element tags (e.g. <summary>...</summary>)
var start = raw.IndexOf('>') + 1;
var end = raw.LastIndexOf('<');
if (start >= end)
{
return null;
}
var inner = raw[start..end];
// Replace <see cref="T:Foo.Bar"/> with the short name "Bar"
// Replace <see langword="null"/> with "null"
// Replace <paramref name="x"/> and <typeparamref name="T"/> with the name
inner = XmlRefTagRegex.Replace(inner, m =>
{
var display = m.Groups["display"].Value;
// For cref values like "T:Foo.Bar.Baz", return only "Baz"
var dot = display.LastIndexOf('.');
return dot >= 0 ? display[(dot + 1)..] : display;
});
// Strip any remaining XML tags (e.g. <c>, <code>, <para>, <b>, etc.)
inner = XmlTagRegex.Replace(inner, string.Empty);
if (string.IsNullOrWhiteSpace(inner))
{
return null;
}
return WhitespaceRegex.Replace(inner.Trim(), " ");
}
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;
}
}

224
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs

@ -1,7 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Threading.Tasks;
using Asp.Versioning; using Asp.Versioning;
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -12,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Volo.Abp.AspNetCore.Mvc.ApiExploring;
using Volo.Abp.AspNetCore.Mvc.Conventions; using Volo.Abp.AspNetCore.Mvc.Conventions;
using Volo.Abp.AspNetCore.Mvc.Utils; using Volo.Abp.AspNetCore.Mvc.Utils;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
@ -29,26 +33,30 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
private readonly IApiDescriptionGroupCollectionProvider _descriptionProvider; private readonly IApiDescriptionGroupCollectionProvider _descriptionProvider;
private readonly AbpAspNetCoreMvcOptions _abpAspNetCoreMvcOptions; private readonly AbpAspNetCoreMvcOptions _abpAspNetCoreMvcOptions;
private readonly AbpApiDescriptionModelOptions _modelOptions; private readonly AbpApiDescriptionModelOptions _modelOptions;
private readonly IXmlDocumentationProvider _xmlDocProvider;
public AspNetCoreApiDescriptionModelProvider( public AspNetCoreApiDescriptionModelProvider(
IOptions<AspNetCoreApiDescriptionModelProviderOptions> options, IOptions<AspNetCoreApiDescriptionModelProviderOptions> options,
IApiDescriptionGroupCollectionProvider descriptionProvider, IApiDescriptionGroupCollectionProvider descriptionProvider,
IOptions<AbpAspNetCoreMvcOptions> abpAspNetCoreMvcOptions, IOptions<AbpAspNetCoreMvcOptions> abpAspNetCoreMvcOptions,
IOptions<AbpApiDescriptionModelOptions> modelOptions) IOptions<AbpApiDescriptionModelOptions> modelOptions,
IXmlDocumentationProvider xmlDocProvider)
{ {
_options = options.Value; _options = options.Value;
_descriptionProvider = descriptionProvider; _descriptionProvider = descriptionProvider;
_abpAspNetCoreMvcOptions = abpAspNetCoreMvcOptions.Value; _abpAspNetCoreMvcOptions = abpAspNetCoreMvcOptions.Value;
_modelOptions = modelOptions.Value; _modelOptions = modelOptions.Value;
_xmlDocProvider = xmlDocProvider;
Logger = NullLogger<AspNetCoreApiDescriptionModelProvider>.Instance; Logger = NullLogger<AspNetCoreApiDescriptionModelProvider>.Instance;
} }
public ApplicationApiDescriptionModel CreateApiModel(ApplicationApiDescriptionModelRequestDto input) public virtual async Task<ApplicationApiDescriptionModel> CreateApiModelAsync(ApplicationApiDescriptionModelRequestDto input)
{ {
//TODO: Can cache the model? //TODO: Can cache the model?
var model = ApplicationApiDescriptionModel.Create(); var model = ApplicationApiDescriptionModel.Create();
var populatedControllers = new HashSet<ControllerApiDescriptionModel>();
foreach (var descriptionGroupItem in _descriptionProvider.ApiDescriptionGroups.Items) foreach (var descriptionGroupItem in _descriptionProvider.ApiDescriptionGroups.Items)
{ {
@ -59,7 +67,7 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
continue; continue;
} }
AddApiDescriptionToModel(apiDescription, model, input); await AddApiDescriptionToModelAsync(apiDescription, model, input, populatedControllers);
} }
} }
@ -80,10 +88,11 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
return model; return model;
} }
private void AddApiDescriptionToModel( private async Task AddApiDescriptionToModelAsync(
ApiDescription apiDescription, ApiDescription apiDescription,
ApplicationApiDescriptionModel applicationModel, ApplicationApiDescriptionModel applicationModel,
ApplicationApiDescriptionModelRequestDto input) ApplicationApiDescriptionModelRequestDto input,
HashSet<ControllerApiDescriptionModel> populatedControllers)
{ {
var controllerType = apiDescription var controllerType = apiDescription
.ActionDescriptor .ActionDescriptor
@ -139,10 +148,21 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
var implementFrom = controllerType.FullName; var implementFrom = controllerType.FullName;
var interfaceType = controllerType.GetInterfaces().FirstOrDefault(i => i.GetMethods().Any(x => x.ToString() == method.ToString())); foreach (var iface in controllerType.GetInterfaces())
if (interfaceType != null)
{ {
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( var actionModel = controllerModel.AddAction(
@ -161,10 +181,22 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
if (input.IncludeTypes) if (input.IncludeTypes)
{ {
AddCustomTypesToModel(applicationModel, method); await AddCustomTypesToModelAsync(applicationModel, method, input.IncludeDescriptions);
} }
AddParameterDescriptionsToModel(actionModel, method, apiDescription); AddParameterDescriptionsToModel(actionModel, method, apiDescription);
if (input.IncludeDescriptions)
{
if (populatedControllers.Add(controllerModel))
{
await PopulateControllerDescriptionsAsync(controllerModel, controllerType);
}
var interfaceMethod = GetInterfaceMethod(method);
await PopulateActionDescriptionsAsync(actionModel, method, interfaceMethod);
await PopulateParameterDescriptionsAsync(actionModel, method, interfaceMethod);
}
} }
private static List<string> GetSupportedVersions(Type controllerType, MethodInfo method, private static List<string> GetSupportedVersions(Type controllerType, MethodInfo method,
@ -191,18 +223,18 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
return supportedVersions.Select(v => v.ToString()).Distinct().ToList(); return supportedVersions.Select(v => v.ToString()).Distinct().ToList();
} }
private void AddCustomTypesToModel(ApplicationApiDescriptionModel applicationModel, MethodInfo method) private async Task AddCustomTypesToModelAsync(ApplicationApiDescriptionModel applicationModel, MethodInfo method, bool includeDescriptions)
{ {
foreach (var parameterInfo in method.GetParameters()) foreach (var parameterInfo in method.GetParameters())
{ {
AddCustomTypesToModel(applicationModel, parameterInfo.ParameterType); await AddCustomTypesToModelAsync(applicationModel, parameterInfo.ParameterType, includeDescriptions);
} }
AddCustomTypesToModel(applicationModel, method.ReturnType); await AddCustomTypesToModelAsync(applicationModel, method.ReturnType, includeDescriptions);
} }
private static void AddCustomTypesToModel(ApplicationApiDescriptionModel applicationModel, private async Task AddCustomTypesToModelAsync(ApplicationApiDescriptionModel applicationModel,
Type? type) Type? type, bool includeDescriptions)
{ {
if (type == null) if (type == null)
{ {
@ -229,14 +261,14 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
if (TypeHelper.IsDictionary(type, out var keyType, out var valueType)) if (TypeHelper.IsDictionary(type, out var keyType, out var valueType))
{ {
AddCustomTypesToModel(applicationModel, keyType); await AddCustomTypesToModelAsync(applicationModel, keyType, includeDescriptions);
AddCustomTypesToModel(applicationModel, valueType); await AddCustomTypesToModelAsync(applicationModel, valueType, includeDescriptions);
return; return;
} }
if (TypeHelper.IsEnumerable(type, out var itemType)) if (TypeHelper.IsEnumerable(type, out var itemType))
{ {
AddCustomTypesToModel(applicationModel, itemType); await AddCustomTypesToModelAsync(applicationModel, itemType, includeDescriptions);
return; return;
} }
@ -244,11 +276,11 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
{ {
var genericTypeDefinition = type.GetGenericTypeDefinition(); var genericTypeDefinition = type.GetGenericTypeDefinition();
AddCustomTypesToModel(applicationModel, genericTypeDefinition); await AddCustomTypesToModelAsync(applicationModel, genericTypeDefinition, includeDescriptions);
foreach (var genericArgument in type.GetGenericArguments()) foreach (var genericArgument in type.GetGenericArguments())
{ {
AddCustomTypesToModel(applicationModel, genericArgument); await AddCustomTypesToModelAsync(applicationModel, genericArgument, includeDescriptions);
} }
return; return;
@ -262,11 +294,16 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
applicationModel.Types[typeName] = TypeApiDescriptionModel.Create(type); applicationModel.Types[typeName] = TypeApiDescriptionModel.Create(type);
AddCustomTypesToModel(applicationModel, type.BaseType); if (includeDescriptions)
{
await PopulateTypeDescriptionsAsync(applicationModel.Types[typeName], type);
}
await AddCustomTypesToModelAsync(applicationModel, type.BaseType, includeDescriptions);
foreach (var propertyInfo in type.GetProperties().Where(p => p.DeclaringType == type)) foreach (var propertyInfo in type.GetProperties().Where(p => p.DeclaringType == type))
{ {
AddCustomTypesToModel(applicationModel, propertyInfo.PropertyType); await AddCustomTypesToModelAsync(applicationModel, propertyInfo.PropertyType, includeDescriptions);
} }
} }
@ -414,4 +451,149 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
return null; return null;
} }
protected virtual async Task PopulateControllerDescriptionsAsync(ControllerApiDescriptionModel controllerModel, Type 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).Where(i => !_modelOptions.IgnoredInterfaces.Contains(i)))
{
controllerModel.Summary = await _xmlDocProvider.GetSummaryAsync(interfaceType);
controllerModel.Remarks = await _xmlDocProvider.GetRemarksAsync(interfaceType);
if (controllerModel.Summary != null || controllerModel.Remarks != null)
{
break;
}
}
}
controllerModel.Description = controllerType.GetCustomAttribute<DescriptionAttribute>()?.Description;
controllerModel.DisplayName = controllerType.GetCustomAttribute<DisplayAttribute>()?.Name;
}
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 && interfaceMethod != null)
{
actionModel.Summary = await _xmlDocProvider.GetSummaryAsync(interfaceMethod);
actionModel.Remarks = await _xmlDocProvider.GetRemarksAsync(interfaceMethod);
}
actionModel.Description = method.GetCustomAttribute<DescriptionAttribute>()?.Description;
actionModel.DisplayName = method.GetCustomAttribute<DisplayAttribute>()?.Name;
actionModel.ReturnValue.Summary = await _xmlDocProvider.GetReturnsAsync(method);
if (actionModel.ReturnValue.Summary == null && interfaceMethod != null)
{
actionModel.ReturnValue.Summary = await _xmlDocProvider.GetReturnsAsync(interfaceMethod);
}
}
protected virtual async Task PopulateParameterDescriptionsAsync(ActionApiDescriptionModel actionModel, MethodInfo method, MethodInfo? interfaceMethod)
{
var methodParameters = method.GetParameters();
foreach (var param in actionModel.ParametersOnMethod)
{
var paramInfo = methodParameters.FirstOrDefault(p => p.Name == param.Name);
if (paramInfo == null)
{
continue;
}
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<DescriptionAttribute>()?.Description;
param.DisplayName = paramInfo.GetCustomAttribute<DisplayAttribute>()?.Name;
}
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)
{
param.Summary = await _xmlDocProvider.GetParameterSummaryAsync(interfaceMethod, param.NameOnMethod);
}
var paramInfo = methodParameters.FirstOrDefault(p => p.Name == param.NameOnMethod);
if (paramInfo != null)
{
param.Description = paramInfo.GetCustomAttribute<DescriptionAttribute>()?.Description;
param.DisplayName = paramInfo.GetCustomAttribute<DisplayAttribute>()?.Name;
}
}
}
private MethodInfo? GetInterfaceMethod(MethodInfo method)
{
var declaringType = method.DeclaringType;
if (declaringType == null || declaringType.IsInterface)
{
return null;
}
foreach (var interfaceType in GetDirectInterfaces(declaringType).Where(i => !_modelOptions.IgnoredInterfaces.Contains(i)))
{
var map = declaringType.GetInterfaceMap(interfaceType);
for (var i = 0; i < map.TargetMethods.Length; i++)
{
if (map.TargetMethods[i] == method)
{
return map.InterfaceMethods[i];
}
}
}
return null;
}
private static IEnumerable<Type> 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 = await _xmlDocProvider.GetSummaryAsync(type);
typeModel.Remarks = await _xmlDocProvider.GetRemarksAsync(type);
typeModel.Description = type.GetCustomAttribute<DescriptionAttribute>()?.Description;
typeModel.DisplayName = type.GetCustomAttribute<DisplayAttribute>()?.Name;
if (typeModel.Properties == null)
{
return;
}
foreach (var propModel in typeModel.Properties)
{
var propInfo = type.GetProperty(propModel.Name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly);
if (propInfo == null)
{
continue;
}
propModel.Summary = await _xmlDocProvider.GetSummaryAsync(propInfo);
propModel.Description = propInfo.GetCustomAttribute<DescriptionAttribute>()?.Description;
propModel.DisplayName = propInfo.GetCustomAttribute<DisplayAttribute>()?.Name;
}
}
} }

7
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 Microsoft.Extensions.Options;
using Volo.Abp.Auditing; using Volo.Abp.Auditing;
using Volo.Abp.Http; using Volo.Abp.Http;
@ -29,11 +30,11 @@ public class AbpServiceProxyScriptController : AbpController
[HttpGet] [HttpGet]
[Produces(MimeTypes.Application.Javascript, MimeTypes.Text.Plain)] [Produces(MimeTypes.Application.Javascript, MimeTypes.Text.Plain)]
public virtual ActionResult GetAll(ServiceProxyGenerationModel model) public virtual async Task<ActionResult> GetAll(ServiceProxyGenerationModel model)
{ {
model.Normalize(); model.Normalize();
var script = ProxyScriptManager.GetScript(model.CreateOptions()); var script = await ProxyScriptManager.GetScriptAsync(model.CreateOptions());
return Content( return Content(
Options.MinifyGeneratedScript == true Options.MinifyGeneratedScript == true

8
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? 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() public ActionApiDescriptionModel()
{ {

2
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ApplicationApiDescriptionModelRequestDto.cs

@ -3,4 +3,6 @@
public class ApplicationApiDescriptionModelRequestDto public class ApplicationApiDescriptionModelRequestDto
{ {
public bool IncludeTypes { get; set; } public bool IncludeTypes { get; set; }
public bool IncludeDescriptions { get; set; }
} }

16
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 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<ControllerInterfaceApiDescriptionModel> Interfaces { get; set; } = default!; public List<ControllerInterfaceApiDescriptionModel> Interfaces { get; set; } = default!;
public Dictionary<string, ActionApiDescriptionModel> Actions { get; set; } = default!; public Dictionary<string, ActionApiDescriptionModel> Actions { get; set; } = default!;
@ -66,6 +74,14 @@ public class ControllerApiDescriptionModel
Type = Type, Type = Type,
Interfaces = Interfaces, Interfaces = Interfaces,
ControllerName = ControllerName, ControllerName = ControllerName,
ControllerGroupName = ControllerGroupName,
IsRemoteService = IsRemoteService,
IsIntegrationService = IsIntegrationService,
ApiVersion = ApiVersion,
Summary = Summary,
Remarks = Remarks,
Description = Description,
DisplayName = DisplayName,
Actions = new Dictionary<string, ActionApiDescriptionModel>() Actions = new Dictionary<string, ActionApiDescriptionModel>()
}; };

4
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IApiDescriptionModelProvider.cs

@ -1,6 +1,8 @@
using System.Threading.Tasks;
namespace Volo.Abp.Http.Modeling; namespace Volo.Abp.Http.Modeling;
public interface IApiDescriptionModelProvider public interface IApiDescriptionModelProvider
{ {
ApplicationApiDescriptionModel CreateApiModel(ApplicationApiDescriptionModelRequestDto input); Task<ApplicationApiDescriptionModel> CreateApiModelAsync(ApplicationApiDescriptionModelRequestDto input);
} }

6
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/MethodParameterApiDescriptionModel.cs

@ -19,6 +19,12 @@ public class MethodParameterApiDescriptionModel
public object? DefaultValue { get; set; } public object? DefaultValue { get; set; }
public string? Summary { get; set; }
public string? Description { get; set; }
public string? DisplayName { get; set; }
public MethodParameterApiDescriptionModel() public MethodParameterApiDescriptionModel()
{ {

6
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? DescriptorName { get; set; }
public string? Summary { get; set; }
public string? Description { get; set; }
public string? DisplayName { get; set; }
public ParameterApiDescriptionModel() public ParameterApiDescriptionModel()
{ {

6
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs

@ -32,6 +32,12 @@ public class PropertyApiDescriptionModel
public bool IsNullable { get; set; } 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) public static PropertyApiDescriptionModel Create(PropertyInfo propertyInfo)
{ {
var customAttributes = propertyInfo.GetCustomAttributes(true); var customAttributes = propertyInfo.GetCustomAttributes(true);

2
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 TypeSimple { get; set; } = default!;
public string? Summary { get; set; }
public ReturnValueApiDescriptionModel() public ReturnValueApiDescriptionModel()
{ {

8
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs

@ -20,6 +20,14 @@ public class TypeApiDescriptionModel
public PropertyApiDescriptionModel[]? Properties { get; set; } 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() public TypeApiDescriptionModel()
{ {

4
framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManager.cs

@ -1,6 +1,8 @@
using System.Threading.Tasks;
namespace Volo.Abp.Http.ProxyScripting; namespace Volo.Abp.Http.ProxyScripting;
public interface IProxyScriptManager public interface IProxyScriptManager
{ {
string GetScript(ProxyScriptingModel scriptingModel); Task<string> GetScriptAsync(ProxyScriptingModel scriptingModel);
} }

7
framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/IProxyScriptManagerCache.cs

@ -1,10 +1,9 @@
using System; using System;
using System.Threading.Tasks;
namespace Volo.Abp.Http.ProxyScripting; namespace Volo.Abp.Http.ProxyScripting;
public interface IProxyScriptManagerCache public interface IProxyScriptManagerCache
{ {
string GetOrAdd(string key, Func<string> factory); Task<string> GetOrAddAsync(string key, Func<Task<string>> factory);
void Set(string key, string value);
} }

13
framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManager.cs

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
@ -32,23 +33,21 @@ public class ProxyScriptManager : IProxyScriptManager, ITransientDependency
_options = options.Value; _options = options.Value;
} }
public string GetScript(ProxyScriptingModel scriptingModel) public async Task<string> GetScriptAsync(ProxyScriptingModel scriptingModel)
{ {
var cacheKey = CreateCacheKey(scriptingModel); var cacheKey = CreateCacheKey(scriptingModel);
if (scriptingModel.UseCache) if (scriptingModel.UseCache)
{ {
return _cache.GetOrAdd(cacheKey, () => CreateScript(scriptingModel)); return await _cache.GetOrAddAsync(cacheKey, () => CreateScriptAsync(scriptingModel));
} }
var script = CreateScript(scriptingModel); return await CreateScriptAsync(scriptingModel);
_cache.Set(cacheKey, script);
return script;
} }
private string CreateScript(ProxyScriptingModel scriptingModel) private async Task<string> CreateScriptAsync(ProxyScriptingModel scriptingModel)
{ {
var apiModel = _modelProvider.CreateApiModel(new ApplicationApiDescriptionModelRequestDto { IncludeTypes = false }); var apiModel = await _modelProvider.CreateApiModelAsync(new ApplicationApiDescriptionModelRequestDto { IncludeTypes = false });
if (scriptingModel.IsPartialRequest()) if (scriptingModel.IsPartialRequest())
{ {

31
framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/ProxyScriptManagerCache.cs

@ -1,26 +1,31 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Threading;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
namespace Volo.Abp.Http.ProxyScripting; namespace Volo.Abp.Http.ProxyScripting;
public class ProxyScriptManagerCache : IProxyScriptManagerCache, ISingletonDependency public class ProxyScriptManagerCache : IProxyScriptManagerCache, ISingletonDependency
{ {
private readonly ConcurrentDictionary<string, string> _cache; private readonly ConcurrentDictionary<string, string> _cache = new();
private readonly ConcurrentDictionary<string, Lazy<Task<string>>> _asyncCache = new();
public ProxyScriptManagerCache() public async Task<string> GetOrAddAsync(string key, Func<Task<string>> factory)
{ {
_cache = new ConcurrentDictionary<string, string>(); if (_cache.TryGetValue(key, out var cached))
} {
return cached;
}
public string GetOrAdd(string key, Func<string> factory) var result = await _asyncCache.GetOrAdd(
{ key,
return _cache.GetOrAdd(key, factory); _ => new Lazy<Task<string>>(factory, LazyThreadSafetyMode.ExecutionAndPublication)
} ).Value;
public void Set(string key, string value) _cache[key] = result;
{ _asyncCache.TryRemove(key, out _);
_cache[key] = value;
return result;
} }
} }

536
framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Description_Tests.cs

@ -0,0 +1,536 @@
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_Controller_Descriptions()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/api/abp/api-definition");
var controller = GetDocumentedController(model);
controller.Summary.ShouldBeNull();
controller.Remarks.ShouldBeNull();
controller.Description.ShouldBeNull();
controller.DisplayName.ShouldBeNull();
}
[Fact]
public async Task Default_Should_Not_Include_Action_Descriptions()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/api/abp/api-definition?includeDescriptions=true");
var controller = GetDocumentedController(model);
controller.DisplayName.ShouldBe("Documented Service");
}
[Fact]
public async Task Controller_Descriptions_Should_Be_Populated_Only_Once_For_Multiple_Actions()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/api/abp/api-definition?includeDescriptions=true");
var controller = GetDocumentedController(model);
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<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/api/abp/api-definition?includeDescriptions=true");
var action = GetAction(GetDocumentedController(model), "GetGreeting");
action.DisplayName.ShouldBe("Get Greeting");
}
[Fact]
public async Task IncludeDescriptions_Should_Populate_ReturnValue_Summary()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/api/abp/api-definition?includeDescriptions=true");
var action = GetAction(GetDocumentedController(model), "GetGreeting");
action.ReturnValue.Summary.ShouldNotBeNullOrEmpty();
action.ReturnValue.Summary.ShouldContain("personalized greeting");
}
[Fact]
public async Task Undocumented_Action_Should_Have_Null_Descriptions()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/api/abp/api-definition?includeDescriptions=true");
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<ApplicationApiDescriptionModel>(
"/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();
param.Summary.ShouldContain("name of the person");
}
[Fact]
public async Task IncludeDescriptions_Should_Populate_ParameterOnMethod_Description_And_DisplayName_From_Attribute()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/api/abp/api-definition?includeDescriptions=true");
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<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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();
param.Summary.ShouldContain("name of the person");
}
[Fact]
public async Task IncludeDescriptions_Should_Populate_Parameter_Description_And_DisplayName_From_Attribute()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/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");
}
[Fact]
public async Task IncludeDescriptions_Should_Leave_Parameter_Attributes_Null_When_Not_Annotated_Http()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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_Summary()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/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");
}
[Fact]
public async Task IncludeDescriptions_With_IncludeTypes_Should_Populate_Property_Description_And_DisplayName()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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();
valueProp.Summary.ShouldNotBeNullOrEmpty();
valueProp.Description.ShouldBe("Value description from attribute");
valueProp.DisplayName.ShouldBeNull();
}
[Fact]
public async Task IncludeTypes_Without_IncludeDescriptions_Should_Not_Populate_Type_Descriptions()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/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();
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 IncludeDescriptions_Should_Fallback_To_Interface_For_Controller_Summary()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/api/abp/api-definition?includeDescriptions=true");
var controller = GetInterfaceOnlyController(model);
controller.Summary.ShouldNotBeNullOrEmpty();
controller.Summary.ShouldContain("documented only on the interface");
}
[Fact]
public async Task IncludeDescriptions_Should_Fallback_To_Interface_For_Controller_Remarks()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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");
}
[Fact]
public async Task IncludeDescriptions_Should_Not_Apply_Container_Param_Summary_To_Expanded_Properties()
{
var model = await GetResponseAsObjectAsync<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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<ApplicationApiDescriptionModel>(
"/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
.SelectMany(m => m.Controllers.Values)
.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
.First(a => a.Name == actionName + "Async" || a.Name == actionName);
}
}

427
framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProviderTests.cs

@ -0,0 +1,427 @@
#nullable enable
using System;
using System.Reflection;
using System.Threading.Tasks;
using System.Xml.Linq;
using Shouldly;
using Volo.Abp.DependencyInjection;
using Xunit;
namespace Volo.Abp.AspNetCore.Mvc.ApiExploring;
public class XmlDocumentationProviderTests
{
// A stub type so we can construct member-name keys that the provider can look up.
private class StubType { }
private static XmlDocumentationProvider CreateProvider(string xmlDocBody)
{
var xml = $@"<?xml version=""1.0""?>
<doc>
<members>
{xmlDocBody}
</members>
</doc>";
return new FakeXmlDocumentationProvider(xml);
}
private static string StubTypeMemberName(string elementName, string xmlContent)
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
return $@"<member name=""{elementName}:{typeName}"">
{xmlContent}
</member>";
}
// Tests for CleanXmlText via GetSummaryAsync(Type)
[Fact]
public async Task GetSummary_Returns_PlainText()
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
var provider = CreateProvider(
$@"<member name=""T:{typeName}""><summary>A simple summary.</summary></member>");
var result = await provider.GetSummaryAsync(typeof(StubType));
result.ShouldBe("A simple summary.");
}
[Fact]
public async Task GetSummary_Expands_SeeCref_To_ShortTypeName()
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
var provider = CreateProvider(
$@"<member name=""T:{typeName}""><summary>Returns a <see cref=""T:System.String"" /> value.</summary></member>");
var result = await provider.GetSummaryAsync(typeof(StubType));
result.ShouldBe("Returns a String value.");
}
[Fact]
public async Task GetSummary_Expands_SeeCref_NestedType()
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
var provider = CreateProvider(
$@"<member name=""T:{typeName}""><summary>See <see cref=""T:System.Collections.Generic.List`1"" /> for details.</summary></member>");
var result = await provider.GetSummaryAsync(typeof(StubType));
result.ShouldBe("See List`1 for details.");
}
[Fact]
public async Task GetSummary_Expands_SeeLangword()
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
var provider = CreateProvider(
$@"<member name=""T:{typeName}""><summary>Returns <see langword=""null"" /> when not found.</summary></member>");
var result = await provider.GetSummaryAsync(typeof(StubType));
result.ShouldBe("Returns null when not found.");
}
[Fact]
public async Task GetSummary_Strips_CodeTag()
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
var provider = CreateProvider(
$@"<member name=""T:{typeName}""><summary>Use <c>DoSomething()</c> to start.</summary></member>");
var result = await provider.GetSummaryAsync(typeof(StubType));
result.ShouldBe("Use DoSomething() to start.");
}
[Fact]
public async Task GetSummary_Strips_ParaTag()
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
var provider = CreateProvider(
$@"<member name=""T:{typeName}""><summary><para>First paragraph.</para></summary></member>");
var result = await provider.GetSummaryAsync(typeof(StubType));
result.ShouldBe("First paragraph.");
}
[Fact]
public async Task GetSummary_Collapses_Whitespace()
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
var provider = CreateProvider(
$@"<member name=""T:{typeName}""><summary>
Multiple
spaces here.
</summary></member>");
var result = await provider.GetSummaryAsync(typeof(StubType));
result.ShouldBe("Multiple spaces here.");
}
[Fact]
public async Task GetSummary_Returns_Null_When_Member_Not_Found()
{
var provider = CreateProvider(string.Empty);
var result = await provider.GetSummaryAsync(typeof(StubType));
result.ShouldBeNull();
}
[Fact]
public async Task GetSummary_Returns_Null_When_Summary_Is_Empty()
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
var provider = CreateProvider(
$@"<member name=""T:{typeName}""><summary> </summary></member>");
var result = await provider.GetSummaryAsync(typeof(StubType));
result.ShouldBeNull();
}
[Fact]
public async Task GetSummary_Expands_Paramref()
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
var provider = CreateProvider(
$@"<member name=""T:{typeName}""><summary>Use the <paramref name=""input"" /> parameter.</summary></member>");
var result = await provider.GetSummaryAsync(typeof(StubType));
result.ShouldBe("Use the input parameter.");
}
[Fact]
public async Task GetSummary_Expands_Typeparamref()
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
var provider = CreateProvider(
$@"<member name=""T:{typeName}""><summary>Returns <typeparamref name=""T"" /> instance.</summary></member>");
var result = await provider.GetSummaryAsync(typeof(StubType));
result.ShouldBe("Returns T instance.");
}
[Fact]
public async Task GetSummary_Expands_Mixed_Tags()
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
var provider = CreateProvider(
$@"<member name=""T:{typeName}""><summary>Returns <see cref=""T:System.String"" /> or <see langword=""null"" /> if <paramref name=""key"" /> not found.</summary></member>");
var result = await provider.GetSummaryAsync(typeof(StubType));
result.ShouldBe("Returns String or null if key not found.");
}
[Fact]
public async Task GetRemarks_Returns_Null_When_No_Remarks_Element()
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
var provider = CreateProvider(
$@"<member name=""T:{typeName}""><summary>Only summary.</summary></member>");
var result = await provider.GetRemarksAsync(typeof(StubType));
result.ShouldBeNull();
}
[Fact]
public async Task GetRemarks_Returns_Remarks_Content()
{
var typeName = typeof(StubType).FullName!.Replace('+', '.');
var provider = CreateProvider(
$@"<member name=""T:{typeName}""><summary>Summary text.</summary><remarks>Some remarks here.</remarks></member>");
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(
$@"<member name=""T:{typeName}""><summary/></member>");
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(
$@"<member name=""T:{typeName}""><summary>See <see cref=""M:Foo.Bar.DoWork"" /> for details.</summary></member>");
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(
$@"<member name=""T:{typeName}""><summary>See <see cref=""P:Foo.Bar.Name"" /> property.</summary></member>");
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(
$@"<member name=""T:{typeName}""><summary><para>First.</para> <c>code</c> <b>bold</b> end.</summary></member>");
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(
$@"<member name=""M:{typeName}.GetValue(System.String,System.Int32)""><summary>Gets a value by key.</summary></member>");
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(
$@"<member name=""M:{typeName}.NoParams""><summary>No params method.</summary></member>");
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(
$@"<member name=""M:{typeName}.GetValue(System.String,System.Int32)""><returns>The resolved value.</returns></member>");
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(
$@"<member name=""M:{typeName}.GetValue(System.String,System.Int32)""><summary>Gets a value.</summary></member>");
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(
$@"<member name=""M:{typeName}.GetValue(System.String,System.Int32)""><param name=""key"">The lookup key.</param><param name=""count"">Max results.</param></member>");
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(
$@"<member name=""M:{typeName}.GetValue(System.String,System.Int32)""><param name=""key"">The key.</param></member>");
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(
$@"<member name=""P:{typeName}.Name""><summary>The name property.</summary></member>");
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(
$@"<member name=""M:{typeName}.GetValue(System.String,System.Int32)""><remarks>Implementation note.</remarks></member>");
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(
$@"<member name=""M:{typeName}.GetValue(System.String,System.Int32)""><summary>Summary only.</summary></member>");
var result = await provider.GetRemarksAsync(method);
result.ShouldBeNull();
}
/// <summary>
/// A fake provider that loads XML from an in-memory string instead of the file system.
/// </summary>
[DisableConventionalRegistration]
private sealed class FakeXmlDocumentationProvider : XmlDocumentationProvider
{
private readonly XDocument _document;
public FakeXmlDocumentationProvider(string xml)
{
_document = XDocument.Parse(xml);
}
protected override Task<XDocument?> LoadXmlDocumentationFromDiskAsync(Assembly assembly)
{
return Task.FromResult<XDocument?>(_document);
}
}
}

1
framework/test/Volo.Abp.TestApp/Volo.Abp.TestApp.csproj

@ -6,6 +6,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<RootNamespace /> <RootNamespace />
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile> <NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

58
framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/DocumentedAppService.cs

@ -0,0 +1,58 @@
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;
/// <summary>
/// A documented application service for testing API descriptions.
/// </summary>
/// <remarks>
/// This service is used in integration tests to verify XML doc extraction.
/// </remarks>
[Description("Documented service description from attribute")]
[Display(Name = "Documented Service")]
public class DocumentedAppService : ApplicationService, IDocumentedAppService
{
/// <summary>
/// Gets a greeting message for the specified name.
/// </summary>
/// <param name="name">The name of the person to greet.</param>
/// <returns>A personalized greeting message.</returns>
[Description("Get greeting description from attribute")]
[Display(Name = "Get Greeting")]
public async Task<string> GetGreetingAsync(string name)
{
return await Task.FromResult($"Hello, {name}!");
}
/// <summary>
/// Creates a documented item.
/// </summary>
/// <param name="input">The input for creating a documented item.</param>
/// <returns>The created documented item.</returns>
public async Task<DocumentedDto> CreateAsync(DocumentedDto input)
{
return await Task.FromResult(input);
}
/// <summary>
/// Searches for items matching the query.
/// </summary>
/// <param name="query">The search query string.</param>
/// <param name="maxResults">The maximum number of results to return.</param>
/// <returns>A list of matching item names.</returns>
public async Task<string> 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;
}
}

25
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;
/// <summary>
/// A documented DTO for testing type and property descriptions.
/// </summary>
[Description("Documented DTO description from attribute")]
[Display(Name = "Documented DTO")]
public class DocumentedDto
{
/// <summary>
/// The name of the documented item.
/// </summary>
[Description("Name description from attribute")]
[Display(Name = "Item Name")]
public string Name { get; set; } = default!;
/// <summary>
/// The value of the documented item.
/// </summary>
[Description("Value description from attribute")]
public int Value { get; set; }
}

38
framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IDocumentedAppService.cs

@ -0,0 +1,38 @@
using System.Threading.Tasks;
using Volo.Abp.Application.Services;
using Volo.Abp.TestApp.Application.Dto;
namespace Volo.Abp.TestApp.Application;
/// <summary>
/// A documented application service for testing API descriptions.
/// </summary>
/// <remarks>
/// This service is used in integration tests to verify XML doc extraction.
/// </remarks>
public interface IDocumentedAppService : IApplicationService
{
/// <summary>
/// Gets a greeting message for the specified name.
/// </summary>
/// <param name="name">The name of the person to greet.</param>
/// <returns>A personalized greeting message.</returns>
Task<string> GetGreetingAsync(string name);
/// <summary>
/// Creates a documented item.
/// </summary>
/// <param name="input">The input for creating a documented item.</param>
/// <returns>The created documented item.</returns>
Task<DocumentedDto> CreateAsync(DocumentedDto input);
/// <summary>
/// Searches for items matching the query.
/// </summary>
/// <param name="query">The search query string.</param>
/// <param name="maxResults">The maximum number of results to return.</param>
/// <returns>A list of matching item names.</returns>
Task<string> SearchAsync(string query, int maxResults);
Task DeleteAsync(int id);
}

20
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;
/// <summary>
/// A service documented only on the interface to test XML doc fallback.
/// </summary>
/// <remarks>
/// Used to verify that documentation is resolved from the interface when the implementation has none.
/// </remarks>
public interface IInterfaceOnlyDocumentedAppService : IApplicationService
{
/// <summary>
/// Gets a message documented only on the interface.
/// </summary>
/// <param name="key">The message key.</param>
/// <returns>The resolved message.</returns>
Task<string> GetMessageAsync(string key);
}

12
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<string> GetMessageAsync(string key)
{
return await Task.FromResult(key);
}
}
Loading…
Cancel
Save