Built-In OpenAPI Documents

Generate an OpenAPI 3.1 document for a minimal API with AddOpenApi and MapOpenApi, serve it as JSON or YAML, and fix the defaults that mislead clients.

Syntaxbuilder.Services.AddOpenApi(o => o.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1); app.MapOpenApi(); // /openapi/v1.json app.MapOpenApi("/openapi/{documentName}.yaml"); // YAML

An OpenAPI document is the machine-readable contract of an HTTP API: paths, operations, parameters, request and response schemas, security. ASP.NET Core builds it from endpoint metadata it already has, using the Microsoft.AspNetCore.OpenApi package (10.0.13 for .NET 10). dotnet new webapi references the package and maps the document in Development only.

How the document is produced

builder.Services.AddOpenApi() registers a document named v1; AddOpenApi("internal") registers another. app.MapOpenApi() maps GET /openapi/{documentName}.json. On each request the generator reads ApiExplorer's descriptions (the same IApiDescriptionGroupCollectionProvider that controllers use), builds JSON Schemas from the System.Text.Json contract of each type, runs your transformers and serializes. Because schemas come from the serializer contract, the naming policy, converters and number handling set in ConfigureHttpJsonOptions appear in the document. Nothing is cached: the document is rebuilt on every request, so chain .CacheOutput() when more than a developer's browser fetches it.

In .NET 10 the output is OpenAPI 3.1 ("openapi": "3.1.1") with JSON Schema 2020-12: a nullable string is "type": ["null", "string"] rather than 3.0's "nullable": true. For a gateway or generator that only reads 3.0, set options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0 (emits 3.0.4). For YAML, map a second route ending in .yaml: app.MapOpenApi("/openapi/{documentName}.yaml").

Defaults to change before anyone generates a client

  • The title is "{ApplicationName} | v1" and the version 1.0.0.
  • Minimal API endpoints without WithTags are tagged with the assembly name.
  • servers is the scheme, host and path base of the request that fetched the document. Behind a proxy without forwarded headers it advertises the pod's internal http:// address.
  • The web JSON defaults read numbers from strings, so every integer property is described as ["integer", "string"] and every decimal or double as ["number", "string"], each with a numeric pattern. Set NumberHandling = JsonNumberHandling.Strict unless clients really send quoted numbers.
  • Enums are bare integers with no list of values unless JsonStringEnumConverter is registered.
  • A document includes endpoints with no group name plus those whose WithGroupName equals the document name.

Which generator

OptionChoose it when
Microsoft.AspNetCore.OpenApiNew projects. Understands typed results and endpoint metadata, works with Native AOT, ships with each ASP.NET Core release.
SwashbuckleAn existing service already depends on its filters. Not in the templates since .NET 9; do not run it beside the built-in one.
NSwagA team already uses its client generation pipeline end to end.

Production failure modes

Mapping the document unconditionally publishes every endpoint, including admin routes, to anyone who guesses /openapi/v1.json. Handlers that return plain IResult (Results.Ok(order)) appear as a 200 with no schema, and generated clients get untyped responses. Both are visible in the document long before a consumer complains: read it in code review.

Example

Example · csharp
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.OpenApi;

var builder = WebApplication.CreateBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
{
    // The document describes what the serializer does, so fix the wire format first.
    options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict;
    options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});

builder.Services.AddOpenApi(options =>
{
    options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1;
    options.AddDocumentTransformer((document, context, cancellationToken) =>
    {
        document.Info.Title = "Orders API";
        document.Info.Version = "v1";
        document.Info.Description = "Place, track and cancel customer orders.";
        return Task.CompletedTask;
    });
});
builder.Services.AddOutputCache();

var app = builder.Build();
app.UseOutputCache();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi().CacheOutput();                                // /openapi/v1.json
    app.MapOpenApi("/openapi/{documentName}.yaml").CacheOutput();  // /openapi/v1.yaml
}

var orders = app.MapGroup("/orders").WithTags("Orders");

orders.MapGet("/{id:int}", Results<Ok<OrderResponse>, NotFound> (int id) =>
    id == 1042
        ? TypedResults.Ok(new OrderResponse(1042, "[email protected]", 129.50m, OrderStatus.Paid,
            new DateTimeOffset(2026, 10, 1, 9, 30, 0, TimeSpan.Zero), null))
        : TypedResults.NotFound())
    .WithName("GetOrder");

