Уявіть, що ви створили чудовий API:
[HttpPost]
public async Task<ActionResult<ProductDto>> Create([FromBody] CreateProductDto dto)
{
var product = await _productService.CreateAsync(dto);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
Що знає розробник, який хоче використати цей API?
❌ Які поля потрібні у CreateProductDto?
❌ Які валідаційні правила?
❌ Які можливі статус-коди відповіді?
❌ Який формат помилок?
❌ Чи потрібна авторизація?
Типовий підхід — документація у Confluence/Notion:
# POST /api/products
Створює новий продукт.
**Request Body:**
- name (string, required) - назва продукту
- price (decimal, required) - ціна від 0.01 до 1,000,000
- stock (int, optional) - кількість на складі
**Responses:**
- 201 Created - продукт створено
- 400 Bad Request - валідаційна помилка
- 401 Unauthorized - не авторизовано
Проблеми:
Реальний сценарій:
// Frontend розробник пише код на основі застарілої документації
const response = await fetch('/api/products', {
method: 'POST',
body: JSON.stringify({
name: 'Laptop',
price: 1299.99,
quantity: 10 // ❌ Поле називається 'stock', а не 'quantity'!
})
});
// → 400 Bad Request (розробник витрачає годину на дебаг)
Рішення — Автоматична генерація документації з коду через OpenAPI (Swagger) + генерація типізованих клієнтів для frontend/mobile.
Ми побудуємо Products API з production-level документацією:
1. XML-коментарі → OpenAPI:
/// <summary>
/// Створює новий продукт
/// </summary>
/// <param name="dto">Дані для створення продукту</param>
/// <returns>Створений продукт</returns>
/// <response code="201">Продукт успішно створено</response>
/// <response code="400">Валідаційна помилка</response>
[HttpPost]
public async Task<ActionResult<ProductDto>> Create([FromBody] CreateProductDto dto)
2. Swashbuckle фільтри:
// Автоматично додає приклади до документації
public class ExamplesOperationFilter : IOperationFilter { }
3. Аутентифікація у Swagger UI:
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { ... });
4. NSwag генерація клієнтів:
# C# клієнт
nswag openapi2csclient /input:swagger.json /output:ProductsClient.cs
# TypeScript клієнт
nswag openapi2tsclient /input:swagger.json /output:products-client.ts
5. Refit автоматичний HTTP-клієнт:
public interface IProductsApi
{
[Get("/api/products")]
Task<List<ProductDto>> GetAllAsync();
}
До кінця статті ви зможете:
OpenAPI — стандарт опису REST API у JSON/YAML форматі:
{
"openapi": "3.0.1",
"info": {
"title": "Products API",
"version": "1.0"
},
"paths": {
"/api/products": {
"get": {
"summary": "Get all products",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": { "$ref": "#/components/schemas/ProductDto" }
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"ProductDto": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"price": { "type": "number" }
}
}
}
}
}
| Характеристика | Swashbuckle | NSwag |
|---|---|---|
| Генерація OpenAPI | ✅ Так | ✅ Так |
| Swagger UI | ✅ Вбудований | ✅ Вбудований |
| Генерація клієнтів | ❌ Ні | ✅ C#, TypeScript, Angular |
| Кастомізація | ✅ Фільтри | ✅ Процесори |
| Популярність | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| Використання | Документація | Документація + клієнти |
Рекомендація:
ASP.NET Core автоматично конвертує XML-коментарі у OpenAPI:
/// <summary>
/// Отримати продукт за ID
/// </summary>
/// <param name="id">ID продукту</param>
/// <returns>Продукт або 404</returns>
/// <response code="200">Продукт знайдено</response>
/// <response code="404">Продукт не знайдено</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductDto>> GetById(int id)
Результат у Swagger UI:
GET /api/products/{id}
Отримати продукт за ID
Parameters:
id (integer, path, required) - ID продукту
Responses:
200 - Продукт знайдено
404 - Продукт не знайдено
Відредагуйте .csproj:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Генерація XML документації -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn> <!-- Вимкнути попередження про відсутні коментарі -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.0" />
</ItemGroup>
</Project>
Створіть файл Models/Product.cs:
using System.ComponentModel.DataAnnotations;
namespace ProductsDocumentationApi.Models;
/// <summary>
/// Продукт у каталозі
/// </summary>
public class Product
{
/// <summary>
/// Унікальний ідентифікатор продукту
/// </summary>
public int Id { get; set; }
/// <summary>
/// Назва продукту
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Опис продукту
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Ціна продукту в USD
/// </summary>
public decimal Price { get; set; }
/// <summary>
/// Кількість на складі
/// </summary>
public int Stock { get; set; }
/// <summary>
/// Чи активний продукт
/// </summary>
public bool IsActive { get; set; } = true;
}
/// <summary>
/// DTO для створення продукту
/// </summary>
public record CreateProductDto
{
/// <summary>
/// Назва продукту (обов'язково, максимум 200 символів)
/// </summary>
/// <example>Laptop Dell XPS 15</example>
[Required(ErrorMessage = "Product name is required")]
[MaxLength(200, ErrorMessage = "Name cannot exceed 200 characters")]
public required string Name { get; init; }
/// <summary>
/// Опис продукту (опціонально)
/// </summary>
/// <example>High-performance laptop with 16GB RAM and 512GB SSD</example>
[MaxLength(1000)]
public string? Description { get; init; }
/// <summary>
/// Ціна продукту в USD (від 0.01 до 1,000,000)
/// </summary>
/// <example>1299.99</example>
[Range(0.01, 1_000_000, ErrorMessage = "Price must be between 0.01 and 1,000,000")]
public decimal Price { get; init; }
/// <summary>
/// Кількість на складі (мінімум 0)
/// </summary>
/// <example>10</example>
[Range(0, int.MaxValue, ErrorMessage = "Stock cannot be negative")]
public int Stock { get; init; }
}
/// <summary>
/// DTO для оновлення продукту
/// </summary>
public record UpdateProductDto
{
/// <summary>
/// Назва продукту
/// </summary>
[Required]
[MaxLength(200)]
public required string Name { get; init; }
/// <summary>
/// Опис продукту
/// </summary>
[MaxLength(1000)]
public string? Description { get; init; }
/// <summary>
/// Ціна продукту в USD
/// </summary>
[Range(0.01, 1_000_000)]
public decimal Price { get; init; }
/// <summary>
/// Кількість на складі
/// </summary>
[Range(0, int.MaxValue)]
public int Stock { get; init; }
}
/// <summary>
/// DTO для відображення продукту
/// </summary>
public record ProductDto
{
/// <summary>
/// ID продукту
/// </summary>
public int Id { get; init; }
/// <summary>
/// Назва продукту
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Опис продукту
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Ціна в USD
/// </summary>
public decimal Price { get; init; }
/// <summary>
/// Кількість на складі
/// </summary>
public int Stock { get; init; }
/// <summary>
/// Чи активний продукт
/// </summary>
public bool IsActive { get; init; }
}
Ключові елементи:
<summary> — опис класу/властивості<example> — приклад значення (відображається у Swagger UI)Створіть файл Controllers/ProductsController.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductsDocumentationApi.Data;
using ProductsDocumentationApi.Models;
using Swashbuckle.AspNetCore.Annotations;
namespace ProductsDocumentationApi.Controllers;
/// <summary>
/// Управління продуктами
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
[SwaggerTag("Endpoints для роботи з продуктами у каталозі")]
public class ProductsController : ControllerBase
{
private readonly ProductDbContext _db;
private readonly ILogger<ProductsController> _logger;
public ProductsController(ProductDbContext db, ILogger<ProductsController> logger)
{
_db = db;
_logger = logger;
}
/// <summary>
/// Отримати список всіх продуктів
/// </summary>
/// <remarks>
/// Повертає список всіх активних продуктів у каталозі.
///
/// Приклад запиту:
///
/// GET /api/products
///
/// </remarks>
/// <returns>Список продуктів</returns>
/// <response code="200">Список продуктів успішно отримано</response>
[HttpGet]
[ProducesResponseType(typeof(List<ProductDto>), StatusCodes.Status200OK)]
[SwaggerOperation(
Summary = "Отримати всі продукти",
Description = "Повертає список всіх активних продуктів",
OperationId = "GetAllProducts",
Tags = new[] { "Products" }
)]
public async Task<ActionResult<List<ProductDto>>> GetAll()
{
var products = await _db.Products
.Where(p => p.IsActive)
.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Price = p.Price,
Stock = p.Stock,
IsActive = p.IsActive
})
.ToListAsync();
return Ok(products);
}
/// <summary>
/// Отримати продукт за ID
/// </summary>
/// <param name="id">ID продукту</param>
/// <returns>Продукт з вказаним ID</returns>
/// <response code="200">Продукт знайдено</response>
/// <response code="404">Продукт не знайдено</response>
[HttpGet("{id:int}", Name = "GetProductById")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[SwaggerOperation(
Summary = "Отримати продукт за ID",
Description = "Повертає детальну інформацію про продукт",
OperationId = "GetProductById"
)]
public async Task<ActionResult<ProductDto>> GetById(
[SwaggerParameter("Унікальний ідентифікатор продукту", Required = true)]
int id)
{
var product = await _db.Products.FindAsync(id);
if (product is null)
{
return NotFound(new ProblemDetails
{
Title = "Product Not Found",
Detail = $"Product with ID {id} was not found",
Status = StatusCodes.Status404NotFound
});
}
var dto = new ProductDto
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
Stock = product.Stock,
IsActive = product.IsActive
};
return Ok(dto);
}
/// <summary>
/// Створити новий продукт
/// </summary>
/// <remarks>
/// Створює новий продукт у каталозі.
///
/// Приклад запиту:
///
/// POST /api/products
/// {
/// "name": "Laptop Dell XPS 15",
/// "description": "High-performance laptop",
/// "price": 1299.99,
/// "stock": 10
/// }
///
/// </remarks>
/// <param name="dto">Дані для створення продукту</param>
/// <returns>Створений продукт</returns>
/// <response code="201">Продукт успішно створено</response>
/// <response code="400">Валідаційна помилка</response>
[HttpPost]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[SwaggerOperation(
Summary = "Створити продукт",
Description = "Створює новий продукт у каталозі",
OperationId = "CreateProduct"
)]
public async Task<ActionResult<ProductDto>> Create(
[FromBody, SwaggerRequestBody("Дані нового продукту", Required = true)]
CreateProductDto dto)
{
var product = new Product
{
Name = dto.Name,
Description = dto.Description,
Price = dto.Price,
Stock = dto.Stock
};
_db.Products.Add(product);
await _db.SaveChangesAsync();
_logger.LogInformation("Product {ProductId} created: {ProductName}", product.Id, product.Name);
var resultDto = new ProductDto
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
Stock = product.Stock,
IsActive = product.IsActive
};
return CreatedAtRoute("GetProductById", new { id = product.Id }, resultDto);
}
/// <summary>
/// Оновити продукт
/// </summary>
/// <param name="id">ID продукту</param>
/// <param name="dto">Оновлені дані продукту</param>
/// <returns>Оновлений продукт</returns>
/// <response code="200">Продукт успішно оновлено</response>
/// <response code="404">Продукт не знайдено</response>
/// <response code="400">Валідаційна помилка</response>
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[SwaggerOperation(
Summary = "Оновити продукт",
Description = "Оновлює існуючий продукт",
OperationId = "UpdateProduct"
)]
public async Task<ActionResult<ProductDto>> Update(
[SwaggerParameter("ID продукту для оновлення")] int id,
[FromBody] UpdateProductDto dto)
{
var product = await _db.Products.FindAsync(id);
if (product is null)
return NotFound();
product.Name = dto.Name;
product.Description = dto.Description;
product.Price = dto.Price;
product.Stock = dto.Stock;
await _db.SaveChangesAsync();
_logger.LogInformation("Product {ProductId} updated", product.Id);
var resultDto = new ProductDto
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
Stock = product.Stock,
IsActive = product.IsActive
};
return Ok(resultDto);
}
/// <summary>
/// Видалити продукт
/// </summary>
/// <param name="id">ID продукту</param>
/// <returns>Статус операції</returns>
/// <response code="204">Продукт успішно видалено</response>
/// <response code="404">Продукт не знайдено</response>
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[SwaggerOperation(
Summary = "Видалити продукт",
Description = "Виконує soft delete продукту (встановлює IsActive = false)",
OperationId = "DeleteProduct"
)]
public async Task<IActionResult> Delete(
[SwaggerParameter("ID продукту для видалення")] int id)
{
var product = await _db.Products.FindAsync(id);
if (product is null)
return NotFound();
product.IsActive = false; // Soft delete
await _db.SaveChangesAsync();
_logger.LogInformation("Product {ProductId} deleted (soft)", product.Id);
return NoContent();
}
}
Ключові елементи:
<summary> — короткий опис методу<remarks> — детальний опис з прикладами<param> — опис параметрів<returns> — що повертає метод<response> — опис статус-кодів[ProducesResponseType] — типи відповідей для OpenAPI[SwaggerOperation] — додаткова кастомізація (з Swashbuckle.AspNetCore.Annotations)[SwaggerParameter] — опис параметрівСтворіть файл Program.cs:
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi.Models;
using ProductsDocumentationApi.Data;
using System.Reflection;
var builder = WebApplication.CreateBuilder(args);
// Database
builder.Services.AddDbContext<ProductDbContext>(options =>
options.UseInMemoryDatabase("ProductsDb"));
builder.Services.AddControllers();
// Swagger/OpenAPI
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
// Базова інформація
options.SwaggerDoc("v1", new OpenApiInfo
{
Version = "v1",
Title = "Products API",
Description = "API для управління продуктами у каталозі",
TermsOfService = new Uri("https://example.com/terms"),
Contact = new OpenApiContact
{
Name = "Support Team",
Email = "support@example.com",
Url = new Uri("https://example.com/contact")
},
License = new OpenApiLicense
{
Name = "MIT License",
Url = new Uri("https://opensource.org/licenses/MIT")
}
});
// XML коментарі
var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFilename);
options.IncludeXmlComments(xmlPath);
// Annotations підтримка
options.EnableAnnotations();
// Приклади у схемах
options.SchemaFilter<ExampleSchemaFilter>();
// Додавання заголовків до операцій
options.OperationFilter<AddResponseHeadersFilter>();
// Сортування endpoints за методами
options.OrderActionsBy(apiDesc => $"{apiDesc.ActionDescriptor.RouteValues["controller"]}_{apiDesc.HttpMethod}");
});
var app = builder.Build();
// Seed database
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ProductDbContext>();
db.Database.EnsureCreated();
}
// Swagger UI (доступний завжди, не тільки у Development)
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "Products API v1");
options.RoutePrefix = string.Empty; // Swagger UI на root URL
options.DocumentTitle = "Products API Documentation";
options.DefaultModelsExpandDepth(2);
options.DefaultModelRendering(Swashbuckle.AspNetCore.SwaggerUI.ModelRendering.Model);
options.DisplayRequestDuration();
options.EnableDeepLinking();
options.EnableFilter();
});
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Декомпозиція:
SwaggerDoc — метадані API (версія, опис, контакти, ліцензія)IncludeXmlComments — підключення XML документаціїEnableAnnotations — підтримка [SwaggerOperation] атрибутівSchemaFilter — кастомізація схем (приклади)OperationFilter — кастомізація операцій (headers)RoutePrefix = string.Empty — Swagger UI на / замість /swaggerСтворіть файл Filters/ExampleSchemaFilter.cs:
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using ProductsDocumentationApi.Models;
namespace ProductsDocumentationApi.Filters;
public class ExampleSchemaFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
if (context.Type == typeof(CreateProductDto))
{
schema.Example = new OpenApiObject
{
["name"] = new OpenApiString("Laptop Dell XPS 15"),
["description"] = new OpenApiString("High-performance laptop with 16GB RAM and 512GB SSD"),
["price"] = new OpenApiDouble(1299.99),
["stock"] = new OpenApiInteger(10)
};
}
else if (context.Type == typeof(UpdateProductDto))
{
schema.Example = new OpenApiObject
{
["name"] = new OpenApiString("Laptop Dell XPS 15 (Updated)"),
["description"] = new OpenApiString("Updated description"),
["price"] = new OpenApiDouble(1199.99),
["stock"] = new OpenApiInteger(15)
};
}
else if (context.Type == typeof(ProductDto))
{
schema.Example = new OpenApiObject
{
["id"] = new OpenApiInteger(1),
["name"] = new OpenApiString("Laptop Dell XPS 15"),
["description"] = new OpenApiString("High-performance laptop"),
["price"] = new OpenApiDouble(1299.99),
["stock"] = new OpenApiInteger(10),
["isActive"] = new OpenApiBoolean(true)
};
}
}
}
Результат: У Swagger UI з'являються приклади для кожної моделі.
Створіть файл Filters/AddResponseHeadersFilter.cs:
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace ProductsDocumentationApi.Filters;
public class AddResponseHeadersFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
// Додаємо X-Correlation-ID header до всіх відповідей
foreach (var response in operation.Responses.Values)
{
response.Headers ??= new Dictionary<string, OpenApiHeader>();
if (!response.Headers.ContainsKey("X-Correlation-ID"))
{
response.Headers.Add("X-Correlation-ID", new OpenApiHeader
{
Description = "Correlation ID для трейсингу запиту",
Schema = new OpenApiSchema { Type = "string" }
});
}
if (!response.Headers.ContainsKey("X-Response-Time-Ms"))
{
response.Headers.Add("X-Response-Time-Ms", new OpenApiHeader
{
Description = "Час виконання запиту в мілісекундах",
Schema = new OpenApiSchema { Type = "integer" }
});
}
}
}
}
Додайте підтримку Bearer token у Swagger:
builder.Services.AddSwaggerGen(options =>
{
// ... попередня конфігурація
// Додаємо схему безпеки
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "JWT Authorization header using the Bearer scheme. Example: \"Bearer {token}\""
});
// Додаємо вимогу безпеки до всіх операцій
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
});
Результат: У Swagger UI з'являється кнопка "Authorize" для введення JWT token.
# Експортуємо OpenAPI spec
curl https://localhost:5001/swagger/v1/swagger.json -o swagger.json
# Генеруємо C# клієнт
nswag openapi2csclient \
/input:swagger.json \
/output:ProductsClient.cs \
/namespace:ProductsApi.Client \
/className:ProductsApiClient \
/generateClientInterfaces:true \
/generateDtoTypes:true \
/injectHttpClient:true
Результат — ProductsClient.cs:
namespace ProductsApi.Client
{
public partial interface IProductsApiClient
{
Task<ICollection<ProductDto>> GetAllAsync(CancellationToken cancellationToken);
Task<ProductDto> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<ProductDto> CreateAsync(CreateProductDto dto, CancellationToken cancellationToken);
Task<ProductDto> UpdateAsync(int id, UpdateProductDto dto, CancellationToken cancellationToken);
Task DeleteAsync(int id, CancellationToken cancellationToken);
}
public partial class ProductsApiClient : IProductsApiClient
{
private readonly HttpClient _httpClient;
public ProductsApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<ICollection<ProductDto>> GetAllAsync(CancellationToken cancellationToken)
{
var url = "api/products";
var response = await _httpClient.GetAsync(url, cancellationToken);
// ... десеріалізація
}
// ... інші методи
}
// DTOs також згенеровані
public partial class ProductDto
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
public bool IsActive { get; set; }
}
}
Використання:
// Реєстрація у DI
builder.Services.AddHttpClient<IProductsApiClient, ProductsApiClient>(client =>
{
client.BaseAddress = new Uri("https://api.example.com");
});
// Використання
public class ProductService
{
private readonly IProductsApiClient _apiClient;
public ProductService(IProductsApiClient apiClient)
{
_apiClient = apiClient;
}
public async Task<ICollection<ProductDto>> GetProductsAsync()
{
return await _apiClient.GetAllAsync(CancellationToken.None);
}
}
nswag openapi2tsclient \
/input:swagger.json \
/output:products-client.ts \
/className:ProductsApiClient \
/template:Fetch \
/promiseType:Promise \
/generateClientInterfaces:true
Результат — products-client.ts:
export interface IProductsApiClient {
getAll(): Promise<ProductDto[]>;
getById(id: number): Promise<ProductDto>;
create(dto: CreateProductDto): Promise<ProductDto>;
update(id: number, dto: UpdateProductDto): Promise<ProductDto>;
delete(id: number): Promise<void>;
}
export class ProductsApiClient implements IProductsApiClient {
private baseUrl: string;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl ?? 'https://api.example.com';
}
async getAll(): Promise<ProductDto[]> {
const url = `${this.baseUrl}/api/products`;
const response = await fetch(url);
return await response.json();
}
// ... інші методи
}
export interface ProductDto {
id: number;
name: string;
description?: string;
price: number;
stock: number;
isActive: boolean;
}
export interface CreateProductDto {
name: string;
description?: string;
price: number;
stock: number;
}
Використання у React/Vue/Angular:
import { ProductsApiClient } from './products-client';
const apiClient = new ProductsApiClient('https://api.example.com');
// Типізовані запити!
const products = await apiClient.getAll();
console.log(products[0].name); // TypeScript знає про властивість 'name'
const newProduct = await apiClient.create({
name: 'Laptop',
price: 1299.99,
stock: 10
});
Refit — бібліотека для автоматичної генерації HTTP-клієнтів з інтерфейсів:
dotnet add package Refit
dotnet add package Refit.HttpClientFactory
using Refit;
using ProductsDocumentationApi.Models;
namespace ProductsApi.Client;
public interface IProductsApi
{
[Get("/api/products")]
Task<List<ProductDto>> GetAllAsync();
[Get("/api/products/{id}")]
Task<ProductDto> GetByIdAsync(int id);
[Post("/api/products")]
Task<ProductDto> CreateAsync([Body] CreateProductDto dto);
[Put("/api/products/{id}")]
Task<ProductDto> UpdateAsync(int id, [Body] UpdateProductDto dto);
[Delete("/api/products/{id}")]
Task DeleteAsync(int id);
}
builder.Services.AddRefitClient<IProductsApi>()
.ConfigureHttpClient(client =>
{
client.BaseAddress = new Uri("https://api.example.com");
});
public class ProductService
{
private readonly IProductsApi _api;
public ProductService(IProductsApi api)
{
_api = api;
}
public async Task<List<ProductDto>> GetProductsAsync()
{
return await _api.GetAllAsync();
}
public async Task<ProductDto> CreateProductAsync(CreateProductDto dto)
{
return await _api.CreateAsync(dto);
}
}
Переваги Refit:
✅ Мінімум boilerplate коду
✅ Типобезпека
✅ Автоматична серіалізація/десеріалізація
✅ Підтримка DI
✅ Легко тестувати (mock інтерфейс)
Документація для кількох версій API:
builder.Services.AddSwaggerGen(options =>
{
// v1
options.SwaggerDoc("v1", new OpenApiInfo
{
Version = "v1",
Title = "Products API v1",
Description = "Stable version"
});
// v2
options.SwaggerDoc("v2", new OpenApiInfo
{
Version = "v2",
Title = "Products API v2",
Description = "Latest version with new features"
});
// Фільтр для розподілу endpoints по версіях
options.DocInclusionPredicate((docName, apiDesc) =>
{
var versions = apiDesc.ActionDescriptor.EndpointMetadata
.OfType<ApiVersionAttribute>()
.SelectMany(attr => attr.Versions);
return versions.Any(v => $"v{v}" == docName);
});
});
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "v1");
options.SwaggerEndpoint("/swagger/v2/swagger.json", "v2");
});
Автоматичне додавання тегів:
public class TagByControllerNameFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var controllerName = context.MethodInfo.DeclaringType?.Name.Replace("Controller", "");
if (!string.IsNullOrEmpty(controllerName))
{
operation.Tags = new List<OpenApiTag>
{
new OpenApiTag { Name = controllerName }
};
}
}
}
[ApiExplorerSettings(IgnoreApi = true)]
[HttpGet("internal")]
public IActionResult InternalEndpoint()
{
// Цей endpoint не з'явиться у Swagger
return Ok();
}
Уникнення конфліктів імен:
builder.Services.AddSwaggerGen(options =>
{
options.CustomSchemaIds(type => type.FullName);
// ProductsApi.Models.ProductDto замість просто ProductDto
});
Альтернатива Swagger UI:
dotnet add package Swashbuckle.AspNetCore.ReDoc
app.UseReDoc(options =>
{
options.SpecUrl = "/swagger/v1/swagger.json";
options.RoutePrefix = "docs";
});
Відкрийте /docs для ReDoc UI (більш читабельний для документації).
Додайте XML коментарі до цього методу:
[HttpGet("search")]
public async Task<ActionResult<List<ProductDto>>> Search(string query, decimal? minPrice, decimal? maxPrice)
{
// ... логіка пошуку
}
/// <summary>
/// Пошук продуктів за критеріями
/// </summary>
/// <param name="query">Пошуковий запит (назва або опис)</param>
/// <param name="minPrice">Мінімальна ціна (опціонально)</param>
/// <param name="maxPrice">Максимальна ціна (опціонально)</param>
/// <returns>Список знайдених продуктів</returns>
/// <response code="200">Продукти знайдено</response>
/// <response code="400">Невалідні параметри пошуку</response>
/// <remarks>
/// Приклад запиту:
///
/// GET /api/products/search?query=laptop&minPrice=1000&maxPrice=2000
///
/// </remarks>
[HttpGet("search")]
[ProducesResponseType(typeof(List<ProductDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<List<ProductDto>>> Search(
[SwaggerParameter("Пошуковий запит")] string query,
[SwaggerParameter("Мінімальна ціна")] decimal? minPrice,
[SwaggerParameter("Максимальна ціна")] decimal? maxPrice)
{
// ... логіка пошуку
}
Які [ProducesResponseType] атрибути потрібні для цього методу?
[HttpPost("bulk")]
public async Task<IActionResult> CreateBulk([FromBody] List<CreateProductDto> dtos)
{
if (dtos.Count > 100)
return BadRequest("Maximum 100 products allowed");
var products = await _service.CreateBulkAsync(dtos);
return Ok(products);
}
[HttpPost("bulk")]
[ProducesResponseType(typeof(List<ProductDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateBulk([FromBody] List<CreateProductDto> dtos)
{
if (dtos.Count > 100)
return BadRequest(new ProblemDetails
{
Title = "Too Many Products",
Detail = "Maximum 100 products allowed"
});
var products = await _service.CreateBulkAsync(dtos);
return Ok(products);
}
Створіть фільтр, що додає приклади для enum типів:
public enum ProductCategory
{
Electronics,
Clothing,
Books,
Home,
Sports
}
public class EnumSchemaFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
if (context.Type.IsEnum)
{
schema.Enum.Clear();
foreach (var enumValue in Enum.GetValues(context.Type))
{
schema.Enum.Add(new OpenApiString(enumValue.ToString()));
}
// Додаємо опис з усіма можливими значеннями
var enumNames = string.Join(", ", Enum.GetNames(context.Type));
schema.Description = $"Possible values: {enumNames}";
// Додаємо приклад
schema.Example = new OpenApiString(Enum.GetNames(context.Type).First());
}
}
}
Реєстрація:
builder.Services.AddSwaggerGen(options =>
{
options.SchemaFilter<EnumSchemaFilter>();
});
Результат у Swagger:
{
"category": "Electronics",
"description": "Possible values: Electronics, Clothing, Books, Home, Sports"
}
Додайте Bearer token тільки для певних endpoints:
public class ConditionalSecurityRequirementsFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
// Перевіряємо наявність [Authorize] атрибута
var hasAuthorize = context.MethodInfo.DeclaringType?
.GetCustomAttributes(true)
.Union(context.MethodInfo.GetCustomAttributes(true))
.OfType<AuthorizeAttribute>()
.Any() ?? false;
if (!hasAuthorize)
{
// Видаляємо security requirements для публічних endpoints
operation.Security?.Clear();
return;
}
// Додаємо Bearer token для захищених endpoints
operation.Security = new List<OpenApiSecurityRequirement>
{
new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
}
};
}
}
Реєстрація:
builder.Services.AddSwaggerGen(options =>
{
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { ... });
options.OperationFilter<ConditionalSecurityRequirementsFilter>();
// НЕ додаємо глобальний AddSecurityRequirement
});
Використання:
[HttpGet] // Публічний — без Bearer token у Swagger
public async Task<ActionResult<List<ProductDto>>> GetAll() { }
[HttpPost]
[Authorize] // Захищений — з Bearer token у Swagger
public async Task<ActionResult<ProductDto>> Create([FromBody] CreateProductDto dto) { }
Додайте приклади відповідей для різних статус-кодів:
public class ResponseExamplesFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
// Приклад для 200 OK
if (operation.Responses.TryGetValue("200", out var response200))
{
if (context.MethodInfo.Name == "GetAll")
{
response200.Content["application/json"].Example = new OpenApiArray
{
new OpenApiObject
{
["id"] = new OpenApiInteger(1),
["name"] = new OpenApiString("Laptop"),
["price"] = new OpenApiDouble(1299.99)
},
new OpenApiObject
{
["id"] = new OpenApiInteger(2),
["name"] = new OpenApiString("Mouse"),
["price"] = new OpenApiDouble(29.99)
}
};
}
}
// Приклад для 404 Not Found
if (operation.Responses.TryGetValue("404", out var response404))
{
response404.Content["application/json"].Example = new OpenApiObject
{
["type"] = new OpenApiString("https://tools.ietf.org/html/rfc9110#section-15.5.5"),
["title"] = new OpenApiString("Not Found"),
["status"] = new OpenApiInteger(404),
["detail"] = new OpenApiString("Product with ID 999 was not found")
};
}
// Приклад для 400 Bad Request
if (operation.Responses.TryGetValue("400", out var response400))
{
response400.Content["application/json"].Example = new OpenApiObject
{
["type"] = new OpenApiString("https://tools.ietf.org/html/rfc9110#section-15.5.1"),
["title"] = new OpenApiString("One or more validation errors occurred"),
["status"] = new OpenApiInteger(400),
["errors"] = new OpenApiObject
{
["Name"] = new OpenApiArray
{
new OpenApiString("Product name is required")
},
["Price"] = new OpenApiArray
{
new OpenApiString("Price must be between 0.01 and 1,000,000")
}
}
};
}
}
}
Створіть систему для документації кількома мовами:
public class LocalizedSwaggerOptions
{
public string DefaultLanguage { get; set; } = "en";
public Dictionary<string, LanguageInfo> Languages { get; set; } = new();
}
public class LanguageInfo
{
public string Title { get; set; } = "";
public string Description { get; set; } = "";
}
public class LocalizedDocumentFilter : IDocumentFilter
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly LocalizedSwaggerOptions _options;
public LocalizedDocumentFilter(
IHttpContextAccessor httpContextAccessor,
IOptions<LocalizedSwaggerOptions> options)
{
_httpContextAccessor = httpContextAccessor;
_options = options.Value;
}
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
var language = _httpContextAccessor.HttpContext?.Request.Query["lang"].ToString()
?? _options.DefaultLanguage;
if (_options.Languages.TryGetValue(language, out var langInfo))
{
swaggerDoc.Info.Title = langInfo.Title;
swaggerDoc.Info.Description = langInfo.Description;
}
}
}
Конфігурація:
builder.Services.Configure<LocalizedSwaggerOptions>(options =>
{
options.DefaultLanguage = "en";
options.Languages = new Dictionary<string, LanguageInfo>
{
["en"] = new() { Title = "Products API", Description = "API for managing products" },
["uk"] = new() { Title = "API Продуктів", Description = "API для управління продуктами" },
["de"] = new() { Title = "Produkte API", Description = "API zur Verwaltung von Produkten" }
};
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddSwaggerGen(options =>
{
options.DocumentFilter<LocalizedDocumentFilter>();
});
Використання:
/swagger/v1/swagger.json?lang=en → English
/swagger/v1/swagger.json?lang=uk → Українська
/swagger/v1/swagger.json?lang=de → Deutsch
Створіть CI/CD pipeline для автоматичної генерації та публікації NuGet пакету з клієнтом:
1. GitHub Actions Workflow (.github/workflows/generate-client.yml):
name: Generate and Publish API Client
on:
push:
branches: [main]
paths:
- 'src/ProductsApi/**'
jobs:
generate-client:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0.x'
- name: Build API
run: dotnet build src/ProductsApi/ProductsApi.csproj
- name: Run API
run: |
dotnet run --project src/ProductsApi/ProductsApi.csproj &
sleep 10
- name: Download OpenAPI spec
run: curl https://localhost:5001/swagger/v1/swagger.json -o swagger.json -k
- name: Install NSwag
run: dotnet tool install -g NSwag.ConsoleCore
- name: Generate C# Client
run: |
nswag openapi2csclient \
/input:swagger.json \
/output:src/ProductsApi.Client/ProductsApiClient.cs \
/namespace:ProductsApi.Client \
/className:ProductsApiClient \
/generateClientInterfaces:true \
/injectHttpClient:true
- name: Build Client Package
run: dotnet pack src/ProductsApi.Client/ProductsApi.Client.csproj -c Release
- name: Publish to NuGet
run: dotnet nuget push src/ProductsApi.Client/bin/Release/*.nupkg -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json
2. Client Project (.csproj):
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PackageId>ProductsApi.Client</PackageId>
<Version>1.0.0</Version>
<Authors>Your Company</Authors>
<Description>Auto-generated client for Products API</Description>
<PackageTags>api;client;products</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>
Результат: При кожному push у main автоматично генерується та публікується новий NuGet пакет.
Створіть кастомний UI для тестування API з історією запитів:
public class ApiExplorerEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapGet("/api-explorer", () =>
{
var html = @"
<!DOCTYPE html>
<html>
<head>
<title>API Explorer</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.endpoint { margin: 10px 0; padding: 10px; border: 1px solid #ddd; }
.method { font-weight: bold; color: #0066cc; }
button { padding: 5px 10px; cursor: pointer; }
.response { background: #f5f5f5; padding: 10px; margin-top: 10px; }
.history { margin-top: 20px; }
</style>
</head>
<body>
<h1>API Explorer</h1>
<div class='endpoint'>
<span class='method'>GET</span> /api/products
<button onclick='testEndpoint(""GET"", ""/api/products"")'>Test</button>
</div>
<div class='endpoint'>
<span class='method'>POST</span> /api/products
<input type='text' id='productName' placeholder='Product Name'>
<input type='number' id='productPrice' placeholder='Price'>
<button onclick='createProduct()'>Test</button>
</div>
<div class='response' id='response'></div>
<div class='history'>
<h2>Request History</h2>
<div id='history'></div>
</div>
<script>
const history = [];
async function testEndpoint(method, url) {
const start = Date.now();
const response = await fetch(url, { method });
const duration = Date.now() - start;
const data = await response.json();
displayResponse(method, url, response.status, data, duration);
addToHistory(method, url, response.status, duration);
}
async function createProduct() {
const name = document.getElementById('productName').value;
const price = parseFloat(document.getElementById('productPrice').value);
const start = Date.now();
const response = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, price, stock: 10 })
});
const duration = Date.now() - start;
const data = await response.json();
displayResponse('POST', '/api/products', response.status, data, duration);
addToHistory('POST', '/api/products', response.status, duration);
}
function displayResponse(method, url, status, data, duration) {
document.getElementById('response').innerHTML = `
<strong>${method} ${url}</strong><br>
Status: ${status}<br>
Duration: ${duration}ms<br>
<pre>${JSON.stringify(data, null, 2)}</pre>
`;
}
function addToHistory(method, url, status, duration) {
history.unshift({ method, url, status, duration, time: new Date().toLocaleTimeString() });
document.getElementById('history').innerHTML = history
.map(h => `<div>${h.time} - ${h.method} ${h.url} - ${h.status} (${h.duration}ms)</div>`)
.join('');
}
</script>
</body>
</html>";
return Results.Content(html, "text/html");
})
.ExcludeFromDescription();
}
}
Результат: Відкрийте /api-explorer для інтерактивного тестування з історією запитів.
У цій статті ви навчилися створювати production-level документацію для Web API:
1. XML Documentation:
<summary> — короткий опис<remarks> — детальний опис з прикладами<param> — опис параметрів<returns> — що повертає метод<response> — опис статус-кодів<example> — приклади значень2. Swashbuckle:
3. Генерація клієнтів:
4. Best Practices:
[ProducesResponseType] для всіх можливих відповідей<example> або SchemaFilter| Підхід | Переваги | Недоліки |
|---|---|---|
| XML Comments | Синхронізовано з кодом | Вручну підтримувати |
| Swashbuckle Annotations | Більше контролю | Більше boilerplate |
| NSwag Generated Clients | Типобезпека | Потребує regeneration |
| Refit | Мінімум коду | Потребує ручного інтерфейсу |
[ProducesResponseType] для всіх статус-кодівSwashbuckle Documentation
NSwag Documentation
Refit Documentation
OpenAPI Specification
IHealthCheck інтерфейс, вбудовані чеки (SQL Server, Redis, RabbitMQ), кастомні health checks, Health Check UI, Kubernetes probes (/healthz, /readyz), structured health response.Гібридна архітектура - Minimal API + Controllers
Комбінування Minimal API та Controllers у одному проєкті, vertical slice architecture, feature folders замість шарів, Carter library для організації endpoints, стратегії розподілу відповідальності.
Health Checks та моніторинг API
IHealthCheck інтерфейс, вбудовані чеки (SQL Server, Redis, RabbitMQ), кастомні health checks (зовнішні API, диск, пам'ять), Health Check UI, tags та фільтри (liveness vs readiness), Kubernetes probes, structured health response.