Web Api

Документація API - Swashbuckle, NSwag та генерація клієнтів

Production-level документація для Web API Controllers, XML-коментарі → OpenAPI, Swashbuckle фільтри (IOperationFilter, IDocumentFilter), аутентифікація у Swagger UI, NSwag для генерації C# та TypeScript клієнтів, Refit автоматичний HTTP-клієнт.

Документація API: Swashbuckle, NSwag та генерація клієнтів

Вступ: Проблема недокументованого API

Уявіть, що ви створили чудовий 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 - не авторизовано

Проблеми:

  • ❌ Документація застаріває (код змінюється, документація — ні)
  • ❌ Потрібно вручну підтримувати синхронізацію
  • ❌ Немає інтерактивного тестування
  • ❌ Клієнти мають хардкодити URL-и та моделі

Реальний сценарій:

// 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.

Передумови: Ця стаття базується на знаннях з попередніх статей Web API Controllers (01-09), а також на розумінні OpenAPI специфікації з курсу API Design (стаття 14).

Що ви створите в цій статті

Ми побудуємо 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();
}

До кінця статті ви зможете:

  • Створювати production-ready документацію через XML-коментарі
  • Використовувати Swashbuckle фільтри для кастомізації
  • Генерувати типізовані клієнти для C# та TypeScript
  • Використовувати Refit для автоматичних HTTP-клієнтів
  • Налаштовувати аутентифікацію у Swagger UI

Фундаментальні концепції

OpenAPI (Swagger) Specification

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 vs NSwag

ХарактеристикаSwashbuckleNSwag
Генерація OpenAPI✅ Так✅ Так
Swagger UI✅ Вбудований✅ Вбудований
Генерація клієнтів❌ Ні✅ C#, TypeScript, Angular
Кастомізація✅ Фільтри✅ Процесори
Популярність⭐⭐⭐⭐⭐⭐⭐⭐⭐
ВикористанняДокументаціяДокументація + клієнти

Рекомендація:

  • Swashbuckle — для документації (простіший, популярніший)
  • NSwag — якщо потрібна генерація клієнтів
У цій статті ми використаємо Swashbuckle для документації + NSwag CLI для генерації клієнтів (best of both worlds).

XML Documentation Comments

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 - Продукт не знайдено

Практична реалізація: Products API з документацією

Крок 1: Налаштування проєкту

Створення проєкту

bash
$ dotnet new webapi -n ProductsDocumentationApi
The template "ASP.NET Core Web API" was created successfully.
$ cd ProductsDocumentationApi
$ dotnet add package Swashbuckle.AspNetCore
info : PackageReference added successfully
$ dotnet add package Swashbuckle.AspNetCore.Annotations
info : PackageReference added successfully
$ dotnet add package Microsoft.EntityFrameworkCore.InMemory
info : PackageReference added successfully

Увімкнення XML Documentation

Відредагуйте .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>

Крок 2: Моделі з XML-коментарями

Створіть файл 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; }
}

Ключові елементи:

  1. <summary> — опис класу/властивості
  2. <example> — приклад значення (відображається у Swagger UI)
  3. DataAnnotations — автоматично конвертуються у OpenAPI constraints

Крок 3: Controller з повною документацією

Створіть файл 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();
    }
}

Ключові елементи:

  1. <summary> — короткий опис методу
  2. <remarks> — детальний опис з прикладами
  3. <param> — опис параметрів
  4. <returns> — що повертає метод
  5. <response> — опис статус-кодів
  6. [ProducesResponseType] — типи відповідей для OpenAPI
  7. [SwaggerOperation] — додаткова кастомізація (з Swashbuckle.AspNetCore.Annotations)
  8. [SwaggerParameter] — опис параметрів

Крок 4: Swashbuckle Configuration

Створіть файл 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();

Декомпозиція:

  1. SwaggerDoc — метадані API (версія, опис, контакти, ліцензія)
  2. IncludeXmlComments — підключення XML документації
  3. EnableAnnotations — підтримка [SwaggerOperation] атрибутів
  4. SchemaFilter — кастомізація схем (приклади)
  5. OperationFilter — кастомізація операцій (headers)
  6. RoutePrefix = string.Empty — Swagger UI на / замість /swagger

Крок 5: Swashbuckle Filters

1. ExampleSchemaFilter — Приклади у схемах

Створіть файл 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 з'являються приклади для кожної моделі.

2. AddResponseHeadersFilter — Додавання headers

Створіть файл 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" }
                });
            }
        }
    }
}

Крок 6: Аутентифікація у Swagger UI

Додайте підтримку 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.


Крок 7: Тестування Swagger UI

bash
$ dotnet run
info: Now listening on: https://localhost:5001
# Відкрийте браузер
$ open https://localhost:5001
✓ Swagger UI відкрито
✓ Всі endpoints задокументовані
✓ XML коментарі відображаються
✓ Приклади доступні
✓ Можна тестувати endpoints

