9.3 - Swagger / OpenAPI
Tóm tắt: OpenAPI là hợp đồng API ở dạng máy đọc được, và Swagger UI chỉ là một cách trình bày nó. Giá trị thật không nằm ở trang web đẹp mà ở chỗ tài liệu sinh ra từ code, nên nó không bao giờ lỗi thời — miễn là bạn cho nó đủ thông tin. Mặc định, ASP.NET Core chỉ biết đường dẫn và kiểu tham số; nó không biết endpoint trả 404 khi nào, trường nào bắt buộc, hay tham số này nghĩa là gì. Đó là việc của XML comments và
[ProducesResponseType]. Và một điều hay bị quên: Swagger UI mở ở production là liệt kê toàn bộ bề mặt tấn công cho bất kỳ ai.
Mục tiêu bài học
Sau bài này bạn có thể:
- Cấu hình Swashbuckle với XML comments và ví dụ.
- Khai báo đầy đủ các mã phản hồi để tài liệu không nói dối.
- Hiển thị nhiều version API trong một Swagger UI.
- Quyết định có mở Swagger UI ở production hay không, và bảo vệ nó.
- Sinh client tự động từ file OpenAPI.
Nội dung bài học
9.3.1 — Swashbuckle, NSwag hay Microsoft.AspNetCore.OpenApi
| Swashbuckle | NSwag | Microsoft.AspNetCore.OpenApi | |
|---|---|---|---|
| Sinh file OpenAPI | Có | Có | Có (.NET 9+) |
| Swagger UI sẵn | Có | Có | Không — tự thêm |
| Sinh client C#/TS | Không | Có | Không |
| Mức tuỳ biến | Rất cao (filter) | Cao | Đang phát triển |
| Phổ biến | Cao nhất | Trung bình | Mới |
Từ .NET 9, template mặc định dùng Microsoft.AspNetCore.OpenApi và không kèm Swagger UI — nhiều người bất ngờ vì /swagger trả 404 sau khi nâng cấp. Bạn tự thêm UI bằng Scalar hoặc Swagger UI.
Với dự án đang chạy, Swashbuckle vẫn là lựa chọn an toàn nhất: hệ sinh thái filter lớn và mọi vấn đề đều đã có người gặp trước.
9.3.2 — Cấu hình
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn> <!-- tắt cảnh báo thiếu XML comment -->
</PropertyGroup>builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "CRM API",
Version = "v1",
Description = "API quản lý khách hàng và liên hệ",
Contact = new OpenApiContact { Name = "Team Backend", Email = "[email protected]" }
});
var xmlPath = Path.Combine(AppContext.BaseDirectory,
$"{Assembly.GetExecutingAssembly().GetName().Name}.xml");
options.IncludeXmlComments(xmlPath, includeControllerXmlComments: true);
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "Nhập JWT token (không cần tiền tố 'Bearer')"
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{ Type = ReferenceType.SecurityScheme, Id = "Bearer" }
},
Array.Empty<string>()
}
});
});GenerateDocumentationFile là bước hay bị quên: không có nó, IncludeXmlComments chỉ ném FileNotFoundException lúc khởi động, hoặc tệ hơn là im lặng không có tài liệu nào.
includeControllerXmlComments: true đưa cả mô tả cấp controller vào — nó trở thành mô tả của nhóm endpoint.
9.3.3 — Cho Swagger biết những gì nó không tự đoán được
/// <summary>
/// Lấy danh sách khách hàng có phân trang.
/// </summary>
/// <remarks>
/// Ví dụ:
///
/// GET /api/v1/customers?page=1&pageSize=20&status=active
///
/// Kết quả sắp xếp theo ngày tạo giảm dần nếu không chỉ định `sortBy`.
/// </remarks>
/// <param name="request">Tham số lọc và phân trang.</param>
/// <param name="ct">Token huỷ của request.</param>
/// <response code="200">Trả về danh sách khách hàng.</response>
/// <response code="400">Tham số phân trang không hợp lệ.</response>
/// <response code="401">Chưa xác thực.</response>
[HttpGet]
[ProducesResponseType<PagedResponse<CustomerDto>>(StatusCodes.Status200OK)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<PagedResponse<CustomerDto>>> GetAll(
[FromQuery] CustomerFilterRequest request, CancellationToken ct);Những gì ASP.NET Core không tự biết và bạn phải khai:
- Các mã lỗi. Không khai thì tài liệu chỉ có 200, và client sinh tự động không có kiểu cho lỗi.
- Kiểu của body lỗi.
ValidationProblemDetailskhácProblemDetails. - Ý nghĩa của tham số.
statusnhận những giá trị nào? - Ví dụ. Một request mẫu tiết kiệm hàng giờ cho người tích hợp.
Khai một lần cho toàn bộ API những mã lỗi dùng chung:
builder.Services.AddControllers(options =>
{
options.Filters.Add(new ProducesResponseTypeAttribute(
typeof(ProblemDetails), StatusCodes.Status500InternalServerError));
options.Filters.Add(new ProducesResponseTypeAttribute(
StatusCodes.Status401Unauthorized));
});Ràng buộc trên DTO cũng chảy vào schema:
public sealed record CreateCustomerRequest
{
/// <summary>Tên đầy đủ của khách hàng.</summary>
/// <example>Nguyễn Văn A</example>
[Required, StringLength(200, MinimumLength = 2)]
public required string Name { get; init; }
}<example> hiện trong "Try it out" — người tích hợp bấm là chạy được ngay, thay vì phải tự đoán dữ liệu hợp lệ.
9.3.4 — Nhiều version trong một UI
builder.Services.AddApiVersioning(...)
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
builder.Services.ConfigureOptions<ConfigureSwaggerOptions>();public sealed class ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider)
: IConfigureOptions<SwaggerGenOptions>
{
public void Configure(SwaggerGenOptions options)
{
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, new OpenApiInfo
{
Title = "CRM API",
Version = description.ApiVersion.ToString(),
Description = description.IsDeprecated
? "Phiên bản này đã ngừng hỗ trợ."
: null
});
}
}
}app.UseSwaggerUI(options =>
{
foreach (var d in app.DescribeApiVersions())
options.SwaggerEndpoint($"/swagger/{d.GroupName}/swagger.json", d.GroupName.ToUpperInvariant());
});IConfigureOptions<SwaggerGenOptions> là cách đúng để cấu hình bằng một service khác — đúng mẫu ở bài 7.7, thay vì gọi BuildServiceProvider().
9.3.5 — Swagger UI ở production
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}Mặc định của template chỉ bật ở Development, và đó là mặc định đúng. Swagger UI công khai:
- Liệt kê mọi endpoint, kể cả endpoint nội bộ và admin.
- Cho biết trường nào bắt buộc, kiểu gì, ràng buộc ra sao.
- Cho phép gọi thử trực tiếp từ trình duyệt.
Nói cách khác, nó là bản đồ bề mặt tấn công, được cập nhật tự động.
Ba lựa chọn hợp lý:
// 1. Chỉ trong mạng nội bộ
app.MapSwagger().RequireHost("localhost", "*.internal.example.com");
// 2. Yêu cầu đăng nhập
app.UseSwaggerUI().RequireAuthorization("ApiDocsPolicy");
// 3. Sinh file OpenAPI lúc build, publish sang công cụ tài liệu riêngCách 3 là sạch nhất cho API công khai: file swagger.json sinh trong CI, đẩy lên một portal tài liệu, còn ứng dụng chạy không hề có endpoint Swagger nào.
dotnet tool install --global Swashbuckle.AspNetCore.Cli
swagger tofile --output swagger.json bin/Release/net9.0/CrmApi.dll v19.3.6 — Sinh client từ OpenAPI
Giá trị lớn nhất của OpenAPI không phải trang UI mà là client sinh tự động:
# C#
nswag openapi2csclient /input:swagger.json /output:CrmApiClient.cs /namespace:Crm.Client
# TypeScript
npx openapi-typescript swagger.json -o src/api/types.tsKhi API đổi, client sinh lại và trình biên dịch chỉ ra ngay chỗ nào gãy — thay vì phát hiện lúc chạy trên production.
Thêm vào CI một bước so sánh file OpenAPI với bản trước để phát hiện breaking change tự động:
npx @redocly/cli diff previous-swagger.json swagger.json --severity errorĐây là cách biến bảng "thay đổi nào là breaking" ở bài 9.2 thành một bước kiểm tra tự động, thay vì trông vào trí nhớ của người review.
9.3.7 — Rà lại code của bạn
Danh sách rà soát OpenAPI
- GenerateDocumentationFile được bật trong .csproj.
- Mọi endpoint công khai có XML summary mô tả bằng tiếng người.
- Mọi mã phản hồi có thể xảy ra đều được khai bằng ProducesResponseType.
- Kiểu body lỗi được khai đúng: ProblemDetails hay ValidationProblemDetails.
- DTO có ràng buộc validation để schema phản ánh đúng.
- Trường quan trọng có <example> để Try it out chạy được ngay.
- Swagger UI không mở công khai ở production.
- Nhiều version hiển thị trong cùng một UI qua IApiVersionDescriptionProvider.
- CI so sánh file OpenAPI để phát hiện breaking change.
Bài tập áp dụng
Bài 1 — Khi tài liệu nói dối
Viết một endpoint trả 404 trong một số trường hợp nhưng không khai [ProducesResponseType(404)]. Sinh client bằng NSwag và xem client xử lý 404 ra sao.
Tiêu chí hoàn thành: bạn chỉ ra được dòng nào trong client sinh ra gây ra hành vi sai, chứ không chỉ nói "client báo lỗi".
Gợi ý và lời giải — Bài 1
Gợi ý. NSwag sinh code từ danh sách mã trạng thái được khai báo. Mã nào không có trong danh sách thì nó coi là ngoại lệ, không phải kết quả hợp lệ.
Lời giải — endpoint thiếu khai báo:
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
// THIẾU: [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
var customer = await _service.GetByIdAsync(id, ct);
return customer is null ? NotFound() : Ok(customer); // 404 có thật
}swagger.json sinh ra chỉ có một nhánh:
"responses": {
"200": { "description": "Success", "content": { "application/json": { ... } } }
}Client NSwag sinh ra từ file đó:
var status = (int)response.StatusCode;
if (status == 200)
{
var objectResponse = await ReadObjectResponseAsync<CustomerDto>(response, headers, ct);
return objectResponse.Object;
}
else
{
var responseData = await response.Content.ReadAsStringAsync();
throw new ApiException(
"The HTTP status code of the response was not expected (" + status + ").",
status, responseData, headers, null); // <- DÒNG GÂY VẤN ĐỀ
}Đây là dòng cần chỉ ra. Không có nhánh 404, nên else bắt nó và ném ApiException.
Hậu quả cụ thể, và vì sao nó tệ hơn vẻ ngoài:
// Người dùng client viết điều tự nhiên nhất
var customer = await _api.GetByIdAsync(id);
if (customer is null) return NotFound(); // KHÔNG BAO GIỜ chạy tới đâyNhánh customer is null không bao giờ đúng, vì lời gọi đã ném trước đó. Lập trình viên phía client phải viết:
try { customer = await _api.GetByIdAsync(id); }
catch (ApiException ex) when (ex.StatusCode == 404) { customer = null; }Tức là dùng ngoại lệ cho một luồng hoàn toàn bình thường — "không tìm thấy" là kết quả thường gặp, không phải sự cố. Ngoài chuyện đọc khó, nó còn tốn kém khi nằm trong vòng lặp, đúng như bài 4.9 đã đo.
Sau khi khai đúng:
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CustomerDto>> GetById(Guid id, CancellationToken ct)NSwag sinh thêm nhánh trả null thay vì ném, và đoạn code tự nhiên ở trên chạy đúng.
Mẹo bỏ bớt việc lặp lại. Khai mã lỗi chung một lần cho toàn bộ ứng dụng thay vì gõ lại trên từng action:
builder.Services.AddControllers(o =>
{
o.Filters.Add(new ProducesResponseTypeAttribute(typeof(ProblemDetails), 400));
o.Filters.Add(new ProducesResponseTypeAttribute(typeof(ProblemDetails), 500));
});Với .NET 9, [ProducesResponseType] còn suy ra được kiểu từ ActionResult<T>, nên chỉ cần khai những mã mà trình biên dịch không tự biết — chủ yếu là 404, 409 và 422.
Điểm đáng nhớ. Tài liệu sai còn tệ hơn không có tài liệu: không có tài liệu thì người ta đi đọc code, còn tài liệu sai thì họ tin và sinh ra client hỏng.
Bài 2 — Swagger UI như một bản đồ tấn công
Mở Swagger UI của một API có endpoint admin, và liệt kê những thông tin một người lạ thu được trong 2 phút: số endpoint, tên trường, ràng buộc, cơ chế xác thực.
Tiêu chí hoàn thành: bạn viết ra được ít nhất năm thông tin cụ thể hữu ích cho người tấn công, và nêu cách chặn từng loại.
Gợi ý và lời giải — Bài 2
Gợi ý. Đừng chỉ nhìn giao diện. Tải thẳng file mô tả — nó gọn hơn và đọc máy được:
curl -s https://api.example.com/swagger/v1/swagger.json | jq '.paths | keys'Lời giải — năm nhóm thông tin thu được, không cần đăng nhập:
1. Toàn bộ danh sách endpoint, gồm cả phần không có trong giao diện người dùng.
curl -s .../swagger.json | jq -r '.paths | keys[]' | grep -i 'admin\|internal\|debug\|export'/api/v1/admin/users/{id}/impersonate
/api/v1/internal/reindex
/api/v1/debug/configBa endpoint này không xuất hiện ở bất kỳ màn hình nào — nhưng chúng tồn tại và nhận request.
2. Hình dạng chính xác của dữ liệu, kể cả trường nhạy cảm.
curl -s .../swagger.json | jq '.components.schemas.UserDto.properties | keys'["id","email","passwordHash","roles","tenantId","isSuperAdmin","internalNotes"]passwordHash và isSuperAdmin lộ ra vì DTO trả thẳng entity. Đây là lỗi thiết kế, và Swagger chỉ làm nó dễ thấy hơn.
3. Quy tắc xác thực đủ để đoán mật khẩu hợp lệ.
"password": { "minLength": 8, "pattern": "^(?=.*[A-Z])(?=.*\\d).+$" }Người tấn công biết ngay không gian mật khẩu cần thử: tối thiểu 8 ký tự, ít nhất một chữ hoa và một chữ số.
4. Endpoint nào cần token, endpoint nào không.
curl -s .../swagger.json | jq -r '.paths | to_entries[]
| select(.value | to_entries[].value.security == null) | .key'Danh sách trả về chính là các endpoint không yêu cầu xác thực — nơi đáng thử đầu tiên.
5. Nút "Try it out" biến trình duyệt thành công cụ dò. Không cần viết script, không cần Postman: gọi thử ngay trên trang, thấy luôn response thật.
Ba cách chặn, theo thứ tự nên chọn:
// 1. TỐT NHẤT cho API công khai — production không có endpoint Swagger nào
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}File swagger.json vẫn sinh trong CI và đẩy lên portal tài liệu riêng, nên bạn không mất gì:
swagger tofile --output swagger.json bin/Release/net9.0/CrmApi.dll v1// 2. Cần xem trên production — bắt đăng nhập
app.MapSwagger().RequireAuthorization("ApiDocsPolicy");
// 3. Giới hạn theo host nội bộ
app.MapSwagger().RequireHost("*.internal.example.com");Và độc lập với ba cách trên: đừng để schema lộ trường không nên lộ. Trả DTO riêng thay vì entity, đúng như bài 8.4 trình bày. Ẩn Swagger chỉ giấu tấm bản đồ; nó không sửa được việc endpoint thật vẫn trả passwordHash cho bất kỳ ai gọi.
Bài 3 — Bắt breaking change tự động trong CI
Lưu swagger.json hiện tại, xoá một trường khỏi một DTO, sinh lại, và chạy redocly diff. Xác nhận nó báo lỗi.
Tiêu chí hoàn thành: bạn có một bước CI thất bại khi có breaking change và qua khi thay đổi chỉ là thêm trường.
Gợi ý và lời giải — Bài 3
Gợi ý. So sánh phải chạy trên file sinh ra từ bản đã phát hành, không phải file trong nhánh của bạn. Lưu nó thành build artifact hoặc commit vào một thư mục riêng.
Lời giải — sinh file cơ sở rồi so sánh:
# 1. File cơ sở — sinh từ tag đã phát hành
git checkout v1.4.0
dotnet build -c Release
swagger tofile --output baseline.json bin/Release/net9.0/CrmApi.dll v1
# 2. File hiện tại
git checkout -
dotnet build -c Release
swagger tofile --output current.json bin/Release/net9.0/CrmApi.dll v1
# 3. So sánh
npx @redocly/cli diff baseline.json current.jsonKhi xoá trường Phone khỏi CustomerDto:
✗ BREAKING components.schemas.CustomerDto.properties.phone
Property removed from response schema.
1 breaking change found.Lệnh thoát với mã khác 0, nên bước CI đỏ.
Khi chỉ thêm trường Tags:
✓ 1 non-breaking change: components.schemas.CustomerDto.properties.tags added.
No breaking changes found.Xanh — đúng như bảng phân loại ở bài 9.2.
Bước CI hoàn chỉnh:
- name: Kiểm tra breaking change trong hợp đồng API
run: |
npx @redocly/cli diff baseline.json current.json --format=stylish
continue-on-error: falseBa điều bộ so sánh này bắt được mà người review rất hay bỏ sót:
| Thay đổi | Vì sao mắt người bỏ sót |
|---|---|
Xoá một giá trị enum | Diff C# chỉ mất một dòng giữa hàng trăm dòng khác |
Đổi nullable: true → false | Chỉ là bỏ dấu ? trong khai báo thuộc tính |
| Thêm trường bắt buộc vào request | Trông y hệt việc thêm trường vào response |
Một giới hạn phải biết. Công cụ so sánh không phát hiện được đổi ý nghĩa: Total chuyển từ chưa thuế sang đã thuế giữ nguyên tên, nguyên kiểu, nguyên nullable — hợp đồng OpenAPI không đổi một ký tự. Đó vẫn là loại breaking change nguy hiểm nhất, và chỉ con người bắt được khi review.
Cách dùng đúng: đặt redocly diff làm lưới an toàn cho những gì máy kiểm được, và giữ quy tắc "đổi ý nghĩa thì đổi tên trường" cho phần máy không kiểm được.
Tự kiểm tra
Giá trị thật của OpenAPI nằm ở đâu?
Ở chỗ nó là hợp đồng API dạng máy đọc được, sinh ra từ code nên không lỗi thời, và từ đó sinh được client tự động cho nhiều ngôn ngữ. Swagger UI chỉ là một cách trình bày nó. Khi API đổi, client sinh lại và trình biên dịch chỉ ra ngay chỗ gãy thay vì phát hiện lúc chạy.
ASP.NET Core không tự biết những gì?
Các mã lỗi mà endpoint có thể trả, kiểu của body lỗi, ý nghĩa và giá trị hợp lệ của tham số, và ví dụ dữ liệu. Không khai thì tài liệu chỉ có 200 và client sinh tự động không có kiểu cho lỗi. Đó là việc của XML comments và ProducesResponseType.
GenerateDocumentationFile quan trọng thế nào?
Không bật nó thì IncludeXmlComments sẽ ném FileNotFoundException lúc khởi động, hoặc tệ hơn là im lặng không có tài liệu nào. Nó là bước hay bị quên nhất khi cấu hình Swagger.
Có nên mở Swagger UI ở production không?
Mặc định chỉ bật ở Development và đó là mặc định đúng. Swagger UI công khai liệt kê mọi endpoint kể cả nội bộ và admin, cho biết trường nào bắt buộc và ràng buộc ra sao, và cho phép gọi thử trực tiếp. Nó là bản đồ bề mặt tấn công được cập nhật tự động.
Muốn có tài liệu công khai mà không mở Swagger UI thì làm sao?
Sinh file OpenAPI lúc build bằng Swashbuckle CLI, đẩy lên một portal tài liệu riêng, còn ứng dụng chạy không có endpoint Swagger nào. Hai cách khác là giới hạn theo host nội bộ hoặc yêu cầu đăng nhập.
Làm sao phát hiện breaking change tự động?
Thêm vào CI một bước so sánh file OpenAPI với bản trước, ví dụ bằng redocly diff với mức severity error. Nó biến danh sách thay đổi breaking thành một bước kiểm tra tự động thay vì trông vào trí nhớ của người review.
Kết luận
Ba điều đáng nhớ nhất:
- Tài liệu sinh từ code không tự đầy đủ. Khai mã lỗi và kiểu body lỗi, nếu không nó nói dối.
- Swagger UI ở production là bản đồ bề mặt tấn công. Sinh file ra portal riêng.
- Dùng OpenAPI để sinh client và để bắt breaking change trong CI.
Tham khảo
- Generate OpenAPI documents in ASP.NET Core
- ASP.NET Core web API documentation with Swagger / OpenAPI
- Swashbuckle.AspNetCore (GitHub)
- OpenAPI Specification
Điều hướng
- Bài trước: 9.2 — API Versioning
- Bài tiếp theo: 9.4 — Pagination, Filtering, Sorting
- Về module: Trang mục lục