Model binding for query parameters encoded as JSON in ASP.NET Core

I've been building and an API that supports seaching with complex filters. Think of product filtering you see on most shopping websites, with inputs for price, rating, brand and so on.

By convention, search APIs are exposed from a GET endpoint, and take parameters as query strings. Complex nested objects are usually serialized in bracket notation i.e. ?field[subfield]:

GET /products?
  filter[title][contains]=ssd&
  filter[price][lt]=100&
  sort[field]=price&
  sort[direction]=ASC

This is unnecessarily verbose. Field path prefixes are repeated multiple times. ASP.NET Core doesn't seem to support this bracketed format for binding complex types, either.

I prefer querying the API with arguments serialized as JSON. It's more succinct, and also easier for clients to serialize.

GET /products?query=
  {
    "filter": {
      "title": {
        "contains": "ssd"
      },
    },
    "sort": {
      "field": "price",
      "direction": "ASC"
    }
  }

With ASP.NET Core, we get neither of them. To illustrate it, let's create an action that accepts a SearchQuery instance, populated from the query string:

[HttpGet]
public async Task<ActionResult<SearchResult>> SearchProducts(
    [FromQuery] SearchQuery query
) { ... }

When we inspect the OpenAPI / Swagger schema, the action is described as having separate parameters for each property in my query type.

{
  "paths": {
    "/api/products": {
      "get": {
        "parameters": [
          { "name": "Filter.Title.Contains", "in": "query" /* ... */ },
          { "name": "Sort.Field", "in": "query" /* ... */ },
          { "name": "Sort.Direction", "in": "query" /* ... */ }
        ]
  // ...
}

This corresponds to a query string formatted in dot notation field.subfield:

GET /products?
  Filter.Title.Contains=ssd&
  Sort.Field=price&
  Sort.Direction=ASC

and it is rendered as a form with separate inputs in Swagger UI.

swagger ui with separate inputs

If we try to send the parameter as JSON we can't get it to bind to a complex type. SearchQuery query parameter is always empty.

We could POST this JSON in the request body, but that breaks the convention and doesn't signal that the endpoint is actually idempotent.

Binding JSON query parameters

We can work around it by accepting a JSON string, then deserializing it inside the action.

[HttpGet]
public Task<ActionResult<SearchResult>> SearchProducts(
    [FromQuery(Name = "query")] string queryJson
) {
    var query = DeserializeJson(queryJson);
    // ...
}

This works, but I don't like parsing, model binding & validation myself that MVC platform already does for me. It'd be best if we could utilize ASP.NET as much as possible.

MVC gives us a better extension point: model binders. We will use them to create our own binder and use it just like we would with [FromQuery], or [FromBody].

Creating a custom model binder

We start with subclassing ModelBinderAttribute. It's a concrete class, so we don't have to implement anything, but we want to specify that we will be using a custom binder.

internal class FromJsonQueryAttribute : ModelBinderAttribute
{
    public FromJsonQueryAttribute()
    {
        BinderType = typeof(JsonQueryBinder);
    }
}

We can annotate the action parameter with it directly instead of using [ModelBinder] attribute:

public Task<ActionResult<SearchResult>> SearchProducts(
    [FromJsonQuery] ProductySearchQuery query
) { ... }

Now let's create the JsonQueryBinder class to get it working. It needs to implement IModelBinder interface. The steps we need to follow are:

  • Get raw JSON from the request.

    ModelBindingContext contains ValueProvider for retrieving values from the request, if it's not enough, you can also use HttpContext and work on the request directly.

  • Turn it into a useful type.

    We deserialize it with System.Text.Json, which is available for all .NET versions, but you can't go wrong with Json.NET, either.

  • Validate it (later).

  • Accept / reject the result.

internal class JsonQueryBinder : IModelBinder
{
    private readonly ILogger<JsonQueryBinder> _logger;

    public JsonQueryBinder(ILogger<JsonQueryBinder> logger)
    {
        _logger = logger;
    }

    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var value = bindingContext.ValueProvider.GetValue(bindingContext.FieldName).FirstValue;
        if (value == null)
        {
            return Task.CompletedTask;
        }

        try
        {
            var parsed = JsonSerializer.Deserialize(
                value,
                bindingContext.ModelType,
                new JsonSerializerOptions(JsonSerializerDefaults.Web)
            );
            bindingContext.Result = ModelBindingResult.Success(parsed);
        }
        catch (Exception e)
        {
            _logger.LogError(e, "Failed to bind '{FieldName}'", bindingContext.FieldName);
            bindingContext.Result = ModelBindingResult.Failed();
        }

        return Task.CompletedTask;
    }
}

Now when we send a request to the endpoint, it works! SearchQuery query parameter is filled correctly.

GET /products?query={
  "filter": {
    "title": {
      "contains": "ssd"
    }
  },
  "sort": {
    "field": "price",
    "direction": "ASC"
  }
}

But validation doesn't work yet. If we omit a [Required] property or pass in a string to boolean, it just fails. We can improve this further.

Utilizing ASP.NET Core's validation tools

After we deserialize the payload, we can let ASP.NET take care of the validation. IObjectModelValidator interface gives us the tools to validate an object graph with all its properties and sub types.

We inject it into our binder, and call its .Validate() method after deserialization.

internal class JsonQueryBinder : IModelBinder
{
    // inject the validator in constructor
    private readonly IObjectModelValidator _validator;
    
    // ...

    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        // ...
        try
        {
            var parsed = JsonSerializer.Deserialize(value, bindingContext.ModelType,
                new JsonSerializerOptions(JsonSerializerDefaults.Web));
            bindingContext.Result = ModelBindingResult.Success(parsed);

            if (parsed != null)
            {
                _validator.Validate(
                    bindingContext.ActionContext,
                    validationState: bindingContext.ValidationState,
                    prefix: string.Empty,
                    model: parsed
                );
            }
        }
        catch (JsonException e)
        {
            _logger.LogError(e, "Failed to bind parameter '{FieldName}'", bindingContext.FieldName);
            bindingContext.ActionContext.ModelState.TryAddModelError(key: e.Path, exception: e,
                bindingContext.ModelMetadata);
        }
        catch (Exception e) when (e is FormatException || e is OverflowException)
        {
            _logger.LogError(e, "Failed to bind parameter '{FieldName}'", bindingContext.FieldName);
            bindingContext.ActionContext.ModelState.TryAddModelError(string.Empty, e, bindingContext.ModelMetadata);
        }

        return Task.CompletedTask;
    }
}

Now if we have this annotated model:

public record SearchSort(
    [Required] string Field,
    [RegularExpression("ASC|DESC")] string Direction
);

sending an invalid JSON returns a nicely formatted HTTP 400 error[1].

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "00-15833e541708ac49b4829f02c40008d0-4830117fc8dbca43-00",
  "errors": {
    "Sort.Field": [
      "The Field field is required."
    ],
    "Sort.Direction": [
      "The field Direction must match the regular expression 'ASC|DESC'."
    ],
    "q.Sort.Field": [
      "The Field field is required."
    ],
    "q.Sort.Direction": [
      "The field Direction must match the regular expression 'ASC|DESC'."
    ]
  }
}

