Document and Operation Transformers

Customise the generated document with document, operation and schema transformers: add a bearer security scheme, fixed servers and standard error responses.

Syntaxbuilder.Services.AddOpenApi(o => { o.AddDocumentTransformer<BearerSecuritySchemeTransformer>(); o.AddOperationTransformer((op, ctx, ct) => Task.CompletedTask); o.AddSchemaTransformer((schema, ctx, ct) => Task.CompletedTask); });

Metadata describes individual endpoints. Anything that is about the API as a whole, or that should apply to many operations by rule, belongs in a transformer: a callback that receives part of the generated object model and mutates it before serialization.

The three kinds

KindReceivesTypical job
AddDocumentTransformerOpenApiDocument and a context with DocumentName, DescriptionGroups, ApplicationServicesInfo, contact, servers, security schemes in components
AddOperationTransformerOpenApiOperation and a context with Description (the ApiDescription) and DocumentPer-operation security requirements, standard 401/429/500 responses, deprecation flags
AddSchemaTransformerThe schema and a context with JsonTypeInfo and JsonPropertyInfoFormats, examples, constraints for a type or a single property

Each kind accepts a lambda, an instance, or a type: AddDocumentTransformer<BearerSecuritySchemeTransformer>(). Type-based transformers are created through dependency injection when the document is generated, so a constructor can take IAuthenticationSchemeProvider, IOptions<T> or IConfiguration. Within a kind, transformers run in registration order.

The object model changed in .NET 10

The .NET 10 package is built on version 2 of the Microsoft.OpenApi object model. Types live in the Microsoft.OpenApi namespace (no .Models), schema types are the JsonSchemaType flags enum (JsonSchemaType.String | JsonSchemaType.Null), collections such as Components.SecuritySchemes hold interfaces (IOpenApiSecurityScheme), and a reference is its own type: new OpenApiSecuritySchemeReference("Bearer", document). Many collections start as null, so use ??=. Transformers written for .NET 9 need these edits when a service upgrades.

Security: derive it, do not list it

The generator does not describe authentication. A document transformer adds the scheme to components, only when that scheme is actually registered. An operation transformer then adds a requirement to each operation whose endpoint metadata contains IAuthorizeData and no IAllowAnonymous. Reading metadata rather than path lists means a new endpoint is documented correctly the moment someone adds RequireAuthorization(). The rule assumes opt-in authorization. With a fallback policy (SetFallbackPolicy) endpoints are protected with no metadata at all, so invert it: secure every operation unless IAllowAnonymous is present.

Servers

By default servers comes from the request that fetched the document, so it is the internal address behind a load balancer, and it is absent from a document generated at build time. When the public URL is known, replace the list from configuration in a document transformer; when the document is consumed only by tools that ignore it, clear it so build output stays stable.

Production failure modes

  • A transformer that throws turns /openapi/v1.json into a 500 and breaks build-time generation; guard every lookup, such as a missing Responses collection.
  • The document is regenerated per request, so a transformer that calls a database or remote service does that on every fetch.
  • A schema transformer keyed on typeof(decimal) touches every decimal in every schema. Check JsonPropertyInfo when the change is meant for one property.
  • Adding a security requirement to every operation marks health and login endpoints as protected, and generated clients then refuse to call them without a token.

Example

Example · csharp
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];
        options.Audience = "orders-api";
    });
builder.Services.AddAuthorization();

builder.Services.AddOpenApi(options =>
{
    options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
    options.AddOperationTransformer<AuthorizeOperationTransformer>();
    options.AddDocumentTransformer((document, context, cancellationToken) =>
    {
        var publicUrl = context.ApplicationServices
            .GetRequiredService<IConfiguration>()["OpenApi:PublicUrl"];
        document.Servers = string.IsNullOrEmpty(publicUrl)
            ? []
            : [new OpenApiServer { Url = publicUrl, Description = "Public endpoint" }];
        return Task.CompletedTask;
    });
});

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

