DefaultApiProblemDetailsWriter: CanWrite/WriteAsync metadata mismatch silently drops bodies for controllers without [ApiController]
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 10h
- Merged PRs (30d)
- 281
Description
### Is there an existing issue for this?
- [X] I have searched the existing issues
### Describe the bug
`Microsoft.AspNetCore.Mvc.Infrastructure.DefaultApiProblemDetailsWriter` uses a **different metadata key in `CanWrite` than in `WriteAsync`**:
```csharp
// src/Mvc/Mvc.Core/src/Infrastructure/DefaultApiProblemDetailsWriter.cs
public bool CanWrite(ProblemDetailsContext context)
{
var controllerAttribute = context.AdditionalMetadata?.GetMetadata() ??
context.HttpContext.GetEndpoint()?.Metadata.GetMetadata();
return controllerAttribute != null; // ← uses ControllerAttribute (auto-attached to every controller)
}
public ValueTask WriteAsync(ProblemDetailsContext context)
{
var apiControllerAttribute = context.AdditionalMetadata?.GetMetadata() ??
context.HttpContext.GetEndpoint()?.Metadata.GetMetadata();
if (apiControllerAttribute is null || _apiBehaviorOptions.SuppressMapClientErrors)
{
return ValueTask.CompletedTask; // ← uses IApiBehaviorMetadata (only supplied by [ApiController])
}
...
}
```
[`ProblemDetailsService.WriteAsync`](https://github.com/dotnet/aspnetcore/blob/main/src/Http/Http.Extensions/src/ProblemDetailsService.cs) picks the **first writer whose `CanWrite` returns true** and never tries another. So this writer **claims every controller endpoint** then **silently drops the body** for controllers that happen to lack endpoint-level `IApiBehaviorMetadata` — no exception, no log, no way for the next-registered writer (`DefaultProblemDetailsWriter`) to take over.
A second related defect surfaces even on the "happy" path (with `[ApiController]`): `WriteAsync` rebuilds the response via `_problemDetailsFactory.CreateProblemDetails(...)`, which creates a fresh `ProblemDetails` (not a `ValidationProblemDetails`) and only copies `Extensions`. Anything carried in the `ValidationProblemDetails.Errors` structural member is **silently discarded**.
### Expected Behavior
Either:
1. `CanWrite` should mirror `WriteAsync`'s check so it returns `false` for endpoints the writer cannot produce output for — letting `DefaultProblemDetailsWriter` (or any user-registered writer) take over. Minimal patch:
```diff
public bool CanWrite(ProblemDetailsContext context)
{
- var controllerAttribute = context.AdditionalMetadata?.GetMetadata() ??
- context.HttpContext.GetEndpoint()?.Metadata.GetMetadata();
- return controllerAttribute != null;
+ var apiControllerAttribute = context.AdditionalMetadata?.GetMetadata() ??
+ context.HttpContext.GetEndpoint()?.Metadata.GetMetadata();
+ return apiControllerAttribute != null && !_apiBehaviorOptions.SuppressMapClientErrors;
}
```
2. `WriteAsync` should preserve the runtime `ProblemDetails` subtype (e.g. `ValidationProblemDetails`), or at minimum copy `Errors` alongside `Extensions`.
### Steps To Reproduce
Public minimal repro (no third-party dependencies): **https://github.com/xavierjohn/repro-aspnetcore-default-api-problem-details-writer**
```
git clone https://github.com/xavierjohn/repro-aspnetcore-default-api-problem-details-writer
cd repro-aspnetcore-default-api-problem-details-writer/PdWriterMismatch
dotnet run
```
Three probes against the running app — all push the **identical** `ValidationProblemDetails` payload through `IProblemDetailsService`:
```
curl -s http://localhost:5000/_diag/writers
curl -s -i http://localhost:5000/plain/problem # controller without [ApiController]
curl -s -i http://localhost:5000/apicontroller/problem # same controller, with [ApiController]
curl -s -i http://localhost:5000/minimal/problem # Minimal API, control
```
#### Observed
`/_diag/writers` (chain order `ProblemDetailsService` consults):
```
0: Microsoft.AspNetCore.Mvc.Infrastructure.DefaultApiProblemDetailsWriter
1: Microsoft.AspNetCore.Http.DefaultProblemDetailsWriter
```
| Endpoint | Handler chosen | Body | `errors` | `customField` |
| --- | --- | :-: | :-: | :-: |
| `/minimal/problem` (Minimal API — control) | `DefaultProblemDetailsWriter` | ✅ | ✅ | ✅ |
| `/plain/problem` (controller, no `[ApiController]`) | `DefaultApiProblemDetailsWriter` | ❌ **EMPTY** | ❌ | ❌ |
| `/apicontroller/problem` (controller, with `[ApiController]`) | `DefaultApiProblemDetailsWriter` | ✅ | ❌ **LOST** | ✅ |
Raw response bodies for completeness:
```
# /plain/problem
HTTP/1.1 404 Not Found
Content-Length: 0
(no body)
# /apicontroller/problem
HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8
{"type":"https://example.com/probs/repro","title":"Not found","status":404,"detail":"Reproduction payload.","instance":"/apicontroller/problem","traceId":"...","customField":"demonstrates extension preservation"}
↑ note: "errors" dictionary is gone
# /minimal/problem
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type":"https://example.com/probs/repro","title":"Not found","status":404,"detail":"Reproduction payload.","instance":"/minimal/problem","errors":{"field":["is required"]},"customField":"demonstrates extension preservation","traceId":"..."}
↑ errors AND customField present
```
### Exceptions (if any)
None — both failure modes are silent, which is what makes them nasty to diagnose in larger apps.
### .NET Version
10.0.300
### Anything else?
* **ASP.NET Core version:** 10.0.8
* **OS:** Windows 11 (also reproduces on Linux based on identical SDK + shared-framework behavior)
#### Real-world surface
Hit in a sample-style service (`xavierjohn/BuberDinner`) that used `` to apply `[ApiController]` across the assembly. That MSBuild item emits `[assembly: ApiController]`, which enables the `[ApiController]` behavior conventions but **does not propagate `IApiBehaviorMetadata` to per-endpoint metadata** — so every controller is in the silent-empty-body zone of this writer. The empty-body symptom is also commonly masked by libraries that re-order `IProblemDetailsWriter`s; see e.g. [dotnet/aspnet-api-versioning#1191](https://github.com/dotnet/aspnet-api-versioning/issues/1191) for the same "claims via CanWrite and silently drops in WriteAsync" anti-pattern in another writer.
Happy to send a PR for either fix once you've picked a direction.
Contributor guide
Research direction
Start with src/Mvc/Mvc.Core/src/Infrastructure/DefaultApiProblemDetailsWriter.cs and ProblemDetailsService.WriteAsync, then run the linked repro with its three curl probes. Done means writer selection no longer claims endpoints it cannot render, and the controller response preserves ValidationProblemDetails.Errors as well as extensions.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- api, backend-api-design
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100