Adding OpenAPI support

When we created the FromJsonQueryAttribute, its BindingSource property was set to BindingSource.Custom. This keeps Swagger from displaying the properties as individual inputs.

swagger ui with single input

But still, if we try to send a request using the Swagger UI, it serializes the parameter in bracket notation. To solve this problem, we can provide a custom API description for endpoints with [FromJsonQuery] parameters to tell clients to actually serialize the parameter as JSON.

OpenAPI specification addresses our exact use-case, and gives us the option to specify a parameter schema with a specific content type like so:

{
  "paths": {
    "/api/products": {
      "get": {
        "parameters": [
          {
            "name": "query",
            "in": "query",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProductSearchQuery"
                }
              }
            }
  // ...
}

We can fix it by hooking into SwashBuckle with an operation filter and modify the generated schema. From there, we correct the parameters annotated with [FromJsonQuery] with the right schema.

internal static class JsonQuerySwaggerGenExtensions
{
    public static SwaggerGenOptions AddJsonQuerySupport(this SwaggerGenOptions options)
    {
        options.OperationFilter<JsonQueryOperationFilter>();
        return options;
    }

    private class JsonQueryOperationFilter : IOperationFilter
    {
        public void Apply(OpenApiOperation operation, OperationFilterContext context)
        {
            var jsonQueryParams = context.ApiDescription.ActionDescriptor.Parameters
                .Where(ad => ad.BindingInfo.BinderType == typeof(JsonQueryBinder))
                .Select(ad => ad.Name)
                .ToList();

            if (!jsonQueryParams.Any())
            {
                return;
            }

            foreach (var p in operation.Parameters.Where(p => jsonQueryParams.Contains(p.Name)))
            {
                // move the schema under application/json content type
                p.Content = new Dictionary<string, OpenApiMediaType>()
                {
                    [MediaTypeNames.Application.Json] = new OpenApiMediaType()
                    {
                        Schema = p.Schema
                    }
                };
                // then clear it
                p.Schema = null;
            }
        }
    }
}

When writing a plugin for a third party library, I prefer having an extension method to hide the glue code, and keep the implementations as nested private classes.

Anyway, now we can enable the filter in our Startup class.

public void ConfigureServices(IServiceCollection services)
{
    // ...
    services.AddSwaggerGen(c =>
    {
        c.AddJsonQuerySupport();
        // ...
    });
}

When we send a request, the query parameter is now serialized as JSON 💃. Our API is now easier to use, well-annotated and shows up with the correct Swagger UI for testing.

Cheers ✌.

References


  1. You need to annotate your controller with [ApiController] attribute to get auto-validation, along with other useful behaviors for APIs. See docs for further info. ↩ī¸Ž

Last updated: