Skip to content

Enable ProblemDetailsService for TypedResults with ProblemDetails#64967

Open
mikekistler wants to merge 1 commit intomainfrom
feature/typed-results-problemdetails-service
Open

Enable ProblemDetailsService for TypedResults with ProblemDetails#64967
mikekistler wants to merge 1 commit intomainfrom
feature/typed-results-problemdetails-service

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.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@copilot Please add tests for the other result types.

@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
@desjoerd
Copy link
Contributor

desjoerd commented Jan 13, 2026

This PR got me thinking, could this be done via an EndpointFilter so it can be opt-in for Minimal Api's? All the results implement IValueHttpResult which could be used to get the result and invoke the ProblemDetailsService for the value within a filter.

This removes the need for special cases within the existing Results which could be giving surprises to people. (As the ProblemDetails do not get past the HttpJsonOptions anymore).

Edit:
I totally missed that there is already special casing in the constructor for problem details.

// This enables customizations via ProblemDetailsOptions.CustomizeProblemDetails
if (Value is ProblemDetails problemDetails)
{
var problemDetailsService = httpContext.RequestServices.GetService<IProblemDetailsService>();
Copy link
Contributor

Choose a reason for hiding this comment

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

The typical pattern we use here is something like:

if (problemDetailsService is null || !await problemDetailsService.TryWriteAsync(new() { HttpContext = httpContext, ProblemDetails = ProblemDetails }))
        {
            await HttpResultsHelper.WriteResultAsJsonAsync(
                httpContext,
                logger,
                value: ProblemDetails,
                ContentType);
        }

See https://github.com/dotnet/aspnetcore/blob/main/src/Http/Http.Results/src/ProblemHttpResult.cs for an example.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@copilot Please adopt the patterm that @captainsafia shows here.

Copy link
Contributor

@captainsafia captainsafia left a comment

Choose a reason for hiding this comment

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

This looks good with a small structural tweak.

I guess the other thing that remains is if there are other special return types that we want to add this to but we can start here.

Test coverage can also be updated to include the other result types (Conflict, and InternalServerError and so forth).

Copy link
Contributor

Copilot AI commented Jan 16, 2026

@mikekistler I've opened a new pull request, #65099, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Contributor

Copilot AI commented Jan 18, 2026

@mikekistler I've opened a new pull request, #65114, to work on those changes. Once the pull request is ready, I'll request review from you.

@dotnet-policy-service
Copy link
Contributor

Looks like this PR hasn't been active for some time and the codebase could have been changed in the meantime.
To make sure no conflicting changes have occurred, please rerun validation before merging. You can do this by leaving an /azp run comment here (requires commit rights), or by simply closing and reopening.

@dotnet-policy-service dotnet-policy-service bot added the pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun label Jan 25, 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 pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants