OpenAPI spec allows complex objects as query params. .Net Core also allows them.
And, they’re handy in situations when you want to bundle up a few parameters into
an object that you might reuse over multiple endpoints. For example a simple
PagedCollectionFilter
which could easily be re-used for different endpoints might
look something like this:
/// <summary>
/// Basic filter model.
/// </summary>
public class PagedCollectionFilter
{
/// <summary>
/// Page to fetch (optional).
/// </summary>
[ModelBinder(Name = "page")]
public int? Page { get; set; }
/// <summary>
/// Amount of items to fetch (optional).
/// </summary>
[ModelBinder(Name = "pageSize")]
public int? PageSize { get; set; }
/// <summary>
/// Sort by KEY (entity field name) (optional).
/// </summary>
[ModelBinder(Name = "sortBy")]
public string SortBy { get; set; }
/// <summary>
/// Sort direction (optional).
/// Allowed: "ascending" / "asc" / "descending" / "desc".
/// </summary>
[ModelBinder(Name = "sortOperator")]
public string SortOperator { get; set; }
/// <summary>
/// Include deleted entities (optional)?
/// </summary>
[ModelBinder(Name = "includeDeleted")]
public bool? IncludeDeleted { get; set; }
}
Instead of listing these few parameters as separate query params, it’s much easier to just go
Filter([FromQuery]PagedCollectionFilter filter)
instead. However, when you use this approach,
Swashbuckle will ignore the object and will spread the params as if you simply listed them
all one by one. If you want to generate some client-some code, this might not be what you want.
I don’t want to specify every single param, or to even use the spread operator (...filter
) to
accomplish this, I want to use an object.
This is what Swashbuckle will generate by default:
{
"paths": {
"/api/v1/users/filter": {
"get": {
"tags": [
"Users"
],
"operationId": "FilterUsers",
"parameters": [
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "pageSize",
"in": "query",
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "sortBy",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "sortOperator",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "includeDeleted",
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "ownerId",
"in": "query",
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserCollectionModel"
}
}
}
}
}
}
}
}
}
And this is what we actually want:
{
"paths": {
"/api/v1/users/filter": {
"get": {
"tags": [
"Users"
],
"operationId": "FilterUsers",
"parameters": [
{
"name": "filter",
"in": "query",
"schema": {
"$ref": "#/components/schemas/PagedCollectionFilter"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserCollectionModel"
}
}
}
}
}
}
},
},
"components": {
"schemas": {
"PagedCollectionFilter": {
"type": "object",
"properties": {
"Page": {
"type": "integer",
"format": "int32",
"nullable": true
},
"PageSize": {
"type": "integer",
"format": "int32",
"nullable": true
},
"SortBy": {
"type": "string",
"nullable": true
},
"SortOperator": {
"type": "string",
"nullable": true
},
"IncludeDeleted": {
"type": "boolean",
"nullable": true
},
"OwnerId": {
"type": "integer",
"format": "int32",
"nullable": true
}
},
"additionalProperties": false
}
}
}
}
I did a bit of research and tried to find a way around this issue. I found this and this. Neither provided me with a ready-to-use solution, but they pointed me in the right direction.
I ended up writing a custom IOperationFilter
implementation which fixes the problem by removing the
“stand-alone” params which got created by Swashbuckle, and bundles them up in the object I expected
them to be in in the first place.
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Collections.Generic;
using System.Linq;
namespace MyApp.WebAPI.Documentation
{
public class FromQueryModelFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var description = context.ApiDescription;
if (description.HttpMethod.ToLower() != HttpMethod.Get.ToString().ToLower())
{
// We only want to do this for GET requests, if this is not a
// GET request, leave this operation as is, do not modify
return;
}
var actionParameters = description.ActionDescriptor.Parameters;
var apiParameters = description.ParameterDescriptions
.Where(p => p.Source.IsFromRequest)
.ToList();
if (actionParameters.Count == apiParameters.Count)
{
// If no complex query parameters detected, leave this operation as is, do not modify
return;
}
operation.Parameters = CreateParameters(actionParameters, operation.Parameters, context);
}
private IList<OpenApiParameter> CreateParameters(
IList<ParameterDescriptor> actionParameters,
IList<OpenApiParameter> operationParameters,
OperationFilterContext context)
{
var newParameters = actionParameters
.Select(p => CreateParameter(p, operationParameters, context))
.Where(p => p != null)
.ToList();
return newParameters.Any() ? newParameters : null;
}
private OpenApiParameter CreateParameter(
ParameterDescriptor actionParameter,
IList<OpenApiParameter> operationParameters,
OperationFilterContext context)
{
var operationParamNames = operationParameters.Select(p => p.Name);
if (operationParamNames.Contains(actionParameter.Name))
{
// If param is defined as the action method argument, just pass it through
return operationParameters.First(p => p.Name == actionParameter.Name);
}
if (actionParameter.BindingInfo == null)
{
return null;
}
var generatedSchema = context.SchemaGenerator.GenerateSchema(actionParameter.ParameterType, context.SchemaRepository);
var newParameter = new OpenApiParameter
{
Name = actionParameter.Name,
In = ParameterLocation.Query,
Schema = generatedSchema
};
return newParameter;
}
}
}
To use this filter, you’ll have to register this operation filter in your .AddSwaggerGen()
like this:
public static void AddSwaggerServices(this IServiceCollection services)
{
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "My API",
Version = "v1",
});
// ... Whatever else you might have ...
options.OperationFilter<FromQueryModelFilter>();
});
}
Also, if you want to use any combination of complex objects, primitive query params and path params, you can do that.
This solution is not exclusive to a single complex object query param. For example you could add another filter
object to the FilterUsers
example, an extra query param such as [FromQuery]int forGroupId
and some extra path
param in your route definition (i.e. [HttpGet("filter/{whatever}")]
) and that would work fine.
If you have a better way of doing this, or have any significant improvements or potential edge-case fixes which I haven’t thought of, please leave a comment.. Thnx!