Extracting OpenAPI in .NET

What I’ve learnt from extracting OpenAPI specs from ASP.NET projects

Ikechi Michael
6 min readApr 13, 2024

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.

ASP.NET with OpenAPI

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:

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>();
});

--

--

Ikechi Michael

I’ve learned I don’t know anything. I've also learned that people will pay for what I know. Maybe that's why they never pay.