orders.MapPost("/", (PlaceOrderRequest request) =>
    TypedResults.Created("/orders/1043", new OrderResponse(1043, request.CustomerEmail,
        request.Quantity * 24.95m, OrderStatus.Placed, DateTimeOffset.UtcNow, request.Notes)))
    .WithName("PlaceOrder");

app.Run();

public enum OrderStatus { Placed, Paid, Shipped, Cancelled }

public record PlaceOrderRequest(string CustomerEmail, string Sku, int Quantity, string? Notes);

public record OrderResponse(
    int Id, string CustomerEmail, decimal Total, OrderStatus Status,
    DateTimeOffset PlacedAt, string? Notes);

When to use it

  • Give a front-end team a /openapi/v1.json they can generate a typed TypeScript client from on every local build.
  • Serve /openapi/v1.yaml to an API gateway that imports routes from a YAML contract.
  • Emit a 3.0 document for a partner whose code generator cannot read OpenAPI 3.1 type arrays yet.

More examples

What the defaults produce

Every line here is a default worth changing. The title carries the assembly name, the server URL is the internal pod address because the proxy's forwarded headers were not applied, the only tag is the assembly name, and the handler returned <code>Results.Ok</code> so the 200 has no schema. <code>id</code> is integer-or-string because of the web number handling, and the enum is a bare integer. The main example fixes each of these.

Example · json
// GET /openapi/v1.json with AddOpenApi() and no other configuration (excerpt)
{
  "openapi": "3.1.1",
  "info": { "title": "OrdersApi | v1", "version": "1.0.0" },
  "servers": [ { "url": "http://10.42.3.17:8080/" } ],
  "paths": {
    "/orders/{id}": {
      "get": {
        "tags": [ "OrdersApi" ],
        "responses": { "200": { "description": "OK" } }
      }
    }
  },
  "components": {
    "schemas": {
      "OrderResponse": {
        "type": "object",
        "properties": {
          "id": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", "string" ], "format": "int32" },
          "status": { "$ref": "#/components/schemas/OrderStatus" },
          "notes": { "type": [ "null", "string" ] }
        }
      },
      "OrderStatus": { "type": "integer" }
    }
  }
}

One document per audience

<code>WithGroupName("internal")</code> removes the admin endpoint from <code>v1</code>, because the default filter only accepts a null group or one equal to the document name. The reverse is the trap: an ungrouped endpoint lands in every document whose filter you did not override, which is why <code>internal</code> sets <code>ShouldInclude</code> explicitly. <code>WithGroupName</code> and <code>ShouldInclude</code> only shape the documentation; they do not restrict who can call the endpoint, so the admin route still needs <code>RequireAuthorization</code>. Both documents are served in Development only; give partners the build-time <code>v1</code> file instead of a live route.

Example · csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("Admin", policy => policy.RequireRole("orders-admin"));

// v1: the default filter takes endpoints with no group name or group "v1".
builder.Services.AddOpenApi();

// internal: only endpoints explicitly marked for it.
builder.Services.AddOpenApi("internal", options =>
{
    options.ShouldInclude = description => description.GroupName == "internal";
});

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi(); // serves /openapi/v1.json and /openapi/internal.json
}

app.MapGet("/orders/{id:int}", (int id) => TypedResults.Ok(new OrderSummary(id, 129.50m)));

// The group name only decides which document lists it; the policy decides who can call it.
app.MapPost("/admin/orders/{id:int}/reindex", (int id) => TypedResults.Accepted($"/admin/jobs/{id}"))
    .WithGroupName("internal")
    .RequireAuthorization("Admin");

app.Run();

record OrderSummary(int Id, decimal Total);

Adding the package

The generator is a NuGet package, not part of the shared framework, so keep its version aligned with the runtime (10.0.13 here). With <code>GenerateDocumentationFile</code> on, a <code>/// &lt;summary&gt;</code> on a record becomes the schema's description and one on a controller action becomes the operation summary. <code>CS1591</code> is suppressed so undocumented public members do not flood the build with warnings.

Example · xml
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- XML doc comments on types and handlers flow into the document -->
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <NoWarn>$(NoWarn);CS1591</NoWarn>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
  </ItemGroup>

</Project>

Discussion

  • Be the first to comment on this lesson.