Teaching NSwag What Your Endpoints Mean
FastEndpoints' Swagger support is a thousand-line translation layer that knows which DTO properties are route params, which FluentValidation rules are schema constraints, and which endpoint versions belong in which document. Part 10 of FastEndpoints in Depth.
OpenAPI generation is where framework abstractions go to be found out. Whatever clever binding model you invented, the swagger document has to describe it in a vocabulary that predates you — parameters, schemas, content types. FastEndpoints' binding model (Part 4) is very invented: one DTO bound from eight sources. The FastEndpoints.Swagger package is the translation layer that makes that model legible to the rest of the world, and at roughly a thousand lines, its OperationProcessor is the largest single file I read in the whole repository. This post is about what those lines buy you.
Setup and the NSwag choice
builder.Services.SwaggerDocument(o =>
{
o.DocumentSettings = s =>
{
s.Title = "Orders API";
s.DocumentName = "release-2";
s.MaxEndpointVersion = 2;
};
o.ShortSchemaNames = true;
});
...
app.UseSwaggerGen();
The package builds on NSwag rather than Swashbuckle or the newer Microsoft.AspNetCore.OpenApi. Reading the code makes the reason evident: NSwag's processor pipeline (operation processors, document processors, schema processors) gives the library the hooks it needs to rewrite what reflection-based generation would get wrong about its DTOs. (A separate FastEndpoints.OpenApi package tracks the Microsoft generator for those standardizing on it; the NSwag package is the mature path and the one this post describes.)
Each SwaggerDocument() call registers one document, and this is where Part 9's release-group model completes: MaxEndpointVersion makes the document a release snapshot — for every route, the highest endpoint version at or below the ceiling is included. Two SwaggerDocument registrations give you a “release-1” and “release-2” doc whose differences are exactly the endpoints that changed. Add MinEndpointVersion or endpoint tag filtering to slice further (internal vs public docs from one codebase is the classic use).
The problem the OperationProcessor solves
Consider this DTO on PUT /orders/{orderId}/lines/{lineId}:
public class UpdateLineRequest
{
public Guid OrderId { get; set; } // bound from route
public Guid LineId { get; set; } // bound from route
[QueryParam] public bool DryRun { get; set; }
[FromHeader("x-tenant")] public string Tenant { get; set; } = "";
public int Quantity { get; set; } // bound from JSON body
}
Naive schema generation would document a JSON body with five properties — wrong four times over. The client would send orderId in the body where it is ignored (remember from Part 4: route values overwrite body values), and no tooling would know about the header. The OperationProcessor fixes this by replaying the binder's own rules against the document: it walks the route template and the DTO's binding attributes, removes route/query/header-bound properties from the request body schema, and emits them as proper OpenAPI parameters (in: path, in: query, in: header) with the right types, nullability and defaults. If after stripping, nothing body-bound remains, the request body disappears from the doc entirely — which is also why GET endpoints with fully route/query-bound DTOs document cleanly instead of advertising phantom JSON bodies.
That is the core job, and the file's size is the long tail of it done properly: XML documentation comments flowing into summaries and property descriptions, per-property example values, form-data and file-upload content types, [FromBody] envelope handling, multiple-route endpoints, deprecated-version marking, and de-duplication of response metadata (the produces-metadata cleanup in the mapping code exists specifically because .NET 9 changed default metadata behaviour — a fix with the GitHub issue number in a comment; reading it you can watch the library absorb framework churn on your behalf).
Endpoint-side, the Summary() and Description() builders in Configure() feed the same machinery — response codes with descriptions, per-status response types, request/response examples — so documentation lives in the endpoint class next to the code it documents. With EnableJsonComments, your XML doc comments do double duty.
Validation rules become schema constraints
The feature that most impressed me: ValidationSchemaProcessor reads your FluentValidation validators (Part 5) and projects the rules into JSON Schema:
NotEmpty()/NotNull()→ required + minLengthLength(3, 40)→ minLength/maxLengthInclusiveBetween(1, 100)→ minimum/maximumMatches(regex)→ patternEmailAddress()→ format
Client generators then produce client-side validation from the same rules the server enforces — one source of truth, two enforcement points. The processor walks rule chains including child validators and RuleForEach, though conditional rules (When(...)) are — sensibly — left out of the schema, since JSON Schema can't express them. If you have ever maintained a frontend validation config that drifted from the backend's, this single processor is a quiet end to that whole class of bug.
Auth metadata flows too: OperationSecurityProcessor reads the per-endpoint policies from Part 7 and marks operations with their security schemes, so “which endpoints need a token” is visible in the UI instead of tribal knowledge. Anonymous verbs are correctly exempted per-verb.
Names, tags, and the things you'll want to configure
Defaults worth knowing before your API's doc goes public:
- Schema names are full-type-name based unless
ShortSchemaNamesis set; nested DTOs get parent-qualified names. Decide early — schema names are contract surface for generated clients, and renaming later breaks downstream code. - Operation tags default to the first route segment (configurable
AutoTagPathSegmentIndex, or per-endpoint tag overrides), and tag casing is configurable — small, but it decides how your swagger UI groups. - Endpoint naming feeds
operationId, which client generators turn into method names. TheWithName(...)override exists for when the generatedCreateOrderEndpointshape isn't what you want your SDK to read like.
And that leads to the payoff feature: the repository ships client generation as a first-class concern — NSwag-based C#/TypeScript generation and a Kiota-based path (FastEndpoints.ClientGen.Kiota), with an MSBuild-friendly way to emit clients per swagger doc at build time. Combined with release-snapshot documents, each release of your API can publish a matching typed client package. On one project we wired doc generation → client generation → NuGet publish into CI: an endpoint merged on Monday was a typed SDK method by Monday's build, with client-side validation attached. That loop — endpoint class to published SDK with no hand-written contract anywhere — is, for me, the strongest practical argument for the whole FastEndpoints metadata model.
The honest limits
The translation layer is thorough but not omniscient. Exotic custom binders (your own IRequestBinder<T> from Part 4) are invisible to it — document those endpoints manually via Summary(). Polymorphic response types need the polymorphism schema processor and explicit discriminators. And NSwag itself occasionally lags brand-new C# type-system features; the repository's snapshot tests (there is a whole test project diffing generated documents) tell you the maintainers feel that pain first.
The next two parts leave HTTP entirely. FastEndpoints ships an in-process messaging layer — an event bus and a command bus with middleware — that quietly replaces a whole category of MediatR usage, and reading its dispatch code reveals some sharp lifetime choices: Part 11, An Event Bus and a Command Bus, No Extra Package Required.