👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Documenting Web API with OpenAPI and Scalar in ASP.NET

Documenting Web API with OpenAPI and Scalar in ASP.NET

Author - Abdul Rahman (Bhai)

Web API

29 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

Your API documentation is already out of sync. Not eventually — the moment you renamed that parameter, added that new error code, or removed that field without updating the spec, every consumer started working from a lie. The only documentation that stays accurate is documentation that cannot drift because it is generated directly from your code.

Starting with .NET 9, ASP.NET Core ships a first-class Microsoft.AspNetCore.OpenApi package that generates an OpenAPI JSON document directly from your minimal-API or controller-based route registrations. Scalar is a modern, open-source UI that renders that document into a beautiful, interactive playground — and serves as the recommended replacement for Swagger UI in the .NET ecosystem.

Why we gonna do?

Before diving in, let's clear up a decades-old terminology confusion. Many developers use "Swagger" and "OpenAPI" interchangeably — but they mean different things:

  • An OpenAPI specification (formerly called the Swagger specification) is a language-agnostic, standardised description of an HTTP API — either in JSON or YAML. It describes every endpoint, parameter, request body, response shape, and security scheme.
  • Swagger is a set of tools that work with OpenAPI specs: Swagger UI renders an interactive page from a spec, Swagger Editor lets you write specs manually, and various code generators can produce client SDKs from a spec.
  • OpenAPI is now the preferred term when talking about the specification standard itself. Swagger refers specifically to the tooling layer.

Until recently, Swashbuckle (the library behind Swagger UI) was the de-facto standard for .NET API documentation. That changed:

  • Swashbuckle was removed from the default .NET 9 Web API project template because its maintainer could no longer keep up with breaking changes.
  • Swagger UI has an older look-and-feel and requires a third-party package with its own upgrade surface.
  • Without interactive docs, developers are forced to fire up Postman or write curl commands just to try a single endpoint.
  • Manually maintained spec files drift out of sync with the real API within weeks.

The built-in OpenAPI support solves every one of those problems:

  • Zero manual maintenance — the JSON spec is generated from your route registrations, XML comments, and [ProducesResponseType] attributes at request time.
  • Scalar UI is actively maintained, beautiful out of the box, and replaces Swagger UI with a single NuGet package.
  • Multiple API versions are shown separately — each version gets its own JSON document and its own tab in the UI.
  • JWT Bearer and OAuth2 authentication can be wired up globally through a document transformer — no per-endpoint decoration required.
  • Docs stay in sync with the code because they are the code.

How we gonna do?

Step 1: Add NuGet Packages and Enable XML Documentation

Add three packages to your .csproj — the JWT Bearer authentication package, the built-in OpenAPI package (first introduced in .NET 9), and the Scalar UI adapter — then turn on the XML documentation file so that triple-slash comments flow into the generated spec:


<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <!-- Enable triple-slash XML comments -->
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <!-- Suppress CS1591 "Missing XML comment" warnings for public members -->
    <NoWarn>$(NoWarn);1591</NoWarn>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi"                  Version="10.0.3" />
    <PackageReference Include="Scalar.AspNetCore"                              Version="2.13.16" />
  </ItemGroup>

</Project>
            

Step 2: Register OpenAPI Services — One Call Per Version

Call AddOpenApi() once for each API version you want to expose. Pass a literal string for the version name — this is important because the XML-comment source generator analyses syntax nodes at build time and may not resolve non-literal arguments such as variables or named constants.

Use AddDocumentTransformer to enrich each document with a title, description, and security configuration:


using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

