Skip to content

Source generator silently drops [FromBody(EmptyBodyBehavior=Allow)] when two endpoints share a delegate signature #544

@DeagleGross

Description

@DeagleGross

Summary

The Request Delegate source generator silently drops [FromBody] binding-attribute differences (notably EmptyBodyBehavior) when two MapXxx calls in the same compilation share the same delegate signature (parameter type list + return type). The endpoints get collapsed into a single generated interceptor method (MapPost0, etc.) emitted from the first endpoint's parameter metadata, so the second endpoint's [FromBody(EmptyBodyBehavior = Allow)] is effectively ignored at runtime.

Result: the second endpoint returns 400 for an empty body even though it explicitly opted in to allowing empty bodies. The runtime (non-generator) path is unaffected and correctly returns 200.

This is not union-specific — it reproduces with any type that produces a shared delegate signature. Discovered while adding C# unions body-binding test coverage (parent: dotnet#64599), but the bug predates unions.

Repro

// In a Minimal API app:
app.MapPost("/required",    (Todo t) => "ok");
app.MapPost("/allow-empty", ([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] Todo t) => "ok");

Both endpoints share the delegate signature (Todo) => string.

Send an empty-body POST /allow-empty request with Content-Type: application/json and Content-Length: 0.

Expected

200 OK[FromBody(EmptyBodyBehavior = Allow)] should honor empty bodies and invoke the handler with t = default(Todo) (this is what RequestDelegateFactory does at runtime; verified with both Todo (class) and BodyStruct (value type)).

Actual (with source generator enabled)

400 Bad Request. The [FromBody(EmptyBodyBehavior = Allow)] attribute is silently dropped; the generator emits a single interceptor whose parameter metadata is Source = JsonBodyOrService, IsOptional = False — taken from the first endpoint.

Evidence: generated code

For the source

app.MapPost("/required",    (UnionIntString u) => "invoked");
app.MapPost("/nullable",    (UnionIntString? u) => u.HasValue ? "has-value" : "null");
app.MapPost("/allow-empty", ([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] UnionIntString u) => "invoked");

…the generator emits two interceptors:

  • MapPost0 — intercepts BOTH /required and /allow-empty (two [InterceptsLocation] attributes pointing at the same body). Parameter comment: // Endpoint Parameter: u (Type = ..., IsOptional = False, ... Source = JsonBodyOrService). The [FromBody] attribute and EmptyBodyBehavior = Allow are nowhere in the emitted code.
  • MapPost1 — intercepts /nullable only (nullable annotation makes the signature distinct).

So at runtime, /allow-empty runs through MapPost0's body resolution which uses allowEmpty: false from the first endpoint, hits the "empty body + required" branch, and returns 400.

The "different return type" workaround confirms it: changing /allow-empty's handler to return int instead of string makes the delegate signature unique, so each endpoint gets its own interceptor and the attribute is preserved → 200 as expected.

Root cause

StaticRouteHandlerModel/EndpointDelegateComparer.cs groups endpoints by Endpoint.SignatureEquals / GetSignatureHashCode, which in turn delegate to EndpointParameter.SignatureEquals:

https://github.com/dotnet/aspnetcore/blob/main/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/EndpointParameter.cs#L604-L611

public bool SignatureEquals(object obj) =>
    obj is EndpointParameter other &&
    SymbolEqualityComparer.IncludeNullability.Equals(other.Type, Type) &&
    other.SymbolName == SymbolName &&
    other.KeyedServiceKey == KeyedServiceKey;

Source and IsOptional are not part of the signature, so two endpoints whose only difference is a [FromBody] (or [FromQuery], [FromForm], etc.) binding attribute are considered equal and collapsed.

Hash side (Endpoint.GetSignatureHashCode):

foreach (var parameter in endpoint.Parameters)
{
    hashCode.Add(parameter.Type, SymbolEqualityComparer.Default);
}

Same omission.

Suggested fix

Include Source and IsOptional in both SignatureEquals and GetSignatureHashCode so attribute-differing endpoints get distinct interceptors.

public bool SignatureEquals(object obj) =>
    obj is EndpointParameter other &&
    SymbolEqualityComparer.IncludeNullability.Equals(other.Type, Type) &&
    other.SymbolName == SymbolName &&
    other.KeyedServiceKey == KeyedServiceKey &&
    other.Source == Source &&
    other.IsOptional == IsOptional;
foreach (var parameter in endpoint.Parameters)
{
    hashCode.Add(parameter.Type, SymbolEqualityComparer.Default);
    hashCode.Add(parameter.Source);
    hashCode.Add(parameter.IsOptional);
}

Trade-off: endpoints that previously collapsed will emit one interceptor per binding-shape, so generated code grows slightly. Net positive — eliminates silent metadata loss and matches runtime semantics.

Other binding sources potentially affected

Any pair of endpoints with the same delegate signature but differing parameter Source / IsOptional would collapse. Beyond EmptyBodyBehavior:

  • [FromQuery] vs [FromRoute] vs [FromHeader] on a string parameter, if delegate signatures match.
  • [FromForm] vs implicit body.
  • [FromKeyedServices] with different keys (mitigated by KeyedServiceKey already being in the key).

Worth a sweep of test coverage once the fix lands.

Workaround

Until fixed, ensure each unique binding shape has a unique delegate signature. Easy options:

  • Different return type per endpoint, or
  • Different parameter type per endpoint, or
  • Add a dummy second parameter that distinguishes the signature.

Environment

  • Discovered on main (.NET 11 work), but the equality logic above is unchanged on release/10.0 and earlier — likely reproduces on every shipped version of the source generator.
  • Repro doesn't require unions; included a union example only because that's the test suite where this was found.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions