From NSwag To Built-In OpenAPI


With the .NET 9.0 release, and specifically ASP.NET Core 9.0, we got a built-in OpenAPI generator. No need to depend on Swashbuckle or NSwag anymore.

OpenAPI generation has been an issue for Ogma3 for quite some time now, and there was no silver bullet. Swashbuckle would have dodgy support for forms, NSwag would outright not support HEAD method, et cetera.

Thankfully, with the new, first-party support, all those issues should be a song of the past, leaving just one: migrating off NSwag. As it turns out, it was not that hard, and you can read the details below.

There were three customizations I made to how NSwag generates the APIs:

  • Generating separate document for internal and public APIs, based on whether the path starts with /admin or not
  • Automatically tagging the operations based on, again, a path segment. Something that’s automatic when you use controllers, but not with minimal APIs
  • Renaming the query and response objects to contain handler names. Instead of Query12 and Query13, we should have PostRatingQuery and DeleteRatingQuery, for example.

The first two were easy, after some basic debugging and reading through the documentation. The third one, not so much.

Separating documents

That one was extremely easy to do with NSwag. All that was needed is an IOperationProcessor with a single line:

1
2
3
4
5
6
7
public sealed class IncludeInternalApisProcessor : IOperationProcessor
{
public bool Process(OperationProcessorContext context)
{
return context.OperationDescription.Path.StartsWith("/ADMIN", StringComparison.CurrentCultureIgnoreCase);
}
}

this processor is applied to each operation, one by one. If an operation makes the Process method return true it’s included in the document. If not, it’s excluded from it.

The built-in OpenAPI works a little different. While there is an operation processor, it’s there to process the operation, that is change it’s parameters. Not to include or exclude it.

Thankfully, we have access to a document processor, that processes the document and thuse, can filter out items contained within.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public sealed class InternalApiDocumentTransformer : IOpenApiDocumentTransformer
{
public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
{
// filter out all the desired paths
var paths = document.Paths
.Where(p => p.Key.Trim('/').StartsWith("admin"))
.ToDictionary(kv => kv.Key, kv => kv.Value);

// Clear the existing paths
document.Paths.Clear();

// Replace them with the filter result
document.Paths.AddRange(paths);

return Task.CompletedTask;
}
}

Each path contains operations, so by excluding specific paths we exclude all operations that belong to it.

There probably is a better way than deleting all paths and replacing them like that, but I could not find one. document.Paths is a bit odd, in that it is a dictionary, but a dictionary cannot be assigned to it, so there’s no in-place replacing of it.

Automatic tagging

The cool thing about regular controllers, is that they provide built-in tagging. For example, a BooksController will be tagged with "Books". That helps group up the paths, in SwaggerUI, Scalar, or whatever other tool we’re using.

The way to do tagging with minimal APIs is pretty much entirely manual. Since they’re not class-based, and each endpoint can just be a bare lambda, there’s nowhere to get that automatic tag from. Using Immediate.Apis does not change things, not really. Perhaps in the future it will support automatic tags, but not yet.

With NSwag, we needed another IOperationProcessor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public sealed class MinimalApiTagProcessor : IOperationProcessor
{
public bool Process(OperationProcessorContext context)
{
// If it's a controller and not a minimal API endpoint, skip
if (context.ControllerType is not null)
{
return true;
}

// If it already has tags, skip
if (context.OperationDescription.Operation.Tags is { Count: > 0 })
{
return true;
}

// Get the path, split it, get first element
var path = context.OperationDescription.Path;
var tag = path
.Replace("api", "", StringComparison.InvariantCultureIgnoreCase)
.Split('/', StringSplitOptions.RemoveEmptyEntries)[0];

// Add a new tag
context.OperationDescription.Operation.Tags.Add(tag.Pascalize());

// The operation should not be filtered out
return true;
}
}

although it could probably have been rewritten to use list patterns to look a little better. Just like the new one does. We use an IOpenApiOperationTransformer this time, to transform each individual operation, not the whole document.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public sealed class MinimalApiTagOperationTransformer : IOpenApiOperationTransformer
{
public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
{
// Split the path into segments and check if it's not null
if (context.Description.RelativePath?.Split("/", StringSplitOptions.RemoveEmptyEntries) is not {} split)
{
return Task.CompletedTask;
}

// If the path starts with `admin` we want to discard that segment
split = split[0] == "admin" ? split[1..] : split;

// Some list matching to ensure the path starts with `api` and contains a name for us to use
if (split is not ["api", var name, ..])
{
return Task.CompletedTask;
}

// By default, each operation is tagged with project name,
// `Ogma3` in this case.
// When everything is tagged with the same tag, that's kinda useless, so we remove it
operation.Tags.Clear();

// Finally, add the tag
operation.Tags.Add(new OpenApiTag
{
Name = name,
});

return Task.CompletedTask;
}
}

Simple, really. Each processor has access to the entire description, the whole of operation data, so getting the info we need and processing it could not be any easier.

Renaming the query and response types

The way Ogma3 is written, each handler has nested classes for query and response types. Since they are nested, they always require the parent class name to refer to them, so it’s not an issue in regular code.

1
2
3
4
5
6
7
8
public static class GetThingHandler
{
public sealed record Query(int Id);

// handler code

public sealed record Response(string Name, int Count);
}
1
var q = new GetThingHandler.Query(42);

Various OpenAPI generators, alas, do not look at this hierarchy. To them, those objects are just Query and Response. So what happens when we have multiple handlers like that?

  • NSwag: breaks
  • Swashbuckle: breaks
  • Built-in generator: numbers them

That already means we don’t really need to rename them. We’ll be getting Query67 and Query68 in te generated document, but at least nothing breaks.

Well… nothing in the generation does. This issue would break all the Typescript code that currently exists in the codebase. We use a TS client generator that generates both clients and types, based on the names from the schema. It will cause an issue, if the current code is

1
const thing: ThingResponse = await getThing(11);

and ThingResponse gets renamed to Response69 in the new version of generated client.

Search&replace, or even a quick fix would be a fix, but let’s be honest, who wants to see Query119 in their code instead of GetItemQuery?

The renaming, alas, is not something I have solved yet, for the new generator. NSwag handled it with an ISchemaGenerator like so, though:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public sealed class NSwagNestedNameGenerator : ISchemaNameGenerator
{
public string Generate(Type type)
{
var typeAttribute = type.ToCachedType().GetAttribute<JsonSchemaAttribute>(true);

if (!string.IsNullOrEmpty(typeAttribute?.Name))
return typeAttribute.Name;

var cachedType = type.ToCachedType();

if (!cachedType.Type.IsConstructedGenericType)
return GetName(cachedType);

return GetName(cachedType).Split('`').First() + "Of" + string.Join("And",
cachedType.GenericArguments.Select((Func<CachedType, string>)(a => Generate(a.OriginalType))));
}
private static string GetName(CachedType cType)
{
return cType.Name switch
{
"Int16" => GetNullableDisplayName(cType, "Short"),
"Int32" => GetNullableDisplayName(cType, "Integer"),
"Int64" => GetNullableDisplayName(cType, "Long"),
_ when cType.Type.IsConstructedGenericType => GetNullableDisplayName(cType, cType.Name),
_ => GetNullableDisplayName(cType, GetNameWithNamespace(cType)),
};
}
private static string GetNameWithNamespace(CachedType cType)
=> cType.Type.FullName!.Split('.')[^1].Replace("+", "");
private static string GetNullableDisplayName(CachedType type, string actual)
=> (type.IsNullableType ? "Nullable" : "") + actual;
}

I will update this post when I, or someone else, figures out a solution.