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.
builder.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
| Kind | Receives | Typical job |
|---|---|---|
AddDocumentTransformer | OpenApiDocument and a context with DocumentName, DescriptionGroups, ApplicationServices | Info, contact, servers, security schemes in components |
AddOperationTransformer | OpenApiOperation and a context with Description (the ApiDescription) and Document | Per-operation security requirements, standard 401/429/500 responses, deprecation flags |
AddSchemaTransformer | The schema and a context with JsonTypeInfo and JsonPropertyInfo | Formats, 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.jsoninto a 500 and breaks build-time generation; guard every lookup, such as a missingResponsescollection. - 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. CheckJsonPropertyInfowhen 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
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.
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.
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.
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