Generating the Document at Build Time

Write the OpenAPI document to a file on every build, commit it, and use it in CI for contract diffs, breaking-change checks and client generation.

Syntax<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.13" PrivateAssets="all" /> <OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/openapi</OpenApiDocumentsDirectory>

A document served at runtime is only visible to someone who runs the service. Treating the contract as an artefact means writing it to a file during the build, committing it, and letting CI fail when it changes unexpectedly. The Microsoft.Extensions.ApiDescription.Server package does the writing.

How it works

After compilation the package's MSBuild target runs dotnet-getdocument. That tool starts your built assembly in a separate process whose entry assembly is named GetDocument.Insider, executes Program.cs up to app.Run(), which it intercepts so no server starts, resolves the OpenAPI document service and writes every registered document. Without settings the files go to obj/: {ProjectName}.json for v1 and {ProjectName}_{documentName}.json for the others. The build log shows a line such as Writing document named 'v1' to ....

MSBuild propertyUse
OpenApiDocumentsDirectoryWhere files go; point it inside the project so they can be committed.
OpenApiGenerateDocumentsOptionsExtra arguments: --file-name orders-api, --document-name internal, --openapi-version OpenApi3_0.

Two differences from the runtime document matter. There is no request, so the file has no servers entry unless a transformer adds one. And the version on disk is chosen by the tool's own argument, which defaults to 3.1; when consumers need 3.0, pass --openapi-version OpenApi3_0 explicitly rather than relying on the OpenApiVersion set in AddOpenApi, and check the first line of the file in CI.

Your start-up code runs during the build

Everything before app.Run() executes on the build machine: configuration providers that call a secrets vault, eager database connections, migrations run at start-up, cache warmers. In CI those fail for lack of credentials and break the build; on a developer laptop they can migrate a real database. Guard side effects with Assembly.GetEntryAssembly()?.GetName().Name == "GetDocument.Insider". Also keep start-up quiet: text written to standard error during generation is reported as build errors.

What teams do with the file

  • Commit it. Contract changes show up as a diff in the pull request, where reviewers notice a renamed property or a removed response.
  • Check it is current. CI builds and runs git diff --exit-code on the folder, failing when someone changed endpoints without committing the regenerated file.
  • Detect breaking changes. A tool such as oasdiff compares the file on the main branch with the new one and fails on removed operations, new required fields or narrowed types.
  • Generate clients. Kiota (kiota generate -l CSharp -d openapi/orders-api.json -c OrdersClient -n Shop.Clients.Orders -o Clients/Orders), NSwag or a TypeScript generator consume the committed file, so client builds never need the service running.

Alternative: generate it in a test

An integration test can fetch /openapi/v1.json through WebApplicationFactory and compare it with the committed file. It adds no build step and uses the test host's configuration, but the check runs only when tests run, and the comparison must ignore servers, which reflects the test host. Choose the build-time package when other projects (client generators) consume the file; choose the test when the file is only a review aid.

Example

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

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <NoWarn>$(NoWarn);CS1591</NoWarn>

    <!-- Write openapi/orders-api.json next to the project so it can be committed. -->
    <OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/openapi</OpenApiDocumentsDirectory>
    <OpenApiGenerateDocumentsOptions>--file-name orders-api</OpenApiGenerateDocumentsOptions>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
    <!-- Build-only: nothing from this package ships with the app. -->
    <PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.13">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>

</Project>

When to use it

  • Fail a pull request that renames OrderResponse.total without regenerating and committing openapi/orders-api.json.
  • Generate the typed C# client for the payments service from the committed file in the consuming repository, without running the orders API.
  • Block a merge that makes a previously optional field in POST /orders required, using oasdiff against the main branch.

More examples

Keeping start-up side effects out of the build

The document tool executes the same top-level statements as a normal start, stopping only at <code>app.Run()</code>. A required secrets file that does not exist on the build agent, or a warm-up that opens database connections, would fail the build. The entry-assembly check is the documented way to tell the two apart, and service registrations stay unconditional so the document still sees every endpoint.

Example · csharp
using System.Reflection;

var isGeneratingDocument = Assembly.GetEntryAssembly()?.GetName().Name == "GetDocument.Insider";

var builder = WebApplication.CreateBuilder(args);

if (!isGeneratingDocument)
{
    // Pulls secrets from the vault; the build agent has no credentials for it.
    var secretsFile = builder.Configuration["Secrets:MountedFile"] ?? "/run/secrets/orders.json";
    builder.Configuration.AddJsonFile(secretsFile, optional: false, reloadOnChange: false);
}

builder.Services.AddOpenApi();
builder.Services.AddSingleton<StockReservationWarmer>();

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

app.MapGet("/inventory/{sku}", (string sku) => TypedResults.Ok(new { sku, onHand = 42 }))
    .WithName("GetStockLevel");

if (!isGeneratingDocument)
{
    await app.Services.GetRequiredService<StockReservationWarmer>().WarmAsync(CancellationToken.None);
}

app.Run();

sealed class StockReservationWarmer(ILogger<StockReservationWarmer> logger)
{
    public async Task WarmAsync(CancellationToken cancellationToken)
    {
        await Task.Delay(50, cancellationToken);
        logger.LogInformation("Reservation cache warmed");
    }
}

CI: spec is current and not breaking

The build regenerates the file, so <code>git diff --exit-code</code> fails exactly when the code and the committed contract disagree. The second step compares against the version on <code>main</code> and fails on changes oasdiff classifies as breaking, such as a deleted operation or a newly required request property. Intentional breaks then need a new version or an explicit override, not a silent merge. The image is pinned to a version (or better, a digest) so a changed upstream image cannot run unreviewed on every pull request.

Example · yaml
name: api-contract
on:
  pull_request:
    # The document also changes when a referenced project (shared DTOs, contracts) does.
    paths:
      - "src/**"

jobs:
  contract:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "10.0.x"
      - name: Build (regenerates openapi/orders-api.json)
        run: dotnet build src/Orders.Api -c Release
      - name: Committed spec matches the code
        run: git diff --exit-code -- src/Orders.Api/openapi/
      - name: No breaking changes against main
        run: |
          mkdir -p /tmp/base
          git show origin/main:src/Orders.Api/openapi/orders-api.json > /tmp/base/orders-api.json
          docker run --rm -v /tmp/base:/base -v "$PWD/src/Orders.Api/openapi:/head" \
            tufin/oasdiff:v1.34.1 breaking /base/orders-api.json /head/orders-api.json --fail-on ERR

Contract snapshot test with WebApplicationFactory

<code>WebApplicationFactory</code> runs the app in the Development environment by default, so a document mapped only in Development is reachable. Removing <code>servers</code> before comparing avoids a false failure, since the runtime document reflects the test host. Copy the committed file to the test output (a <code>Content</code> item with <code>CopyToOutputDirectory</code>) so the path resolves on CI.

Example · csharp
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

public class OpenApiContractTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Served_document_matches_committed_contract()
    {
        var cancellationToken = TestContext.Current.CancellationToken;
        using var client = factory.CreateClient();

        var live = await client.GetStringAsync("/openapi/v1.json", cancellationToken);
        var committed = await File.ReadAllTextAsync(
            Path.Combine(AppContext.BaseDirectory, "openapi", "orders-api.json"), cancellationToken);

        Assert.Equal(Normalize(committed), Normalize(live));
    }

    private static string Normalize(string json)
    {
        var document = JsonNode.Parse(json)!.AsObject();
        document.Remove("servers"); // the test host reports http://localhost/
        return document.ToJsonString();
    }
}

public partial class Program;

Discussion

  • Be the first to comment on this lesson.