// Register built-in OpenAPI support — one call per API version.
// IMPORTANT: use literal strings so the XML-comment source generator works.
builder.Services.AddOpenApi("v1", options =>
{
    options.AddDocumentTransformer((document, context, _) =>
    {
        document.Info = new()
        {
            Title       = "ILoveDotNet API",
            Version     = context.DocumentName,   // "v1"
            Description = "A sample API demonstrating built-in .NET 10 OpenAPI support " +
                          "with Scalar UI, XML comments, versioning, JWT and OAuth2 authentication."
        };

        // Declare Bearer and OAuth2 security schemes globally for v1.
        document.Components ??= new OpenApiComponents();
        document.Components.SecuritySchemes = new Dictionary<string, IOpenApiSecurityScheme>
        {
            [JwtBearerDefaults.AuthenticationScheme] = new OpenApiSecurityScheme
            {
                Type         = SecuritySchemeType.Http,
                Scheme       = JwtBearerDefaults.AuthenticationScheme.ToLower(),
                BearerFormat = "JWT",
                Description  = "Enter your JWT token in the field below."
            },
            ["OAuth2"] = new OpenApiSecurityScheme
            {
                Description = "Authenticate using the OAuth2 Authorization Code flow.",
                Type        = SecuritySchemeType.OAuth2,
                Flows       = new OpenApiOAuthFlows
                {
                    AuthorizationCode = new OpenApiOAuthFlow
                    {
                        AuthorizationUrl = new Uri("https://idp.ilovedotnetapi.com/connect/authorize"),
                        TokenUrl         = new Uri("https://idp.ilovedotnetapi.com/connect/token"),
                        Scopes           = new Dictionary<string, string>
                        {
                            { "openid",                    "Identity"        },
                            { "profile",                   "Profile"         },
                            { "email",                     "Email"           },
                            { "ilovedotnetapi.fullaccess", "ILoveDotNet API" }
                        }
                    }
                }
            }
        };

        document.Security =
        [
            new OpenApiSecurityRequirement
            {
                [new OpenApiSecuritySchemeReference(JwtBearerDefaults.AuthenticationScheme, document)] = []
            },
            new OpenApiSecurityRequirement
            {
                [new OpenApiSecuritySchemeReference("OAuth2", document)] = []
            }
        ];

        return Task.CompletedTask;
    });
});

// v2 follows the same transformer shape as v1 — only Version and Description differ.
builder.Services.AddOpenApi("v2", options =>
{
    options.AddDocumentTransformer((document, context, _) =>
    {
        document.Info = new()
        {
            Title       = "ILoveDotNet API",
            Version     = context.DocumentName,   // "v2"
            Description = "Version 2 of the ILoveDotNet API — includes enhanced article details."
        };

        // To secure v2 endpoints, copy the Components and Security setup from AddOpenApi("v1").
        // Omitted here for brevity; the structure is identical.
        return Task.CompletedTask;
    });
});

// Register controller services so MVC can discover and activate controllers.
builder.Services.AddControllers();

var app = builder.Build();
            

A few important details about the security setup:

  • In Microsoft.OpenApi v2.0 (which ships with .NET 10), the namespace is Microsoft.OpenApi, not Microsoft.OpenApi.Models.
  • Use JwtBearerDefaults.AuthenticationScheme.ToLower() for the Scheme property — this keeps the value consistent with the framework constant rather than relying on the hard-coded string "bearer".
  • The OAuth2 scheme uses OpenApiOAuthFlows with an AuthorizationCode flow. The authorization and token URLs must match the endpoints of your identity provider.

Step 3: Map the OpenAPI JSON Endpoint and Scalar UI

Both middleware calls are wrapped in an IsDevelopment() guard — this ensures the OpenAPI JSON spec and the Scalar UI are never exposed in production. The custom /openapi/{documentName}.json pattern means a single route serves /openapi/v1.json and /openapi/v2.json. When you use a custom route pattern you must also pass it to WithOpenApiRoutePattern() — otherwise Scalar cannot locate the spec files and the UI will be blank.

AddAuthorizationCodeFlow() configures Scalar's built-in OAuth2 PKCE login panel, so developers can exchange a client ID for a real token without leaving the browser. A root redirect to /scalar/v1 is registered last so hitting the app root opens the docs directly.


if (app.Environment.IsDevelopment())
{
    // Expose the OpenAPI JSON docs: /openapi/v1.json and /openapi/v2.json
    app.MapOpenApi("/openapi/{documentName}.json");

    // Optional: also serve YAML if you prefer that format — /openapi/v1.yml and /openapi/v2.yml
    app.MapOpenApi("/openapi/{documentName}.yml");

    // Add Scalar interactive documentation UI at /scalar/v1 and /scalar/v2
    app.MapScalarApiReference(options =>
    {
        options
            .WithTitle("ILoveDotNet API")
            .WithTheme(ScalarTheme.Solarized)
            .WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient)
            .AddPreferredSecuritySchemes("OAuth2", JwtBearerDefaults.AuthenticationScheme)
            .AddAuthorizationCodeFlow("OAuth2", flow =>
            {
                flow.ClientId = "ilovedotnetapiscalarclient";
                flow.Pkce     = Pkce.Sha256;
            })
            .WithOpenApiRoutePattern("/openapi/{documentName}.json")
            .SortTagsAlphabetically()
            .HideDarkModeToggle();
    });

    // Redirect root to the Scalar UI for the default version
    app.MapGet("/", () => Results.Redirect("/scalar/v1"))
       .ExcludeFromDescription();
}

// Map attribute-routed controllers.
app.MapControllers();
            

After this, navigate to /scalar/v1 in the browser (or hit the root URL / which redirects there automatically). A version switcher lets you toggle between v1 and v2, and the authentication panel exposes both the Bearer token field and the OAuth2 login button.

Scalar UI overview — ILoveDotNet API with JWT Bearer and OAuth2 authentication scalar-ui-overview

Clicking any endpoint expands a panel with the full description, parameter types, expected response shapes (derived from [ProducesResponseType] attributes), and a live Try it button that sends a real HTTP request — JWT token included.

Scalar UI endpoint detail — "Get all articles" with description, response types, and a live C# HttpClient code snippet scalar-ui-endpoint-detail

Step 4: Document Endpoints with XML Comments and Attributes

Decorate controller classes and action methods with attributes. The triple-slash XML comments above each action drive the OpenAPI description fields; the data annotation attributes control response types and content negotiation:


[Produces(MediaTypeNames.Application.Json)]
[Consumes(MediaTypeNames.Application.Json)]
[ApiController]
[Tags("ArticleEndpoints")]                     // groups endpoints in Scalar sidebar
public class ArticlesController : ControllerBase
{
    /// <summary>Retrieves all articles (v1).</summary>
    /// <returns>A list of articles.</returns>
    [HttpGet("api/v1/articles", Name = "GetArticlesV1")]
    [ProducesResponseType(typeof(IEnumerable<Article>), StatusCodes.Status200OK)]
    public ActionResult<IEnumerable<Article>> GetAll()
    {
        return Ok(ArticleStore.All);
    }

    /// <summary>Retrieves a single article by its identifier.</summary>
    /// <param name="id">The article identifier.</param>
    /// <returns>The matching article, or 404 if not found.</returns>
    [HttpGet("api/v1/articles/{id:int}", Name = "GetArticleByIdV1")]
    [ProducesResponseType(typeof(Article), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public ActionResult<Article> GetById(int id)
    {
        var article = ArticleStore.All.FirstOrDefault(a => a.Id == id);
        return article is null ? NotFound() : Ok(article);
    }
}

[Produces(MediaTypeNames.Application.Json)]
[Consumes(MediaTypeNames.Application.Json)]
[ApiController]
[Tags("ArticleEndpoints")]
public class ArticlesV2Controller : ControllerBase
{
    /// <summary>Retrieves all articles (v2 — with featured flag).</summary>
    /// <returns>A list of enriched articles.</returns>
    [HttpGet("api/v2/articles", Name = "GetArticlesV2")]
    [ProducesResponseType(typeof(IEnumerable<ArticleDetailDto>), StatusCodes.Status200OK)]
    public ActionResult<IEnumerable<ArticleDetailDto>> GetAll()
    {
        var enriched = ArticleStore.All
            .Select(a => new ArticleDetailDto(
                a.Id, a.Title, a.Author, a.Channel,
                IsFeatured: a.Id == 1));
        return Ok(enriched);
    }
}
            

[Tags("ArticleEndpoints")] applied at the controller class level assigns every action inside it to a named tag. Scalar groups all tagged endpoints under a collapsible sidebar section, and because SortTagsAlphabetically() was enabled in Step 3, those sections appear in alphabetical order as the API grows.

The [ProducesResponseType(StatusCodes.Status400BadRequest)] on the by-ID action documents the response produced automatically by the {id:int} route constraint. When a caller passes a non-integer value for id, ASP.NET Core short-circuits the pipeline and returns a 400 before the action is ever invoked — registering this status code makes that behaviour visible in the Scalar UI.

Step 5: Define Models and an In-Memory Article Store

The final block defines the data model records and the static in-memory store used by the endpoints registered in Step 4:


record Article(int Id, string Title, string Author, string Channel);
record ArticleDetailDto(int Id, string Title, string Author, string Channel, bool IsFeatured);

static class ArticleStore
{
    public static readonly List<Article> All =
    [
        new(1, "Documenting Web API with OpenAPI and Scalar", "Abdul Rahman", "WebAPI"),
        new(2, "Building AI Chat Applications with Microsoft Extensions AI", "Abdul Rahman", "AI"),
        new(3, "Getting Started with Blazor WebAssembly", "Abdul Rahman", "Blazor"),
    ];
}
            

Step 6: Testing Authenticated Endpoints Directly from Scalar

One of Scalar's most useful features is the ability to test JWT-protected endpoints without leaving the browser. First, generate a development token with dotnet user-jwts:


# Create a token matching our API's issuer and audience
dotnet user-jwts create --issuer "https://localhost:7001" --audience "ilovedotnetapi"

# Retrieve the signing key for the token store
dotnet user-jwts key --issuer "https://localhost:7001"

# List all locally generated tokens
dotnet user-jwts list

# Print a previously generated token by its ID
dotnet user-jwts print <token-id>

# Remove a token
dotnet user-jwts remove <token-id>

# Clear all tokens
dotnet user-jwts clear
            

Once you have the token, navigate to /scalar/v1 in your browser. In the Authentication section on the right, paste the token into the Bearer field. From that point on, Scalar automatically includes the Authorization: Bearer <token> header on every Try it request — no manual header manipulation required.

If you prefer OAuth2, click the OAuth2 button in the same authentication panel. Scalar launches the Authorization Code + PKCE flow configured in Step 3 — it opens the identity provider login page, exchanges the code for a token, and injects it automatically. No separate token generation step is needed.

Summary

  • Microsoft.AspNetCore.OpenApi is a first-class, built-in package that generates an OpenAPI JSON spec from your route registrations at runtime — no manual YAML required.
  • Scalar is the modern replacement for Swagger UI, added via a single NuGet package and one MapScalarApiReference() call.
  • Call AddOpenApi() with a literal string per API version — one call per document you want to expose in the Scalar UI.
  • Use AddDocumentTransformer to set the spec title, description, and global security (Bearer and OAuth2) without touching individual endpoints.
  • Enable GenerateDocumentationFile in your project file so that triple-slash XML comments feed directly into the spec.
  • When using a custom MapOpenApi() route pattern, always pair it with WithOpenApiRoutePattern() in the Scalar options.

To go deeper on the JWT security configured in Step 2 and tested in Step 6, read Securing ASP.NET Web API with JWT Bearer Token Authentication. For a comprehensive look at the versioning strategies that pair with this documentation approach, see URI-Based API Versioning in ASP.NET Web API.

👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • Web API
  • OpenAPI
  • Scalar
  • API Documentation
  • Swagger
  • JWT Bearer