Генерація клієнтів

NSwag CLI для генерації клієнтів

Встановлення NSwag

bash
$ dotnet tool install -g NSwag.ConsoleCore
Tool 'nswag' was successfully installed.

Генерація C# клієнта

# Експортуємо 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);
    }
}

Генерація TypeScript клієнта

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-клієнт

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);
}

Реєстрація у DI

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 інтерфейс)


Просунуті техніки

1. Versioned API Documentation

Документація для кількох версій 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");
});

2. Custom Operation Processor

Автоматичне додавання тегів:

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 }
            };
        }
    }
}

3. Hide Endpoints from Documentation

[ApiExplorerSettings(IgnoreApi = true)]
[HttpGet("internal")]
public IActionResult InternalEndpoint()
{
    // Цей endpoint не з'явиться у Swagger
    return Ok();
}

4. Custom Schema IDs

Уникнення конфліктів імен:

builder.Services.AddSwaggerGen(options =>
{
    options.CustomSchemaIds(type => type.FullName);
    // ProductsApi.Models.ProductDto замість просто ProductDto
});

5. ReDoc Alternative UI

Альтернатива Swagger UI:

dotnet add package Swashbuckle.AspNetCore.ReDoc
app.UseReDoc(options =>
{
    options.SpecUrl = "/swagger/v1/swagger.json";
    options.RoutePrefix = "docs";
});

Відкрийте /docs для ReDoc UI (більш читабельний для документації).


Практичні завдання

Рівень 1: Базове розуміння

Завдання 1.1: XML Documentation

Додайте XML коментарі до цього методу:

[HttpGet("search")]
public async Task<ActionResult<List<ProductDto>>> Search(string query, decimal? minPrice, decimal? maxPrice)
{
    // ... логіка пошуку
}

Завдання 1.2: ProducesResponseType

Які [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);
}

Рівень 2: Логіка та розширення

Завдання 2.1: Custom Schema Filter

Створіть фільтр, що додає приклади для enum типів:

Завдання 2.2: Conditional Security Requirements

Додайте Bearer token тільки для певних endpoints:

Завдання 2.3: Response Examples

Додайте приклади відповідей для різних статус-кодів:


Рівень 3: Архітектура та створення

Завдання 3.1: Multi-Language Documentation

Створіть систему для документації кількома мовами:

Завдання 3.2: Auto-Generated Client Package

Створіть CI/CD pipeline для автоматичної генерації та публікації NuGet пакету з клієнтом:

Завдання 3.3: Interactive API Explorer

Створіть кастомний UI для тестування API з історією запитів:


Резюме

У цій статті ви навчилися створювати production-level документацію для Web API:

Ключові концепції

1. XML Documentation:

  • <summary> — короткий опис
  • <remarks> — детальний опис з прикладами
  • <param> — опис параметрів
  • <returns> — що повертає метод
  • <response> — опис статус-кодів
  • <example> — приклади значень

2. Swashbuckle:

  • Автоматична генерація OpenAPI з коду
  • Фільтри для кастомізації (ISchemaFilter, IOperationFilter, IDocumentFilter)
  • Swagger UI для інтерактивного тестування
  • Підтримка аутентифікації (Bearer token)

3. Генерація клієнтів:

  • NSwag — C# та TypeScript клієнти з OpenAPI spec
  • Refit — автоматичні HTTP-клієнти з інтерфейсів
  • Типобезпека та автоматична серіалізація

4. Best Practices:

  • ✅ Завжди додавайте XML коментарі до публічних API
  • ✅ Використовуйте [ProducesResponseType] для всіх можливих відповідей
  • ✅ Додавайте приклади через <example> або SchemaFilter
  • ✅ Документуйте помилки (ProblemDetails)
  • ✅ Генеруйте типізовані клієнти для frontend/mobile

Порівняння підходів

ПідхідПеревагиНедоліки
XML CommentsСинхронізовано з кодомВручну підтримувати
Swashbuckle AnnotationsБільше контролюБільше boilerplate
NSwag Generated ClientsТипобезпекаПотребує regeneration
RefitМінімум кодуПотребує ручного інтерфейсу
Production Checklist:
  • ✅ XML коментарі для всіх публічних endpoints
  • [ProducesResponseType] для всіх статус-кодів
  • ✅ Приклади для складних моделей
  • ✅ Аутентифікація у Swagger UI
  • ✅ Версіонування документації
  • ✅ CI/CD для генерації клієнтів
  • ✅ ReDoc для читабельної документації

Додаткові ресурси

Swashbuckle Documentation

OpenAPI Specification


Наступна стаття:Health Checks та моніторинг APIIHealthCheck інтерфейс, вбудовані чеки (SQL Server, Redis, RabbitMQ), кастомні health checks, Health Check UI, Kubernetes probes (/healthz, /readyz), structured health response.