Skip to content

Conversation

@mikekistler
Copy link
Contributor

Summary

This PR makes TypedResults.BadRequest<ProblemDetails> and similar result types utilize IProblemDetailsService customizations when the value is a ProblemDetails instance, providing consistency with ProblemHttpResult and ValidationProblem.

Motivation

Previously, TypedResults.BadRequest<ProblemDetails>() would directly serialize the ProblemDetails to JSON, bypassing any customizations configured via ProblemDetailsOptions.CustomizeProblemDetails. This was inconsistent with TypedResults.Problem() which already uses the ProblemDetailsService.

This inconsistency meant developers couldn't apply global customizations (like adding traceId, environment info, or custom properties) to ProblemDetails returned via BadRequest<ProblemDetails> and similar result types.

Changes

Updated the following result types to use IProblemDetailsService when the value is a ProblemDetails type:

  • BadRequest<TValue> (Status 400)
  • Conflict<TValue> (Status 409)
  • UnprocessableEntity<TValue> (Status 422)
  • InternalServerError<TValue> (Status 500)
  • NotFound<TValue> (Status 404)

Implementation Pattern

Each result type now follows this pattern in ExecuteAsync:

public async Task ExecuteAsync(HttpContext httpContext)
{
    // ... existing setup code ...
    
    // If the value is a ProblemDetails, attempt to use IProblemDetailsService for writing
    // This enables customizations via ProblemDetailsOptions.CustomizeProblemDetails
    if (Value is ProblemDetails problemDetails)
    {
        var problemDetailsService = httpContext.RequestServices.GetService<IProblemDetailsService>();
        if (problemDetailsService is not null && 
            await problemDetailsService.TryWriteAsync(new() { HttpContext = httpContext, ProblemDetails = problemDetails }))
        {
            return;
        }
    }

    // Fallback to direct JSON serialization
    await HttpResultsHelper.WriteResultAsJsonAsync(...);
}

Tests

Added comprehensive test coverage in BadRequestOfTResultTests.cs:

  1. CustomizeProblemDetails is applied - Verifies custom properties from configuration are added
  2. TraceId is automatically injected - Confirms automatic traceId addition works
  3. Backward compatibility - Tests fallback when service not registered
  4. Derived ProblemDetails types - Validates HttpValidationProblemDetails works correctly
  5. Non-ProblemDetails types unaffected - Ensures existing behavior unchanged
  6. Service not called for non-ProblemDetails - Confirms optimization works

Example Usage

// Configure ProblemDetails customization once
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = context =>
    {
        context.ProblemDetails.Extensions["environment"] = builder.Environment.EnvironmentName;
        context.ProblemDetails.Extensions["requestId"] = context.HttpContext.TraceIdentifier;
    };
});

// All of these will now apply customizations:
app.MapGet("/bad", () => TypedResults.BadRequest(new ProblemDetails { Title = "Error" }));
app.MapGet("/conflict", () => TypedResults.Conflict(new ProblemDetails { Title = "Conflict" }));
app.MapGet("/notfound", () => TypedResults.NotFound(new ProblemDetails { Title = "Not Found" }));
app.MapGet("/error", () => TypedResults.InternalServerError(new ProblemDetails { Title = "Error" }));

Benefits

Consistency - All ProblemDetails results now use the same customization mechanism
Flexibility - Developers can customize problem details in one place
Automatic Features - TraceId injection and other standard customizations work everywhere
Backward Compatible - Gracefully falls back when service not registered
No Breaking Changes - Existing behavior preserved; this is purely an enhancement

Checklist

  • Implementation follows existing pattern from ProblemHttpResult
  • Comprehensive test coverage added
  • No compilation errors
  • Backward compatible
  • Consistent across all affected result types

This change makes TypedResults.BadRequest<ProblemDetails> and similar result
types utilize IProblemDetailsService customizations when the value is a
ProblemDetails instance.

Changes:
- BadRequest<TValue>
- Conflict<TValue>
- UnprocessableEntity<TValue>
- InternalServerError<TValue>
- NotFound<TValue>

All now check if the value is ProblemDetails and use IProblemDetailsService
when available, falling back to direct JSON serialization otherwise.

Added comprehensive tests to verify customizations are applied correctly.
Copilot AI review requested due to automatic review settings January 7, 2026 18:56
@github-actions github-actions bot added the area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions label Jan 7, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enables TypedResults.BadRequest<ProblemDetails> and similar result types to utilize IProblemDetailsService for customizations, providing consistency with TypedResults.Problem(). Previously, these result types would bypass any configured ProblemDetailsOptions.CustomizeProblemDetails customizations.

  • Updated five result types to check if the value is ProblemDetails and use IProblemDetailsService when available
  • Added comprehensive test coverage for BadRequest<TValue> to verify the new behavior
  • Maintained backward compatibility with graceful fallback when service is not registered

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/Http/Http.Results/src/BadRequestOfT.cs Added ProblemDetails detection and IProblemDetailsService integration to ExecuteAsync method
src/Http/Http.Results/src/ConflictOfT.cs Added ProblemDetails detection and IProblemDetailsService integration to ExecuteAsync method
src/Http/Http.Results/src/InternalServerErrorOfT.cs Added ProblemDetails detection and IProblemDetailsService integration to ExecuteAsync method
src/Http/Http.Results/src/NotFoundOfT.cs Added ProblemDetails detection and IProblemDetailsService integration to ExecuteAsync method
src/Http/Http.Results/src/UnprocessableEntityOfT.cs Added ProblemDetails detection and IProblemDetailsService integration to ExecuteAsync method
src/Http/Http.Results/test/BadRequestOfTResultTests.cs Added six comprehensive tests covering customization, traceId injection, fallback behavior, derived types, and non-ProblemDetails types

