Skip to content

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

@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: #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

    area-minimalIncludes minimal APIs, endpoint filters, parameter binding, request delegate generator etcfeature-source-generators

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions