Swagger and OpenAPI in ASP.NET Core Web API: Documentation with Swashbuckle
We add an OpenAPI document and Swagger UI to an ASP.NET Core Web API with Swashbuckle: the right package version, XML comments, documenting response types, an Authorize button for JWT, and common pitfalls with real error messages.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
In the previous post we gave our API a version number and saw how to open new endpoints without breaking older clients. Nice, but one problem is still there: how is your teammate on the frontend supposed to learn which body each endpoint expects and which status codes it can return? Hopefully not by opening the controller and reading the code.
In this post we will set up the standard answer to that question: an OpenAPI document and Swagger UI, which turns it into a page you can browse. A small question before we start. With the code we are about to write, running the app in Development and opening /swagger brings up the documentation page. If you start the same app with ASPNETCORE_ENVIRONMENT=Production and go to the same address, what do you see? The answer is halfway through the post.
OpenAPI, Swagger and Swashbuckle: Who Is Who?
These three names are used interchangeably all the time, so let's separate them first. OpenAPI is a specification: the standard way to describe an HTTP API's endpoints, parameters, and request and response schemas in JSON or YAML. Swagger is the old name of that specification and today the brand of the tools around it. Swagger UI is the tool that reads an OpenAPI document and turns it into a page in the browser where you can try requests.
Swashbuckle is the ASP.NET Core side of the story. While the app runs, it inspects your controllers, routes and return types, produces a swagger.json from them and serves a bundled copy of Swagger UI. It doesn't make anything up; it reads the metadata collected by an ASP.NET Core layer called ApiExplorer. That is why the quality of the document depends on how much information you give the controller. With a restaurant analogy: the kitchen is your code, the OpenAPI document is the menu, and Swagger UI is the printed menu on the table. Swashbuckle is the waiter who writes the menu by looking into the kitchen; it writes down whatever is cooking and adds the rest if you tell it.
Installing Swashbuckle and Picking the Right Version
When you run dotnet new webapi with .NET 6, Swashbuckle already comes with the template; the version it uses is 6.2.3. If you add it by hand, always pick the version yourself. Make the csproj look like this:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Generate the XML comment file, silence the missing comment warning (CS1591) -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.2" />
</ItemGroup>
</Project>
Why 7.3.2? If you look at the package contents on NuGet, the picture is clear: the 6.x and 7.x lines target net6.0 directly, and 7.3.2 is the last version that does. With 8.0, net6.0 drops off the list, and 10.x targets net8.0, net9.0 and net10.0. Putting 7.3.2 into a net10.0 project works fine too; we built and ran the code in this post on both targets with that version.
What happens if you run dotnet add package Swashbuckle.AspNetCore without a version? NuGet adds the newest one, 10.3.1, and says it is "compatible". But that version has no dependency group for net6.0, so in practice no assemblies come with it, and the build blows up like this:
Program.cs(4,18): error CS1061: 'IServiceCollection' does not contain a definition for 'AddSwaggerGen' and no accessible extension method 'AddSwaggerGen' accepting a first argument of type 'IServiceCollection' could be found (are you missing a using directive or an assembly reference?)
The package looks installed, the methods are not there. If you see this error, the first place to look is the version in the csproj.
Now Program.cs:
using System.Reflection;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// Also exposes minimal API endpoints to ApiExplorer
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "ProductCatalog API",
Version = "v1",
Description = "Products, categories and orders."
});
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
// /swagger/v1/swagger.json and the /swagger UI
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapControllers();
app.Run();
AddSwaggerGen registers the document generator in DI, and SwaggerDoc defines a document named "v1". That name has to match the /swagger/v1/swagger.json part of the URL. UseSwagger and UseSwaggerUI are the middleware that serve the JSON and the UI.
Looking at swagger.json
The controller we will use is a familiar one: the ProductCatalog API's ProductsController, backed by an in-memory list for now.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
namespace ProductCatalog.Api.Controllers;
/// <summary>
/// Manages the products in the catalog.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class ProductsController : ControllerBase
{
private static readonly List<ProductDto> Products = new()
{
new ProductDto { Id = 1, Name = "Mechanical Keyboard", Price = 1450m },
new ProductDto { Id = 2, Name = "Wireless Mouse", Price = 650m }
};
/// <summary>
/// Returns a single product by its id.
/// </summary>
/// <param name="id">The product id.</param>
/// <response code="200">The product was found.</response>
/// <response code="404">There is no product with this id.</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public ActionResult<ProductDto> GetById(int id)
{
var product = Products.FirstOrDefault(p => p.Id == id);
if (product is null)
{
return NotFound();
}
return product;
}
/// <summary>
/// Adds a new product to the catalog.
/// </summary>
/// <response code="201">The product was created.</response>
/// <response code="400">The request body is invalid.</response>
[HttpPost]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public ActionResult<ProductDto> Create(CreateProductDto dto)
{
var product = new ProductDto
{
Id = Products.Max(p => p.Id) + 1,
Name = dto.Name,
Price = dto.Price
};
Products.Add(product);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
}
public class ProductDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
public class CreateProductDto
{
/// <summary>
/// Display name of the product.
/// </summary>
/// <example>Mechanical Keyboard</example>
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Unit price in TRY.
/// </summary>
/// <example>1450</example>
[Range(0.01, 1_000_000)]
public decimal Price { get; set; }
}
Run the app and open /swagger, and you will see two endpoints under the Products heading; with "Try it out" you can send the request right from the page. The real product, though, is the JSON behind it. In the output of curl http://localhost:5161/swagger/v1/swagger.json, the part for the GET endpoint looks like this:
"/api/Products/{id}": {
"get": {
"tags": [
"Products"
],
"summary": "Returns a single product by its id.",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The product id.",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "The product was found.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProductDto"
}
}
}
},
"404": {
"description": "There is no product with this id.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
This JSON is not only for humans. Tools like NSwag, Kiota or openapi-generator can generate a TypeScript or C# client from this file, and Postman can import it directly. The more accurate the document, the more accurate the generated client.
Here is the answer to the guess, too. Run the same app in the Production environment and you get:
curl -i http://localhost:5163/swagger/index.html
HTTP/1.1 404 Not Found
Content-Length: 0
Date: Tue, 03 Nov 2026 21:15:12 GMT
Server: Kestrel
Because we put the Swagger middleware inside the IsDevelopment() block, there is neither JSON nor UI in production. That is not a bug, it is a deliberate choice: you may not want to hand everyone a public map of your whole API surface. If you want it in production too, move it out of the block, but then at least think about restricting access.
Adding Descriptions with XML Comments
The sentence "Returns a single product by its id." in the JSON above didn't appear by itself. It comes from the /// comments in the controller. Getting them into the document takes two steps. The first is GenerateDocumentationFile in the csproj: during the build the compiler produces ProductCatalog.Api.xml and puts it next to the DLL. The 1591 in the NoWarn line silences the CS1591 warning raised for every public member without a comment; otherwise your build output drowns in warnings. The second step is telling Swashbuckle where the file is:
// Inside AddSwaggerGen, after SwaggerDoc
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFile));
<summary> becomes the summary line, <param> the parameter description, and <response code="..."> the description of that status code. The <example> on DTO properties goes into the schema as a sample value; when you hit "Try it out" in Swagger UI, the body box comes prefilled with "Mechanical Keyboard" and 1450. Data Annotations aren't wasted either: [Required], [StringLength(100)] and [Range] show up in the schema as required, maxLength, minimum and maximum. So your validation rules both run on the server and are written in the document.
These comments are in English because they are no longer just code comments. They are text that goes into the document and therefore to the client.
Documenting Response Types
Delete the [ProducesResponseType] lines and look at the document again, and you will notice something interesting. 404 is still listed, because the <response code="404"> comment gives it a description; but there is no content under it, so the document no longer knows the body is a ProblemDetails. Delete the comments as well and only one response remains for GET: "200", described as "OK". Swashbuckle can infer the return type from ActionResult<ProductDto>, but it cannot know that the action returns NotFound() somewhere inside. It doesn't run the code, it only looks at the signature.
That is why we state every status code the action can return with an attribute. Passing typeof(ProblemDetails) matters too: since [ApiController] produces a ProblemDetails body for 404 and 400, the document tells the truth. The controller-level [Produces("application/json")] narrows the response content down to a single media type; without it, the document also lists text/plain and text/json for every response.
If writing the same 400 and 500 lines on every action gets tedious, you can move the attributes up to the controller level or add ProducesResponseTypeAttribute as a global filter. But to start with, the rule is simple: every kind of return you see in the action body should have a counterpart in the document.
An Authorize Button in Swagger UI with JWT
In the JWT post we protected endpoints with [Authorize]. If you want to try those endpoints from Swagger UI, every request needs to carry the Authorization header. To teach the UI that, we add a security scheme to the document:
// Inside AddSwaggerGen
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "Paste the token only, without the Bearer prefix."
});
// Apply the definition to every operation
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
The first call says "there is an authentication method called Bearer, sent in a header, of the HTTP bearer type". The second declares that method required for all operations. The result is an Authorize button in the top right corner of the page. You paste the token there, and the UI adds the Authorization: Bearer ... header to every request itself. Since we chose Type = SecuritySchemeType.Http, you must not type the "Bearer " prefix yourself; that is why we left that note in the description. In the SecuritySchemeType.ApiKey variant you often see in older blog posts, you are expected to type the prefix by hand.
In the document this definition shows up under components.securitySchemes as "type": "http", "scheme": "bearer". Don't forget that this is only a documentation setting: the real protection is still in AddJwtBearer and [Authorize]. Adding a security definition to Swagger protects no endpoint at all.
Common Pitfalls
A missing XML file doesn't only break Swagger. If you turn off GenerateDocumentationFile but keep IncludeXmlComments, you get a System.IO.FileNotFoundException in Development, and you see it as a 500 not only on /swagger but on every request, /api/products/1 included, because the Swagger middleware asks DI for the document generator on every request. In Production nothing happens since the middleware doesn't run, which hides the bug from one environment to the next.
If two classes with the same name live in different namespaces, say Controllers.ProductDto and Admin.ProductDto, swagger.json returns a 500 that contains this line:
System.InvalidOperationException: Can't use schemaId "$ProductDto" for type "$ProductCatalog.Api.Controllers.ProductDto". The same schemaId is already used for type "$ProductCatalog.Api.Admin.ProductDto"
By default Swashbuckle takes the schema name from the class name. The quickest fix is options.CustomSchemaIds(type => type.FullName);, the cleaner one is giving your DTOs names that don't collide.
A public action without an HTTP method attribute brings the document down as well: Ambiguous HTTP method for action - ProductCatalog.Api.Controllers.AdminProductsController.Recalculate. If your controller has a public method that is not an endpoint, mark it with [NonAction] or make it private.
Then there is the nullable issue. The document says "nullable": true for ProductDto.name, even though string is not nullable on the C# side. By default Swashbuckle doesn't read nullable reference type information; you can turn it on with options.SupportNonNullableReferenceTypes(). That is usually the reason every field in a generated TypeScript client is string | null.
Swashbuckle Today and After .NET 9
The setup in this post was the default of the webapi template from .NET 5 to .NET 8. In March 2024 the ASP.NET Core team announced that Swashbuckle's maintenance had stalled and removed it from the .NET 9 template; the framework's own document generator took its place. Swashbuckle's maintenance later picked up again, and the 10.x line supports .NET 10. However, 10.x moved to version 2.x of the Microsoft.OpenApi library underneath. Build our code with Swashbuckle 10.3.1 on net10.0 and it falls over on the first line:
Program.cs(2,25): error CS0234: The type or namespace name 'Models' does not exist in the namespace 'Microsoft.OpenApi' (are you missing an assembly reference?)
The security definition is written differently in that version too. That is why we stay on 7.3.2 throughout Block A. We will look at the built-in OpenAPI support, using Scalar instead of Swagger UI, and moving to Swashbuckle 10 in detail in the .NET 9 and .NET 10 posts.
A Small Experiment
★☆☆ Pass includeControllerXmlComments: true as the second argument to IncludeXmlComments. Find the controller's summary sentence under tags at the bottom of swagger.json, then look for the same sentence next to the Products heading in Swagger UI.
★★☆ Add a Delete action to ProductsController with [HttpDelete("{id:int}")]. Make it return 204 and 404. First look at the document without any attributes or comments, then add [ProducesResponseType] and <response> comments and compare.
★★★ Bring the login endpoint from the JWT post into this project and protect one endpoint with [Authorize]. Send the same request from Swagger UI before and after pasting the token into Authorize; check the outgoing header in the browser's developer tools. Then delete AddSecurityRequirement and watch the lock icons disappear.
Our document is ready, and clients now know what to call. But are we really going to hit the database and compute the same answer every time for a client that asks for the same product list thousands of times a minute? In the next post we move on to caching: IMemoryCache, IDistributedCache with Redis, and the response caching middleware.