8 min read 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:

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

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:

1
public class QueryParameterFilter : IOperationFilter
2
{
3
public void Apply(OpenApiOperation operation, OperationFilterContext context)
4
{
5
operation.Parameters.Add(new OpenApiParameter
6
{
7
Name = "Custom",
8
Description = "Dynamic query parameter we added ourselves",
9
In = ParameterLocation.Query,
10
Required = false,
11
Schema = new OpenApiSchema
12
{
13
Type = "string",
14
}
15
});
16
}
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:

1
builder.Services.AddSwaggerGen(cfg =>
2
{
3
cfg.OperationFilter<QueryParameterFilter>();
4
});

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.

1
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)]
2
public class QueryParameterAttribute : Attribute
3
{
4
public QueryParameterAttribute(string name, ParameterType type)
5
{
6
Name = name;
7
Type = type;
8
}
9
10
public string Name { get; }
11
public ParameterType Type { get; }
12
13
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:

1
var queryAttributes = Enumerable.Empty<object>()
2
/​/​find attributes on the declaring class
3
.Union(methodInfo.DeclaringType.GetCustomAttributes(true))
4
/​/​find attributes on the method
5
.Union(methodInfo.GetCustomAttributes(true))
6
/​/​find attributes in the endpoint metadata
7
.Union(context.ApiDescription.ActionDescriptor.EndpointMetadata)
8
/​/​only get our QueryParameterAttribute
9
.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:

1
foreach (var attribute in queryAttributes)
2
{
3
operation.Parameters.Add(new OpenApiParameter
4
{
5
Name = attribute.Name,
6
Description = attribute.Description,
7
In = ParameterLocation.Query,
8
Required = false,
9
Schema = new OpenApiSchema
10
{
11
Type = attribute.Type.ToString().ToLower(),
12
}
13
});
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:

1
app.MapGet("/​example", () =>
2
{
3
return new ExampleDTO("Hello World!");
4
})
5
.WithMetadata(new QueryParameterAttribute("size", ParameterType.String))

Or this:

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

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:

1
public static TBuilder WithQueryParameter<TBuilder>(
2
this TBuilder builder,
3
string name,
4
ParameterType type,
5
string description
6
) where TBuilder : IEndpointConventionBuilder
7
{
8
return builder.WithMetadata(new QueryParameterAttribute(name, type)
9
{
10
Description = description,
11
});
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:

1
public class GenericQueryParameterAttribute<T> : Attribute where T : class
2
{
3
public GenericQueryParameterAttribute(string name)
4
{
5
Name = name;
6
Type = typeof(T);
7
}
8
9
public string Name { get; }
10
public string? Description { get; set; }
11
public Type Type { get; set; }
12
}
13
14
/​/​Usage
15
[GenericQueryParameter<Person>("person")]
16
public Example GetExample(){
17
var person = HttpContext.Request.Query["person"].FirstOrDefault();
18
/​/​.​.​.
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.