9 minutes read

Generate OpenAPI documentation for dynamic query parameters in .NET 7

Minimal APIs got me pretty excited when they came out. Yes I had to wait for .NET 7 before I could really start use it as a replacement for WebAPI, but overall I have been very happy with the experience. However, when I wanted to document the dynamic query parameters of my new API I still had to write a custom solution. So let’s find out together how I achieved that!

Misty landscape

In this article I will cover how we can extend the API documentation generated by Swashbuckle, how we can further customize this using custom attributes and finally how to wrap it all up in a nice package for our fellow developers. Be sure to stick around to the end, where I will discuss some ways to improve upon our solution.

Why should you document dynamic parameters as well

Let me paint the picture: At my current company we have packages for standardized features. One of these packages lets us add sorting, filtering and pagination by simply passing the HttpContext to an extension method on the IQueryable interface. Something like this:


_10
app.MapGet("/example",(HttpContext httpContext, ExampleContext exampleContext) =>
_10
{
_10
var result = exampleContext.Query(httpContext);
_10
return result; //automagically sorted, filtered and with pagination
_10
});

Under the hood the Query method gets the sort, page and pageSize query parameters from the HttpContext and uses them to build the query that gets executed on the DbContext.

It works pretty good, but the SwaggerUI shows none of these query parameters. We can’t even add them manually to test the Query functionality.

A SwaggerUI generated by Swashbuckle with missing query parameters
Fig 1: SwaggerUI with missing query parameters

We want to document all the dynamic query parameters a user is able to use. Not only so that we can test our own API, but also to show the consumers of our API everything they can do!

Extending Swashbuckle using IOperationFilter

Swashbuckle is pretty flexible. Much like WebAPI and MinimalAPIs it has a filter pipeline that let’s us customize the generation process. After Swashbuckle has generated the metadata for us it passes the metadata into the pipeline so that we can further modify it. We can extend the generator with the following filters:

  • Operation filters: modify the OpenApiOperation using the IOperationFilter
  • Schema filters: modify the OpenApiSchema using the ISchemaFilter
  • Document filters: modify the OpenApiDocument using the IDocumentFilter

We want to use the IOperationFilter since we need to add more query parameters to the Operation. We can do this pretty easily by creating a class called QueryParameterFilter and implement the IOperationFilter like this:


_17
public class QueryParameterFilter : IOperationFilter
_17
{
_17
public void Apply(OpenApiOperation operation, OperationFilterContext context)
_17
{
_17
operation.Parameters.Add(new OpenApiParameter
_17
{
_17
Name = "Custom",
_17
Description = "Dynamic query parameter we added ourselves",
_17
In = ParameterLocation.Query,
_17
Required = false,
_17
Schema = new OpenApiSchema
_17
{
_17
Type = "string",
_17
}
_17
});
_17
}
_17
}

In this snippet we add a new query parameter called Custom to every operation. The type is a string and it’s not required. We can add this filter to our Swashbuckle configuration like this:


_10
builder.Services.AddSwaggerGen(cfg =>
_10
{
_10
cfg.OperationFilter<QueryParameterFilter>();
_10
});

Customize per route with custom attributes

Well that’s all nice and dandy, but now every route has the custom query parameter listed in our OpenAPI docs. It makes more sense to make this an opt-in feature since not every route has dynamic query parameters called Custom.

To allow us some more customization we start with a custom attribute:

Where ParameterType is an enum of the allowed OpenAPI data types.


_14
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)]
_14
public class QueryParameterAttribute : Attribute
_14
{
_14
public QueryParameterAttribute(string name, ParameterType type)
_14
{
_14
Name = name;
_14
Type = type;
_14
}
_14
_14
public string Name { get; }
_14
public ParameterType Type { get; }
_14
_14
public string? Description { get; set; }
_14
}

As you can see in the first line of this snippet, we made our attribute usable on both methods and classes. This allows us to add the attribute to the controller and override it on the method level. We can also add multiple attributes to a single method or controller.

Next we’ll need our QueryParameterFilter to find the custom attributes and add the OpenApi Query Parameters using the data in the Attribute.

We need to use a little reflection to find all the QueryParameterAttributes. As you might have seen, we can declare our attribute both on the method and class/controller. However, with Minimal APIs the standard is to add any additional info as metadata. We can query all those places with the following code:


