Apricot Framework

Model binding

Requiring a query-string key at the routing layer, and turning form binding off.

Two attributes that change how MVC selects and binds an action.

Requiring a query-string key

[RequiredFromQuery] binds a parameter from the query string like [FromQuery], and additionally makes the action match only when that key is present.

using ApricotFramework.AspNetCore.Extensions.ModelBinding;

[HttpGet]
public IActionResult GetById([RequiredFromQuery] string id) => this.Ok(store.Find(id));

[HttpGet]
public IActionResult Search([RequiredFromQuery] string query) => this.Ok(store.Search(query));

Two actions, one route, told apart by which key the caller sent. ?id=42 reaches the first and ?query=report the second, which plain [FromQuery] cannot express — both actions would match every request and the route would fail as ambiguous.

Warning

The keys must be mutually exclusive in practice. A request sending both ?id=42 and ?query=report satisfies both constraints, matches both actions, and ASP.NET Core throws AmbiguousMatchException — a 500. The constraint can only add a requirement; it cannot express "this key and not that one". Where a caller might plausibly send both, give the actions distinct routes instead, or keep one action and branch inside it.

Pass a name to require a different key from the parameter's own:

[HttpGet("by-slug")]
public IActionResult GetBySlug([RequiredFromQuery("report-slug")] string slug) => this.Ok(slug);

Warning

The requirement is enforced by routing, not validation, so a request missing the key matches no action and the answer is 404 Not Found — not 400 Bad Request. That is the right answer when the key selects between actions, and the wrong one when there is a single action and the caller simply forgot a parameter. In that case use an ordinary [FromQuery] and mark it required for validation instead.

Only presence is checked, so ?id= satisfies the constraint and the empty value then goes through model binding and validation as usual. Whether the value is usable is not a routing question, and failing here would report a missing route rather than a bad value.

The constraint runs after the framework's own, which sit at or below order 100, so HTTP method and content-type matching still decide first and this only narrows a candidate that already matched.

Turning form binding off

[DisableFormValueModelBinding] stops MVC reading the request body as a form, leaving it intact for the action to stream.

[HttpPost("upload")]
[DisableFormValueModelBinding]
public async Task<IActionResult> Upload(CancellationToken cancellationToken)
{
    var fields = await this.Request.StreamFilesAsync(HandleAsync, cancellationToken);

    return this.Ok(new { Fields = fields.Keys });
}

It removes the three value provider factories that read a form — FormValueProviderFactory, FormFileValueProviderFactory and JQueryFormValueProviderFactory — and leaves every other factory alone, so route, query and header binding on the same action keep working.

Note

Required on any action doing its own multipart reading. Model binding buffers the whole body to populate the form collection, and once it has, the body cannot be read again — a streaming action without this attribute finds nothing there. See uploads.

The attribute applies to a class or a method, and nothing needs restoring afterwards: the factory list is built per request, so a removal cannot leak into another one.

On this page