From f3fc197375fc4c1f38acf5413d7c9d39b79a24ff Mon Sep 17 00:00:00 2001 From: sheepla <62412884+sheepla@users.noreply.github.com> Date: Sat, 2 May 2026 13:45:16 +0900 Subject: [PATCH 1/3] Fix issue #16: OpenAPI response body type --- ...points.Extensions.CsvHelper.csproj.lscache | 1 + .../Internal/EndpointRegistry.cs | 20 +- .../AxisEndpoints.Example.csproj.lscache | 1 - .../AxisEndpoints.Tests.csproj | 1 + .../AxisEndpoints.Tests.csproj.lscache | 210 ++++++++++++++++-- .../Integration/EndpointIntegrationTests.cs | 15 ++ .../Integration/TestWebApplicationFactory.cs | 3 + 7 files changed, 225 insertions(+), 26 deletions(-) diff --git a/src/AxisEndpoints.Extensions.CsvHelper/AxisEndpoints.Extensions.CsvHelper.csproj.lscache b/src/AxisEndpoints.Extensions.CsvHelper/AxisEndpoints.Extensions.CsvHelper.csproj.lscache index 1a6c8de..6484baa 100644 --- a/src/AxisEndpoints.Extensions.CsvHelper/AxisEndpoints.Extensions.CsvHelper.csproj.lscache +++ b/src/AxisEndpoints.Extensions.CsvHelper/AxisEndpoints.Extensions.CsvHelper.csproj.lscache @@ -58,6 +58,7 @@ CsvBindingException.cs CsvBindingExceptionFilter.cs CsvRequest.cs CsvResponse.cs +ICsvBindingErrors.cs obj/Debug/net10.0/ .NETCoreApp,Version=v10.0.AssemblyAttributes.cs AxisEndpoints.Extensions.CsvHelper.AssemblyInfo.cs diff --git a/src/AxisEndpoints/Internal/EndpointRegistry.cs b/src/AxisEndpoints/Internal/EndpointRegistry.cs index 81aaaec..ef0b329 100644 --- a/src/AxisEndpoints/Internal/EndpointRegistry.cs +++ b/src/AxisEndpoints/Internal/EndpointRegistry.cs @@ -474,13 +474,14 @@ EndpointConfiguration config // When TResult is Response, register Produces(200) automatically from the // inferred response type. When TResult is IResult, skip auto-registration — the // caller is responsible for declaring response shapes via ProducesSuccess/ProducesError. + var openApiResponseType = GetOpenApiResponseType(config.ResponseType); var isIResult = - config.ResponseType == typeof(IResult) - || typeof(IResult).IsAssignableFrom(config.ResponseType); + openApiResponseType == typeof(IResult) + || typeof(IResult).IsAssignableFrom(openApiResponseType); if (!isIResult) { - routeBuilder.Produces(200, config.ResponseType); + routeBuilder.Produces(200, openApiResponseType); } } @@ -490,6 +491,19 @@ EndpointConfiguration config } } + private static Type GetOpenApiResponseType(Type responseType) + { + if ( + responseType.IsGenericType + && responseType.GetGenericTypeDefinition() == typeof(Response<>) + ) + { + return responseType.GetGenericArguments()[0]; + } + + return responseType; + } + private static void ApplyGroupMetadata( RouteGroupBuilder groupBuilder, EndpointGroupConfiguration config diff --git a/tests/AxisEndpoints.Example/AxisEndpoints.Example.csproj.lscache b/tests/AxisEndpoints.Example/AxisEndpoints.Example.csproj.lscache index 96b621f..5c8a91b 100644 --- a/tests/AxisEndpoints.Example/AxisEndpoints.Example.csproj.lscache +++ b/tests/AxisEndpoints.Example/AxisEndpoints.Example.csproj.lscache @@ -73,7 +73,6 @@ Features/ FindById/ FindByIdEndpoint.cs FindByIdRequest.cs - FindUserByIdRequest.cs ImportFromCsv/ImportFromCsvEndpoint.cs List/ ListUsersEndpoint.cs diff --git a/tests/AxisEndpoints.Tests/AxisEndpoints.Tests.csproj b/tests/AxisEndpoints.Tests/AxisEndpoints.Tests.csproj index 1fb715b..dbf6f0e 100644 --- a/tests/AxisEndpoints.Tests/AxisEndpoints.Tests.csproj +++ b/tests/AxisEndpoints.Tests/AxisEndpoints.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/tests/AxisEndpoints.Tests/AxisEndpoints.Tests.csproj.lscache b/tests/AxisEndpoints.Tests/AxisEndpoints.Tests.csproj.lscache index a5c9fa1..adf0896 100644 --- a/tests/AxisEndpoints.Tests/AxisEndpoints.Tests.csproj.lscache +++ b/tests/AxisEndpoints.Tests/AxisEndpoints.Tests.csproj.lscache @@ -39,7 +39,7 @@ TemporaryDependencyNodeTargetIdentifier=net10.0 /define:TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NET10_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER /highentropyva+ /nullable:enable -/features:"InterceptorsNamespaces=;Microsoft.Extensions.Validation.Generated" +/features:"InterceptorsNamespaces=;Microsoft.AspNetCore.OpenApi.Generated;;Microsoft.Extensions.Validation.Generated" /debug+ /debug:portable /filealign:512 @@ -54,14 +54,186 @@ TemporaryDependencyNodeTargetIdentifier=net10.0 /warnaserror+:NU1605,SYSLIB0011 [sourceFiles] -/microsoft.net.test.sdk/17.14.1/build/net8.0/Microsoft.NET.Test.Sdk.Program.cs +../../.dotnet-cli/.nuget/packages/microsoft.net.test.sdk/17.14.1/build/net8.0/Microsoft.NET.Test.Sdk.Program.cs +Integration/ + EndpointIntegrationTests.cs + TestEndpoints.cs + TestWebApplicationFactory.cs obj/Debug/net10.0/ .NETCoreApp,Version=v10.0.AssemblyAttributes.cs AxisEndpoints.Tests.AssemblyInfo.cs AxisEndpoints.Tests.GlobalUsings.g.cs -UnitTest1.cs +Unit/ + EndpointConfigurationTests.cs + EndpointGroupConfigurationTests.cs + ResponseExecutorTests.cs + ServiceRegistrationTests.cs [metadataReferences] +../../.dotnet-cli/.nuget/packages/ + fluentassertions/8.9.0/lib/net6.0/FluentAssertions.dll + microsoft.aspnetcore.mvc.testing/10.0.7/lib/net10.0/Microsoft.AspNetCore.Mvc.Testing.dll + microsoft.aspnetcore.openapi/10.0.0/lib/net10.0/Microsoft.AspNetCore.OpenApi.dll + microsoft.aspnetcore.testhost/10.0.7/lib/net10.0/Microsoft.AspNetCore.TestHost.dll + microsoft.codecoverage/17.14.1/lib/net8.0/Microsoft.VisualStudio.CodeCoverage.Shim.dll + microsoft.extensions.configuration.abstractions/10.0.7/lib/net10.0/Microsoft.Extensions.Configuration.Abstractions.dll + microsoft.extensions.configuration.binder/10.0.7/lib/net10.0/Microsoft.Extensions.Configuration.Binder.dll + microsoft.extensions.configuration.commandline/10.0.7/lib/net10.0/Microsoft.Extensions.Configuration.CommandLine.dll + microsoft.extensions.configuration.environmentvariables/10.0.7/lib/net10.0/Microsoft.Extensions.Configuration.EnvironmentVariables.dll + microsoft.extensions.configuration.fileextensions/10.0.7/lib/net10.0/Microsoft.Extensions.Configuration.FileExtensions.dll + microsoft.extensions.configuration.json/10.0.7/lib/net10.0/Microsoft.Extensions.Configuration.Json.dll + microsoft.extensions.configuration.usersecrets/10.0.7/lib/net10.0/Microsoft.Extensions.Configuration.UserSecrets.dll + microsoft.extensions.configuration/10.0.7/lib/net10.0/Microsoft.Extensions.Configuration.dll + microsoft.extensions.dependencyinjection.abstractions/10.0.7/lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll + microsoft.extensions.dependencyinjection/10.0.7/lib/net10.0/Microsoft.Extensions.DependencyInjection.dll + microsoft.extensions.dependencymodel/10.0.7/lib/net10.0/Microsoft.Extensions.DependencyModel.dll + microsoft.extensions.diagnostics.abstractions/10.0.7/lib/net10.0/Microsoft.Extensions.Diagnostics.Abstractions.dll + microsoft.extensions.diagnostics/10.0.7/lib/net10.0/Microsoft.Extensions.Diagnostics.dll + microsoft.extensions.fileproviders.abstractions/10.0.7/lib/net10.0/Microsoft.Extensions.FileProviders.Abstractions.dll + microsoft.extensions.fileproviders.physical/10.0.7/lib/net10.0/Microsoft.Extensions.FileProviders.Physical.dll + microsoft.extensions.filesystemglobbing/10.0.7/lib/net10.0/Microsoft.Extensions.FileSystemGlobbing.dll + microsoft.extensions.hosting.abstractions/10.0.7/lib/net10.0/Microsoft.Extensions.Hosting.Abstractions.dll + microsoft.extensions.hosting/10.0.7/lib/net10.0/Microsoft.Extensions.Hosting.dll + microsoft.extensions.logging.abstractions/10.0.7/lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll + microsoft.extensions.logging.configuration/10.0.7/lib/net10.0/Microsoft.Extensions.Logging.Configuration.dll + microsoft.extensions.logging.console/10.0.7/lib/net10.0/Microsoft.Extensions.Logging.Console.dll + microsoft.extensions.logging.debug/10.0.7/lib/net10.0/Microsoft.Extensions.Logging.Debug.dll + microsoft.extensions.logging.eventlog/10.0.7/lib/net10.0/Microsoft.Extensions.Logging.EventLog.dll + microsoft.extensions.logging.eventsource/10.0.7/lib/net10.0/Microsoft.Extensions.Logging.EventSource.dll + microsoft.extensions.logging/10.0.7/lib/net10.0/Microsoft.Extensions.Logging.dll + microsoft.extensions.options.configurationextensions/10.0.7/lib/net10.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll + microsoft.extensions.options/10.0.7/lib/net10.0/Microsoft.Extensions.Options.dll + microsoft.extensions.primitives/10.0.7/lib/net10.0/Microsoft.Extensions.Primitives.dll + microsoft.openapi/2.0.0/lib/net8.0/Microsoft.OpenApi.dll + microsoft.testplatform.testhost/17.14.1/lib/net8.0/ + Microsoft.TestPlatform.CommunicationUtilities.dll + Microsoft.TestPlatform.CoreUtilities.dll + Microsoft.TestPlatform.CrossPlatEngine.dll + Microsoft.TestPlatform.PlatformAbstractions.dll + Microsoft.TestPlatform.Utilities.dll + Microsoft.VisualStudio.TestPlatform.Common.dll + Microsoft.VisualStudio.TestPlatform.ObjectModel.dll + testhost.dll + newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll + system.diagnostics.eventlog/10.0.7/lib/net10.0/System.Diagnostics.EventLog.dll + xunit.abstractions/2.0.3/lib/netstandard2.0/xunit.abstractions.dll + xunit.assert/2.9.3/lib/net6.0/xunit.assert.dll + xunit.extensibility.core/2.9.3/lib/netstandard1.1/xunit.core.dll + xunit.extensibility.execution/2.9.3/lib/netstandard1.1/xunit.execution.dotnet.dll +../../src/AxisEndpoints/obj/Debug/net10.0/ref/AxisEndpoints.dll +/packs/Microsoft.AspNetCore.App.Ref/10.0.2/ref/net10.0/ + Microsoft.AspNetCore.Antiforgery.dll + Microsoft.AspNetCore.Authentication.Abstractions.dll + Microsoft.AspNetCore.Authentication.BearerToken.dll + Microsoft.AspNetCore.Authentication.Cookies.dll + Microsoft.AspNetCore.Authentication.Core.dll + Microsoft.AspNetCore.Authentication.dll + Microsoft.AspNetCore.Authentication.OAuth.dll + Microsoft.AspNetCore.Authorization.dll + Microsoft.AspNetCore.Authorization.Policy.dll + Microsoft.AspNetCore.Components.Authorization.dll + Microsoft.AspNetCore.Components.dll + Microsoft.AspNetCore.Components.Endpoints.dll + Microsoft.AspNetCore.Components.Forms.dll + Microsoft.AspNetCore.Components.Server.dll + Microsoft.AspNetCore.Components.Web.dll + Microsoft.AspNetCore.Connections.Abstractions.dll + Microsoft.AspNetCore.CookiePolicy.dll + Microsoft.AspNetCore.Cors.dll + Microsoft.AspNetCore.Cryptography.Internal.dll + Microsoft.AspNetCore.Cryptography.KeyDerivation.dll + Microsoft.AspNetCore.DataProtection.Abstractions.dll + Microsoft.AspNetCore.DataProtection.dll + Microsoft.AspNetCore.DataProtection.Extensions.dll + Microsoft.AspNetCore.Diagnostics.Abstractions.dll + Microsoft.AspNetCore.Diagnostics.dll + Microsoft.AspNetCore.Diagnostics.HealthChecks.dll + Microsoft.AspNetCore.dll + Microsoft.AspNetCore.HostFiltering.dll + Microsoft.AspNetCore.Hosting.Abstractions.dll + Microsoft.AspNetCore.Hosting.dll + Microsoft.AspNetCore.Hosting.Server.Abstractions.dll + Microsoft.AspNetCore.Html.Abstractions.dll + Microsoft.AspNetCore.Http.Abstractions.dll + Microsoft.AspNetCore.Http.Connections.Common.dll + Microsoft.AspNetCore.Http.Connections.dll + Microsoft.AspNetCore.Http.dll + Microsoft.AspNetCore.Http.Extensions.dll + Microsoft.AspNetCore.Http.Features.dll + Microsoft.AspNetCore.Http.Results.dll + Microsoft.AspNetCore.HttpLogging.dll + Microsoft.AspNetCore.HttpOverrides.dll + Microsoft.AspNetCore.HttpsPolicy.dll + Microsoft.AspNetCore.Identity.dll + Microsoft.AspNetCore.Localization.dll + Microsoft.AspNetCore.Localization.Routing.dll + Microsoft.AspNetCore.Metadata.dll + Microsoft.AspNetCore.Mvc.Abstractions.dll + Microsoft.AspNetCore.Mvc.ApiExplorer.dll + Microsoft.AspNetCore.Mvc.Core.dll + Microsoft.AspNetCore.Mvc.Cors.dll + Microsoft.AspNetCore.Mvc.DataAnnotations.dll + Microsoft.AspNetCore.Mvc.dll + Microsoft.AspNetCore.Mvc.Formatters.Json.dll + Microsoft.AspNetCore.Mvc.Formatters.Xml.dll + Microsoft.AspNetCore.Mvc.Localization.dll + Microsoft.AspNetCore.Mvc.Razor.dll + Microsoft.AspNetCore.Mvc.RazorPages.dll + Microsoft.AspNetCore.Mvc.TagHelpers.dll + Microsoft.AspNetCore.Mvc.ViewFeatures.dll + Microsoft.AspNetCore.OutputCaching.dll + Microsoft.AspNetCore.RateLimiting.dll + Microsoft.AspNetCore.Razor.dll + Microsoft.AspNetCore.Razor.Runtime.dll + Microsoft.AspNetCore.RequestDecompression.dll + Microsoft.AspNetCore.ResponseCaching.Abstractions.dll + Microsoft.AspNetCore.ResponseCaching.dll + Microsoft.AspNetCore.ResponseCompression.dll + Microsoft.AspNetCore.Rewrite.dll + Microsoft.AspNetCore.Routing.Abstractions.dll + Microsoft.AspNetCore.Routing.dll + Microsoft.AspNetCore.Server.HttpSys.dll + Microsoft.AspNetCore.Server.IIS.dll + Microsoft.AspNetCore.Server.IISIntegration.dll + Microsoft.AspNetCore.Server.Kestrel.Core.dll + Microsoft.AspNetCore.Server.Kestrel.dll + Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.dll + Microsoft.AspNetCore.Server.Kestrel.Transport.Quic.dll + Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.dll + Microsoft.AspNetCore.Session.dll + Microsoft.AspNetCore.SignalR.Common.dll + Microsoft.AspNetCore.SignalR.Core.dll + Microsoft.AspNetCore.SignalR.dll + Microsoft.AspNetCore.SignalR.Protocols.Json.dll + Microsoft.AspNetCore.StaticAssets.dll + Microsoft.AspNetCore.StaticFiles.dll + Microsoft.AspNetCore.WebSockets.dll + Microsoft.AspNetCore.WebUtilities.dll + Microsoft.Extensions.Caching.Abstractions.dll + Microsoft.Extensions.Caching.Memory.dll + Microsoft.Extensions.Configuration.Ini.dll + Microsoft.Extensions.Configuration.KeyPerFile.dll + Microsoft.Extensions.Configuration.Xml.dll + Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.dll + Microsoft.Extensions.Diagnostics.HealthChecks.dll + Microsoft.Extensions.Features.dll + Microsoft.Extensions.FileProviders.Composite.dll + Microsoft.Extensions.FileProviders.Embedded.dll + Microsoft.Extensions.Http.dll + Microsoft.Extensions.Identity.Core.dll + Microsoft.Extensions.Identity.Stores.dll + Microsoft.Extensions.Localization.Abstractions.dll + Microsoft.Extensions.Localization.dll + Microsoft.Extensions.Logging.TraceSource.dll + Microsoft.Extensions.ObjectPool.dll + Microsoft.Extensions.Options.DataAnnotations.dll + Microsoft.Extensions.Validation.dll + Microsoft.Extensions.WebEncoders.dll + Microsoft.JSInterop.dll + Microsoft.Net.Http.Headers.dll + System.Formats.Cbor.dll + System.Security.Cryptography.Xml.dll + System.Threading.RateLimiting.dll /packs/Microsoft.NETCore.App.Ref/10.0.2/ref/net10.0/ Microsoft.CSharp.dll Microsoft.VisualBasic.Core.dll @@ -230,24 +402,21 @@ UnitTest1.cs System.Xml.XPath.dll System.Xml.XPath.XDocument.dll WindowsBase.dll -/ - microsoft.codecoverage/17.14.1/lib/net8.0/Microsoft.VisualStudio.CodeCoverage.Shim.dll - microsoft.testplatform.testhost/17.14.1/lib/net8.0/ - Microsoft.TestPlatform.CommunicationUtilities.dll - Microsoft.TestPlatform.CoreUtilities.dll - Microsoft.TestPlatform.CrossPlatEngine.dll - Microsoft.TestPlatform.PlatformAbstractions.dll - Microsoft.TestPlatform.Utilities.dll - Microsoft.VisualStudio.TestPlatform.Common.dll - Microsoft.VisualStudio.TestPlatform.ObjectModel.dll - testhost.dll - newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll - xunit.abstractions/2.0.3/lib/netstandard2.0/xunit.abstractions.dll - xunit.assert/2.9.3/lib/net6.0/xunit.assert.dll - xunit.extensibility.core/2.9.3/lib/netstandard1.1/xunit.core.dll - xunit.extensibility.execution/2.9.3/lib/netstandard1.1/xunit.execution.dotnet.dll [analyzerReferences] +../../.dotnet-cli/.nuget/packages/ + microsoft.aspnetcore.openapi/10.0.0/analyzers/dotnet/cs/Microsoft.AspNetCore.OpenApi.SourceGenerators.dll + microsoft.extensions.logging.abstractions/10.0.7/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Logging.Generators.dll + microsoft.extensions.options/10.0.7/analyzers/dotnet/roslyn4.4/cs/Microsoft.Extensions.Options.SourceGeneration.dll + xunit.analyzers/1.18.0/analyzers/dotnet/cs/ + xunit.analyzers.dll + xunit.analyzers.fixes.dll +/packs/Microsoft.AspNetCore.App.Ref/10.0.2/analyzers/dotnet/cs/ + Microsoft.AspNetCore.App.Analyzers.dll + Microsoft.AspNetCore.App.CodeFixes.dll + Microsoft.AspNetCore.App.SourceGenerators.dll + Microsoft.AspNetCore.Components.Analyzers.dll + Microsoft.Extensions.Validation.ValidationsGenerator.dll /packs/Microsoft.NETCore.App.Ref/10.0.2/analyzers/dotnet/cs/ Microsoft.Interop.ComInterfaceGenerator.dll Microsoft.Interop.JavaScript.JSImportGenerator.dll @@ -258,9 +427,6 @@ UnitTest1.cs /sdk/10.0.102/Sdks/Microsoft.NET.Sdk/analyzers/ Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll Microsoft.CodeAnalysis.NetAnalyzers.dll -/xunit.analyzers/1.18.0/analyzers/dotnet/cs/ - xunit.analyzers.dll - xunit.analyzers.fixes.dll [analyzerConfigFiles] /sdk/10.0.102/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_default.globalconfig diff --git a/tests/AxisEndpoints.Tests/Integration/EndpointIntegrationTests.cs b/tests/AxisEndpoints.Tests/Integration/EndpointIntegrationTests.cs index 1cda29f..4a14ae6 100644 --- a/tests/AxisEndpoints.Tests/Integration/EndpointIntegrationTests.cs +++ b/tests/AxisEndpoints.Tests/Integration/EndpointIntegrationTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json.Nodes; using FluentAssertions; namespace AxisEndpoints.Tests.Integration; @@ -69,4 +70,18 @@ public async Task GetGrouped_Returns200WithGroupPrefix() body.Should().NotBeNull(); body!.Message.Should().Be("Grouped!"); } + + [Fact] + public async Task OpenApi_UsesResponseBodyTypeInsteadOfResponseWrapper() + { + var document = await _client.GetFromJsonAsync("/openapi/v1.json"); + + document.Should().NotBeNull(); + + var schema = document!["paths"]?["/hello"]?["get"]?["responses"]?["200"]?["content"]?["application/json"]?["schema"]; + schema.Should().NotBeNull(); + schema!["$ref"]!.GetValue().Should().Be("#/components/schemas/HelloResponse"); + + document["components"]?["schemas"]?["ResponseOfHelloResponse"].Should().BeNull(); + } } diff --git a/tests/AxisEndpoints.Tests/Integration/TestWebApplicationFactory.cs b/tests/AxisEndpoints.Tests/Integration/TestWebApplicationFactory.cs index 3798c88..ae6385f 100644 --- a/tests/AxisEndpoints.Tests/Integration/TestWebApplicationFactory.cs +++ b/tests/AxisEndpoints.Tests/Integration/TestWebApplicationFactory.cs @@ -2,6 +2,7 @@ using AxisEndpoints.Extensions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace AxisEndpoints.Tests.Integration; @@ -17,9 +18,11 @@ public async Task InitializeAsync() { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); + builder.Services.AddOpenApi(); builder.Services.AddAxisEndpoints(_testAssembly); _app = builder.Build(); + _app.MapOpenApi(); _app.MapAxisEndpoints(_testAssembly); await _app.StartAsync(); From 22d2c79eb2071d696b101da13dcd1a886f399aeb Mon Sep 17 00:00:00 2001 From: sheepla <62412884+sheepla@users.noreply.github.com> Date: Sat, 2 May 2026 14:05:40 +0900 Subject: [PATCH 2/3] Fix OpenAPI response body schemas --- .../Internal/EndpointRegistry.cs | 5 +- .../AxisEndpoints.Example.Tests.csproj | 1 + .../ExampleWebApplicationFactory.cs | 3 ++ .../OpenApiTests.cs | 47 +++++++++++++++++++ .../Integration/EndpointIntegrationTests.cs | 18 +++++++ 5 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 tests/AxisEndpoints.Example.Tests/OpenApiTests.cs diff --git a/src/AxisEndpoints/Internal/EndpointRegistry.cs b/src/AxisEndpoints/Internal/EndpointRegistry.cs index ef0b329..6aa5efa 100644 --- a/src/AxisEndpoints/Internal/EndpointRegistry.cs +++ b/src/AxisEndpoints/Internal/EndpointRegistry.cs @@ -475,11 +475,14 @@ EndpointConfiguration config // inferred response type. When TResult is IResult, skip auto-registration — the // caller is responsible for declaring response shapes via ProducesSuccess/ProducesError. var openApiResponseType = GetOpenApiResponseType(config.ResponseType); + var isNoBodyResponse = + openApiResponseType == typeof(EmptyResponse) + || typeof(EmptyResponse).IsAssignableFrom(openApiResponseType); var isIResult = openApiResponseType == typeof(IResult) || typeof(IResult).IsAssignableFrom(openApiResponseType); - if (!isIResult) + if (!isIResult && !isNoBodyResponse) { routeBuilder.Produces(200, openApiResponseType); } diff --git a/tests/AxisEndpoints.Example.Tests/AxisEndpoints.Example.Tests.csproj b/tests/AxisEndpoints.Example.Tests/AxisEndpoints.Example.Tests.csproj index 0d29ef5..dc52533 100644 --- a/tests/AxisEndpoints.Example.Tests/AxisEndpoints.Example.Tests.csproj +++ b/tests/AxisEndpoints.Example.Tests/AxisEndpoints.Example.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/tests/AxisEndpoints.Example.Tests/ExampleWebApplicationFactory.cs b/tests/AxisEndpoints.Example.Tests/ExampleWebApplicationFactory.cs index e1190f4..4ff8d02 100644 --- a/tests/AxisEndpoints.Example.Tests/ExampleWebApplicationFactory.cs +++ b/tests/AxisEndpoints.Example.Tests/ExampleWebApplicationFactory.cs @@ -4,6 +4,7 @@ using AxisEndpoints.Extensions.CsvHelper; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace AxisEndpoints.Example.Tests; @@ -22,10 +23,12 @@ public async Task InitializeAsync() var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); + builder.Services.AddOpenApi(); builder.Services.AddAxisEndpoints(ExampleAssembly); builder.Services.AddAxisEndpointsCsvHelper(); _app = builder.Build(); + _app.MapOpenApi(); _app.MapAxisEndpoints(ExampleAssembly); await _app.StartAsync(); diff --git a/tests/AxisEndpoints.Example.Tests/OpenApiTests.cs b/tests/AxisEndpoints.Example.Tests/OpenApiTests.cs new file mode 100644 index 0000000..26ac861 --- /dev/null +++ b/tests/AxisEndpoints.Example.Tests/OpenApiTests.cs @@ -0,0 +1,47 @@ +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using FluentAssertions; + +namespace AxisEndpoints.Example.Tests; + +public class OpenApiTests : IClassFixture +{ + private readonly HttpClient _client; + + public OpenApiTests(ExampleWebApplicationFactory factory) + { + _client = factory.Client; + } + + [Fact] + public async Task OpenApi_UsesBodyTypeForJsonResponseEndpoints() + { + var document = await _client.GetFromJsonAsync("/openapi/v1.json"); + + document.Should().NotBeNull(); + + var schema = document!["paths"]?["/health"]?["get"]?["responses"]?["200"]?["content"]?["application/json"]?["schema"]; + schema.Should().NotBeNull(); + schema!["$ref"]!.GetValue().Should().Be("#/components/schemas/HealthResponse"); + + document["components"]?["schemas"]?["ResponseOfHealthResponse"].Should().BeNull(); + } + + [Fact] + public async Task OpenApi_DoesNotRegisterEmptyResponseAsJsonBody() + { + var document = await _client.GetFromJsonAsync("/openapi/v1.json"); + + document.Should().NotBeNull(); + + var responses = document!["paths"]?["/api/users/{id}"]?["delete"]?["responses"]?.AsObject(); + responses.Should().NotBeNull(); + + var successResponse = responses!["200"] ?? responses["204"]; + successResponse.Should().NotBeNull(); + successResponse!["content"]?["application/json"]?["schema"].Should().BeNull(); + + document["components"]?["schemas"]?["ResponseOfEmptyResponse"].Should().BeNull(); + document["components"]?["schemas"]?["EmptyResponse"].Should().BeNull(); + } +} diff --git a/tests/AxisEndpoints.Tests/Integration/EndpointIntegrationTests.cs b/tests/AxisEndpoints.Tests/Integration/EndpointIntegrationTests.cs index 4a14ae6..899f29f 100644 --- a/tests/AxisEndpoints.Tests/Integration/EndpointIntegrationTests.cs +++ b/tests/AxisEndpoints.Tests/Integration/EndpointIntegrationTests.cs @@ -84,4 +84,22 @@ public async Task OpenApi_UsesResponseBodyTypeInsteadOfResponseWrapper() document["components"]?["schemas"]?["ResponseOfHelloResponse"].Should().BeNull(); } + + [Fact] + public async Task OpenApi_DoesNotRegisterEmptyResponseAsJsonBody() + { + var document = await _client.GetFromJsonAsync("/openapi/v1.json"); + + document.Should().NotBeNull(); + + var responses = document!["paths"]?["/items/{id}"]?["delete"]?["responses"]?.AsObject(); + responses.Should().NotBeNull(); + + var successResponse = responses!["200"] ?? responses["204"]; + successResponse.Should().NotBeNull(); + successResponse!["content"]?["application/json"]?["schema"].Should().BeNull(); + + document["components"]?["schemas"]?["ResponseOfEmptyResponse"].Should().BeNull(); + document["components"]?["schemas"]?["EmptyResponse"].Should().BeNull(); + } } From ef44744e62a971047f6a88de13efa2f6ba2623e0 Mon Sep 17 00:00:00 2001 From: sheepla <62412884+sheepla@users.noreply.github.com> Date: Sat, 2 May 2026 14:16:53 +0900 Subject: [PATCH 3/3] Fix OpenAPI success status metadata --- src/AxisEndpoints/IEndpointConfiguration.cs | 14 +++++---- .../Internal/EndpointRegistry.cs | 30 +++++++++++++++---- .../OpenApiTests.cs | 11 ++++++- .../Users/Create/CreateUserEndpoint.cs | 1 + .../Users/Delete/DeleteUserEndpoint.cs | 2 ++ .../ImportFromCsv/ImportFromCsvEndpoint.cs | 4 ++- .../Integration/EndpointIntegrationTests.cs | 19 +++++++++++- .../Integration/TestEndpoints.cs | 22 +++++++++++++- 8 files changed, 87 insertions(+), 16 deletions(-) diff --git a/src/AxisEndpoints/IEndpointConfiguration.cs b/src/AxisEndpoints/IEndpointConfiguration.cs index e3a9edb..ef0bcb1 100644 --- a/src/AxisEndpoints/IEndpointConfiguration.cs +++ b/src/AxisEndpoints/IEndpointConfiguration.cs @@ -40,14 +40,16 @@ IEndpointConfiguration Group() IEndpointConfiguration Summary(string summary); IEndpointConfiguration Description(string description); - // OpenAPI response metadata for IResult-returning endpoints. - // When HandleAsync returns Response, the 200 schema is inferred automatically and - // these methods are not needed. Use them when the return type is IResult to declare the - // success and error response shapes explicitly. + // OpenAPI response metadata. + // When HandleAsync returns Response, a 200 success response is inferred automatically. + // Use ProducesSuccess to override that default when the endpoint returns a different success + // status code such as 201 or 204. When HandleAsync returns IResult, use ProducesSuccess and + // ProducesError to declare the response shapes explicitly. /// - /// Declares a success response for OpenAPI. Use when HandleAsync returns - /// and the 200 schema cannot be inferred automatically. + /// Declares a success response for OpenAPI. Use this to override the default 200 response + /// metadata for endpoints or to declare success metadata when + /// HandleAsync returns . /// IEndpointConfiguration ProducesSuccess(HttpStatusCode statusCode = HttpStatusCode.OK); diff --git a/src/AxisEndpoints/Internal/EndpointRegistry.cs b/src/AxisEndpoints/Internal/EndpointRegistry.cs index 6aa5efa..463a0d6 100644 --- a/src/AxisEndpoints/Internal/EndpointRegistry.cs +++ b/src/AxisEndpoints/Internal/EndpointRegistry.cs @@ -475,22 +475,22 @@ EndpointConfiguration config // inferred response type. When TResult is IResult, skip auto-registration — the // caller is responsible for declaring response shapes via ProducesSuccess/ProducesError. var openApiResponseType = GetOpenApiResponseType(config.ResponseType); - var isNoBodyResponse = - openApiResponseType == typeof(EmptyResponse) - || typeof(EmptyResponse).IsAssignableFrom(openApiResponseType); var isIResult = openApiResponseType == typeof(IResult) || typeof(IResult).IsAssignableFrom(openApiResponseType); + var hasExplicitSuccessResponse = config.ExtraProducesEntries.Any(entry => + entry.StatusCode is >= 200 and < 300 + ); - if (!isIResult && !isNoBodyResponse) + if (!isIResult && !hasExplicitSuccessResponse) { - routeBuilder.Produces(200, openApiResponseType); + RegisterProduces(routeBuilder, 200, openApiResponseType); } } foreach (var (statusCode, bodyType) in config.ExtraProducesEntries) { - routeBuilder.Produces(statusCode, bodyType); + RegisterProduces(routeBuilder, statusCode, bodyType); } } @@ -507,6 +507,24 @@ private static Type GetOpenApiResponseType(Type responseType) return responseType; } + private static void RegisterProduces( + RouteHandlerBuilder routeBuilder, + int statusCode, + Type bodyType + ) + { + var isNoBodyResponse = + bodyType == typeof(EmptyResponse) || typeof(EmptyResponse).IsAssignableFrom(bodyType); + + if (isNoBodyResponse) + { + routeBuilder.Produces(statusCode); + return; + } + + routeBuilder.Produces(statusCode, bodyType); + } + private static void ApplyGroupMetadata( RouteGroupBuilder groupBuilder, EndpointGroupConfiguration config diff --git a/tests/AxisEndpoints.Example.Tests/OpenApiTests.cs b/tests/AxisEndpoints.Example.Tests/OpenApiTests.cs index 26ac861..2b24c81 100644 --- a/tests/AxisEndpoints.Example.Tests/OpenApiTests.cs +++ b/tests/AxisEndpoints.Example.Tests/OpenApiTests.cs @@ -1,6 +1,7 @@ using System.Net.Http.Json; using System.Text.Json.Nodes; using FluentAssertions; +using AxisEndpoints.Example.Features.Health; namespace AxisEndpoints.Example.Tests; @@ -25,6 +26,12 @@ public async Task OpenApi_UsesBodyTypeForJsonResponseEndpoints() schema!["$ref"]!.GetValue().Should().Be("#/components/schemas/HealthResponse"); document["components"]?["schemas"]?["ResponseOfHealthResponse"].Should().BeNull(); + + var createdSchema = + document["paths"]?["/api/users"]?["post"]?["responses"]?["201"]?["content"]?["application/json"]?["schema"] + ?? document["paths"]?["/api/users/"]?["post"]?["responses"]?["201"]?["content"]?["application/json"]?["schema"]; + createdSchema.Should().NotBeNull(); + createdSchema!["$ref"]!.GetValue().Should().Be("#/components/schemas/UserResponse"); } [Fact] @@ -37,7 +44,9 @@ public async Task OpenApi_DoesNotRegisterEmptyResponseAsJsonBody() var responses = document!["paths"]?["/api/users/{id}"]?["delete"]?["responses"]?.AsObject(); responses.Should().NotBeNull(); - var successResponse = responses!["200"] ?? responses["204"]; + responses!.ContainsKey("200").Should().BeFalse(); + + var successResponse = responses["204"]; successResponse.Should().NotBeNull(); successResponse!["content"]?["application/json"]?["schema"].Should().BeNull(); diff --git a/tests/AxisEndpoints.Example/Features/Users/Create/CreateUserEndpoint.cs b/tests/AxisEndpoints.Example/Features/Users/Create/CreateUserEndpoint.cs index b5c3f26..0303847 100644 --- a/tests/AxisEndpoints.Example/Features/Users/Create/CreateUserEndpoint.cs +++ b/tests/AxisEndpoints.Example/Features/Users/Create/CreateUserEndpoint.cs @@ -20,6 +20,7 @@ public void Configure(IEndpointConfiguration config) .Group() .Summary("Create a user") .Description("Creates a new user. Returns 400 if validation fails.") + .ProducesSuccess(HttpStatusCode.Created) .AddFilter(); } diff --git a/tests/AxisEndpoints.Example/Features/Users/Delete/DeleteUserEndpoint.cs b/tests/AxisEndpoints.Example/Features/Users/Delete/DeleteUserEndpoint.cs index cf64e1f..8f68ed2 100644 --- a/tests/AxisEndpoints.Example/Features/Users/Delete/DeleteUserEndpoint.cs +++ b/tests/AxisEndpoints.Example/Features/Users/Delete/DeleteUserEndpoint.cs @@ -1,4 +1,5 @@ using AxisEndpoints; +using System.Net; namespace AxisEndpoints.Example.Features.Users.Delete; @@ -21,6 +22,7 @@ public void Configure(IEndpointConfiguration config) "Permanently removes a user. " + "In a real application, restrict this to the Admin role via .RequireAuthorization(\"Admin\")." ) + .ProducesSuccess(HttpStatusCode.NoContent) .AllowAnonymous(); } diff --git a/tests/AxisEndpoints.Example/Features/Users/ImportFromCsv/ImportFromCsvEndpoint.cs b/tests/AxisEndpoints.Example/Features/Users/ImportFromCsv/ImportFromCsvEndpoint.cs index 907d9be..163e30f 100644 --- a/tests/AxisEndpoints.Example/Features/Users/ImportFromCsv/ImportFromCsvEndpoint.cs +++ b/tests/AxisEndpoints.Example/Features/Users/ImportFromCsv/ImportFromCsvEndpoint.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Net; using AxisEndpoints.Extensions.CsvHelper; using CsvHelper.Configuration.Attributes; @@ -50,7 +51,8 @@ public void Configure(IEndpointConfiguration config) .Description( "Accepts a CSV file with columns: name, email, role. " + "Validates each row and returns a ValidationProblem on failure." - ); + ) + .ProducesSuccess(HttpStatusCode.NoContent); } public Task> HandleAsync( diff --git a/tests/AxisEndpoints.Tests/Integration/EndpointIntegrationTests.cs b/tests/AxisEndpoints.Tests/Integration/EndpointIntegrationTests.cs index 899f29f..e1ffc77 100644 --- a/tests/AxisEndpoints.Tests/Integration/EndpointIntegrationTests.cs +++ b/tests/AxisEndpoints.Tests/Integration/EndpointIntegrationTests.cs @@ -71,6 +71,17 @@ public async Task GetGrouped_Returns200WithGroupPrefix() body!.Message.Should().Be("Grouped!"); } + [Fact] + public async Task PostCreatedHello_Returns201WithMessage() + { + var response = await _client.PostAsync("/hello-created", content: null); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var body = await response.Content.ReadFromJsonAsync(); + body.Should().NotBeNull(); + body!.Message.Should().Be("Created!"); + } + [Fact] public async Task OpenApi_UsesResponseBodyTypeInsteadOfResponseWrapper() { @@ -83,6 +94,10 @@ public async Task OpenApi_UsesResponseBodyTypeInsteadOfResponseWrapper() schema!["$ref"]!.GetValue().Should().Be("#/components/schemas/HelloResponse"); document["components"]?["schemas"]?["ResponseOfHelloResponse"].Should().BeNull(); + + var createdSchema = document["paths"]?["/hello-created"]?["post"]?["responses"]?["201"]?["content"]?["application/json"]?["schema"]; + createdSchema.Should().NotBeNull(); + createdSchema!["$ref"]!.GetValue().Should().Be("#/components/schemas/HelloResponse"); } [Fact] @@ -95,7 +110,9 @@ public async Task OpenApi_DoesNotRegisterEmptyResponseAsJsonBody() var responses = document!["paths"]?["/items/{id}"]?["delete"]?["responses"]?.AsObject(); responses.Should().NotBeNull(); - var successResponse = responses!["200"] ?? responses["204"]; + responses!.ContainsKey("200").Should().BeFalse(); + + var successResponse = responses["204"]; successResponse.Should().NotBeNull(); successResponse!["content"]?["application/json"]?["schema"].Should().BeNull(); diff --git a/tests/AxisEndpoints.Tests/Integration/TestEndpoints.cs b/tests/AxisEndpoints.Tests/Integration/TestEndpoints.cs index 56e3245..fc4894f 100644 --- a/tests/AxisEndpoints.Tests/Integration/TestEndpoints.cs +++ b/tests/AxisEndpoints.Tests/Integration/TestEndpoints.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Net; using Microsoft.AspNetCore.Mvc; namespace AxisEndpoints.Tests.Integration; @@ -58,7 +59,7 @@ public class DeleteItemEndpoint : IEndpoint(HttpStatusCode.NoContent); } public Task> HandleAsync(DeleteItemRequest request, CancellationToken cancel) @@ -69,6 +70,25 @@ public Task> HandleAsync(DeleteItemRequest request, Canc public record DeleteItemRequest([property: FromRoute] int Id); +public class CreatedHelloEndpoint : IEndpoint> +{ + public void Configure(IEndpointConfiguration config) + { + config.Post("/hello-created").ProducesSuccess(HttpStatusCode.Created); + } + + public Task> HandleAsync(CancellationToken cancel) + { + return Task.FromResult( + new Response + { + StatusCode = HttpStatusCode.Created, + Body = new HelloResponse("Created!") + } + ); + } +} + public class ApiGroup : IEndpointGroup { public void Configure(IEndpointGroupConfiguration config)