Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions src/AxisEndpoints/IEndpointConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,16 @@ IEndpointConfiguration Group<TGroup>()
IEndpointConfiguration Summary(string summary);
IEndpointConfiguration Description(string description);

// OpenAPI response metadata for IResult-returning endpoints.
// When HandleAsync returns Response<TBody>, 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<TBody>, 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.

/// <summary>
/// Declares a success response for OpenAPI. Use when HandleAsync returns <see cref="IResult"/>
/// and the 200 schema cannot be inferred automatically.
/// Declares a success response for OpenAPI. Use this to override the default 200 response
/// metadata for <see cref="Response{TBody}"/> endpoints or to declare success metadata when
/// HandleAsync returns <see cref="IResult"/>.
/// </summary>
IEndpointConfiguration ProducesSuccess<TBody>(HttpStatusCode statusCode = HttpStatusCode.OK);

Expand Down
45 changes: 40 additions & 5 deletions src/AxisEndpoints/Internal/EndpointRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
Type endpointType
)
{
var instance = System.Runtime.Serialization.FormatterServices.GetUninitializedObject(

Check warning on line 94 in src/AxisEndpoints/Internal/EndpointRegistry.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'FormatterServices' is obsolete: 'Formatter-based serialization is obsolete and should not be used.' (https://aka.ms/dotnet-warnings/SYSLIB0050)

Check warning on line 94 in src/AxisEndpoints/Internal/EndpointRegistry.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'FormatterServices' is obsolete: 'Formatter-based serialization is obsolete and should not be used.' (https://aka.ms/dotnet-warnings/SYSLIB0050)
endpointType
);

Expand Down Expand Up @@ -474,20 +474,55 @@
// When TResult is Response<TBody>, 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);
var hasExplicitSuccessResponse = config.ExtraProducesEntries.Any(entry =>
entry.StatusCode is >= 200 and < 300
);

if (!isIResult)
if (!isIResult && !hasExplicitSuccessResponse)
{
routeBuilder.Produces(200, config.ResponseType);
RegisterProduces(routeBuilder, 200, openApiResponseType);
}
}

foreach (var (statusCode, bodyType) in config.ExtraProducesEntries)
{
routeBuilder.Produces(statusCode, bodyType);
RegisterProduces(routeBuilder, statusCode, bodyType);
}
}

private static Type GetOpenApiResponseType(Type responseType)
{
if (
responseType.IsGenericType
&& responseType.GetGenericTypeDefinition() == typeof(Response<>)
)
{
return responseType.GetGenericArguments()[0];
}

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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.*" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down
56 changes: 56 additions & 0 deletions tests/AxisEndpoints.Example.Tests/OpenApiTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using FluentAssertions;
using AxisEndpoints.Example.Features.Health;

namespace AxisEndpoints.Example.Tests;

public class OpenApiTests : IClassFixture<ExampleWebApplicationFactory>
{
private readonly HttpClient _client;

public OpenApiTests(ExampleWebApplicationFactory factory)
{
_client = factory.Client;
}

[Fact]
public async Task OpenApi_UsesBodyTypeForJsonResponseEndpoints()
{
var document = await _client.GetFromJsonAsync<JsonObject>("/openapi/v1.json");

document.Should().NotBeNull();

var schema = document!["paths"]?["/health"]?["get"]?["responses"]?["200"]?["content"]?["application/json"]?["schema"];
schema.Should().NotBeNull();
schema!["$ref"]!.GetValue<string>().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<string>().Should().Be("#/components/schemas/UserResponse");
}

[Fact]
public async Task OpenApi_DoesNotRegisterEmptyResponseAsJsonBody()
{
var document = await _client.GetFromJsonAsync<JsonObject>("/openapi/v1.json");

document.Should().NotBeNull();

var responses = document!["paths"]?["/api/users/{id}"]?["delete"]?["responses"]?.AsObject();
responses.Should().NotBeNull();

responses!.ContainsKey("200").Should().BeFalse();

var successResponse = responses["204"];
successResponse.Should().NotBeNull();
successResponse!["content"]?["application/json"]?["schema"].Should().BeNull();

document["components"]?["schemas"]?["ResponseOfEmptyResponse"].Should().BeNull();
document["components"]?["schemas"]?["EmptyResponse"].Should().BeNull();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ Features/
FindById/
FindByIdEndpoint.cs
FindByIdRequest.cs
FindUserByIdRequest.cs
ImportFromCsv/ImportFromCsvEndpoint.cs
List/
ListUsersEndpoint.cs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public void Configure(IEndpointConfiguration config)
.Group<UsersEndpointGroup>()
.Summary("Create a user")
.Description("Creates a new user. Returns 400 if validation fails.")
.ProducesSuccess<UserResponse>(HttpStatusCode.Created)
.AddFilter<AuditFilter>();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AxisEndpoints;
using System.Net;

namespace AxisEndpoints.Example.Features.Users.Delete;

Expand All @@ -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<EmptyResponse>(HttpStatusCode.NoContent)
.AllowAnonymous();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Net;
using AxisEndpoints.Extensions.CsvHelper;
using CsvHelper.Configuration.Attributes;

Expand Down Expand Up @@ -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<EmptyResponse>(HttpStatusCode.NoContent);
}

public Task<Response<EmptyResponse>> HandleAsync(
Expand Down
1 change: 1 addition & 0 deletions tests/AxisEndpoints.Tests/AxisEndpoints.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.*" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
Expand Down
Loading
Loading