Skip to content
Kumar Chandrachooda
.NET

Documenting an API That Has No Controllers

Delete your controllers and Swashbuckle goes blind. Convey rebuilds the OpenAPI document by hand from its endpoint registry - a document filter, a naming convention, and a few spec violations it hopes you won't notice.

By Kumar Chandrachooda 26 Feb 2026 6 min read
An OpenAPI document assembled from an endpoint registry

There is a hidden dependency in every .NET service that serves Swagger: ApiExplorer. Swashbuckle doesn't inspect your routes — it asks MVC's metadata layer what actions exist, and that layer only knows about controllers. The moment a toolkit like Convey (open source, by DevMentors) replaces controllers with a lambda-based endpoint DSL, Swashbuckle's view of your API becomes an empty document. Technically valid. Completely useless.

Convey's fix spans two small packages, and the split itself is the first design lesson. Convey.Docs.Swagger is generic Swashbuckle wiring — options binding, UI hosting — usable by any service including ones with real controllers. Convey.WebApi.Swagger is the bridge: one document filter that teaches Swashbuckle about the DSL's routes. Transport documentation and route discovery are different problems, and keeping them in different assemblies means the plain package doesn't drag the DSL along as a dependency.

The generic half: options in, UI out

Convey.Docs.Swagger follows the chassis pattern to the letter — a "swagger" configuration section, an options class, an Add/Use pair:

"swagger": {
  "enabled": true,
  "reDocEnabled": false,
  "name": "v1",
  "title": "Orders Service",
  "version": "v1",
  "routePrefix": "docs",
  "includeSecurity": true
}

AddSwaggerDocs() binds that, registers SwaggerGen with a single doc, and honours a couple of switches worth knowing. ReDocEnabled swaps the SwaggerUI front end for ReDoc at the same route prefix — a one-boolean migration I've used when a service's docs audience shifted from “developers poking endpoints” to “partners reading reference material”. SerializeAsOpenApiV2 serves the old Swagger 2.0 shape for tooling that never learned OpenAPI 3. There is also a fluent ISwaggerOptionsBuilder overload for configuring in code, which mainly exists so tests and samples don't need appsettings files.

One flag deserves a warning label. includeSecurity: true adds a Bearer scheme via AddSecurityDefinition — and does nothing else. No AddSecurityRequirement, no operation filter. In OpenAPI terms that means the scheme is declared but never applied to any operation: SwaggerUI shows the Authorize button, tokens get attached when you use it, but the generated spec does not mark a single endpoint as requiring auth. Any client generator or API gateway consuming the spec will conclude your API is anonymous. I've watched that exact misunderstanding ship: the padlock in the UI convinced everyone the documentation was security-aware. It's decorative. If the spec needs to tell the truth about auth, you add the security requirement yourself.

The bridge: rebuilding the document by hand

Now the interesting half. Recall from part 5 that Convey's EndpointsBuilder records every registered route into a singleton WebApiEndpointDefinitions list — method, path, input type, output type, plus an Example instance of each type built via GetDefaultInstance(). That list was the API metadata all along; Convey.WebApi.Swagger just plugs it into Swashbuckle's one extensibility point that operates on the whole document:

public static IConveyBuilder AddWebApiSwaggerDocs(this IConveyBuilder builder)
    => builder.AddWebApiSwaggerDocs(b => b.AddSwaggerDocs());
// ...then:
builder.Services.AddSwaggerGen(c => c.DocumentFilter<WebApiDocumentFilter>());

WebApiDocumentFilter.Apply runs after Swashbuckle has produced its (empty) document and fills it in: group definitions by path, create an OpenApiPathItem per group, an operation per method, then map the recorded types into request bodies, parameters and responses. The Example instances become indented-JSON samples on every operation — so the docs show a realistic CreateOrder payload without anyone writing an XML doc comment or an example annotation, ever.

The most Convey-flavoured line in the filter is where it decides what a GET's input means:

// Convey.WebApi.Swagger/Filters/WebApiDocumentFilter.cs
else if (parameter.In is InQuery)
{
    if (parameter.Type.GetInterface("IQuery") is not null)
    {
        operation.RequestBody = new OpenApiRequestBody
        {
            Content = new Dictionary<string, OpenApiMediaType>
            {
                ["application/json"] = new() { Schema = ... }
            }
        };
    }
    ...

If the GET's input type implements IQuery, the filter documents it as a JSON request body rather than as query-string parameters. That mirrors how the DSL actually binds queries (the whole object deserialized from route + query values, part 5's ReadQuery), so the docs are honest about the convention — but a GET-with-body is itself unusual enough that consumers reading your spec will raise an eyebrow. When your framework has a nonstandard convention, its documentation inherits the nonstandardness. There's no way around that; there's only being aware of it.

Where the seams show

This filter is ~140 lines doing a job Swashbuckle normally spreads across thousands, so corners were cut. Reading them is instructive:

  • Schema types are CLR names. Every generated schema sets Type = parameter.Type.Name — so the spec says "type": "CreateOrder" where OpenAPI permits only object, string, integer and friends. SwaggerUI shrugs and renders it; anything that validates the spec — client generators, linters, gateway importers — rejects it. The examples carry the real information; the schemas are structurally decorative.
  • Four verbs, or else. The method-to-operation mapping is a switch over GET/POST/PUT/DELETE that returns null for anything unmatched, and the caller immediately dereferences the result. The DSL can't currently register a PATCH, so the NullReferenceException is unreachable — until someone extends EndpointsBuilder with Patch<T> (a natural first contribution!) and discovers the doc filter was the hidden coupling.
  • Examples come from GetUninitializedObject. The shared example builder creates instances without running constructors, filling properties via reflection with a cycle-guard cache. For DTOs it's fine; for types whose constructors establish invariants you get examples no real instance could exhibit — empty GUIDs, nulls where the constructor would have thrown.
  • Response codes are guessed. Definitions record 200 for GET and a blanket 202 for everything else (the DSL's dispatcher endpoints actually default to 200 for commands, and your afterDispatch might send 201). The documented status and the served status can simply disagree. Nothing reconciles them.

I want to be fair about what these warts mean. For the audience this documentation actually serves — a developer opening /docs to see what endpoints exist and what a payload looks like before wiring up a client — none of them matter. The docs are accurate where humans read them and sloppy where machines do. But it does mean you should not point openapi-generator or a contract-testing tool at a Convey service's swagger.json and expect joy. For machine-consumable contracts, the /_contracts endpoint from part 6 is the honest source; the Swagger doc is a human-facing courtesy. Knowing which artifact is authoritative for which audience is, I'd argue, the actual takeaway of this part.

The pattern worth stealing

Strip away Swashbuckle and the design generalises: when you build a routing abstraction, record rich metadata at registration time, because someone will need to project it later. Convey's DSL cost itself nothing by appending a definition object per endpoint — and that list has now paid for Swagger docs, and could as easily feed a gateway route table or a client SDK generator. .NET itself landed on the same principle: Minimal APIs attach EndpointMetadataCollection to every route, and modern Swashbuckle (and .NET 9's built-in OpenAPI generation) read from it. Convey just got there first, with a List<T> and a document filter.

That closes out Convey's HTTP story: a DSL instead of controllers (part 5), dispatchers instead of handlers (part 6), and a hand-assembled OpenAPI document to keep it all visible (this part). Next, the series leaves HTTP behind entirely — part 8 opens the messaging abstractions, where Convey's conventions stop being a convenience and start being the thing holding a distributed system together.