_10
var queryAttributes = Enumerable.Empty<object>()
_10
//find attributes on the declaring class
_10
.Union(methodInfo.DeclaringType.GetCustomAttributes(true))
_10
//find attributes on the method
_10
.Union(methodInfo.GetCustomAttributes(true))
_10
//find attributes in the endpoint metadata
_10
.Union(context.ApiDescription.ActionDescriptor.EndpointMetadata)
_10
//only get our QueryParameterAttribute
_10
.OfType<QueryParameterAttribute>();

Now that we have our list of attributes we can simply loop over them and add all the query parameters we have declared:


_14
foreach (var attribute in queryAttributes)
_14
{
_14
operation.Parameters.Add(new OpenApiParameter
_14
{
_14
Name = attribute.Name,
_14
Description = attribute.Description,
_14
In = ParameterLocation.Query,
_14
Required = false,
_14
Schema = new OpenApiSchema
_14
{
_14
Type = attribute.Type.ToString().ToLower(),
_14
}
_14
});
_14
}

Unlike our previous example, we now use the data from the attribute to build the OpenApiParameter. We also use the Type property of the QueryParameterAttribute to set the Type property of the OpenApiSchema.

Wrapping up

Personally, when writing utility features like this I like to spend a couple more minutes to improve the developer experience. I like to show my pride in my work by giving it that final polish.

In this case I like to wrap this project up by providing some easy extension methods.

For starters we could create a better way to add our dynamic query parameters to a Minimal API. Right now someone would need to do either this:


_10
app.MapGet("/example", () =>
_10
{
_10
return new ExampleDTO("Hello World!");
_10
})
_10
.WithMetadata(new QueryParameterAttribute("size", ParameterType.String))

Or this:


_10
app.MapGet("/example",[QueryParameterAttribute("size", ParameterType.String)] () =>
_10
{
_10
return new ExampleDTO("Hello World!");
_10
})

Both of which I don’t really like, especially when you need more than one dynamic query parameters.

So let’s give our users an extension method WithQueryParameter:


_12
public static TBuilder WithQueryParameter<TBuilder>(
_12
this TBuilder builder,
_12
string name,
_12
ParameterType type,
_12
string description
_12
) where TBuilder : IEndpointConventionBuilder
_12
{
_12
return builder.WithMetadata(new QueryParameterAttribute(name, type)
_12
{
_12
Description = description,
_12
});
_12
}

Finally, I would do the same for configuring our QueryParameterFilter. If I expect users to remember which classes they should register where and when I’m bound to get people who didn’t RTFM.

So by registering the QueryParameterFilter by using the IOptions pattern and adding a nice UseSwaggerQueryParameters method we can also hide away the configuration for the QueryParameterFilter.

Side note: Hiding the configuration might not make much sense in this case. We’re only registering one class. But when my configuration is more complex this is definitely a step I might consider.

Final thoughts

And there you have it! Don’t shy away of adding features you’re missing. You might feel stuck when the library you’re using doesn’t provide everything you need. But as you’ve seen, it’s not that hard to implement it yourself.

We've explored how to document dynamic query parameters in Minimal APIs by extending Swashbuckle using IOperationFilter, creating custom attributes, and even giving our solution a nice polish with some easy extension methods.

Keep experimenting and don't hesitate to shoot me a message on Twitter @Larsv94 if you want to dive deeper or need help with anything. See you at the next one!

Further improvements: Generic attributes

The current implementation of our QueryParameterFilter currently only supports String, Number, Integer and Boolean since Lists and Objects require a lot more complexity. Complexity that I judged to be out of scope for this tutorial. (Though if you want me to cover it anyway, shoot me a message on Twitter)

With C#11 we also got support for Generic Attributes, allowing us to use generics in our attributes like this:


_19
public class GenericQueryParameterAttribute<T> : Attribute where T : class
_19
{
_19
public GenericQueryParameterAttribute(string name)
_19
{
_19
Name = name;
_19
Type = typeof(T);
_19
}
_19
_19
public string Name { get; }
_19
public string? Description { get; set; }
_19
public Type Type { get; set; }
_19
}
_19
_19
//Usage
_19
[GenericQueryParameter<Person>("person")]
_19
public Example GetExample(){
_19
var person = HttpContext.Request.Query["person"].FirstOrDefault();
_19
//...
_19
}

So if you feel real inspired after reading this article and want to create a nice little library for your colleagues be sure to support all possible objects. And sprinkle over some generic magic if you’re feeling extra.

What to read next:

I really hope you enjoyed this article. If you did, you might want to check out some of these articles I've written on similar topics.