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
andQuery13
, we should havePostRatingQuery
andDeleteRatingQuery
, 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 | public sealed class IncludeInternalApisProcessor : IOperationProcessor |
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 | public sealed class InternalApiDocumentTransformer : IOpenApiDocumentTransformer |
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 | public sealed class MinimalApiTagProcessor : IOperationProcessor |
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 | public sealed class MinimalApiTagOperationTransformer : IOpenApiOperationTransformer |
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 | public static class GetThingHandler |
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 | public sealed class NSwagNestedNameGenerator : ISchemaNameGenerator |
I will update this post when I, or someone else, figures out a solution.