diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureMvcOptions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureMvcOptions.cs index 73e612fa22..7800e75735 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureMvcOptions.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureMvcOptions.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; @@ -7,39 +6,24 @@ namespace JsonApiDotNetCore.OpenApi.Swashbuckle; internal sealed class ConfigureMvcOptions : IConfigureOptions { - private readonly IJsonApiRoutingConvention _jsonApiRoutingConvention; private readonly JsonApiRequestFormatMetadataProvider _jsonApiRequestFormatMetadataProvider; - private readonly IJsonApiOptions _jsonApiOptions; + private readonly JsonApiOptions _jsonApiOptions; - public ConfigureMvcOptions(IJsonApiRoutingConvention jsonApiRoutingConvention, JsonApiRequestFormatMetadataProvider jsonApiRequestFormatMetadataProvider, - IJsonApiOptions jsonApiOptions) + public ConfigureMvcOptions(JsonApiRequestFormatMetadataProvider jsonApiRequestFormatMetadataProvider, IJsonApiOptions jsonApiOptions) { - ArgumentNullException.ThrowIfNull(jsonApiRoutingConvention); ArgumentNullException.ThrowIfNull(jsonApiRequestFormatMetadataProvider); ArgumentNullException.ThrowIfNull(jsonApiOptions); - _jsonApiRoutingConvention = jsonApiRoutingConvention; _jsonApiRequestFormatMetadataProvider = jsonApiRequestFormatMetadataProvider; - _jsonApiOptions = jsonApiOptions; + _jsonApiOptions = (JsonApiOptions)jsonApiOptions; } public void Configure(MvcOptions options) { ArgumentNullException.ThrowIfNull(options); - AddSwashbuckleCliCompatibility(options); - options.InputFormatters.Add(_jsonApiRequestFormatMetadataProvider); - ((JsonApiOptions)_jsonApiOptions).IncludeExtensions(OpenApiMediaTypeExtension.OpenApi, OpenApiMediaTypeExtension.RelaxedOpenApi); - } - - private void AddSwashbuckleCliCompatibility(MvcOptions options) - { - if (!options.Conventions.Any(convention => convention is IJsonApiRoutingConvention)) - { - // See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1957 for why this is needed. - options.Conventions.Insert(0, _jsonApiRoutingConvention); - } + _jsonApiOptions.IncludeExtensions(OpenApiMediaTypeExtension.OpenApi, OpenApiMediaTypeExtension.RelaxedOpenApi); } } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs index 72d0dcfab0..5d05490e8d 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Net; using System.Reflection; using JsonApiDotNetCore.Configuration; @@ -36,8 +37,10 @@ internal sealed partial class JsonApiActionDescriptorCollectionProvider : IActio private readonly JsonApiEndpointMetadataProvider _jsonApiEndpointMetadataProvider; private readonly IJsonApiOptions _options; private readonly ILogger _logger; + private readonly ConcurrentDictionary> _versionedActionDescriptorCache = new(); - public ActionDescriptorCollection ActionDescriptors => GetActionDescriptors(); + public ActionDescriptorCollection ActionDescriptors => + _versionedActionDescriptorCache.GetOrAdd(_defaultProvider.ActionDescriptors.Version, LazyGetActionDescriptors).Value; public JsonApiActionDescriptorCollectionProvider(IActionDescriptorCollectionProvider defaultProvider, IControllerResourceMapping controllerResourceMapping, JsonApiEndpointMetadataProvider jsonApiEndpointMetadataProvider, IJsonApiOptions options, ILogger logger) @@ -55,7 +58,13 @@ public JsonApiActionDescriptorCollectionProvider(IActionDescriptorCollectionProv _logger = logger; } - private ActionDescriptorCollection GetActionDescriptors() + private Lazy LazyGetActionDescriptors(int version) + { + // https://andrewlock.net/making-getoradd-on-concurrentdictionary-thread-safe-using-lazy/ + return new Lazy(() => GetActionDescriptors(version), LazyThreadSafetyMode.ExecutionAndPublication); + } + + private ActionDescriptorCollection GetActionDescriptors(int version) { List descriptors = []; @@ -106,8 +115,7 @@ private ActionDescriptorCollection GetActionDescriptors() descriptors.Add(descriptor); } - int descriptorVersion = _defaultProvider.ActionDescriptors.Version; - return new ActionDescriptorCollection(descriptors.AsReadOnly(), descriptorVersion); + return new ActionDescriptorCollection(descriptors.AsReadOnly(), version); } internal static bool IsVisibleEndpoint(ActionDescriptor descriptor) @@ -221,9 +229,9 @@ private ActionDescriptor[] SetEndpointMetadata(ActionDescriptor descriptor, Buil { Dictionary descriptorsByRelationship = []; - JsonApiEndpointMetadata? endpointMetadata = _jsonApiEndpointMetadataProvider.Get(descriptor); + JsonApiEndpointMetadata endpointMetadata = _jsonApiEndpointMetadataProvider.Get(descriptor); - switch (endpointMetadata?.RequestMetadata) + switch (endpointMetadata.RequestMetadata) { case AtomicOperationsRequestMetadata atomicOperationsRequestMetadata: { @@ -259,7 +267,7 @@ private ActionDescriptor[] SetEndpointMetadata(ActionDescriptor descriptor, Buil } } - switch (endpointMetadata?.ResponseMetadata) + switch (endpointMetadata.ResponseMetadata) { case AtomicOperationsResponseMetadata atomicOperationsResponseMetadata: { diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs index dc4d7cdd9c..7de84f0345 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs @@ -26,17 +26,19 @@ public JsonApiEndpointMetadataProvider(IControllerResourceMapping controllerReso _nonPrimaryDocumentTypeFactory = nonPrimaryDocumentTypeFactory; } - public JsonApiEndpointMetadata? Get(ActionDescriptor descriptor) + public JsonApiEndpointMetadata Get(ActionDescriptor descriptor) { ArgumentNullException.ThrowIfNull(descriptor); var actionMethod = OpenApiActionMethod.Create(descriptor); + JsonApiEndpointMetadata? metadata = null; switch (actionMethod) { case AtomicOperationsActionMethod: { - return new JsonApiEndpointMetadata(AtomicOperationsRequestMetadata.Instance, AtomicOperationsResponseMetadata.Instance); + metadata = new JsonApiEndpointMetadata(AtomicOperationsRequestMetadata.Instance, AtomicOperationsResponseMetadata.Instance); + break; } case JsonApiActionMethod jsonApiActionMethod: { @@ -45,13 +47,13 @@ public JsonApiEndpointMetadataProvider(IControllerResourceMapping controllerReso IJsonApiRequestMetadata? requestMetadata = GetRequestMetadata(jsonApiActionMethod.Endpoint, primaryResourceType); IJsonApiResponseMetadata? responseMetadata = GetResponseMetadata(jsonApiActionMethod.Endpoint, primaryResourceType); - return new JsonApiEndpointMetadata(requestMetadata, responseMetadata); - } - default: - { - return null; + metadata = new JsonApiEndpointMetadata(requestMetadata, responseMetadata); + break; } } + + ConsistencyGuard.ThrowIf(metadata == null); + return metadata; } private IJsonApiRequestMetadata? GetRequestMetadata(JsonApiEndpoints endpoint, ResourceType primaryResourceType) diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiRequestFormatMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiRequestFormatMetadataProvider.cs index e73c0d120e..9c0c1bb668 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiRequestFormatMetadataProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiRequestFormatMetadataProvider.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Formatters; @@ -10,12 +11,14 @@ namespace JsonApiDotNetCore.OpenApi.Swashbuckle; internal sealed class JsonApiRequestFormatMetadataProvider : IInputFormatter, IApiRequestFormatMetadataProvider { /// + [ExcludeFromCodeCoverage] public bool CanRead(InputFormatterContext context) { return false; } /// + [ExcludeFromCodeCoverage] public Task ReadAsync(InputFormatterContext context) { throw new UnreachableException(); diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerationTracer.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerationTracer.cs index 6dbd6bb2f3..199eb0ac86 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerationTracer.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerationTracer.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.Logging; @@ -87,7 +88,7 @@ private static string GetSchemaTypeName(Type type) private sealed partial class SchemaGenerationTraceScope : ISchemaGenerationTraceScope { - private static readonly AsyncLocal RecursionDepthAsyncLocal = new(); + private static readonly AsyncLocal> RecursionDepthAsyncLocal = new(); private readonly ILogger _logger; private readonly string _schemaTypeName; @@ -101,8 +102,10 @@ public SchemaGenerationTraceScope(ILogger logger, string schemaTypeName) _logger = logger; _schemaTypeName = schemaTypeName; - RecursionDepthAsyncLocal.Value++; - LogStarted(RecursionDepthAsyncLocal.Value, _schemaTypeName); + RecursionDepthAsyncLocal.Value ??= new StrongBox(0); + int depth = Interlocked.Increment(ref RecursionDepthAsyncLocal.Value.Value); + + LogStarted(depth, _schemaTypeName); } public void TraceSucceeded(string schemaId) @@ -112,16 +115,18 @@ public void TraceSucceeded(string schemaId) public void Dispose() { + int depth = RecursionDepthAsyncLocal.Value!.Value; + if (_schemaId != null) { - LogSucceeded(RecursionDepthAsyncLocal.Value, _schemaTypeName, _schemaId); + LogSucceeded(depth, _schemaTypeName, _schemaId); } else { - LogFailed(RecursionDepthAsyncLocal.Value, _schemaTypeName); + LogFailed(depth, _schemaTypeName); } - RecursionDepthAsyncLocal.Value--; + Interlocked.Decrement(ref RecursionDepthAsyncLocal.Value.Value); } [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, Message = "({Depth:D2}) Started for {SchemaTypeName}.")] diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ServiceCollectionExtensions.cs index 10d4ec50cb..791a69cd49 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ServiceCollectionExtensions.cs @@ -62,7 +62,7 @@ private static void AddCustomApiExplorer(IServiceCollection services) AddApiExplorer(services); - services.AddSingleton, ConfigureMvcOptions>(); + services.TryAddEnumerable(ServiceDescriptor.Singleton, ConfigureMvcOptions>()); } private static void AddApiExplorer(IServiceCollection services) diff --git a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs index fb69fa5ae5..b8df0227c6 100644 --- a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs @@ -33,20 +33,6 @@ public static void UseJsonApi(this IApplicationBuilder builder) inverseNavigationResolver.Resolve(); } - var jsonApiApplicationBuilder = builder.ApplicationServices.GetRequiredService(); - - jsonApiApplicationBuilder.ConfigureMvcOptions = options => - { - var inputFormatter = builder.ApplicationServices.GetRequiredService(); - options.InputFormatters.Insert(0, inputFormatter); - - var outputFormatter = builder.ApplicationServices.GetRequiredService(); - options.OutputFormatters.Insert(0, outputFormatter); - - var routingConvention = builder.ApplicationServices.GetRequiredService(); - options.Conventions.Insert(0, routingConvention); - }; - builder.UseMiddleware(); } diff --git a/src/JsonApiDotNetCore/Configuration/ConfigureMvcOptions.cs b/src/JsonApiDotNetCore/Configuration/ConfigureMvcOptions.cs new file mode 100644 index 0000000000..4710f01135 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/ConfigureMvcOptions.cs @@ -0,0 +1,38 @@ +using JsonApiDotNetCore.Middleware; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace JsonApiDotNetCore.Configuration; + +internal sealed class ConfigureMvcOptions : IConfigureOptions +{ + private readonly IJsonApiInputFormatter _inputFormatter; + private readonly IJsonApiOutputFormatter _outputFormatter; + private readonly IJsonApiRoutingConvention _routingConvention; + + public ConfigureMvcOptions(IJsonApiInputFormatter inputFormatter, IJsonApiOutputFormatter outputFormatter, IJsonApiRoutingConvention routingConvention) + { + ArgumentNullException.ThrowIfNull(inputFormatter); + ArgumentNullException.ThrowIfNull(outputFormatter); + ArgumentNullException.ThrowIfNull(routingConvention); + + _inputFormatter = inputFormatter; + _outputFormatter = outputFormatter; + _routingConvention = routingConvention; + } + + public void Configure(MvcOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + options.EnableEndpointRouting = true; + + options.InputFormatters.Insert(0, _inputFormatter); + options.OutputFormatters.Insert(0, _outputFormatter); + options.Conventions.Insert(0, _routingConvention); + + options.Filters.AddService(); + options.Filters.AddService(); + options.Filters.AddService(); + } +} diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs deleted file mode 100644 index 459e5be291..0000000000 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace JsonApiDotNetCore.Configuration; - -internal interface IJsonApiApplicationBuilder -{ - public Action? ConfigureMvcOptions { set; } -} diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 65646b7697..dd844be361 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace JsonApiDotNetCore.Configuration; @@ -25,15 +26,13 @@ namespace JsonApiDotNetCore.Configuration; /// A utility class that builds a JSON:API application. It registers all required services and allows the user to override parts of the startup /// configuration. /// -internal sealed class JsonApiApplicationBuilder : IJsonApiApplicationBuilder +internal sealed class JsonApiApplicationBuilder { private readonly IServiceCollection _services; private readonly IMvcCoreBuilder _mvcBuilder; private readonly JsonApiOptions _options = new(); private readonly ResourceDescriptorAssemblyCache _assemblyCache = new(); - public Action? ConfigureMvcOptions { get; set; } - public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mvcBuilder) { ArgumentNullException.ThrowIfNull(services); @@ -105,15 +104,6 @@ public void ConfigureResourceGraph(ICollection dbContextTypes, Action public void ConfigureMvc() { - _mvcBuilder.AddMvcOptions(options => - { - options.EnableEndpointRouting = true; - options.Filters.AddService(); - options.Filters.AddService(); - options.Filters.AddService(); - ConfigureMvcOptions?.Invoke(options); - }); - if (_options.ValidateModelState) { _mvcBuilder.AddDataAnnotations(); @@ -175,7 +165,6 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) private void AddMiddlewareLayer() { _services.TryAddSingleton(_options); - _services.TryAddSingleton(this); _services.TryAddSingleton(); _services.TryAddScoped(); _services.TryAddScoped(); @@ -183,6 +172,7 @@ private void AddMiddlewareLayer() _services.TryAddSingleton(); _services.TryAddSingleton(); _services.TryAddSingleton(); + _services.TryAddEnumerable(ServiceDescriptor.Singleton, ConfigureMvcOptions>()); _services.TryAddSingleton(provider => provider.GetRequiredService()); _services.TryAddSingleton(); _services.TryAddSingleton(); diff --git a/test/OpenApiTests/MixedControllers/MixedControllerTests.cs b/test/OpenApiTests/MixedControllers/MixedControllerTests.cs index c15552b380..bd064968ff 100644 --- a/test/OpenApiTests/MixedControllers/MixedControllerTests.cs +++ b/test/OpenApiTests/MixedControllers/MixedControllerTests.cs @@ -44,7 +44,7 @@ public async Task Default_JsonApi_endpoints_are_exposed() } [Fact] - public async Task Upload_endpoint_is_exposed() + public async Task Upload_file_endpoint_is_exposed() { // Act JsonElement document = await _testContext.GetSwaggerDocumentAsync(); @@ -97,7 +97,7 @@ public async Task Upload_endpoint_is_exposed() } [Fact] - public async Task Exists_endpoint_is_exposed() + public async Task File_exists_endpoint_is_exposed() { // Act JsonElement document = await _testContext.GetSwaggerDocumentAsync(); @@ -159,7 +159,7 @@ public async Task Exists_endpoint_is_exposed() } [Fact] - public async Task Download_endpoint_is_exposed() + public async Task Download_file_endpoint_is_exposed() { // Act JsonElement document = await _testContext.GetSwaggerDocumentAsync(); @@ -227,4 +227,139 @@ public async Task Download_endpoint_is_exposed() } """); } + + [Fact] + public async Task Send_email_endpoint_is_exposed() + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath("paths./emails/send.post").Should().BeJson(""" + { + "tags": [ + "emails" + ], + "description": "Sends an email to the specified recipient.", + "operationId": "sendEmail", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/email" + } + ], + "description": "The email to send." + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/httpValidationProblemDetails" + } + } + } + } + } + } + """); + } + + [Fact] + public async Task Emails_sent_since_endpoint_is_exposed() + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath("paths./emails/sent-since.get").Should().BeJson(""" + { + "tags": [ + "emails" + ], + "description": "Gets all emails sent since the specified date/time.", + "operationId": "getSentSince", + "parameters": [ + { + "name": "sinceUtc", + "in": "query", + "description": "The date/time (in UTC) since which the email was sent.", + "required": true, + "schema": { + "type": "string", + "description": "The date/time (in UTC) since which the email was sent.", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/email" + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/httpValidationProblemDetails" + } + } + } + } + } + } + """); + + document.Should().ContainPath("paths./emails/sent-since.head").Should().BeJson(""" + { + "tags": [ + "emails" + ], + "description": "Gets all emails sent since the specified date/time.", + "operationId": "tryGetSentSince", + "parameters": [ + { + "name": "sinceUtc", + "in": "query", + "description": "The date/time (in UTC) since which the email was sent.", + "required": true, + "schema": { + "type": "string", + "description": "The date/time (in UTC) since which the email was sent.", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + """); + } } diff --git a/test/OpenApiTests/OpenApiTestContext.cs b/test/OpenApiTests/OpenApiTestContext.cs index 7743ab7533..5151e9a73a 100644 --- a/test/OpenApiTests/OpenApiTestContext.cs +++ b/test/OpenApiTests/OpenApiTestContext.cs @@ -28,7 +28,7 @@ internal async Task GetSwaggerDocumentAsync() return await _lazySwaggerDocument.Value; } - private async Task CreateSwaggerDocumentAsync() + internal async Task CreateSwaggerDocumentAsync() { string content = await GetAsync("/swagger/v1/swagger.json"); diff --git a/test/OpenApiTests/ResourceInheritance/ConcurrencyTests.cs b/test/OpenApiTests/ResourceInheritance/ConcurrencyTests.cs new file mode 100644 index 0000000000..b7a7ce8e29 --- /dev/null +++ b/test/OpenApiTests/ResourceInheritance/ConcurrencyTests.cs @@ -0,0 +1,40 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace OpenApiTests.ResourceInheritance; + +public sealed class ConcurrencyTests : ResourceInheritanceTests +{ + private readonly OpenApiTestContext, ResourceInheritanceDbContext> _testContext; + + public ConcurrencyTests(OpenApiTestContext, ResourceInheritanceDbContext> testContext, + ITestOutputHelper testOutputHelper) + : base(testContext, testOutputHelper, true, false) + { + _testContext = testContext; + + testContext.ConfigureServices(services => services.AddLogging(loggingBuilder => loggingBuilder.ClearProviders())); + } + + [Fact] + public async Task Can_download_OpenAPI_documents_in_parallel() + { + // Arrange + const int count = 15; + var downloadTasks = new Task[count]; + + for (int index = 0; index < count; index++) + { + downloadTasks[index] = _testContext.CreateSwaggerDocumentAsync(); + } + + // Act + Func action = async () => await Task.WhenAll(downloadTasks); + + // Assert + await action.Should().NotThrowAsync(); + } +}