Skip to content
Shiny.NET

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);

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.

/// <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.

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.

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
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.

o.ConfigureOperation = (operation, endpoint) =>
{
if (operation.RequiresAuthorization)
operation.Responses.Add(new ApiResponse { StatusCode = 401 });
};

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.

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.

  • 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.