Extracting OpenAPI in .NET
What I’ve learnt from extracting OpenAPI specs from ASP.NET projects
For the past year, I have been maintaining the solution used to generate OpenAPI clients for ASP.NET projects at my organization. This involves extracting the OpenAPI schema, then converting it to client packages that can be published to a package manager for reuse across the organization.
OpenAPI (used to be called Swagger) is a standard for defining and documenting Http APIs. It helps us describe endpoints, request/response formats, authentication methods, and other details of an API in a machine-readable format, such using JSON or YAML. By using this standard, developers can have an easier time understanding and interacting with APIs, promoting interoperability and efficient development practices.
We have a lot of ASP.NET projects, both .NET Framework and Core, and this means I’ve had the opportunity to explore working with OpenAPI in both.
For this post, I will limit myself to talking about extracting OpenAPI schema in ASP.NET Core projects, talk about the gotchas I’ve learnt, and try to explain the problems I faced to get to this point.
If you just want the full code, the link to the GitHub gist is at the bottom.
1. Using the Swashbuckle.AspNetCore.Cli dotnet tool
For some, this is their first introduction to dotnet tools, and I’m happy to introduce you.
Just like you can distribute packages containing class libraries via Nuget, you can also distribute executables which act as tooling. To do this, we create a .config/dotnet-tools.json
file with:
{
"version": 1,
"isRoot": true,
"tools": {
"swashbuckle.aspnetcore.cli": {
"version": "6.2.3",
"commands": [
"swagger"
]
}
}
}
And then we run dotnet tool restore
to install the Swashbuckle.AspNetCore.Cli dotnet tool.
We will add the following command to the *.csproj
file of our ASP.NET API project:
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="dotnet tool restore" />
<Exec Command="dotnet swagger tofile --output obj/swagger.json $(OutputPath)$(AssemblyName).dll v1" EnvironmentVariables="ASPNETCORE_ENVIRONMENT=Development" />
</Target>
So that msbuild will execute our tool to extract a swagger.json
file from our project’s DLL, after it’s built.
2. Using FullNames in OperationIds
An operationId
is a unique identifier for every operation (think http endpoint) in an API. The “unique” here, is important, because this operationId
will be used by client generators, to name the functions/methods that will be used to invoke an http request to the endpoint represented by the operationId
, so it is important that an operationId
is well named.
Now, we know naming is hard, so I prefer to automate it rather than have developers come up with unique names for each endpoint. To do this:
using Microsoft.Extensions.DependencyInjection;
using Swashbuckle.AspNetCore.SwaggerGen;
services.AddSwaggerGen(options => {
options.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}");
});
This means that an endpoint represented by PersonController
and GetPersons
action, will have an operationId
value of Person_GetPersons
.
If it feels like tautology, remember that as your API grows, you just might have multiple GetPersons
actions across controllers in the system, so this is a pragmatic way to keep them unique without doing the hard work of coming up with unique semantic names for each endpoint.
3. Using FullNames in Schema Ids
It is equally important for the schema in the OpenAPI spec to have full names i.e. including the namespace, so a model class Person
used in a request, should appear in the schema as Sample.Person
where Sample
is its namespace.
This is to prevent duplicate schema in the spec, because you may have another Person
class somewhere that does not have the same shape.
To do this:
using Microsoft.Extensions.DependencyInjection;
using Swashbuckle.AspNetCore.SwaggerGen;
services.AddSwaggerGen(options => {
options.CustomSchemaIds(type => type.FullName?.Replace("+", "."));
});
4. Add Metadata to the OpenAPI spec
Having a title
, and apiVersion
can go a long way in helping manage your APIs.
The title
can be the FullName of the entry assembly e.g. Sample.Api
, and this helps different its OpenAPI spec document from another with a title, DifferentSample.Api
.
The apiVersion
is usually the entry assembly version i.e. the value specified by <Version>1.0.0</Version>
in the Sample.Api.csproj
file. This is important because the apiVersion
then becomes the package version of the generated clients.
These can be set by:
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Interfaces;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
services.AddSwaggerGen(options => {
var entryAssembly = typeof(Program)
.GetTypeInfo()
.Assembly;
string projectVersion = entryAssembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion ?? "1.0.0";
options.SwaggerDoc(
"v1",
new OpenApiInfo
{
Title = entryAssembly.GetName().Name,
Extensions = new Dictionary<string, IOpenApiExtension>
{
{ "apiVersion", new OpenApiString(projectVersion) }
}
}
);
});
5. Working with Nullable Enums
I found that Swashbuckle has a problem with marking Enum properties as nullable
in the generated spec. This means that if a model class Person
is defined as:
public record Person
{
public Level? Level { get; set; }
}
public enum Level
{
Low,
Medium,
High
}
Then the Level
property would not be marked as nullable
in the generated schema. This is a problem because generated clients might want to enforce the schema when making requests, or receiving responses (and they should, if they are good clients).
To fix this, I use a custom NullableEnumSchemaFilter
with code in this GitHub gist:
using Microsoft.Extensions.DependencyInjection;
using Swashbuckle.AspNetCore.SwaggerGen;
services.AddSwaggerGen(options => {
options.SchemaFilter<NullableEnumSchemaFilter>();
});
Now, the Level
property in the generated spec will look like:
{
"level": {
"allOf": [
{
"$ref": "#/components/schemas/Sample.Level"
}
],
"nullable": true
}
}
6. Describing Enums
Another one on enums is you absolutely have to describe them. One of the best ways I’ve found is to use the JsonStringEnumConverter
found here.
However, the JsonStringEnumConverter
uses string representations for enums, which is excellent for readability, but kinda sucks when added to an existing API that already has clients working with it, using the numeric values to receive enums. Adding JsonStringEnumConverter
would mean that these clients would have to be modified to receive the string enum values. So while it works great, and you should be using it for new APIs, I found I had to use something different for existing APIs.
I wanted a way that:
- these APIs could keep sending and receiving their numeric enum values
- the OpenAPI spec could know the string representations for these numeric values
- the generated clients could use these string representations to provide a mapping to the numeric values
It turns out, there isn’t a standard for this 😱. Each client generator kinda has its own, so I wrote EnumDescriptionSchemaFilter
(see GitHub Gist), a custom Schema Filter to leverage the ones used by the client generators I use, which are:
- OpenAPI-Zod-Client which uses
x-enumNames
- OpenApi Generator for CSharp which uses
x-enum-varnames
using Microsoft.Extensions.DependencyInjection;
using Swashbuckle.AspNetCore.SwaggerGen;
services.AddSwaggerGen(options => {
options.SchemaFilter<EnumDescriptionSchemaFilter>();
});
This way, my enum schema looks like:
{
"Sample.Level": {
"enum": [
0,
1,
2
],
"type": "integer",
"description": "0 = Low\n1 = Medium\n2 = High",
"format": "int64",
"x-enum-varnames": [
"Low",
"Medium",
"High"
],
"x-enumNames": [
"Low",
"Medium",
"High"
]
}
}
This mapping can be used by the client generators, and the description
can provide a fallback if it shows in the API documentation.
7. Working with Required Properties
When you have <Nullable>enabled</Nullable>
, then you want properties that not nullable to be required in the OpenAPI schema.
And in all cases, you want properties with the [Required]
attribute, to be required in the OpenAPI schema.
E.g.
public record Person
{
public string Name { get; set; }
public DateTime? DateOfBirth { get; set; }
}
should emit a schema like:
{
"Sample.Person": {
"required": [
"name"
],
"type": "object",
"properties": {
"name": {
"type": "string"
},
"dateOfBirth": {
"type": "string",
"format": "date-time",
"nullable": true
},
},
"additionalProperties": false
}
}
Using the above schema, a typescript client generator would define its type as:
type Sample_Person = {
name: string;
dateOfBirth?: string;
}
So it is important that we are able to mark properties as required in the schema.
To do this, we use the RequiredPropertiesSchemaFilter
(see GitHub gist).
using Microsoft.Extensions.DependencyInjection;
using Swashbuckle.AspNetCore.SwaggerGen;
services.AddSwaggerGen(options => {
options.SchemaFilter<RequiredPropertiesSchemaFilter>();
});