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!
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:
_10app.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.
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 theIOperationFilter
- Schema filters: modify the
OpenApiSchema
using theISchemaFilter
- Document filters: modify the
OpenApiDocument
using theIDocumentFilter
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:
_17public 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:
_10builder.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)]_14public 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:
_14foreach (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:
_10app.MapGet("/example", () =>_10{_10 return new ExampleDTO("Hello World!");_10})_10.WithMetadata(new QueryParameterAttribute("size", ParameterType.String))
Or this:
_10app.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
:
_12public 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:
_19public 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")]_19public 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.
- Read Three ways to structure .NET Minimal APIs— 9 minutes readThree ways to structure .NET Minimal APIs
- Read Should you use WebApi or Minimal APIs?— 7 minutes readShould you use WebApi or Minimal APIs?
- Read Organizing Your OpenAPI Docs in .NET: Creating Custom Groups with Swashbuckle— 6 minutes readOrganizing Your OpenAPI Docs in .NET: Creating Custom Groups with Swashbuckle