dotnet / dotnet/aspnetcore

DefaultApiProblemDetailsWriter: CanWrite/WriteAsync metadata mismatch silently drops bodies for controllers without [ApiController]

Open
#67,053 1 comment 1 reaction 0 assignees View on GitHub
area-mvc feature-problem-details
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.