// The live route is for developers; partners get the build-time file, which the same transformers shape.
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.MapGet("/orders/{id:int}", Results<Ok<OrderResponse>, NotFound> (int id) =>
        id == 1042 ? TypedResults.Ok(new OrderResponse(1042, 129.50m, "EUR")) : TypedResults.NotFound())
    .WithName("GetOrder")
    .RequireAuthorization();

app.MapGet("/catalog/products", () => TypedResults.Ok(new[] { "KETTLE-BLUE", "MUG-WHITE" }))
    .WithName("ListProducts");

app.Run();

public record OrderResponse(int Id, decimal Total, string Currency);

internal sealed class BearerSecuritySchemeTransformer(IAuthenticationSchemeProvider schemes)
    : IOpenApiDocumentTransformer
{
    public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context,
        CancellationToken cancellationToken)
    {
        var registered = await schemes.GetAllSchemesAsync();
        if (!registered.Any(s => s.Name == JwtBearerDefaults.AuthenticationScheme))
        {
            return;
        }

        document.Components ??= new OpenApiComponents();
        document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
        document.Components.SecuritySchemes[JwtBearerDefaults.AuthenticationScheme] = new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.Http,
            Scheme = "bearer",
            BearerFormat = "JWT",
            Description = "Access token issued by the identity provider for audience orders-api."
        };
    }
}

internal sealed class AuthorizeOperationTransformer : IOpenApiOperationTransformer
{
    public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context,
        CancellationToken cancellationToken)
    {
        var metadata = context.Description.ActionDescriptor.EndpointMetadata;
        var requiresAuth = metadata.OfType<IAuthorizeData>().Any() && !metadata.OfType<IAllowAnonymous>().Any();
        if (!requiresAuth)
        {
            return Task.CompletedTask;
        }

        operation.Security ??= [];
        operation.Security.Add(new OpenApiSecurityRequirement
        {
            [new OpenApiSecuritySchemeReference(JwtBearerDefaults.AuthenticationScheme, context.Document)] = []
        });

        operation.Responses ??= new OpenApiResponses();
        operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Missing or invalid access token" });
        operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Token lacks the required permission" });
        return Task.CompletedTask;
    }
}

When to use it

  • Add a JWT bearer security scheme and mark only endpoints with RequireAuthorization as needing a token, so anonymous catalog calls stay usable.
  • Replace the internal pod URL in servers with https://api.example.com taken from configuration per environment.
  • Document decimal money fields as format decimal so generated clients stop mapping order totals to double.

More examples

Documenting 429 with Retry-After

The rate limiter answers before the handler runs, so no metadata on the endpoint mentions 429. <code>RequireRateLimiting</code> adds an <code>EnableRateLimitingAttribute</code> to the endpoint metadata, and the transformer keys on that instead of a list of paths. Only operations that can actually return 429 get the response and its header. The header is real only because <code>OnRejected</code> writes it from the lease metadata: the middleware sends <code>Retry-After</code> only when <code>OnRejected</code> sets it, so without that callback the document would promise a header clients never receive.

Example · csharp
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.OpenApi;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    // The middleware never sets Retry-After itself; without this the documented header is a lie.
    options.OnRejected = (context, cancellationToken) =>
    {
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
        }
        return ValueTask.CompletedTask;
    };
    options.AddFixedWindowLimiter("checkout", limiter =>
    {
        limiter.PermitLimit = 20;
        limiter.Window = TimeSpan.FromMinutes(1);
        limiter.QueueLimit = 0;
    });
});

builder.Services.AddOpenApi(options =>
{
    options.AddOperationTransformer((operation, context, cancellationToken) =>
    {
        var limited = context.Description.ActionDescriptor.EndpointMetadata
            .OfType<EnableRateLimitingAttribute>().Any();
        if (!limited)
        {
            return Task.CompletedTask;
        }

        operation.Responses ??= new OpenApiResponses();
        operation.Responses.TryAdd("429", new OpenApiResponse
        {
            Description = "Rate limit exceeded; retry after the number of seconds in Retry-After.",
            Headers = new Dictionary<string, IOpenApiHeader>
            {
                ["Retry-After"] = new OpenApiHeader
                {
                    Description = "Seconds to wait before retrying.",
                    Schema = new OpenApiSchema { Type = JsonSchemaType.Integer }
                }
            }
        });
        return Task.CompletedTask;
    });
});

var app = builder.Build();
app.UseRateLimiter();
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.MapPost("/checkout", (CheckoutRequest request) => TypedResults.Accepted($"/orders/{request.CartId}"))
    .WithName("Checkout")
    .RequireRateLimiting("checkout");

app.Run();

record CheckoutRequest(Guid CartId, string PaymentMethodId);

Schema transformer for money and examples

By default a <code>decimal</code> is documented as <code>number</code> with format <code>double</code>, which tells generated clients to use floating point for money. The first rule applies to every decimal by type. The second uses <code>JsonPropertyInfo</code> (its <code>Name</code> is the serialized, camelCase name) and the declaring type so a pattern and example land on one property, not on every string called currency.

Example · csharp
using System.Text.Json.Nodes;
using Microsoft.OpenApi;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi(options =>
{
    options.AddSchemaTransformer((schema, context, cancellationToken) =>
    {
        // Every decimal in the API is money: say so instead of the default "double".
        if (context.JsonTypeInfo.Type == typeof(decimal))
        {
            schema.Format = "decimal";
        }

        // One property only: the ISO 4217 currency code on payment types.
        if (context.JsonPropertyInfo is { Name: "currency" }
            && context.JsonPropertyInfo.DeclaringType == typeof(PaymentResponse))
        {
            schema.Pattern = "^[A-Z]{3}$";
            schema.Example = JsonValue.Create("EUR");
        }
        return Task.CompletedTask;
    });
});

var app = builder.Build();
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.MapGet("/payments/{id:guid}", (Guid id) =>
        TypedResults.Ok(new PaymentResponse(id, 129.50m, "EUR", "Captured")))
    .WithName("GetPayment");

app.Run();

public record PaymentResponse(Guid Id, decimal Amount, string Currency, string Status);

A typed transformer reading options

Registering by type lets the transformer take constructor dependencies, here validated options bound from the <code>ApiDocs</code> section. Environment-specific values such as the public URL then come from configuration rather than code. <code>context.DocumentName</code> lets one transformer serve several documents and still label each one correctly.

Example · csharp
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOptions<ApiDocsOptions>()
    .BindConfiguration("ApiDocs")
    .ValidateDataAnnotations()
    .ValidateOnStart();

builder.Services.AddOpenApi(options => options.AddDocumentTransformer<ApiInfoTransformer>());

var app = builder.Build();
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}
app.MapGet("/customers/{id:int}", (int id) => TypedResults.Ok(new { id, name = "Dana Ruiz" }))
    .WithName("GetCustomer");
app.Run();

public sealed class ApiDocsOptions
{
    [System.ComponentModel.DataAnnotations.Required]
    public string Title { get; set; } = "";
    public string? PublicUrl { get; set; }
    public string SupportEmail { get; set; } = "[email protected]";
}

internal sealed class ApiInfoTransformer(IOptions<ApiDocsOptions> options) : IOpenApiDocumentTransformer
{
    public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context,
        CancellationToken cancellationToken)
    {
        var docs = options.Value;
        document.Info.Title = $"{docs.Title} ({context.DocumentName})";
        document.Info.Contact = new OpenApiContact { Name = "Orders team", Email = docs.SupportEmail };
        document.Servers = docs.PublicUrl is null ? [] : [new OpenApiServer { Url = docs.PublicUrl }];
        return Task.CompletedTask;
    }
}

Discussion

  • Be the first to comment on this lesson.