OpenAPI
app.MapOpenApi(configure: o =>{ o.Title = "Widgets"; o.Version = "1.0.0"; o.AddBearerAuthentication();});/openapi.json now serves an OpenAPI 3.0.3 document. It is built on the first request and cached —
the route table is frozen by the time anything can ask for it — and the endpoint excludes itself from
what it publishes.
To write it out at build or test time instead of serving it:
var json = OpenApiDocumentBuilder.BuildJson(app);File.WriteAllText("openapi.json", json);Where the document comes from
Section titled “Where the document comes from”Two sources, both compile-time.
The route table. Generated endpoints carry an ApiOperation the generator emitted from the
analysis it had already done to write the binder: parameter names and sources, the body type, the
return type, and the <summary> from the method’s doc comment. Raw routes get path parameters
inferred from their template, including the constraint’s type.
Your JsonSerializerContext. Schemas are read off JsonTypeInfo rather than
Type.GetProperties(), so property names go through the same naming policy the serializer uses — the
document cannot describe a payload the server would not actually produce. It also means schema
generation is reflection-free and survives AOT.
The whole thing is written with Utf8JsonWriter straight to bytes: no document object model to keep
in sync with the spec, and nothing to register in order to serialize it.
Describing generated endpoints
Section titled “Describing generated endpoints”/// <summary>Fetches a single widget by id.</summary>[Get("/{id:int}")][Produces(200, typeof(Widget))][Produces(404, Description = "No widget has that id")]public async Task<IActionResult> GetWidget(int id, CancellationToken ct) => …;| Attribute | Effect |
|---|---|
[Produces(status, type?)] |
Declares a response. Also takes Description and ContentType |
[ApiTags("…")] |
Groups operations. On a class it covers every endpoint; on a method it replaces the class’s. Without it the class name is the tag |
[ApiExclude] |
Omits a class or method from the document — the route still works |
[Produces] matters most for a method returning IActionResult, which has deliberately hidden its
status codes; that is the point of the abstraction. When the generator can see the return type, it
infers a 200 from it.
The <summary> doc comment becomes the operation summary, so the documentation you already write for
your own team is the documentation the API publishes.
Describing raw routes
Section titled “Describing raw routes”Describe applies to the most recently mapped route:
app.MapGet("/ping", ctx => ctx.Response.WriteAsync("pong")) .Describe(o => { o.Summary = "Liveness probe"; o.Tags.Add("ops"); o.Responses.Add(new ApiResponse { StatusCode = 200, Type = typeof(string), ContentType = "text/plain" }); });There are shorthands on the route-builder form used by modules and groups: WithSummary, WithTags
and ExcludeFromDescription.
ApiOperation carries Summary, Description, OperationId, Deprecated, Exclude,
RequiresAuthorization, Tags, Parameters, RequestBody and Responses.
Options
Section titled “Options”| Property | Default | Notes |
|---|---|---|
Title / Version / Description |
API, 1.0.0 |
The info block |
Servers |
empty | Left empty, no servers block is written — tooling reads that as “relative to wherever you fetched this from”, which is usually right for a server whose address is a tunnel URL decided at runtime |
Indented |
true |
Documents are read by people at least as often as by tools |
IncludeUndescribedRoutes |
true |
Set false to publish only what was described deliberately |
SecuritySchemes |
empty | Keyed by the name operations reference |
DefaultSecurityScheme |
null |
Applied to operations that require authorization |
ConfigureOperation |
null |
A convention applied to every endpoint before it is written |
Security
Section titled “Security”o.AddBearerAuthentication();Adds a Bearer/JWT scheme and makes it the default, so every endpoint carrying [Authorize] is
documented as needing a token — and the “Authorize” button in whatever UI reads this actually does
something. ApiSecurityScheme covers the http, apiKey and openIdConnect shapes if you need a
different one.
Conventions
Section titled “Conventions”o.ConfigureOperation = (operation, endpoint) =>{ if (operation.RequiresAuthorization) operation.Responses.Add(new ApiResponse { StatusCode = 401 });};One document per API version
Section titled “One document per API version”With API versioning, put a {documentName} token in the pattern:
app.MapOpenApi("/openapi/{documentName}.json", o => o.Title = "Widgets");/openapi/v1.json and /openapi/v2.json each describe one version — the name is the version as
ApiVersioningOptions.FormatGroupName writes it, or any text ApiVersion parses, so /openapi/1.0.json
works too. The token may share its segment with literal text, as here. Each version’s document holds
the endpoints serving that version plus every unversioned and version-neutral one; info.version is
the API version; operations the version deprecates are marked deprecated. A {version:apiVersion}
route segment is written as the version (/api/v2/users) rather than as a parameter. An unknown name
is a 404, and each document is built on first request and cached, like the single one.
For one fixed version, set o.ApiVersion = new ApiVersion(2, 0); to build one in code,
OpenApiDocumentBuilder.BuildJson(app, options, new ApiVersion(2, 0)).
OpenApiDocumentBuilder.GetApiVersions(app) lists every version served — what a UI’s version picker
needs.
An API reference page with Scalar
Section titled “An API reference page with Scalar”app.MapOpenApi();app.MapScalarApiReference(); // → /scalar/scalar serves an interactive reference for the document, built with Scalar.
It lists every operation and its schemas, and a client on the page sends real requests to the server.
No UI is bundled with the library. The server sends a few hundred bytes of HTML, and the browser loads Scalar’s script from its CDN. The app ships no JavaScript and grows by nothing, which matters on a device where every megabyte is download size. The page is built on the first request and excludes itself from the document.
With one document per API version, point it at the same pattern. Scalar lists the versions in a picker, with the newest selected:
app.MapOpenApi("/openapi/{documentName}.json");app.MapScalarApiReference(configure: o => o.DocumentUrl = "/openapi/{documentName}.json");Configuration uses Scalar’s own names, and anything left unset keeps Scalar’s default:
app.MapScalarApiReference("/docs", o =>{ o.Title = "Widgets API"; o.Theme = ScalarTheme.Purple; o.DefaultHttpClient = ("csharp", "httpclient"); // Scalar starts on curl otherwise o.PreferredSecurityScheme = "bearer"; o.AdditionalConfiguration["hideSearch"] = true; // any key this class doesn't name});| Option | Scalar key | Notes |
|---|---|---|
ScriptUrl |
— | Where the browser loads Scalar from. The CDN’s latest build by default |
DocumentUrl |
url / sources |
/openapi.json by default; a {documentName} pattern lists every version |
Documents / AddDocument |
sources |
Explicit documents, e.g. several APIs or one hosted elsewhere |
Theme, Layout |
theme, layout |
|
DarkMode, ForceColorMode, HideDarkModeToggle |
darkMode, forceDarkModeState, hideDarkModeToggle |
|
HideModels, HideClientButton, ShowSidebar, DefaultOpenAllTags |
same names | |
DefaultHttpClient |
defaultHttpClient |
(targetKey, clientKey) |
PreferredSecurityScheme |
authentication.preferredSecurityScheme |
|
CustomCss, Favicon, SearchHotKey, WithDefaultFonts |
same names | |
ProxyUrl, BaseServerUrl |
proxyUrl, baseServerURL |
|
AdditionalConfiguration |
anything | Written as given, and wins over the properties above |
It is an ordinary route, so .RequireAuthorization() straight after it protects the page. Protect the
document route too: the page only points at the document, and anyone who can fetch the document can
read it. Never put a token or secret in AdditionalConfiguration, because anyone who can load the page
can read its configuration.
Limits
Section titled “Limits”- OpenAPI 3.0.3 only.
- A type only gets a schema if it is in a registered
JsonSerializerContext— the same requirement as returning it from an endpoint, and the generator warns (SWS006) when it is not met. - A catch-all token is documented as an ordinary path parameter, since OpenAPI cannot express a multi-segment one.
- A trailing optional route parameter becomes two paths, because OpenAPI has no way to say a path segment is optional and the route genuinely matches two URLs.


