diff --git a/plugins/dotnet-aspnet/skills/aspnet-openapi/SKILL.md b/plugins/dotnet-aspnet/skills/aspnet-openapi/SKILL.md new file mode 100644 index 0000000000..cebf7a9742 --- /dev/null +++ b/plugins/dotnet-aspnet/skills/aspnet-openapi/SKILL.md @@ -0,0 +1,380 @@ +--- +name: aspnet-openapi +description: > + Add OpenAPI documentation to ASP.NET Core APIs (.NET 8+). Covers technology selection + (Microsoft.AspNetCore.OpenApi vs Swashbuckle vs NSwag), Scalar and Swagger UI setup, + JWT Bearer security scheme configuration, endpoint metadata for minimal APIs and MVC + controllers, and document/operation transformers. + USE FOR: adding an OpenAPI spec endpoint to a new or existing ASP.NET Core project, + setting up an interactive API explorer (Scalar, Swagger UI), configuring JWT Bearer + security in the OpenAPI document, annotating minimal API endpoints with response types + and tags, customizing the generated spec with transformers, or migrating from + Swashbuckle.AspNetCore to the built-in package on .NET 9+. + DO NOT USE FOR: generating typed API client code from an OpenAPI spec (use NSwag CLI + or the dotnet-openapi tooling instead), adding OpenAPI to non-ASP.NET projects, or + validating incoming requests against a schema at runtime. +--- + +# ASP.NET Core OpenAPI + +.NET 9 changed the landscape. `Microsoft.AspNetCore.OpenApi` is now the first-party +solution, backed by the ASP.NET Core team, and ships in the default web project templates. +Swashbuckle — the de-facto standard for the previous decade — still works but is no longer +the default, and its major versions have lagged .NET releases by months. NSwag remains the +right choice when client code generation is the primary goal. + +## Stop Signals + +- **Need to generate C# or TypeScript API clients?** — Use NSwag or `dotnet-openapi`. This skill covers spec *generation*, not *consumption*. +- **Targeting .NET 8 or earlier?** — `AddOpenApi()` / `MapOpenApi()` document generation requires .NET 9+. Use Swashbuckle 6.x on .NET 8. +- **Already on Swashbuckle and no .NET 9 migration planned?** — Don't migrate just for its own sake. See [references/swashbuckle-migration.md](references/swashbuckle-migration.md) if migration is wanted later. +- **Review request on existing OpenAPI config?** — Jump directly to the Validation checklist. + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Target framework | Yes | Determines which packages are available | +| API style | Yes | Minimal APIs or MVC controllers — affects annotation syntax | +| Auth scheme | Recommended | JWT Bearer, OAuth2, or API key — drives security scheme config | +| Existing packages | Recommended | Check whether Swashbuckle or NSwag is already referenced | + +## Workflow + +### Step 1: Choose the Package + +| Situation | Package | Reason | +|-----------|---------|--------| +| New project on .NET 9+ | `Microsoft.AspNetCore.OpenApi` | First-party, default in templates, actively maintained by the ASP.NET Core team | +| New project on .NET 8 | `Swashbuckle.AspNetCore` 6.x | `AddOpenApi()` / `MapOpenApi()` require .NET 9+ | +| Need typed client code generation | `NSwag.AspNetCore` | Only mature option with C#/TypeScript codegen; pair with the spec output | +| Complex polymorphism or discriminators | `NSwag.AspNetCore` | More schema control than either alternative | +| Existing project already on Swashbuckle | Keep Swashbuckle | Migration is optional — see [references/swashbuckle-migration.md](references/swashbuckle-migration.md) | + +**Hard rule:** Do not mix the built-in generator (`Microsoft.AspNetCore.OpenApi`) with the +Swashbuckle *document generator* (`Swashbuckle.AspNetCore` via `AddSwaggerGen`/`UseSwagger`) +in the same project. Both enumerate endpoints at startup and produce conflicting OpenAPI +documents — pick one generator. Using the standalone `Swashbuckle.AspNetCore.SwaggerUI` +package alongside `Microsoft.AspNetCore.OpenApi` is fine, as long as it only points at the +built-in `/openapi/v1.json` endpoint (shown in Step 4). + +--- + +### Step 2: Set Up Microsoft.AspNetCore.OpenApi (.NET 9+) + +```bash +dotnet add package Microsoft.AspNetCore.OpenApi +``` + +```csharp +// Program.cs +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOpenApi(); // Register document generation + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); // Serves spec JSON at /openapi/v1.json +} + +app.Run(); +``` + +> **CRITICAL:** `MapOpenApi()` serves raw JSON — it is **not** a UI. Add a UI package in Step 4. + +> **CRITICAL:** The default spec path is `/openapi/v1.json`. This is **not** the Swashbuckle +> default of `/swagger/v1/swagger.json`. Any tooling, CI pipeline, or Postman collection +> importing from the old path will silently get a 404. + +**Multiple document versions:** + +```csharp +builder.Services.AddOpenApi("v1"); +builder.Services.AddOpenApi("v2"); + +// Both served at their named paths: /openapi/v1.json and /openapi/v2.json +app.MapOpenApi("/openapi/{documentName}.json"); +``` + +--- + +### Step 3: Set Up Swashbuckle (.NET 8 / existing projects) + +```bash +# 6.x for .NET 8 — 7.x for .NET 9 +dotnet add package Swashbuckle.AspNetCore +``` + +```csharp +// Program.cs +var builder = WebApplication.CreateBuilder(args); + +// CRITICAL: Required for minimal API endpoints to appear in the spec. +// Swashbuckle uses the ApiExplorer infrastructure, which minimal APIs do NOT +// register automatically. Without this line every MapGet/MapPost endpoint is +// invisible in the generated spec. MVC controllers are unaffected. +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); // Serves spec at /swagger/v1/swagger.json + app.UseSwaggerUI(); // Serves Swagger UI at /swagger +} + +app.Run(); +``` + +--- + +### Step 4: Add an API Explorer UI + +#### Scalar (recommended with Microsoft.AspNetCore.OpenApi) + +```bash +dotnet add package Scalar.AspNetCore +``` + +```csharp +using Scalar.AspNetCore; + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.MapScalarApiReference(); // Default at /scalar/v1 +} +``` + +Scalar reads from `MapOpenApi()` automatically. For customization: + +```csharp +app.MapScalarApiReference(options => +{ + options.WithTitle("Contoso API"); + options.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient); + options.WithPreferredScheme("Bearer"); +}); +``` + +#### Swagger UI alongside Microsoft.AspNetCore.OpenApi + +If Swagger UI is a hard requirement, use only the UI package — not the full Swashbuckle stack: + +```bash +dotnet add package Swashbuckle.AspNetCore.SwaggerUI +``` + +```csharp +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.UseSwaggerUI(options => + { + // CRITICAL: Point at the built-in package's path, not the Swashbuckle default. + options.SwaggerEndpoint("/openapi/v1.json", "v1"); + options.RoutePrefix = "swagger"; + }); +} +``` + +--- + +### Step 5: Annotate Endpoints + +The spec is only as useful as the metadata you put in. Sparse annotations produce a sparse, +useless spec — and waste the entire investment in Step 2–4. + +**Minimal APIs:** + +```csharp +app.MapGet("/products/{id:int}", async (int id, IProductRepository repo) => +{ + var product = await repo.GetByIdAsync(id); + return product is null ? Results.NotFound() : Results.Ok(product); +}) +.WithName("GetProductById") +.WithSummary("Get a product by ID") +.WithDescription("Returns a single product including pricing and stock level.") +.WithTags("Products") +.Produces(StatusCodes.Status200OK) +.ProducesProblem(StatusCodes.Status404NotFound); + +app.MapPost("/products", async ([FromBody] CreateProductRequest req, IProductRepository repo) => +{ + var id = await repo.CreateAsync(req); + return Results.CreatedAtRoute("GetProductById", new { id }); +}) +.WithName("CreateProduct") +.WithTags("Products") +.Accepts("application/json") +.Produces(StatusCodes.Status201Created) +.ProducesValidationProblem() +.ProducesProblem(StatusCodes.Status409Conflict); + +// Exclude internal/infra endpoints entirely +app.MapGet("/internal/health", () => "ok") + .ExcludeFromDescription(); +``` + +**MVC Controllers:** Use `[ProducesResponseType]`, `[Produces]`, `[Consumes]`, and XML +`` / `` doc comments. Both packages consume these through the ApiExplorer +infrastructure. Enable XML doc generation: + +```xml + + + true + +``` + +For Swashbuckle, wire up the XML file in `AddSwaggerGen`: + +```csharp +builder.Services.AddSwaggerGen(options => +{ + var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFile)); +}); +``` + +--- + +### Step 6: Configure JWT Bearer Security Scheme + +The built-in package does not auto-detect `AddAuthentication()`. You must declare the +security scheme via a document transformer. + +```csharp +// Program.cs +builder.Services.AddOpenApi(options => +{ + options.AddDocumentTransformer(); +}); +``` + +```csharp +// BearerSecuritySchemeTransformer.cs +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +internal sealed class BearerSecuritySchemeTransformer : IOpenApiDocumentTransformer +{ + public Task TransformAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + CancellationToken cancellationToken) + { + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + In = ParameterLocation.Header, + BearerFormat = "JWT", + Description = "Enter a valid JWT. Example: eyJhbGci..." + }; + + // Apply to every operation. For per-endpoint selective auth, + // use an operation transformer — see references/transformers.md. + var securityRequirement = new OpenApiSecurityRequirement + { + [new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }] = [] + }; + + foreach (var pathItem in document.Paths.Values) + { + foreach (var operation in pathItem.Operations.Values) + { + operation.Security ??= []; + operation.Security.Add(securityRequirement); + } + } + + return Task.CompletedTask; + } +} +``` + +> For **per-endpoint** security (some routes public, some require auth), or for **OAuth2** +> and **API key** schemes, see [references/transformers.md](references/transformers.md). + +For **Swashbuckle**, the equivalent uses `AddSecurityDefinition` and `AddSecurityRequirement` +inside `AddSwaggerGen` — full examples in [references/transformers.md](references/transformers.md). + +--- + +### Step 7: Secure the Spec Endpoint + +The OpenAPI document lists every endpoint, parameter, and auth scheme. Do not expose it +publicly in production. + +**Option A — Development only** (most common for internal or backend APIs): + +```csharp +// Already shown — wrap MapOpenApi() and the UI in IsDevelopment(). +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.MapScalarApiReference(); +} +``` + +**Option B — Authenticated users only** (staging, internal portals): + +```csharp +app.MapOpenApi() + .RequireAuthorization(); +``` + +**Option C — Specific policy or role**: + +```csharp +app.MapOpenApi() + .RequireAuthorization("InternalToolsPolicy"); +``` + +> **CRITICAL:** Do not combine Option B/C with the JWT transformer from Step 6 without +> verifying the flow end-to-end. If the spec endpoint requires a token, and the UI reads the +> spec before the user authenticates, the UI will load blank. Either serve the UI and spec +> without auth (Options A), or ensure both are behind the same authenticated session. + +--- + +## Validation + +- [ ] `dotnet build -warnaserror` completes cleanly +- [ ] Navigate to the spec path in a running instance — verify well-formed JSON is returned +- [ ] Every endpoint with a non-trivial return type has at least one `Produces()` or `[ProducesResponseType]` annotation +- [ ] Error paths return `ProblemDetails` — not anonymous `{ error: "..." }` objects +- [ ] If auth is configured, the `securitySchemes` section appears in the spec JSON and the scheme name matches the `$ref` value exactly (case-sensitive) +- [ ] The spec endpoint is either restricted to Development or requires authorization — never `AllowAnonymous` in production + +## Common Pitfalls + +| Pitfall | Solution | +|---------|----------| +| Forgot `AddEndpointsApiExplorer()` with Swashbuckle | Minimal API endpoints won't appear — MVC controllers are unaffected. This is the single most common Swashbuckle setup bug | +| Spec path mismatch after switching packages | Built-in defaults to `/openapi/v1.json`; Swashbuckle defaults to `/swagger/v1/swagger.json`. Update all downstream tooling explicitly | +| Mixed `Microsoft.AspNetCore.OpenApi` + Swashbuckle document generator | Both enumerate endpoints at startup and produce conflicting specs. Remove one generator. The standalone `Swashbuckle.AspNetCore.SwaggerUI` package is fine alongside the built-in package | +| Security scheme `$ref` name mismatch | The `Id` in `OpenApiReference` must exactly match the key in `SecuritySchemes` — `"Bearer"` ≠ `"bearer"` | +| Spec endpoint returns 401 unexpectedly | Either wrapped in `IsDevelopment()` (run in Development env) or secured with `RequireAuthorization()` (authenticate first, or remove the auth requirement from the spec endpoint) | +| Swashbuckle 6.x on .NET 9 | Swashbuckle 6.x does not support .NET 9+ reflection APIs. Upgrade to 7.x or migrate to the built-in package | +| `MapOpenApi()` produces empty `paths` | `AddOpenApi()` was not called, `MapOpenApi()` was mapped on a different `app` instance or route than your API endpoints, or you're requesting a document name that doesn't match the one registered in `AddOpenApi()` | +| Scalar UI loads but shows no auth button | `WithPreferredScheme("Bearer")` not set, or the security scheme was not added to `document.Components.SecuritySchemes` | + +## Reference Files + +- **[references/transformers.md](references/transformers.md)** — Document, operation, and schema transformers for `Microsoft.AspNetCore.OpenApi`; equivalent `IDocumentFilter`, `IOperationFilter`, and `ISchemaFilter` patterns for Swashbuckle; OAuth2 and API key scheme setup. **Load when** customizing the generated spec, configuring non-JWT security schemes, adding XML documentation to the spec, or applying per-endpoint auth annotations. + +- **[references/swashbuckle-migration.md](references/swashbuckle-migration.md)** — Step-by-step migration from `Swashbuckle.AspNetCore` to `Microsoft.AspNetCore.OpenApi` on .NET 9+, including package changes, middleware rewrites, filter-to-transformer mapping, and common post-migration failures. **Load when** the project currently uses Swashbuckle and the developer wants to move to the built-in package. diff --git a/plugins/dotnet-aspnet/skills/aspnet-openapi/references/swashbuckle-migration.md b/plugins/dotnet-aspnet/skills/aspnet-openapi/references/swashbuckle-migration.md new file mode 100644 index 0000000000..5bccb6abaa --- /dev/null +++ b/plugins/dotnet-aspnet/skills/aspnet-openapi/references/swashbuckle-migration.md @@ -0,0 +1,196 @@ +# Migrating from Swashbuckle to Microsoft.AspNetCore.OpenApi + +## Migrate or Stay? + +Migration is not mandatory. Swashbuckle continues to work on .NET 9+ with version 7.x. +Migrate when: + +- You are on .NET 9+ and want to reduce third-party dependencies +- You want the first-party package that ships with the default templates going forward +- Your Swashbuckle customizations are minimal (basic JWT setup, simple metadata) + +Stay on Swashbuckle when: + +- You rely on heavily customized `IDocumentFilter` / `IOperationFilter` logic — the + migration cost is non-trivial and the behavior parity must be verified +- You use Swashbuckle's built-in `IncludeXmlComments()` for MVC XML docs and have + not validated the equivalent setup with the built-in package +- You need features the built-in package does not yet support (advanced polymorphism, + `oneOf`/`anyOf` discriminators, complex `$ref` scenarios) + +--- + +## Package Changes + +```xml + + + + + + + + + + + +``` + +--- + +## Registration Changes + +### Before (Swashbuckle) + +```csharp +// builder.Services +builder.Services.AddEndpointsApiExplorer(); // Required for minimal APIs +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" }); + options.AddSecurityDefinition("Bearer", /* ... */); + options.AddSecurityRequirement(/* ... */); + options.DocumentFilter(); + options.OperationFilter(); +}); +``` + +### After (Microsoft.AspNetCore.OpenApi) + +```csharp +// builder.Services +// AddEndpointsApiExplorer() is no longer needed — remove it. +builder.Services.AddOpenApi(options => +{ + options.AddDocumentTransformer((document, context, ct) => + { + document.Info = new OpenApiInfo { Title = "My API", Version = "v1" }; + return Task.CompletedTask; + }); + options.AddDocumentTransformer(); + options.AddDocumentTransformer(); // replaces DocumentFilter + options.AddOperationTransformer(); // replaces OperationFilter +}); +``` + +--- + +## Middleware Changes + +### Before + +```csharp +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); // Serves at /swagger/v1/swagger.json + app.UseSwaggerUI(); // Serves UI at /swagger +} +``` + +### After — with Scalar (recommended) + +```csharp +using Scalar.AspNetCore; + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); // Serves at /openapi/v1.json + app.MapScalarApiReference(); // Serves UI at /scalar/v1 +} +``` + +### After — keeping Swagger UI + +```csharp +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.UseSwaggerUI(options => + { + // CRITICAL: Must point at the new path — not the old Swashbuckle default. + options.SwaggerEndpoint("/openapi/v1.json", "v1"); + options.RoutePrefix = "swagger"; + }); +} +``` + +--- + +## Filter-to-Transformer Mapping + +| Swashbuckle | Microsoft.AspNetCore.OpenApi | Notes | +|-------------|------------------------------|-------| +| `IDocumentFilter` | `IOpenApiDocumentTransformer` | Same scope: transforms the whole document | +| `IOperationFilter` | `IOpenApiOperationTransformer` | Same scope: transforms individual operations | +| `ISchemaFilter` | `IOpenApiSchemaTransformer` | Same scope: transforms individual schemas | +| `IParameterFilter` | `IOpenApiOperationTransformer` | Access parameters via `operation.Parameters` | +| `options.DocumentFilter()` | `options.AddDocumentTransformer()` | Direct rename | +| `options.OperationFilter()` | `options.AddOperationTransformer()` | Direct rename | +| `options.SchemaFilter()` | `options.AddSchemaTransformer()` | Direct rename | +| `options.IncludeXmlComments(path)` | `GenerateDocumentationFile` in `.csproj` | XML docs for MVC are auto-loaded; see note below | +| `context.MethodInfo` (in `IOperationFilter`) | `context.Description.ActionDescriptor` | Access endpoint metadata from `ActionDescriptor.EndpointMetadata` | +| `context.ApiDescription` (in `IOperationFilter`) | `context.Description` | Same type: `ApiDescription` | + +### XML Documentation Note + +With Swashbuckle, you explicitly load the XML file via `IncludeXmlComments()`. With the +built-in package, XML comments on MVC controller actions are picked up automatically +when `GenerateDocumentationFile` is set to `true` in the project file and the XML file +is present alongside the assembly. For minimal API endpoints, use `.WithSummary()` and +`.WithDescription()` — XML comments on handler delegates are not picked up automatically. + +--- + +## Behavioral Differences to Verify + +After migrating, check these known behavioral differences: + +| Area | Swashbuckle behavior | Built-in package behavior | +|------|---------------------|--------------------------| +| Default spec URL | `/swagger/v1/swagger.json` | `/openapi/v1.json` | +| Spec format | OpenAPI 3.0 | OpenAPI 3.0 (same) | +| `$ref` resolution | Inline + `$ref` mix | More aggressive use of `$ref` components | +| Nullable annotations | Configurable | `nullable: true` emitted for nullable reference types when NRT is enabled | +| Enum representation | Integers by default | Integers by default — add schema transformer for string enums (see transformers.md) | +| Anonymous type schemas | Named after generated types | May differ — test complex response shapes | +| Endpoint ordering in spec | Alphabetical by route | By registration order | + +--- + +## Common Post-Migration Failures + +**The spec endpoint returns 404** + +The old URL (`/swagger/v1/swagger.json`) no longer exists. Update all references to +`/openapi/v1.json`. Check: CI pipeline spec validation, Postman collections, client +generation scripts, integration tests that assert against the spec. + +**The spec JSON is returned but the UI is blank** + +If using `UseSwaggerUI`, verify `SwaggerEndpoint` is pointing at `/openapi/v1.json`, +not the old path. + +**Some endpoints disappeared from the spec** + +Remove `AddEndpointsApiExplorer()` — it should be gone, but it does not cause +disappearing endpoints. More likely cause: a transformer throwing an unhandled exception +during document generation. Check application logs for transformer errors. + +**Custom `IDocumentFilter` logic no longer applies** + +Rewire it as an `IOpenApiDocumentTransformer`. The `Apply` method signature becomes +`TransformAsync` and is async. The `context` object is `OpenApiDocumentTransformerContext` +rather than `DocumentFilterContext` — check which properties you use and map them. + +**`[SwaggerIgnore]` or `SwaggerExcludeAttribute` stopped working** + +These are Swashbuckle-specific attributes. Replace with `.ExcludeFromDescription()` on +minimal API endpoints, or `[ApiExplorerSettings(IgnoreApi = true)]` on MVC controller +actions. Both work with the built-in package. + +**Security scheme shows in spec but the UI padlock icon is missing** + +Verify the scheme name in `SecuritySchemes` (e.g., `"Bearer"`) exactly matches the +`Id` in `OpenApiReference`. The comparison is case-sensitive. Also confirm the +security requirement was applied to operations — check the raw JSON for a `security` +array on each operation. diff --git a/plugins/dotnet-aspnet/skills/aspnet-openapi/references/transformers.md b/plugins/dotnet-aspnet/skills/aspnet-openapi/references/transformers.md new file mode 100644 index 0000000000..44eb95d005 --- /dev/null +++ b/plugins/dotnet-aspnet/skills/aspnet-openapi/references/transformers.md @@ -0,0 +1,419 @@ +# OpenAPI Transformers and Filters + +Transformer patterns for `Microsoft.AspNetCore.OpenApi` (.NET 9+) and equivalent +filter patterns for `Swashbuckle.AspNetCore`. + +--- + +## Microsoft.AspNetCore.OpenApi Transformers + +The built-in package exposes three transformer interfaces, all registered through +`AddOpenApi(options => ...)`: + +| Interface | Scope | Use for | +|-----------|-------|---------| +| `IOpenApiDocumentTransformer` | Whole document | Title/version/contact metadata, security schemes, global tags | +| `IOpenApiOperationTransformer` | Single operation | Per-endpoint auth, deprecation, response headers | +| `IOpenApiSchemaTransformer` | Single schema | Enum string names, nullable annotations, polymorphism | + +### Registering Transformers + +```csharp +using Microsoft.OpenApi.Models; + +builder.Services.AddOpenApi(options => +{ + // DI-enabled class transformer (can take constructor dependencies) + options.AddDocumentTransformer(); + options.AddOperationTransformer(); + options.AddSchemaTransformer(); + + // Inline lambda for simple one-liners + options.AddDocumentTransformer((document, context, ct) => + { + document.Info.Title = "Contoso API"; + document.Info.Version = "v1"; + document.Info.Contact = new OpenApiContact + { + Name = "Contoso Platform Team", + Email = "api-support@contoso.com" + }; + return Task.CompletedTask; + }); +}); +``` + +DI-enabled transformers are registered as transient by default. If a transformer +is expensive to construct, register it explicitly: + +```csharp +builder.Services.AddSingleton(); +builder.Services.AddOpenApi(options => +{ + options.AddDocumentTransformer(); +}); +``` + +--- + +### Document Transformer: XML Documentation + +The built-in package reads XML summary comments through the `ApiDescription` +infrastructure when `GenerateDocumentationFile` is enabled. Set this in the `.csproj`: + +```xml + + true + + $(NoWarn);1591 + +``` + +For **minimal APIs**, XML comments on the handler delegate are not picked up +automatically — use `.WithSummary()` and `.WithDescription()` on the endpoint +instead (shown in SKILL.md Step 5). For **MVC controller actions**, XML `` +comments are picked up automatically once the XML file exists on disk. + +--- + +### Document Transformer: Multiple Documents with Different Metadata + +When versioning produces multiple documents, the document name is available on the context: + +```csharp +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +internal sealed class VersionedInfoTransformer : IOpenApiDocumentTransformer +{ + public Task TransformAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + CancellationToken cancellationToken) + { + document.Info = context.DocumentName switch + { + "v1" => new OpenApiInfo { Title = "Contoso API", Version = "1.0" }, + "v2" => new OpenApiInfo { Title = "Contoso API", Version = "2.0", + Description = "Adds bulk operations and webhook support." }, + _ => document.Info + }; + return Task.CompletedTask; + } +} +``` + +--- + +### Operation Transformer: Per-Endpoint Security + +Apply the Bearer requirement only to endpoints marked with `[Authorize]` or +`.RequireAuthorization()`. Endpoints marked `[AllowAnonymous]` or +`.AllowAnonymous()` are skipped. + +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +internal sealed class BearerOperationTransformer : IOpenApiOperationTransformer +{ + public Task TransformAsync( + OpenApiOperation operation, + OpenApiOperationTransformerContext context, + CancellationToken cancellationToken) + { + var metadata = context.Description.ActionDescriptor.EndpointMetadata; + + bool requiresAuth = metadata.OfType().Any(); + bool allowsAnonymous = metadata.OfType().Any(); + + if (!requiresAuth || allowsAnonymous) + return Task.CompletedTask; + + operation.Security ??= []; + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }] = [] + }); + + return Task.CompletedTask; + } +} +``` + +Register alongside the document transformer that declares the scheme: + +```csharp +builder.Services.AddOpenApi(options => +{ + options.AddDocumentTransformer(); // declares scheme + options.AddOperationTransformer(); // applies per-endpoint +}); +``` + +--- + +### Operation Transformer: Mark Deprecated Endpoints + +```csharp +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +internal sealed class DeprecationTransformer : IOpenApiOperationTransformer +{ + public Task TransformAsync( + OpenApiOperation operation, + OpenApiOperationTransformerContext context, + CancellationToken cancellationToken) + { + var isDeprecated = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .Any(); + + if (isDeprecated) + operation.Deprecated = true; + + return Task.CompletedTask; + } +} +``` + +Mark endpoints in minimal APIs with a custom attribute or use the `[Obsolete]` +attribute on MVC controller actions. + +--- + +### Schema Transformer: Enums as Strings + +By default, enums serialize as integers in the spec. To represent them as string values: + +```csharp +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +internal sealed class EnumSchemaTransformer : IOpenApiSchemaTransformer +{ + public Task TransformAsync( + OpenApiSchema schema, + OpenApiSchemaTransformerContext context, + CancellationToken cancellationToken) + { + if (context.JsonTypeInfo.Type.IsEnum) + { + schema.Type = "string"; + schema.Enum = Enum.GetNames(context.JsonTypeInfo.Type) + .Select(name => new OpenApiString(name)) + .Cast() + .ToList(); + } + + return Task.CompletedTask; + } +} +``` + +> This transformer only affects the OpenAPI schema. Ensure your `JsonSerializerOptions` +> (or `System.Text.Json` attributes) are also configured to serialize enums as strings at +> runtime — the spec and the actual wire format must agree. + +--- + +### Document Transformer: OAuth2 Security Scheme + +For OAuth2 authorization code flow (e.g., Azure AD, Entra ID): + +```csharp +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +internal sealed class OAuth2SecuritySchemeTransformer : IOpenApiDocumentTransformer +{ + private readonly IConfiguration _configuration; + + public OAuth2SecuritySchemeTransformer(IConfiguration configuration) + => _configuration = configuration; + + public Task TransformAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + CancellationToken cancellationToken) + { + var tenantId = _configuration["AzureAd:TenantId"] + ?? throw new InvalidOperationException("AzureAd:TenantId not configured"); + var clientId = _configuration["AzureAd:ClientId"] + ?? throw new InvalidOperationException("AzureAd:ClientId not configured"); + + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes["OAuth2"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri( + $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/authorize"), + TokenUrl = new Uri( + $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"), + Scopes = new Dictionary + { + [$"api://{clientId}/access_as_user"] = "Access the API as the signed-in user" + } + } + } + }; + + return Task.CompletedTask; + } +} +``` + +Wire up Scalar to pre-populate the OAuth2 client ID: + +```csharp +app.MapScalarApiReference(options => +{ + options.WithOAuth2Authentication(oauth => + { + oauth.ClientId = builder.Configuration["AzureAd:ClientId"]!; + }); +}); +``` + +--- + +### Document Transformer: API Key Security Scheme + +```csharp +// Inside a document transformer's TransformAsync method: +document.Components ??= new OpenApiComponents(); +document.Components.SecuritySchemes["ApiKey"] = new OpenApiSecurityScheme +{ + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Header, + Name = "X-Api-Key", + Description = "API key passed in the X-Api-Key header" +}; +``` + +--- + +## Swashbuckle Filter Equivalents + +### IDocumentFilter → IOpenApiDocumentTransformer + +```csharp +// Swashbuckle +public class TitleDocumentFilter : IDocumentFilter +{ + public void Apply(OpenApiDocument document, DocumentFilterContext context) + { + document.Info.Title = "Contoso API"; + } +} + +// Register +builder.Services.AddSwaggerGen(options => +{ + options.DocumentFilter(); +}); +``` + +### IOperationFilter → IOpenApiOperationTransformer + +```csharp +// Swashbuckle +public class BearerOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var requiresAuth = context.MethodInfo + .GetCustomAttributes(true) + .OfType() + .Any() + || context.MethodInfo.DeclaringType? + .GetCustomAttributes(true) + .OfType() + .Any() == true; + + if (!requiresAuth) + return; + + operation.Security ??= []; + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }] = [] + }); + } +} + +// Register +builder.Services.AddSwaggerGen(options => +{ + options.OperationFilter(); +}); +``` + +### ISchemaFilter → IOpenApiSchemaTransformer + +```csharp +// Swashbuckle — enums as strings +public class EnumSchemaFilter : ISchemaFilter +{ + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + if (!context.Type.IsEnum) + return; + + schema.Type = "string"; + schema.Enum.Clear(); + foreach (var name in Enum.GetNames(context.Type)) + schema.Enum.Add(new OpenApiString(name)); + } +} +``` + +### JWT Security with Swashbuckle + +```csharp +builder.Services.AddSwaggerGen(options => +{ + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + In = ParameterLocation.Header, + BearerFormat = "JWT", + Description = "Enter a valid JWT. Example: eyJhbGci..." + }); + + // Apply globally — or use an IOperationFilter for per-endpoint control. + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + [new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }] = [] + }); +}); +``` diff --git a/tests/dotnet-aspnet/aspnet-openapi/eval.yaml b/tests/dotnet-aspnet/aspnet-openapi/eval.yaml new file mode 100644 index 0000000000..928492e11a --- /dev/null +++ b/tests/dotnet-aspnet/aspnet-openapi/eval.yaml @@ -0,0 +1,83 @@ +scenarios: + - name: "Add OpenAPI with Scalar UI to a new .NET 9 minimal API" + prompt: | + I've just created a new ASP.NET Core 9 minimal API project with dotnet new webapi + and I want to add an OpenAPI spec endpoint and an interactive API explorer. + Show me the complete setup using the built-in Microsoft.AspNetCore.OpenApi package + and Scalar UI. Include the NuGet packages I need and the Program.cs changes. + assertions: + - type: "output_matches" + pattern: "(AddOpenApi|MapOpenApi)" + - type: "output_matches" + pattern: "(Scalar\\.AspNetCore|MapScalarApiReference)" + - type: "output_matches" + pattern: "(Microsoft\\.AspNetCore\\.OpenApi|dotnet add package)" + rubric: + - "Installs Microsoft.AspNetCore.OpenApi and Scalar.AspNetCore packages" + - "Calls AddOpenApi() to register document generation and MapOpenApi() to serve the spec" + - "Calls MapScalarApiReference() to serve the Scalar UI" + - "Wraps MapOpenApi() and MapScalarApiReference() inside IsDevelopment() to avoid exposing the spec in production" + - "States or implies the correct default spec path (/openapi/v1.json), not the Swashbuckle default" + timeout: 180 + + - name: "Swashbuckle spec is empty — no endpoints appear for minimal API" + prompt: | + I added Swashbuckle.AspNetCore to my ASP.NET Core 9 minimal API project. + The Swagger UI loads at /swagger but the spec is completely empty — none of my + MapGet or MapPost endpoints appear. My MVC controllers show up fine. + What's wrong and how do I fix it? + assertions: + - type: "output_contains" + value: "AddEndpointsApiExplorer" + rubric: + - "Identifies the missing AddEndpointsApiExplorer() call as the root cause" + - "Explains that MVC controllers register with ApiExplorer automatically but minimal API endpoints do not" + - "Provides the fix: call builder.Services.AddEndpointsApiExplorer() before AddSwaggerGen()" + timeout: 120 + + - name: "Configure JWT Bearer security scheme in the OpenAPI document" + prompt: | + My ASP.NET Core 9 minimal API uses JWT Bearer authentication via AddAuthentication() + and AddJwtBearer(). I'm using Microsoft.AspNetCore.OpenApi with Scalar. The spec + generates correctly but there's no padlock icon in Scalar and no securitySchemes + section in the JSON output. How do I add JWT Bearer security scheme documentation + to the OpenAPI spec? + assertions: + - type: "output_matches" + pattern: "(IOpenApiDocumentTransformer|AddDocumentTransformer)" + - type: "output_matches" + pattern: "(SecuritySchemes|SecuritySchemeType\\.Http)" + - type: "output_matches" + pattern: "(BearerFormat|bearer)" + rubric: + - "Explains that the built-in package does not auto-detect AddAuthentication() — the scheme must be declared explicitly" + - "Implements or shows an IOpenApiDocumentTransformer that adds the Bearer scheme to document.Components.SecuritySchemes" + - "Adds a security requirement to the operations in the document (or uses an operation transformer for per-endpoint control)" + - "Registers the transformer with options.AddDocumentTransformer() inside AddOpenApi()" + - "The security scheme type is Http with scheme 'bearer', not ApiKey or OAuth2" + timeout: 180 + + - name: "Migrate from Swashbuckle to Microsoft.AspNetCore.OpenApi on .NET 9" + prompt: | + I have an ASP.NET Core 9 Web API that uses Swashbuckle.AspNetCore 7.x. I want to + migrate to the built-in Microsoft.AspNetCore.OpenApi package. The project uses: + - AddEndpointsApiExplorer() and AddSwaggerGen() for registration + - UseSwagger() and UseSwaggerUI() in middleware + - One custom IDocumentFilter that sets the API title and contact info + - JWT Bearer security via AddSecurityDefinition and AddSecurityRequirement + Walk me through the migration steps. + assertions: + - type: "output_matches" + pattern: "(AddOpenApi|MapOpenApi)" + - type: "output_matches" + pattern: "(IOpenApiDocumentTransformer|AddDocumentTransformer)" + - type: "output_matches" + pattern: "(/openapi/v1\\.json|openapi.*v1)" + rubric: + - "Removes Swashbuckle.AspNetCore and adds Microsoft.AspNetCore.OpenApi (and optionally Scalar.AspNetCore)" + - "Removes AddEndpointsApiExplorer() — it is no longer needed with the built-in package" + - "Replaces UseSwagger()/UseSwaggerUI() with MapOpenApi() and either MapScalarApiReference() or UseSwaggerUI() pointed at /openapi/v1.json" + - "Migrates the IDocumentFilter to an IOpenApiDocumentTransformer with a TransformAsync method" + - "Migrates AddSecurityDefinition/AddSecurityRequirement to a document transformer that populates document.Components.SecuritySchemes" + - "Calls attention to the spec URL change from /swagger/v1/swagger.json to /openapi/v1.json and the need to update downstream tooling" + timeout: 240