Comment on lines +176 to +332
[Fact]
public async Task BadRequestObjectResult_WithProblemDetails_UsesDefaultsFromProblemDetailsService()
{
// Arrange
var details = new ProblemDetails();
var result = new BadRequest<ProblemDetails>(details);
var stream = new MemoryStream();
var services = CreateServiceCollection()
.AddProblemDetails(options => options.CustomizeProblemDetails = context => context.ProblemDetails.Extensions["customProperty"] = "customValue")
.BuildServiceProvider();
var httpContext = new DefaultHttpContext()
{
RequestServices = services,
Response =
{
Body = stream,
},
};

// Act
await result.ExecuteAsync(httpContext);

// Assert
Assert.Equal(StatusCodes.Status400BadRequest, httpContext.Response.StatusCode);
stream.Position = 0;
var responseDetails = System.Text.Json.JsonSerializer.Deserialize<ProblemDetails>(stream, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web));
Assert.NotNull(responseDetails);
Assert.Equal(StatusCodes.Status400BadRequest, responseDetails.Status);
Assert.True(responseDetails.Extensions.ContainsKey("customProperty"));
Assert.Equal("customValue", responseDetails.Extensions["customProperty"]?.ToString());
}

[Fact]
public async Task BadRequestObjectResult_WithProblemDetails_AppliesTraceIdFromService()
{
// Arrange
var details = new ProblemDetails();
var result = new BadRequest<ProblemDetails>(details);
var stream = new MemoryStream();
var services = CreateServiceCollection()
.AddProblemDetails()
.BuildServiceProvider();
var httpContext = new DefaultHttpContext()
{
RequestServices = services,
Response =
{
Body = stream,
},
};

// Act
await result.ExecuteAsync(httpContext);

// Assert
Assert.Equal(StatusCodes.Status400BadRequest, httpContext.Response.StatusCode);
stream.Position = 0;
var responseDetails = System.Text.Json.JsonSerializer.Deserialize<ProblemDetails>(stream, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web));
Assert.NotNull(responseDetails);
Assert.True(responseDetails.Extensions.ContainsKey("traceId"));
Assert.NotNull(responseDetails.Extensions["traceId"]);
}

[Fact]
public async Task BadRequestObjectResult_WithProblemDetails_FallsBackWhenServiceNotRegistered()
{
// Arrange
var details = new ProblemDetails { Title = "Test Error" };
var result = new BadRequest<ProblemDetails>(details);
var stream = new MemoryStream();
var httpContext = new DefaultHttpContext()
{
RequestServices = CreateServices(),
Response =
{
Body = stream,
},
};

// Act
await result.ExecuteAsync(httpContext);

// Assert
Assert.Equal(StatusCodes.Status400BadRequest, httpContext.Response.StatusCode);
stream.Position = 0;
var responseDetails = System.Text.Json.JsonSerializer.Deserialize<ProblemDetails>(stream, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web));
Assert.NotNull(responseDetails);
Assert.Equal("Test Error", responseDetails.Title);
Assert.Equal(StatusCodes.Status400BadRequest, responseDetails.Status);
}

[Fact]
public async Task BadRequestObjectResult_WithHttpValidationProblemDetails_UsesDefaultsFromProblemDetailsService()
{
// Arrange
var details = new HttpValidationProblemDetails();
var result = new BadRequest<HttpValidationProblemDetails>(details);
var stream = new MemoryStream();
var services = CreateServiceCollection()
.AddProblemDetails(options => options.CustomizeProblemDetails = context => context.ProblemDetails.Extensions["customValidation"] = "applied")
.BuildServiceProvider();
var httpContext = new DefaultHttpContext()
{
RequestServices = services,
Response =
{
Body = stream,
},
};

// Act
await result.ExecuteAsync(httpContext);

// Assert
Assert.Equal(StatusCodes.Status400BadRequest, httpContext.Response.StatusCode);
stream.Position = 0;
var responseDetails = System.Text.Json.JsonSerializer.Deserialize<HttpValidationProblemDetails>(stream, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web));
Assert.NotNull(responseDetails);
Assert.True(responseDetails.Extensions.ContainsKey("customValidation"));
Assert.Equal("applied", responseDetails.Extensions["customValidation"]?.ToString());
}

[Fact]
public async Task BadRequestObjectResult_WithNonProblemDetails_DoesNotUseProblemDetailsService()
{
// Arrange
var details = new { error = "test error" };
var result = new BadRequest<object>(details);
var stream = new MemoryStream();
var customizationCalled = false;
var services = CreateServiceCollection()
.AddProblemDetails(options => options.CustomizeProblemDetails = context => customizationCalled = true)
.BuildServiceProvider();
var httpContext = new DefaultHttpContext()
{
RequestServices = services,
Response =
{
Body = stream,
},
};

// Act
await result.ExecuteAsync(httpContext);

// Assert
Assert.False(customizationCalled, "CustomizeProblemDetails should not be called for non-ProblemDetails types");
Assert.Equal(StatusCodes.Status400BadRequest, httpContext.Response.StatusCode);
}

private static ServiceCollection CreateServiceCollection()
{
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
return services;
}

Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comprehensive test coverage has been added for BadRequest, but similar tests are missing for the other result types that received the same implementation changes (Conflict, NotFound, InternalServerError, and UnprocessableEntity). These test cases should be duplicated for those result types to ensure consistent behavior and catch any regressions.

Copilot uses AI. Check for mistakes.
@mikekistler mikekistler added area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc and removed area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions labels Jan 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants