Repository navigation
Minimal API binds a variant-typed parameter as null for a valid literal and answers 200 #116
Description
A minimal-API parameter typed as a sealed variant — Int32Range.Finite, DateRange.UnboundedEnd, … — binds null for a valid literal of that shape and answers 200, including when the parameter is not nullable. Measured on main (7f4dc47) with WebApplication on .NET 10 and no registration:
app.MapGet("/union", (Int32Range seats) => ...); // ?seats=[1,5] → 200, seats = [1,5]
app.MapGet("/finite", (Int32Range.Finite seats) => ...); // ?seats=[1,5] → 200, seats = null
// ?seats=(,5] → 200, seats = null
// ?seats=garbage → 400
app.MapGet("/finite-nullable", (Int32Range.Finite? seats) => ...); // ?seats=[1,5] → 200, nullCause. The variants have no TryParse of their own; minimal APIs find the inherited static Int32Range.TryParse(string?, IFormatProvider?, out Int32Range), call it, and then can't assign the union-typed result to the variant-typed parameter, so the parameter stays null while the parse "succeeded". Garbage still fails the inherited TryParse, which is why it gets a 400. MVC is unaffected: it binds through the variant's own RangeVariantTypeConverter, which TypeConverterContractTests holds, and refuses another shape.
Authority. docs/serialization.md says minimal APIs and MVC bind query, route and form values through the static TryParse, and that a property typed as one shape rejects the others. Here a valid literal of the right shape is dropped and the handler runs with null. HostBindingTests has Minimal_BindsTheQueryStringThroughTryParse for the union only.
Fix: give every sealed variant its own static Parse/TryParse returning the variant (the literal must have that shape; otherwise false), mirroring RangeVariantTypeConverter. Additive, not breaking — but the binding answers wrongly without throwing today, so under the release rule it ships in 10.0.0 rather than waiting.
Tests: a minimal-API case in HostBindingTests with a variant-typed parameter: right shape binds, wrong shape and garbage give 400. Seed: remove one variant's TryParse.
Found in the 10.0.0 bug hunt (code-analyst reading, reproduced here).
Activity
CaffeinatedCoder commentedon Oct 17, 2026
Measured wider by
HostCultureProbeTests(#124), the host half of route B step 2. The defect is not confined to a query parameter:EmptyRange,Finite,UnboundedStart,UnboundedEnd,Infinity).TryParse: query, nullable query, route value and form field. The JSON body is correct (it goes through the variant's[JsonConverter]), and so is MVC on all five sources (it uses the variant'sTypeConverter).null. A literal of another shape should be 400, as MVC answers it.RequestLocalization). The culture plays no part.That makes 160 failing requests per range type, all bound to the same cause. They're registered as candidates in
test/oracles/candidates.json, one per range type, with rows likeInt32Range variant minimal TryParse. Each one fails ascandidate fixedwhen the fix lands, so the fix PR removes the 11 entries. The test the fix needs already exists: the sweep is that test.CaffeinatedCoder commentedon Oct 17, 2026
Would catch:
HostCultureProbeTests(#125), rowInt32Range. For example:Int32Range variant minimal TryParse query as Int32Range.Finite: answered 200 having bound null for [-5,10) under the default culture. The same failure appears for every variant × {query, nullable query, route value, form field} × {default, de-DE, fa-IR, th-TH}, and with a literal of another shape, in all 11 range rows.Verified by running the sweep on main at 3ecfa10 (the defect is unfixed), before the candidates were registered: 11 rows failed with 160 such lines each, and nothing else failed. The 11
candidates.jsonrows (<Type> variant minimal TryParse) are the standing proof. Each fails ascandidate fixedonce the fix lands.CaffeinatedCoder commentedon Oct 17, 2026
Oracle check (main at 3ecfa10): no oracle on main catches this yet (pending). Verified by a run: every class in
oracles.jsonpasses on 3ecfa10 with the defect present.TypeConverterContractTestsreaches only the MVC path, which is correct. The minimal-API path is held only byHostBindingTests(J15), a worked example for the union type, not an oracle.The pair is host × variant-typed parameter in
axes.md. Route B step 2'sHostCultureProbeTests(#125, see the comment above) catches it, so this becomes would catch when #125 merges.CaffeinatedCoder commentedon Oct 18, 2026
Fixed in #177 (stacked on #175 → #173 → #171). Every shape of the 11 range types now has its own
ParseandTryParse(string?, IFormatProvider?, out TShape?)throughRangeFormat.ParseVariant, which the shapes'TypeConverteralso uses. A minimal-API parameter typed as a shape binds a literal of that shape and answers 400 for another shape or garbage, on every source and request culture. The 11 candidates are gone, claim J38 covers the new surface, and the seedvariant-without-tryparse.patchfailsHostCultureProbeTests. Addedpackage/nodatime: the NodaTime shapes got the same members.