Якщо контролер — це серце вашого API, то типи повернення (response types) — це його мова спілкування з клієнтом. Кожна відповідь API — це не просто дані; це структурована розмова, де HTTP-код каже "що сталося", заголовки надають метадані, а тіло містить корисне навантаження. Неправильно обраний тип повернення або статус-код може зруйнувати user experience, навіть якщо ваша бізнес-логіка бездоганна.
Уявіть, що ви замовляєте каву в кав'ярні. Бариста може відповісти по-різному:
Кожна з цих відповідей несе семантичне значення, і клієнт (мобільний додаток, веб-інтерфейс, інший сервіс) повинен розуміти, як реагувати. У попередній статті ми навчилися створювати контролери; тепер настав час опанувати мистецтво формування відповідей.
Ми побудуємо Products API — сервіс управління товарами інтернет-магазину. Цей API демонструватиме всі типи відповідей у реальних сценаріях:
До кінця статті ви зможете:
ActionResult<T> vs IActionResult обґрунтовано[ProducesResponseType]ControllerBase — це не просто базовий клас для успадкування. Це фабрика допоміжних методів, що спрощують створення HTTP-відповідей. Замість ручного конструювання об'єктів ObjectResult, StatusCodeResult та інших, ви використовуєте лаконічні методи.
Розглянемо ключові методи ControllerBase:
// 200 OK - Успішна операція з даними
Ok(data) // ObjectResult з StatusCode = 200
// 201 Created - Ресурс створено
Created(uri, data) // CreatedResult з Location header
CreatedAtAction(actionName, routeValues, data) // Автоматична генерація URI
CreatedAtRoute(routeName, routeValues, data) // URI через іменований маршрут
// 202 Accepted - Запит прийнято до обробки (асинхронні операції)
Accepted(uri, data) // AcceptedResult
// 204 No Content - Успішно, але без даних у відповіді
NoContent() // NoContentResult
// 400 Bad Request - Невалідний запит
BadRequest() // BadRequestResult
BadRequest(error) // BadRequestObjectResult з деталями
BadRequest(ModelState) // Автоматичні помилки валідації
// 401 Unauthorized - Не автентифікований
Unauthorized() // UnauthorizedResult
// 403 Forbidden - Автентифікований, але немає прав
Forbid() // ForbidResult
// 404 Not Found - Ресурс не знайдено
NotFound() // NotFoundResult
NotFound(message) // NotFoundObjectResult з повідомленням
// 409 Conflict - Конфлікт бізнес-логіки
Conflict() // ConflictResult
Conflict(error) // ConflictObjectResult
// 422 Unprocessable Entity - Семантично невалідний запит
UnprocessableEntity() // UnprocessableEntityResult
UnprocessableEntity(ModelState)
// 500 Internal Server Error - Загальна помилка сервера
StatusCode(500) // StatusCodeResult
StatusCode(500, error) // ObjectResult з кодом 500
// Загальний метод для будь-якого коду
StatusCode(statusCode)
StatusCode(statusCode, value)
// Повернення файлів
File(bytes, contentType) // FileContentResult
File(stream, contentType) // FileStreamResult
PhysicalFile(path, contentType) // PhysicalFileResult
File(bytes, contentType, fileDownloadName) // З ім'ям для завантаження
// Перенаправлення
Redirect(url) // RedirectResult (302)
RedirectPermanent(url) // RedirectResult (301)
LocalRedirect(url) // LocalRedirectResult (безпечніше)
// Проблемні деталі (RFC 9457)
Problem(detail, instance, statusCode, title, type) // ProblemDetails
ValidationProblem(ModelState) // ValidationProblemDetails
StatusCode(200, data) пишіть Ok(data) — це робить код самодокументованим та зрозумілим.Всі методи ControllerBase повертають об'єкти, що успадковуються від IActionResult або ActionResult<T>. Розуміння цієї ієрархії критично важливе для правильного вибору типу повернення.
Ключові класи:
IActionResult — базовий інтерфейс для всіх результатів. Містить єдиний метод ExecuteResultAsync(), що виконує формування HTTP-відповіді.ActionResult — абстрактний клас, що реалізує IActionResult. Базовий клас для всіх конкретних результатів.ObjectResult — результат з об'єктом у тілі відповіді. Автоматично серіалізується в JSON/XML через content negotiation.StatusCodeResult — результат лише зі статус-кодом, без тіла.FileResult — базовий клас для повернення файлів (зображення, PDF, CSV).Один з найважливіших рішень при проєктуванні API-методу — вибір між ActionResult<T> та IActionResult. Ця відмінність впливає на документацію OpenAPI, type safety та зручність використання.
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var product = await _db.Products.FindAsync(id);
if (product is null)
return NotFound(); // Повертаємо NotFoundResult
return Ok(product); // Повертаємо OkObjectResult
}
Переваги:
Недоліки:
Product[ProducesResponseType] атрибути для документації[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetById(int id)
{
var product = await _db.Products.FindAsync(id);
if (product is null)
return NotFound(); // Повертаємо NotFoundResult
return product; // Неявна конвертація Product → OkObjectResult
}
Переваги:
ProductProduct для 200 OKT напряму, без Ok()TНедоліки:
ActionResult<T> для API-методів, де є чіткий "успішний" тип відповіді. Використовуйте IActionResult лише для методів, що повертають принципово різні типи (наприклад, файл або JSON залежно від параметра).| Аспект | IActionResult | ActionResult<T> |
|---|---|---|
| Type Safety | ❌ Немає | ✅ Є |
| OpenAPI документація | ⚠️ Потрібні атрибути | ✅ Автоматична |
| Неявна конвертація | ❌ Завжди Ok(data) | ✅ Можна return data |
| Гнучкість | ✅ Максимальна | ⚠️ Обмежена одним типом |
| Intellisense | ❌ Немає підказок | ✅ Підказки для T |
| Використання | Рідко (legacy) | ✅ За замовчуванням |
ActionResult<T> підтримує неявну конвертацію з типу T та з будь-якого ActionResult. Це дозволяє писати лаконічніший код:
// Варіант 1: Явне Ok()
[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetById_Explicit(int id)
{
var product = await _db.Products.FindAsync(id);
if (product is null) return NotFound();
return Ok(product); // Явно обгортаємо в OkObjectResult
}
// Варіант 2: Неявна конвертація (рекомендовано)
[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetById_Implicit(int id)
{
var product = await _db.Products.FindAsync(id);
if (product is null) return NotFound();
return product; // Неявна конвертація Product → ActionResult<Product>
}
Обидва варіанти працюють ідентично, але другий — чистіший та ідіоматичніший. Компілятор автоматично обгортає product у OkObjectResult.
null напряму, це призведе до 204 No Content, а не 200 OK з null у тілі. Якщо потрібен саме 200 OK з null, використовуйте Ok(null) явно.Настав час застосувати теорію на практиці. Створимо повноцінний API для управління товарами інтернет-магазину, демонструючи всі типи відповідей у реальних сценаріях.
Створіть файл Models/Product.cs:
using System.ComponentModel.DataAnnotations;
namespace ProductsApi.Models;
public class Product
{
public int Id { get; set; }
[Required]
[MaxLength(200)]
public required string Name { get; set; }
[MaxLength(1000)]
public string? Description { get; set; }
[Range(0.01, 1_000_000)]
public decimal Price { get; set; }
[Range(0, int.MaxValue)]
public int Stock { get; set; }
[MaxLength(100)]
public string? Category { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
}
Створіть файл DTOs/ProductDtos.cs:
using System.ComponentModel.DataAnnotations;
namespace ProductsApi.DTOs;
public record CreateProductDto
{
[Required(ErrorMessage = "Product name is required")]
[MaxLength(200)]
public required string Name { get; init; }
[MaxLength(1000)]
public string? Description { get; init; }
[Range(0.01, 1_000_000, ErrorMessage = "Price must be between 0.01 and 1,000,000")]
public decimal Price { get; init; }
[Range(0, int.MaxValue, ErrorMessage = "Stock cannot be negative")]
public int Stock { get; init; }
[MaxLength(100)]
public string? Category { get; init; }
}
public record UpdateProductDto
{
[MaxLength(200)]
public string? Name { get; init; }
[MaxLength(1000)]
public string? Description { get; init; }
[Range(0.01, 1_000_000)]
public decimal? Price { get; init; }
[Range(0, int.MaxValue)]
public int? Stock { get; init; }
[MaxLength(100)]
public string? Category { get; init; }
public bool? IsActive { get; init; }
}
public record ProductResponseDto
{
public int Id { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public decimal Price { get; init; }
public int Stock { get; init; }
public string? Category { get; init; }
public bool IsActive { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
}
Створіть файл Data/ProductDbContext.cs:
using Microsoft.EntityFrameworkCore;
using ProductsApi.Models;
namespace ProductsApi.Data;
public class ProductDbContext : DbContext
{
public ProductDbContext(DbContextOptions<ProductDbContext> options)
: base(options)
{
}
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired();
entity.Property(e => e.Price).HasPrecision(18, 2);
entity.HasIndex(e => e.Category);
entity.HasIndex(e => e.IsActive);
// Seed data для демонстрації
entity.HasData(
new Product
{
Id = 1,
Name = "Laptop Dell XPS 15",
Description = "High-performance laptop for developers",
Price = 1499.99m,
Stock = 10,
Category = "Electronics",
CreatedAt = DateTime.UtcNow.AddDays(-30)
},
new Product
{
Id = 2,
Name = "Mechanical Keyboard",
Description = "RGB mechanical keyboard with Cherry MX switches",
Price = 129.99m,
Stock = 25,
Category = "Accessories",
CreatedAt = DateTime.UtcNow.AddDays(-15)
},
new Product
{
Id = 3,
Name = "USB-C Hub",
Description = "7-in-1 USB-C hub with HDMI and Ethernet",
Price = 49.99m,
Stock = 0,
Category = "Accessories",
IsActive = false,
CreatedAt = DateTime.UtcNow.AddDays(-60)
}
);
});
}
}
using Microsoft.EntityFrameworkCore;
using ProductsApi.Data;
var builder = WebApplication.CreateBuilder(args);
// Реєстрація DbContext з InMemory провайдером
builder.Services.AddDbContext<ProductDbContext>(options =>
options.UseInMemoryDatabase("ProductsDb"));
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new()
{
Title = "Products API",
Version = "v1",
Description = "API для управління товарами інтернет-магазину"
});
// Підключення XML-коментарів для документації
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
if (File.Exists(xmlPath))
{
options.IncludeXmlComments(xmlPath);
}
});
var app = builder.Build();
// Ініціалізація бази даних
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ProductDbContext>();
db.Database.EnsureCreated();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
UseInMemoryDatabase для простоти демонстрації. У production-середовищі замініть на реальну базу даних (SQL Server, PostgreSQL, тощо).Створіть файл Controllers/ProductsController.cs. Ми реалізуємо кожен endpoint з правильним типом повернення та HTTP-кодом.
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductsApi.Data;
using ProductsApi.DTOs;
using ProductsApi.Models;
namespace ProductsApi.Controllers;
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
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>
/// <returns>Список товарів</returns>
/// <response code="200">Успішно отримано список товарів</response>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<ProductResponseDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<ProductResponseDto>>> GetAll()
{
_logger.LogInformation("Fetching all active products");
var products = await _db.Products
.Where(p => p.IsActive)
.OrderByDescending(p => p.CreatedAt)
.ToListAsync();
var response = products.Select(MapToDto);
return Ok(response);
}
/// <summary>
/// Отримати товар за ID
/// </summary>
/// <param name="id">Ідентифікатор товару</param>
/// <returns>Товар або 404</returns>
/// <response code="200">Товар знайдено</response>
/// <response code="404">Товар не знайдено</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ProductResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductResponseDto>> GetById(int id)
{
_logger.LogInformation("Fetching product with ID {ProductId}", id);
var product = await _db.Products.FindAsync(id);
if (product is null)
{
_logger.LogWarning("Product with ID {ProductId} not found", id);
return NotFound(new ProblemDetails
{
Title = "Product not found",
Detail = $"Product with ID {id} does not exist",
Status = StatusCodes.Status404NotFound,
Instance = HttpContext.Request.Path
});
}
// Неявна конвертація ProductResponseDto → ActionResult<ProductResponseDto>
return MapToDto(product);
}
/// <summary>
/// Створити новий товар
/// </summary>
/// <param name="dto">Дані для створення товару</param>
/// <returns>Створений товар</returns>
/// <response code="201">Товар успішно створено</response>
/// <response code="400">Невалідні дані</response>
/// <response code="409">Товар з такою назвою вже існує</response>
[HttpPost]
[ProducesResponseType(typeof(ProductResponseDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<ActionResult<ProductResponseDto>> Create(CreateProductDto dto)
{
_logger.LogInformation("Creating new product: {ProductName}", dto.Name);
// Перевірка на дублікат назви
var existingProduct = await _db.Products
.FirstOrDefaultAsync(p => p.Name == dto.Name);
if (existingProduct is not null)
{
_logger.LogWarning("Product with name '{ProductName}' already exists", dto.Name);
return Conflict(new ProblemDetails
{
Title = "Product already exists",
Detail = $"A product with the name '{dto.Name}' already exists",
Status = StatusCodes.Status409Conflict,
Instance = HttpContext.Request.Path
});
}
var product = new Product
{
Name = dto.Name,
Description = dto.Description,
Price = dto.Price,
Stock = dto.Stock,
Category = dto.Category,
CreatedAt = DateTime.UtcNow
};
_db.Products.Add(product);
await _db.SaveChangesAsync();
var response = MapToDto(product);
// CreatedAtAction автоматично генерує Location header
return CreatedAtAction(
nameof(GetById),
new { id = product.Id },
response
);
}
/// <summary>
/// Оновити існуючий товар
/// </summary>
/// <param name="id">Ідентифікатор товару</param>
/// <param name="dto">Дані для оновлення</param>
/// <returns>Оновлений товар</returns>
/// <response code="200">Товар успішно оновлено</response>
/// <response code="404">Товар не знайдено</response>
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(ProductResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductResponseDto>> Update(int id, UpdateProductDto dto)
{
_logger.LogInformation("Updating product with ID {ProductId}", 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} does not exist",
Status = StatusCodes.Status404NotFound
});
}
// Оновлюємо тільки передані поля
if (dto.Name is not null) product.Name = dto.Name;
if (dto.Description is not null) product.Description = dto.Description;
if (dto.Price.HasValue) product.Price = dto.Price.Value;
if (dto.Stock.HasValue) product.Stock = dto.Stock.Value;
if (dto.Category is not null) product.Category = dto.Category;
if (dto.IsActive.HasValue) product.IsActive = dto.IsActive.Value;
product.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return MapToDto(product);
}
/// <summary>
/// Видалити товар (soft delete - деактивація)
/// </summary>
/// <param name="id">Ідентифікатор товару</param>
/// <returns>204 No Content</returns>
/// <response code="204">Товар успішно деактивовано</response>
/// <response code="404">Товар не знайдено</response>
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
_logger.LogInformation("Deactivating product with ID {ProductId}", 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} does not exist",
Status = StatusCodes.Status404NotFound
});
}
// Soft delete - просто деактивуємо
product.IsActive = false;
product.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
// NoContent() - успішно, але без даних у відповіді
return NoContent();
}
/// <summary>
/// Видалити товар назавжди (hard delete)
/// </summary>
/// <param name="id">Ідентифікатор товару</param>
/// <returns>204 No Content</returns>
/// <response code="204">Товар успішно видалено</response>
/// <response code="404">Товар не знайдено</response>
/// <response code="409">Неможливо видалити товар з ненульовим залишком</response>
[HttpDelete("{id:int}/permanent")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> DeletePermanent(int id)
{
_logger.LogInformation("Permanently deleting product with ID {ProductId}", 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} does not exist",
Status = StatusCodes.Status404NotFound
});
}
// Бізнес-правило: не можна видаляти товар з залишком
if (product.Stock > 0)
{
return Conflict(new ProblemDetails
{
Title = "Cannot delete product",
Detail = $"Product has {product.Stock} items in stock. Reduce stock to zero before deletion.",
Status = StatusCodes.Status409Conflict
});
}
_db.Products.Remove(product);
await _db.SaveChangesAsync();
return NoContent();
}
/// <summary>
/// Оновити залишок товару
/// </summary>
/// <param name="id">Ідентифікатор товару</param>
/// <param name="quantity">Нова кількість</param>
/// <returns>204 No Content</returns>
/// <response code="204">Залишок оновлено</response>
/// <response code="400">Невалідна кількість</response>
/// <response code="404">Товар не знайдено</response>
[HttpPatch("{id:int}/stock")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateStock(int id, [FromQuery] int quantity)
{
if (quantity < 0)
{
return BadRequest(new ProblemDetails
{
Title = "Invalid quantity",
Detail = "Stock quantity cannot be negative",
Status = StatusCodes.Status400BadRequest
});
}
var product = await _db.Products.FindAsync(id);
if (product is null)
{
return NotFound(new ProblemDetails
{
Title = "Product not found",
Detail = $"Product with ID {id} does not exist",
Status = StatusCodes.Status404NotFound
});
}
product.Stock = quantity;
product.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return NoContent();
}
// Приватний допоміжний метод для маппінгу
private static ProductResponseDto MapToDto(Product product) => new()
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
Stock = product.Stock,
Category = product.Category,
IsActive = product.IsActive,
CreatedAt = product.CreatedAt,
UpdatedAt = product.UpdatedAt
};
}
Декомпозиція коду:
[ProducesResponseType] — документує можливі типи відповідей для OpenAPI/Swagger<summary>, <response> генерують документаціюProblemDetails — стандартизований формат помилок (RFC 9457)CreatedAtAction() — автоматично генерує Location: /api/products/4 headerreturn MapToDto(product) замість return Ok(MapToDto(product))Окрім JSON-даних, API часто потребує повертати файли: звіти у PDF, експорт у CSV, зображення товарів. ASP.NET Core надає кілька способів роботи з файлами через спеціалізовані результати.
FileContentResult — файл з масиву байтів (для невеликих файлів у пам'яті)FileStreamResult — файл зі стріму (для великих файлів, що генеруються на льоту)PhysicalFileResult — файл з файлової системи за абсолютним шляхомVirtualFileResult — файл з wwwroot або іншого віртуального шляхуДодайте метод до ProductsController:
/// <summary>
/// Експортувати всі товари у CSV-файл
/// </summary>
/// <returns>CSV-файл</returns>
/// <response code="200">CSV-файл успішно згенеровано</response>
[HttpGet("export/csv")]
[Produces("text/csv")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public async Task<IActionResult> ExportToCsv()
{
_logger.LogInformation("Exporting products to CSV");
var products = await _db.Products.ToListAsync();
// Генерація CSV
var csv = new StringBuilder();
csv.AppendLine("Id,Name,Description,Price,Stock,Category,IsActive");
foreach (var product in products)
{
csv.AppendLine($"{product.Id}," +
$"\"{product.Name}\"," +
$"\"{product.Description ?? ""}\"," +
$"{product.Price}," +
$"{product.Stock}," +
$"\"{product.Category ?? ""}\"," +
$"{product.IsActive}");
}
var bytes = Encoding.UTF8.GetBytes(csv.ToString());
// File() з масиву байтів + ім'я файлу для завантаження
return File(
bytes,
"text/csv",
$"products_{DateTime.UtcNow:yyyyMMdd_HHmmss}.csv"
);
}
Що відбувається:
Encoding.UTF8File() з MIME-типом text/csvДля генерації PDF використаємо бібліотеку QuestPDF (встановіть через NuGet):
dotnet add package QuestPDF
Додайте метод:
/// <summary>
/// Згенерувати PDF-звіт по товарах
/// </summary>
/// <returns>PDF-файл</returns>
/// <response code="200">PDF-звіт успішно згенеровано</response>
[HttpGet("export/pdf")]
[Produces("application/pdf")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public async Task<IActionResult> ExportToPdf()
{
_logger.LogInformation("Generating PDF report");
var products = await _db.Products
.Where(p => p.IsActive)
.OrderBy(p => p.Category)
.ThenBy(p => p.Name)
.ToListAsync();
// Генерація PDF через QuestPDF
var pdfBytes = GenerateProductsPdf(products);
return File(
pdfBytes,
"application/pdf",
$"products_report_{DateTime.UtcNow:yyyyMMdd}.pdf"
);
}
private byte[] GenerateProductsPdf(List<Product> products)
{
// Спрощена версія - у реальному проєкті використовуйте QuestPDF
// Тут просто повертаємо placeholder
var content = $"Products Report\n\nTotal: {products.Count} items";
return Encoding.UTF8.GetBytes(content);
}
Додайте метод для отримання зображення товару з файлової системи:
/// <summary>
/// Отримати зображення товару
/// </summary>
/// <param name="id">Ідентифікатор товару</param>
/// <returns>Зображення або 404</returns>
/// <response code="200">Зображення знайдено</response>
/// <response code="404">Зображення не знайдено</response>
[HttpGet("{id:int}/image")]
[Produces("image/jpeg", "image/png")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductImage(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} does not exist",
Status = StatusCodes.Status404NotFound
});
}
// Шлях до зображення (у реальному проєкті зберігайте в БД)
var imagePath = Path.Combine(
Directory.GetCurrentDirectory(),
"wwwroot",
"images",
"products",
$"{id}.jpg"
);
if (!System.IO.File.Exists(imagePath))
{
// Повертаємо placeholder зображення
imagePath = Path.Combine(
Directory.GetCurrentDirectory(),
"wwwroot",
"images",
"placeholder.jpg"
);
}
// PhysicalFile() для файлів з файлової системи
return PhysicalFile(imagePath, "image/jpeg");
}
Для великих файлів (>100 MB) використовуйте FileStreamResult, щоб не завантажувати весь файл у пам'ять:
/// <summary>
/// Завантажити великий файл (стрімінг)
/// </summary>
/// <returns>Файл</returns>
[HttpGet("download/large-file")]
[Produces("application/octet-stream")]
public IActionResult DownloadLargeFile()
{
var filePath = Path.Combine(
Directory.GetCurrentDirectory(),
"Data",
"large-export.zip"
);
// FileStream відкривається і читається по частинах
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
return File(stream, "application/octet-stream", "export.zip");
}
File(byte[], ...) — найпростішеPhysicalFile(path, ...) — ефективнішеFile(Stream, ...) — стрімінг без завантаження в пам'ятьFile(MemoryStream, ...) або File(byte[], ...)Тепер, коли ми розглянули всі типи результатів, зведемо best practices для типових сценаріїв.
Найпоширеніший патерн — отримання ресурсу за ID:
[HttpGet("{id}")]
public async Task<ActionResult<ProductResponseDto>> GetById(int id)
{
var product = await _db.Products.FindAsync(id);
if (product is null)
return NotFound(); // або NotFound(ProblemDetails)
return MapToDto(product); // Неявна конвертація
}
Ключові моменти:
ActionResult<T> для type safetyNotFound() для відсутніх ресурсівСтворення нового ресурсу з поверненням URI:
[HttpPost]
public async Task<ActionResult<ProductResponseDto>> Create(CreateProductDto dto)
{
var product = new Product { /* ... */ };
_db.Products.Add(product);
await _db.SaveChangesAsync();
return CreatedAtAction(
nameof(GetById), // Назва action для генерації URI
new { id = product.Id }, // Route values
MapToDto(product) // Тіло відповіді
);
}
Результат:
HTTP/1.1 201 Created
Location: https://api.example.com/api/products/42
Content-Type: application/json
{
"id": 42,
"name": "New Product",
...
}
Для операцій, де клієнту не потрібні оновлені дані:
[HttpPatch("{id}/stock")]
public async Task<IActionResult> UpdateStock(int id, [FromQuery] int quantity)
{
var product = await _db.Products.FindAsync(id);
if (product is null) return NotFound();
product.Stock = quantity;
await _db.SaveChangesAsync();
return NoContent(); // 204 - успішно, без тіла
}
Коли використовувати:
Повернення структурованих помилок валідації:
[HttpPost]
public async Task<ActionResult<ProductResponseDto>> Create(CreateProductDto dto)
{
// [ApiController] автоматично перевіряє ModelState
// Але для кастомної валідації:
if (await _db.Products.AnyAsync(p => p.Name == dto.Name))
{
return Conflict(new ProblemDetails
{
Title = "Product already exists",
Detail = $"A product with the name '{dto.Name}' already exists",
Status = StatusCodes.Status409Conflict,
Type = "https://api.example.com/errors/duplicate-product",
Instance = HttpContext.Request.Path
});
}
// Створення...
}
Структура ProblemDetails:
{
"type": "https://api.example.com/errors/duplicate-product",
"title": "Product already exists",
"status": 409,
"detail": "A product with the name 'Laptop' already exists",
"instance": "/api/products"
}
Різні типи відповідей залежно від умов:
[HttpGet("{id}/details")]
public async Task<IActionResult> GetDetails(int id, [FromQuery] string format = "json")
{
var product = await _db.Products.FindAsync(id);
if (product is null) return NotFound();
return format.ToLower() switch
{
"json" => Ok(MapToDto(product)),
"xml" => Ok(MapToDto(product)), // Content negotiation обробить
"pdf" => File(GeneratePdf(product), "application/pdf"),
"csv" => File(GenerateCsv(product), "text/csv"),
_ => BadRequest(new { message = "Unsupported format" })
};
}
IActionResult замість ActionResult<T>, оскільки повертаємо принципово різні типи (JSON, файл).Атрибут [ProducesResponseType] — це не просто декорація. Він генерує метадані OpenAPI, що дозволяють Swagger UI та клієнтським генераторам (NSwag, OpenAPI Generator) розуміти структуру відповідей.
[HttpGet("{id}")]
[ProducesResponseType(typeof(ProductResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductResponseDto>> GetById(int id)
{
// ...
}
Що це дає:
Після додавання [ProducesResponseType], Swagger UI показує:
GET /api/products/{id}
Responses:
200 - Success
Content-Type: application/json
Schema: ProductResponseDto
Example:
{
"id": 1,
"name": "Laptop",
"price": 1499.99,
...
}
404 - Not Found
Content-Type: application/problem+json
Schema: ProblemDetails
Example:
{
"type": "https://tools.ietf.org/html/rfc9457",
"title": "Product not found",
"status": 404,
...
}
Якщо endpoint повертає різні формати:
[HttpGet("{id}")]
[Produces("application/json", "application/xml")]
[ProducesResponseType(typeof(ProductResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ProductResponseDto>> GetById(int id)
{
// Content negotiation обробить Accept header
}
| HTTP-код | Метод ControllerBase | Коли використовувати | Приклад |
|---|---|---|---|
| 200 OK | Ok(data) | Успішна операція з даними | GET, PUT з поверненням даних |
| 201 Created | CreatedAtAction() | Ресурс створено | POST |
| 202 Accepted | Accepted() | Запит прийнято до обробки | Асинхронні операції |
| 204 No Content | NoContent() | Успішно, без даних | DELETE, PATCH без повернення |
| 400 Bad Request | BadRequest() | Невалідний запит | Помилки валідації |
| 401 Unauthorized | Unauthorized() | Не автентифікований | Відсутній/невалідний токен |
| 403 Forbidden | Forbid() | Немає прав доступу | Недостатньо прав |
| 404 Not Found | NotFound() | Ресурс не знайдено | GET за неіснуючим ID |
| 409 Conflict | Conflict() | Конфлікт бізнес-логіки | Дублікат, порушення правил |
| 422 Unprocessable Entity | UnprocessableEntity() | Семантично невалідний | Бізнес-валідація |
| 500 Internal Server Error | StatusCode(500) | Помилка сервера | Необроблені винятки |
Закріпіть вивчений матеріал, виконавши наступні завдання.
Для кожного сценарію оберіть правильний тип повернення:
GetAll() повертає список товарівDelete() видаляє товар без повернення данихGetImage() повертає зображення або JSON-помилкуActionResult<IEnumerable<ProductResponseDto>> — є чіткий тип успішної відповідіIActionResult — немає даних у відповіді (204 No Content)IActionResult — повертає різні типи (файл або JSON)Який HTTP-код та метод використати для кожного сценарію?
CreatedAtAction(nameof(GetById), new { id }, data)NotFound() або NotFound(ProblemDetails)Conflict(ProblemDetails)NoContent()Знайдіть та виправте помилки у коді:
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id) // Помилка 1
{
var product = await _db.Products.FindAsync(id);
if (product is null) return Ok(null); // Помилка 2
return product; // Помилка 3
}
Помилки:
IActionResult замість ActionResult<ProductResponseDto> (втрата type safety)Ok(null) замість NotFound() — неправильний HTTP-кодproduct напряму з IActionResult (потрібен Ok(product))Виправлений код:
[HttpGet("{id}")]
public async Task<ActionResult<ProductResponseDto>> GetById(int id)
{
var product = await _db.Products.FindAsync(id);
if (product is null) return NotFound();
return MapToDto(product); // Неявна конвертація
}
Реалізуйте метод ActivateProduct(int id), що активує деактивований товар. Можливі відповіді:
/// <summary>
/// Активувати деактивований товар
/// </summary>
[HttpPost("{id}/activate")]
[ProducesResponseType(typeof(ProductResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<ActionResult<ProductResponseDto>> ActivateProduct(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} does not exist",
Status = StatusCodes.Status404NotFound
});
}
if (product.IsActive)
{
return Conflict(new ProblemDetails
{
Title = "Product already active",
Detail = $"Product with ID {id} is already active",
Status = StatusCodes.Status409Conflict
});
}
product.IsActive = true;
product.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return MapToDto(product);
}
Розширте метод ExportToCsv(), додавши фільтрацію за категорією:
GET /api/products/export/csv?category=Electronics
[HttpGet("export/csv")]
[Produces("text/csv")]
public async Task<IActionResult> ExportToCsv([FromQuery] string? category = null)
{
var query = _db.Products.AsQueryable();
if (!string.IsNullOrEmpty(category))
{
query = query.Where(p => p.Category == category);
}
var products = await query.ToListAsync();
var csv = new StringBuilder();
csv.AppendLine("Id,Name,Price,Stock,Category");
foreach (var product in products)
{
csv.AppendLine($"{product.Id}," +
$"\"{product.Name}\"," +
$"{product.Price}," +
$"{product.Stock}," +
$"\"{product.Category ?? ""}\"");
}
var bytes = Encoding.UTF8.GetBytes(csv.ToString());
var filename = string.IsNullOrEmpty(category)
? $"products_{DateTime.UtcNow:yyyyMMdd}.csv"
: $"products_{category}_{DateTime.UtcNow:yyyyMMdd}.csv";
return File(bytes, "text/csv", filename);
}
Реалізуйте endpoint для імпорту товарів з CSV-файлу. Оскільки обробка може тривати довго, поверніть 202 Accepted з URL для перевірки статусу:
POST /api/products/import
→ 202 Accepted
Location: /api/products/import/status/{jobId}
// DTO для статусу імпорту
public record ImportStatus
{
public required string JobId { get; init; }
public required string Status { get; init; } // "pending", "processing", "completed", "failed"
public int ProcessedCount { get; init; }
public int TotalCount { get; init; }
public string? ErrorMessage { get; init; }
}
// Словник для зберігання статусів (у production використовуйте Redis/БД)
private static readonly Dictionary<string, ImportStatus> _importJobs = new();
[HttpPost("import")]
[ProducesResponseType(typeof(ImportStatus), StatusCodes.Status202Accepted)]
public async Task<IActionResult> ImportFromCsv(IFormFile file)
{
if (file == null || file.Length == 0)
{
return BadRequest(new ProblemDetails
{
Title = "Invalid file",
Detail = "CSV file is required",
Status = StatusCodes.Status400BadRequest
});
}
var jobId = Guid.NewGuid().ToString();
var status = new ImportStatus
{
JobId = jobId,
Status = "pending",
ProcessedCount = 0,
TotalCount = 0
};
_importJobs[jobId] = status;
// Запускаємо обробку в фоні (у production використовуйте Hangfire/BackgroundService)
_ = Task.Run(async () => await ProcessImportAsync(jobId, file));
return AcceptedAtAction(
nameof(GetImportStatus),
new { jobId },
status
);
}
[HttpGet("import/status/{jobId}")]
[ProducesResponseType(typeof(ImportStatus), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetImportStatus(string jobId)
{
if (!_importJobs.TryGetValue(jobId, out var status))
{
return NotFound(new ProblemDetails
{
Title = "Job not found",
Detail = $"Import job with ID {jobId} does not exist",
Status = StatusCodes.Status404NotFound
});
}
return Ok(status);
}
private async Task ProcessImportAsync(string jobId, IFormFile file)
{
// Симуляція обробки
await Task.Delay(5000);
_importJobs[jobId] = _importJobs[jobId] with
{
Status = "completed",
ProcessedCount = 100,
TotalCount = 100
};
}
Створіть новий контролер CategoriesController з повним CRUD та правильними типами повернення:
GET /api/categories → ActionResult<IEnumerable<CategoryDto>>GET /api/categories/{id} → ActionResult<CategoryDto>POST /api/categories → ActionResult<CategoryDto> (201 Created)PUT /api/categories/{id} → ActionResult<CategoryDto> (200 OK)DELETE /api/categories/{id} → IActionResult (204 No Content або 409 Conflict)Додайте бізнес-правило: категорію не можна видалити, якщо в ній є товари.
[ApiController]
[Route("api/[controller]")]
public class CategoriesController : ControllerBase
{
private readonly ProductDbContext _db;
public CategoriesController(ProductDbContext db)
{
_db = db;
}
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<string>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<string>>> GetAll()
{
var categories = await _db.Products
.Where(p => p.Category != null)
.Select(p => p.Category!)
.Distinct()
.OrderBy(c => c)
.ToListAsync();
return Ok(categories);
}
[HttpDelete("{name}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(string name)
{
var productsInCategory = await _db.Products
.CountAsync(p => p.Category == name);
if (productsInCategory > 0)
{
return Conflict(new ProblemDetails
{
Title = "Cannot delete category",
Detail = $"Category '{name}' has {productsInCategory} products",
Status = StatusCodes.Status409Conflict
});
}
// Логіка видалення...
return NoContent();
}
// Інші методи...
}
У цій статті ми здійснили глибоке занурення в світ типів повернення ASP.NET Core Web API. Ви навчилися не просто повертати дані, а формувати семантично правильні HTTP-відповіді, що роблять ваш API передбачуваним та зручним для клієнтів.
Ключові висновки:
Ok(), NotFound(), Created()) замість ручного конструювання результатів — це робить код самодокументованим.ActionResult<T> для type safety та автоматичної документації OpenAPI. Використовуйте IActionResult лише для методів з принципово різними типами відповідей.[ProducesResponseType] — це не опціональна декорація, а критично важлива частина API-контракту.File(), PhysicalFile() для експорту даних у різних форматах.У наступній статті ми розглянемо Content Negotiation — механізм, що дозволяє одному endpoint повертати дані у різних форматах (JSON, XML, CSV) залежно від запиту клієнта.
Від Minimal API до Controller-based API
Еволюція від endpoint-орієнтованого підходу до структурованої архітектури Web API Controllers. Порівняння парадигм, міграційні патерни та обґрунтування вибору підходу.
Content Negotiation - JSON, XML та власні форматери
Механізм узгодження формату відповіді між клієнтом та сервером. System.Text.Json vs Newtonsoft.Json, XML-серіалізація та створення кастомних форматерів для CSV, YAML, MessagePack.