diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpRemoteServiceApiDescriptionProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpRemoteServiceApiDescriptionProvider.cs index 8c93400929..e7979584d8 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpRemoteServiceApiDescriptionProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpRemoteServiceApiDescriptionProvider.cs @@ -59,13 +59,20 @@ public class AbpRemoteServiceApiDescriptionProvider : IApiDescriptionProvider, I protected virtual IEnumerable GetApiResponseTypes() { - foreach (var apiResponse in _options.SupportedResponseTypes) + foreach (var template in _options.SupportedResponseTypes) { - apiResponse.ModelMetadata = _modelMetadataProvider.GetMetadataForType(apiResponse.Type!); + var apiResponse = new ApiResponseType + { + Type = template.Type, + StatusCode = template.StatusCode, + Description = template.Description, + IsDefaultResponse = template.IsDefaultResponse, + ModelMetadata = _modelMetadataProvider.GetMetadataForType(template.Type!) + }; foreach (var responseTypeMetadataProvider in _mvcOptions.OutputFormatters.OfType()) { - var formatterSupportedContentTypes = responseTypeMetadataProvider.GetSupportedContentTypes(null!, apiResponse.Type!); + var formatterSupportedContentTypes = responseTypeMetadataProvider.GetSupportedContentTypes(null!, template.Type!); if (formatterSupportedContentTypes == null) { continue; @@ -80,8 +87,8 @@ public class AbpRemoteServiceApiDescriptionProvider : IApiDescriptionProvider, I }); } } - } - return _options.SupportedResponseTypes; + yield return apiResponse; + } } } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpRemoteServiceApiDescriptionProvider_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpRemoteServiceApiDescriptionProvider_Tests.cs new file mode 100644 index 0000000000..9a062e0fc3 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpRemoteServiceApiDescriptionProvider_Tests.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; + +public class AbpRemoteServiceApiDescriptionProvider_Tests +{ + private static (AbpRemoteServiceApiDescriptionProvider provider, + AbpRemoteServiceApiDescriptionProviderOptions options) + CreateProvider() + { + var options = new AbpRemoteServiceApiDescriptionProviderOptions(); + options.SupportedResponseTypes.Add(new ApiResponseType + { + Type = typeof(string), + StatusCode = 400 + }); + options.SupportedResponseTypes.Add(new ApiResponseType + { + Type = typeof(string), + StatusCode = 500 + }); + + var mvcOptions = new MvcOptions(); + mvcOptions.OutputFormatters.Add(new FakeOutputFormatter()); + + var provider = new AbpRemoteServiceApiDescriptionProvider( + new EmptyModelMetadataProvider(), + new OptionsWrapper(mvcOptions), + new OptionsWrapper(options)); + + return (provider, options); + } + + private static ApiDescriptionProviderContext CreateContext() + { + var actionDescriptor = new ControllerActionDescriptor + { + ControllerName = "Fake", + ActionName = "Get", + ControllerTypeInfo = typeof(FakeRemoteController).GetTypeInfo(), + MethodInfo = typeof(FakeRemoteController).GetMethod(nameof(FakeRemoteController.Get))!, + AttributeRouteInfo = new AttributeRouteInfo { Template = "api/fake" } + }; + + var description = new ApiDescription + { + ActionDescriptor = actionDescriptor, + HttpMethod = "GET", + RelativePath = "api/fake" + }; + + return new ApiDescriptionProviderContext(new[] { actionDescriptor }) + { + Results = { description } + }; + } + + [Fact] + public void Should_Not_Mutate_Shared_Options_On_Repeated_Calls() + { + var (provider, options) = CreateProvider(); + + provider.OnProvidersExecuting(CreateContext()); + provider.OnProvidersExecuting(CreateContext()); + provider.OnProvidersExecuting(CreateContext()); + + foreach (var template in options.SupportedResponseTypes) + { + template.ApiResponseFormats.ShouldBeEmpty(); + } + } + + [Fact] + public void Should_Add_Response_Types_With_Preserved_Template_Fields_And_Formats() + { + var options = new AbpRemoteServiceApiDescriptionProviderOptions(); + options.SupportedResponseTypes.Add(new ApiResponseType + { + Type = typeof(string), + StatusCode = 418, + Description = "I'm a teapot", + IsDefaultResponse = true + }); + + var mvcOptions = new MvcOptions(); + mvcOptions.OutputFormatters.Add(new FakeOutputFormatter()); + + var provider = new AbpRemoteServiceApiDescriptionProvider( + new EmptyModelMetadataProvider(), + new OptionsWrapper(mvcOptions), + new OptionsWrapper(options)); + + var context = CreateContext(); + provider.OnProvidersExecuting(context); + + var result = context.Results.Single(); + var added = result.SupportedResponseTypes.ShouldHaveSingleItem(); + added.StatusCode.ShouldBe(418); + added.Type.ShouldBe(typeof(string)); + added.Description.ShouldBe("I'm a teapot"); + added.IsDefaultResponse.ShouldBeTrue(); + added.ModelMetadata.ShouldNotBeNull(); + added.ApiResponseFormats.Select(x => x.MediaType) + .ShouldBe(new[] { "application/json", "text/json" }); + } + + [Fact] + public void Should_Not_Throw_Under_Concurrent_Calls() + { + var (provider, _) = CreateProvider(); + + var exception = Record.Exception(() => + Parallel.For(0, 8, _ => + { + for (var i = 0; i < 500; i++) + { + provider.OnProvidersExecuting(CreateContext()); + } + })); + + exception.ShouldBeNull(); + } + + private sealed class FakeRemoteController : IRemoteService + { + public string Get() => string.Empty; + } + + private sealed class FakeOutputFormatter : IOutputFormatter, IApiResponseTypeMetadataProvider + { + public bool CanWriteResult(OutputFormatterCanWriteContext context) => true; + + public Task WriteAsync(OutputFormatterWriteContext context) => Task.CompletedTask; + + public IReadOnlyList GetSupportedContentTypes(string? contentType, Type objectType) + => new[] { "application/json", "text/json" }; + } +}