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.
Summary
The Request Delegate source generator silently drops
[FromBody]binding-attribute differences (notablyEmptyBodyBehavior) when twoMapXxxcalls 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
Both endpoints share the delegate signature
(Todo) => string.Send an empty-body
POST /allow-emptyrequest withContent-Type: application/jsonandContent-Length: 0.Expected
200 OK—[FromBody(EmptyBodyBehavior = Allow)]should honor empty bodies and invoke the handler witht = default(Todo)(this is whatRequestDelegateFactorydoes at runtime; verified with bothTodo(class) andBodyStruct(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 isSource = JsonBodyOrService, IsOptional = False— taken from the first endpoint.Evidence: generated code
For the source
…the generator emits two interceptors:
MapPost0— intercepts BOTH/requiredand/allow-empty(two[InterceptsLocation]attributes pointing at the same body). Parameter comment:// Endpoint Parameter: u (Type = ..., IsOptional = False, ... Source = JsonBodyOrService). The[FromBody]attribute andEmptyBodyBehavior = Alloware nowhere in the emitted code.MapPost1— intercepts/nullableonly (nullable annotation makes the signature distinct).So at runtime,
/allow-emptyruns throughMapPost0's body resolution which usesallowEmpty: falsefrom 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 returnintinstead ofstringmakes the delegate signature unique, so each endpoint gets its own interceptor and the attribute is preserved → 200 as expected.Root cause
StaticRouteHandlerModel/EndpointDelegateComparer.csgroups endpoints byEndpoint.SignatureEquals/GetSignatureHashCode, which in turn delegate toEndpointParameter.SignatureEquals:https://github.com/dotnet/aspnetcore/blob/main/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/EndpointParameter.cs#L604-L611
SourceandIsOptionalare 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):Same omission.
Suggested fix
Include
SourceandIsOptionalin bothSignatureEqualsandGetSignatureHashCodeso attribute-differing endpoints get distinct interceptors.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/IsOptionalwould collapse. BeyondEmptyBodyBehavior:[FromQuery]vs[FromRoute]vs[FromHeader]on a string parameter, if delegate signatures match.[FromForm]vs implicit body.[FromKeyedServices]with different keys (mitigated byKeyedServiceKeyalready 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:
Environment
main(.NET 11 work), but the equality logic above is unchanged onrelease/10.0and earlier — likely reproduces on every shipped version of the source generator.