Four Ways to Generate an API Client
FastEndpoints will hand you a typed client four different ways - NSwag-generated C# or TypeScript, or Kiota clients built over either OpenAPI stack - served as a download endpoint or produced by a run-then-exit build mode. The lineages, the seams, and one version pin that tells a story. Part 9 of FastEndpoints — The Missing Chapters.
The parent series spent one sentence on client generation - “ClientGen/Kiota generate typed clients from the docs” - which under this series' own audit rules counts as not covered at all. Deservedly not, it turns out: there are three packages here (FastEndpoints.ClientGen, FastEndpoints.ClientGen.Kiota, FastEndpoints.OpenApi.Kiota), four distinct generation lineages, two delivery mechanisms, and a version pin whose comment explains the whole NSwag-to-native migration in one line. Consider this the missing chapter on never writing an HTTP client by hand again.
The two-by-two
Every option answers the same two questions: which engine writes the client code, and which stack produced the OpenAPI document it reads?
Document from NSwag (Src/Swagger) |
Document from Microsoft.AspNetCore.OpenApi (Src/OpenApi) |
|
|---|---|---|
| NSwag CodeGen (C# / TypeScript) | FastEndpoints.ClientGen |
- |
| Kiota (C#, TS, Go, Python, Java…) | FastEndpoints.ClientGen.Kiota |
FastEndpoints.OpenApi.Kiota |
The empty cell is no accident - NSwag's code generators consume NSwag's document model, so they retire together when the deprecation lands. The two Kiota packages are near-twins that differ only in where the JSON comes from, which is why they make such a clean case study in what the migration actually changes.
Lineage one: NSwag CodeGen
FastEndpoints.ClientGen wraps NSwag.CodeGeneration.CSharp and .TypeScript. You can hang generation off document creation itself:
bld.Services.SwaggerDocument(o =>
o.DocumentSettings = s =>
{
s.DocumentName = "v1";
s.GenerateCSharpClient(c => c.ClassName = "OrdersClient",
destination: "../Clients/OrdersClient.cs");
});
That registers an NSwag document processor which, whenever the document is generated, writes the client file. Reading the processor turned up the one flaw I'd flag in review: the write is a fire-and-forget _ = File.WriteAllTextAsync(...) - nothing awaits it, so a process that exits promptly after generation can, in principle, race the write. In the normal hosting flow it is invisible; in scripted use I'd prefer the exit modes below, which do their own synchronous-enough orchestration.
The second delivery mechanism is my favourite party trick of the family:
app.MapCSharpClientEndpoint("/cs-client", "v1", c => c.ClassName = "OrdersClient");
app.MapTypeScriptClientEndpoint("/ts-client", "v1");
Your API now serves its own client SDK: GET /cs-client downloads a ready-to-compile ApiClient.cs generated from the live document (and the route excludes itself from the docs, avoiding strange loops). For internal APIs where consumers are three teams down the hallway, “the client is at /cs-client” beats a package registry pipeline more often than architecture diagrams admit.
Lineage two, twice: Kiota
Kiota is Microsoft's cross-language generator, and both Kiota packages expose the same three-part surface. A download endpoint - MapApiClientEndpoint("/api-client", c => { ... }) - that generates into a temp workspace, zips the result, and streams the archive. And two exit modes for build pipelines:
await app.GenerateApiClientsAndExitAsync(c =>
{
c.OpenApiDocumentName = "v1"; // SwaggerDocumentName in ClientGen.Kiota
c.Language = GenerationLanguage.CSharp;
c.ClientClassName = "OrdersClient";
c.OutputPath = "../clients/csharp";
});
The pattern deserves a name - call it run-then-exit tooling. The line is inert during normal hosting; run the same binary with --generateclients true and the app boots far enough to build its document, runs KiotaBuilder, writes the client, and calls Environment.Exit(0). Your deployable is your codegen tool - no separate CLI to version-match against the app, no drift between the spec the tool saw and the endpoints you shipped. The repo's own integration tests exercise it exactly the way your CI would: spawn dotnet run -- --generateclients true, assert exit code zero and that the zip contains .cs files.
The differences between the twins are small but telling. ClientGen.Kiota names its config SwaggerDocumentName and pulls JSON via NSwag's IOpenApiDocumentGenerator; OpenApi.Kiota says OpenApiDocumentName and resolves the platform's keyed IOpenApiDocumentProvider. The newer package targets net10.0 only, uses C# 14 extension members, cleans up with a disposable workspace and async zipping - and is marked AOT-compatible except for the pieces that can't be: MapApiClientEndpoint carries [RequiresDynamicCode], an honest admission that Kiota generation itself needs a JIT even when your document pipeline doesn't.
And the exit-mode flags are a museum of accreted naming: --generateclients (both Kiota packages and ClientGen), --exportswaggerjson (NSwag lineage), --exportopenapijson (new Kiota package), --export-openapi-docs (the core OpenApi export - the only hyphenated one). Four spellings for “boot, write files, exit.” Nothing is wrong, exactly; it is what organic growth across a migration looks like, preserved in amber.
The version pin that explains everything
In Directory.Packages.props, Kiota's builder is pinned hard - [1.29.0], brackets meaning exactly this version - with a comment: newer Kiota builds against Microsoft.OpenApi 3.x, while Microsoft.AspNetCore.OpenApi still uses 2.x. Two Microsoft libraries, one shared dependency, incompatible major versions; FastEndpoints sits in the middle and holds the line at the last version that agrees with both.
I highlight it because this is the true cost of the two-stack transition period, stated more plainly than any changelog: while the ecosystem migrates, integrators absorb the diamond-dependency problem. If you adopt OpenApi.Kiota today, that pin is your pin too - you inherit both the stability and the staleness until the OpenApi 3.x wave completes.
Choosing
My rules after reading all three packages. Already on NSwag documents with C#-only consumers: ClientGen is fine and going nowhere before the next major. Anything new: Kiota over whichever document stack you run - and if that decision is open too, take the native stack and OpenApi.Kiota, aligning with where the repo is visibly heading. Multi-language consumers: Kiota, no contest; one generator and one shape across C#, TypeScript and the rest beats maintaining NSwag templates per language. And regardless of lineage, prefer the exit modes in CI over committing generated code by hand - a client regenerated on every build cannot drift, and drift is the entire disease typed clients exist to cure.
What we have now is a pipeline: endpoint metadata into a document (last week), document into typed clients (this week). The remaining chapters ask a stranger question - what if the caller is not a developer with a client at all, but a rules engine, or an AI agent?