Уявіть, що ви керуєте рестораном, де кожен гість має свої уподобання щодо подачі страв. Один хоче їжу на тарілці, інший — у контейнері на винос, третій — у вакуумній упаковці для довгого зберігання. Страва (дані) залишається тією самою, але формат подачі змінюється залежно від потреб клієнта. Саме це робить Content Negotiation (узгодження контенту) у веб-API.
Content Negotiation — це механізм, що дозволяє одному endpoint повертати дані у різних форматах залежно від того, що запитує клієнт через HTTP-заголовок Accept. Це не просто технічна можливість — це фундаментальний принцип REST-архітектури, що забезпечує гнучкість та сумісність API з різними типами клієнтів:
Замість створення окремих endpoints для кожного формату (/api/products.json, /api/products.xml, /api/products.csv), ви створюєте один універсальний endpoint, що автоматично адаптується до потреб клієнта.
Ми побудуємо Books API — сервіс управління бібліотекою, що підтримує 5 форматів відповідей:
Один запит — різні формати:
GET /api/books/1
Accept: application/json
→ { "id": 1, "title": "Clean Code", ... }
GET /api/books/1
Accept: application/xml
→ <Book><Id>1</Id><Title>Clean Code</Title>...</Book>
GET /api/books/1
Accept: text/csv
→ Id,Title,Author,Year
1,Clean Code,Robert Martin,2008
До кінця статті ви зможете:
OutputFormatter та InputFormatter[Produces] та [Consumes] атрибутиContent Negotiation базується на HTTP-заголовках, що передаються між клієнтом та сервером:
Accept — клієнт вказує, які формати він може обробити:
GET /api/books/1 HTTP/1.1
Accept: application/json
Можна вказати кілька форматів з пріоритетами (q-factor):
Accept: application/json, application/xml;q=0.9, text/csv;q=0.8
Розшифровка:
application/json — найвищий пріоритет (q=1.0 за замовчуванням)application/xml;q=0.9 — другий пріоритетtext/csv;q=0.8 — третій пріоритетContent-Type — клієнт вказує формат даних у тілі запиту (для POST/PUT):
POST /api/books HTTP/1.1
Content-Type: application/json
{ "title": "Clean Code", "author": "Robert Martin" }
Content-Type — сервер повідомляє, який формат він повернув:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{ "id": 1, "title": "Clean Code" }
Алгоритм вибору форматера:
Accept з запитуOutputFormatter-івContent-Type у відповідіReturnHttpNotAcceptable = true у налаштуваннях.ASP.NET Core використовує два типи форматерів:
InputFormatter — десеріалізує дані з тіла запиту у C#-об'єкти (для POST/PUT):
HTTP Request Body (JSON/XML/CSV) → InputFormatter → C# Object
OutputFormatter — серіалізує C#-об'єкти у формат відповіді:
C# Object → OutputFormatter → HTTP Response Body (JSON/XML/CSV)
Вбудовані форматери в ASP.NET Core:
| Форматер | MIME-тип | Input | Output | За замовчуванням |
|---|---|---|---|---|
SystemTextJsonInputFormatter | application/json | ✅ | ❌ | ✅ |
SystemTextJsonOutputFormatter | application/json | ❌ | ✅ | ✅ |
XmlSerializerInputFormatter | application/xml, text/xml | ✅ | ❌ | ❌ |
XmlSerializerOutputFormatter | application/xml, text/xml | ❌ | ✅ | ❌ |
XmlDataContractSerializerInputFormatter | application/xml | ✅ | ❌ | ❌ |
StringOutputFormatter | text/plain | ❌ | ✅ | ✅ |
AddXmlSerializerFormatters() або AddXmlDataContractSerializerFormatters().Настав час створити реальний API, що підтримує різні формати відповідей.
Створіть файл Models/Book.cs:
using System.ComponentModel.DataAnnotations;
using System.Xml.Serialization;
namespace BooksApi.Models;
// Атрибут для XML-серіалізації
[XmlRoot("Book")]
public class Book
{
[XmlElement("Id")]
public int Id { get; set; }
[Required]
[MaxLength(200)]
[XmlElement("Title")]
public required string Title { get; set; }
[Required]
[MaxLength(100)]
[XmlElement("Author")]
public required string Author { get; set; }
[MaxLength(20)]
[XmlElement("ISBN")]
public string? ISBN { get; set; }
[Range(1000, 2100)]
[XmlElement("PublicationYear")]
public int PublicationYear { get; set; }
[Range(0, 10000)]
[XmlElement("Pages")]
public int Pages { get; set; }
[MaxLength(50)]
[XmlElement("Genre")]
public string? Genre { get; set; }
[Range(0, 5)]
[XmlElement("Rating")]
public double Rating { get; set; }
[XmlElement("IsAvailable")]
public bool IsAvailable { get; set; } = true;
}
Декомпозиція атрибутів:
[XmlRoot("Book")] — кореневий елемент XML-документа[XmlElement("PropertyName")] — назва XML-елемента (за замовчуванням збігається з назвою властивості)Створіть файл Data/BookDbContext.cs:
using Microsoft.EntityFrameworkCore;
using BooksApi.Models;
namespace BooksApi.Data;
public class BookDbContext : DbContext
{
public BookDbContext(DbContextOptions<BookDbContext> options)
: base(options)
{
}
public DbSet<Book> Books => Set<Book>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Book>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.ISBN).IsUnique();
entity.HasIndex(e => e.Genre);
// Seed data
entity.HasData(
new Book
{
Id = 1,
Title = "Clean Code",
Author = "Robert C. Martin",
ISBN = "978-0132350884",
PublicationYear = 2008,
Pages = 464,
Genre = "Programming",
Rating = 4.7
},
new Book
{
Id = 2,
Title = "Design Patterns",
Author = "Gang of Four",
ISBN = "978-0201633610",
PublicationYear = 1994,
Pages = 395,
Genre = "Programming",
Rating = 4.6
},
new Book
{
Id = 3,
Title = "The Pragmatic Programmer",
Author = "Andrew Hunt, David Thomas",
ISBN = "978-0135957059",
PublicationYear = 2019,
Pages = 352,
Genre = "Programming",
Rating = 4.8
}
);
});
}
}
Тепер найважливіша частина — налаштування Content Negotiation:
using Microsoft.EntityFrameworkCore;
using BooksApi.Data;
using System.Text.Json;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
// Реєстрація DbContext
builder.Services.AddDbContext<BookDbContext>(options =>
options.UseInMemoryDatabase("BooksDb"));
// Налаштування Controllers з форматерами
builder.Services.AddControllers(options =>
{
// Повертати 406 Not Acceptable, якщо формат не підтримується
options.ReturnHttpNotAcceptable = true;
// Видалити StringOutputFormatter (text/plain) для чистоти
options.OutputFormatters.RemoveType<Microsoft.AspNetCore.Mvc.Formatters.StringOutputFormatter>();
})
.AddJsonOptions(options =>
{
// Налаштування System.Text.Json
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.WriteIndented = true; // Pretty-print для dev
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
})
.AddXmlSerializerFormatters() // Додаємо підтримку XML
.AddXmlDataContractSerializerFormatters(); // Альтернативний XML-серіалізатор
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Ініціалізація бази даних
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BookDbContext>();
db.Database.EnsureCreated();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Декомпозиція налаштувань:
ReturnHttpNotAcceptable = true — якщо клієнт запитує непідтримуваний формат, повертати 406 замість fallback на JSONAddJsonOptions() — налаштування System.Text.Json:
PropertyNamingPolicy.CamelCase — властивості у camelCase (замість PascalCase)WriteIndented = true — форматований JSON (для читабельності)DefaultIgnoreCondition.WhenWritingNull — не серіалізувати null-значенняJsonStringEnumConverter — enum як рядки замість чиселAddXmlSerializerFormatters() — додає XmlSerializerInputFormatter та XmlSerializerOutputFormatterAddXmlDataContractSerializerFormatters() — альтернативний XML-серіалізатор (підтримує більше типів)Створіть файл Controllers/BooksController.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using BooksApi.Data;
using BooksApi.Models;
namespace BooksApi.Controllers;
[ApiController]
[Route("api/[controller]")]
[Produces("application/json", "application/xml")] // Підтримувані формати
public class BooksController : ControllerBase
{
private readonly BookDbContext _db;
private readonly ILogger<BooksController> _logger;
public BooksController(BookDbContext db, ILogger<BooksController> logger)
{
_db = db;
_logger = logger;
}
/// <summary>
/// Отримати всі книги
/// </summary>
/// <returns>Список книг у запитаному форматі</returns>
/// <response code="200">Успішно отримано список</response>
/// <response code="406">Формат не підтримується</response>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<Book>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status406NotAcceptable)]
public async Task<ActionResult<IEnumerable<Book>>> GetAll()
{
_logger.LogInformation("Fetching all books. Accept header: {AcceptHeader}",
Request.Headers.Accept.ToString());
var books = await _db.Books
.OrderBy(b => b.Title)
.ToListAsync();
return Ok(books);
}
/// <summary>
/// Отримати книгу за ID
/// </summary>
/// <param name="id">Ідентифікатор книги</param>
/// <returns>Книга у запитаному форматі</returns>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(Book), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status406NotAcceptable)]
public async Task<ActionResult<Book>> GetById(int id)
{
var book = await _db.Books.FindAsync(id);
if (book is null)
{
return NotFound(new ProblemDetails
{
Title = "Book not found",
Detail = $"Book with ID {id} does not exist",
Status = StatusCodes.Status404NotFound
});
}
return book;
}
/// <summary>
/// Створити нову книгу
/// </summary>
/// <param name="book">Дані книги</param>
/// <returns>Створена книга</returns>
[HttpPost]
[Consumes("application/json", "application/xml")] // Приймаємо JSON або XML
[ProducesResponseType(typeof(Book), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status415UnsupportedMediaType)]
public async Task<ActionResult<Book>> Create(Book book)
{
_logger.LogInformation("Creating book: {Title}. Content-Type: {ContentType}",
book.Title, Request.ContentType);
// Перевірка на дублікат ISBN
if (!string.IsNullOrEmpty(book.ISBN))
{
var existingBook = await _db.Books
.FirstOrDefaultAsync(b => b.ISBN == book.ISBN);
if (existingBook is not null)
{
return Conflict(new ProblemDetails
{
Title = "Duplicate ISBN",
Detail = $"A book with ISBN '{book.ISBN}' already exists",
Status = StatusCodes.Status409Conflict
});
}
}
_db.Books.Add(book);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = book.Id }, book);
}
/// <summary>
/// Оновити книгу
/// </summary>
[HttpPut("{id:int}")]
[Consumes("application/json", "application/xml")]
[ProducesResponseType(typeof(Book), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Book>> Update(int id, Book book)
{
if (id != book.Id)
{
return BadRequest(new ProblemDetails
{
Title = "ID mismatch",
Detail = "The ID in the URL does not match the ID in the request body",
Status = StatusCodes.Status400BadRequest
});
}
var existingBook = await _db.Books.FindAsync(id);
if (existingBook is null)
{
return NotFound();
}
// Оновлення властивостей
existingBook.Title = book.Title;
existingBook.Author = book.Author;
existingBook.ISBN = book.ISBN;
existingBook.PublicationYear = book.PublicationYear;
existingBook.Pages = book.Pages;
existingBook.Genre = book.Genre;
existingBook.Rating = book.Rating;
existingBook.IsAvailable = book.IsAvailable;
await _db.SaveChangesAsync();
return existingBook;
}
/// <summary>
/// Видалити книгу
/// </summary>
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
var book = await _db.Books.FindAsync(id);
if (book is null)
{
return NotFound();
}
_db.Books.Remove(book);
await _db.SaveChangesAsync();
return NoContent();
}
}
Ключові атрибути:
[Produces("application/json", "application/xml")] на рівні класу — вказує, які формати підтримує контролер[Consumes("application/json", "application/xml")] на методі — вказує, які формати приймає метод у тілі запитуStatusCodes.Status406NotAcceptable — документуємо можливість повернення 406StatusCodes.Status415UnsupportedMediaType — документуємо можливість повернення 415 для непідтримуваного Content-TypeЗапустіть проєкт та протестуйте різні формати:
ASP.NET Core за замовчуванням використовує System.Text.Json (з .NET Core 3.0+), але багато проєктів все ще використовують Newtonsoft.Json (Json.NET). Розглянемо відмінності та коли використовувати кожен.
| Аспект | System.Text.Json | Newtonsoft.Json |
|---|---|---|
| Продуктивність | ✅ Швидший (2-3x) | Повільніше |
| Пам'ять | ✅ Менше алокацій | Більше алокацій |
| Функціональність | ⚠️ Базова | ✅ Розширена |
| Підтримка типів | ⚠️ Обмежена | ✅ Широка |
| Налаштування | ⚠️ Менше опцій | ✅ Гнучке |
| Підтримка .NET | .NET Core 3.0+ | Всі версії |
| Розмір бібліотеки | ✅ Вбудована | Зовнішній пакет |
✅ Використовуйте за замовчуванням, якщо:
Приклад налаштування:
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
// Naming policy
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
// Форматування
options.JsonSerializerOptions.WriteIndented = true;
// Null-значення
options.JsonSerializerOptions.DefaultIgnoreCondition =
JsonIgnoreCondition.WhenWritingNull;
// Enum як рядки
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
// Дозволити trailing commas
options.JsonSerializerOptions.AllowTrailingCommas = true;
// Case-insensitive property names
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
// Циклічні посилання (з .NET 6)
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
});
✅ Перейдіть на Newtonsoft.Json, якщо:
[JsonProperty] атрибутиМіграція на Newtonsoft.Json:
dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson
builder.Services.AddControllers()
.AddNewtonsoftJson(options =>
{
// Naming strategy
options.SerializerSettings.ContractResolver =
new CamelCasePropertyNamesContractResolver();
// Форматування
options.SerializerSettings.Formatting = Formatting.Indented;
// Null-значення
options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
// Enum як рядки
options.SerializerSettings.Converters.Add(new StringEnumConverter());
// Циклічні посилання
options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
// Дати у ISO 8601
options.SerializerSettings.DateFormatHandling = DateFormatHandling.IsoDateFormat;
});
AddJsonOptions() та AddNewtonsoftJson() є взаємовиключним.Вбудовані форматери (JSON, XML) покривають більшість сценаріїв, але іноді потрібні специфічні формати: CSV для Excel, YAML для конфігурацій, MessagePack для продуктивності. Створимо власний CSV OutputFormatter.
Створіть файл Formatters/CsvOutputFormatter.cs:
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Net.Http.Headers;
using System.Text;
using BooksApi.Models;
namespace BooksApi.Formatters;
public class CsvOutputFormatter : TextOutputFormatter
{
public CsvOutputFormatter()
{
// Реєструємо підтримувані MIME-типи
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/csv"));
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/csv"));
// Підтримувані кодування
SupportedEncodings.Add(Encoding.UTF8);
SupportedEncodings.Add(Encoding.Unicode);
}
// Перевірка, чи можемо серіалізувати цей тип
protected override bool CanWriteType(Type? type)
{
// Підтримуємо Book та IEnumerable<Book>
if (type == typeof(Book))
return true;
if (type == null)
return false;
// Перевірка на колекцію
if (typeof(IEnumerable<Book>).IsAssignableFrom(type))
return true;
return false;
}
// Основний метод серіалізації
public override async Task WriteResponseBodyAsync(
OutputFormatterWriteContext context,
Encoding selectedEncoding)
{
var httpContext = context.HttpContext;
var response = httpContext.Response;
var csv = new StringBuilder();
// Заголовок CSV
csv.AppendLine("Id,Title,Author,ISBN,PublicationYear,Pages,Genre,Rating,IsAvailable");
// Серіалізація даних
if (context.Object is IEnumerable<Book> books)
{
foreach (var book in books)
{
csv.AppendLine(FormatBookAsCsvRow(book));
}
}
else if (context.Object is Book book)
{
csv.AppendLine(FormatBookAsCsvRow(book));
}
// Запис у response stream
await response.WriteAsync(csv.ToString(), selectedEncoding);
}
// Допоміжний метод для форматування рядка
private static string FormatBookAsCsvRow(Book book)
{
return $"{book.Id}," +
$"\"{EscapeCsvValue(book.Title)}\"," +
$"\"{EscapeCsvValue(book.Author)}\"," +
$"\"{EscapeCsvValue(book.ISBN ?? "")}\"," +
$"{book.PublicationYear}," +
$"{book.Pages}," +
$"\"{EscapeCsvValue(book.Genre ?? "")}\"," +
$"{book.Rating}," +
$"{book.IsAvailable}";
}
// Екранування спеціальних символів CSV
private static string EscapeCsvValue(string value)
{
if (string.IsNullOrEmpty(value))
return value;
// Подвоюємо лапки для екранування
return value.Replace("\"", "\"\"");
}
}
Декомпозиція коду:
TextOutputFormatter — базовий клас для текстових форматерів (альтернатива: OutputFormatter для бінарних)SupportedMediaTypes — список MIME-типів, що обробляє форматерSupportedEncodings — підтримувані кодування (UTF-8, Unicode)CanWriteType() — перевірка, чи може форматер серіалізувати цей типWriteResponseBodyAsync() — основна логіка серіалізаціїEscapeCsvValue() — екранування спеціальних символів (лапки, коми)Додайте форматер у Program.cs:
using BooksApi.Formatters;
builder.Services.AddControllers(options =>
{
options.ReturnHttpNotAcceptable = true;
// Додаємо наш кастомний CSV форматер
options.OutputFormatters.Add(new CsvOutputFormatter());
})
.AddJsonOptions(/* ... */)
.AddXmlSerializerFormatters();
Додайте підтримку CSV у BooksController:
[ApiController]
[Route("api/[controller]")]
[Produces("application/json", "application/xml", "text/csv")] // Додали CSV
public class BooksController : ControllerBase
{
// Методи залишаються без змін!
}
Content-Disposition:response.Headers.Append("Content-Disposition",
$"attachment; filename=books_{DateTime.UtcNow:yyyyMMdd}.csv");
Тепер створимо форматер для прийому CSV-даних у POST/PUT запитах.
Створіть файл Formatters/CsvInputFormatter.cs:
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Net.Http.Headers;
using System.Text;
using BooksApi.Models;
namespace BooksApi.Formatters;
public class CsvInputFormatter : TextInputFormatter
{
public CsvInputFormatter()
{
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/csv"));
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/csv"));
SupportedEncodings.Add(Encoding.UTF8);
SupportedEncodings.Add(Encoding.Unicode);
}
protected override bool CanReadType(Type type)
{
return type == typeof(Book) ||
typeof(IEnumerable<Book>).IsAssignableFrom(type);
}
public override async Task<InputFormatterResult> ReadRequestBodyAsync(
InputFormatterContext context,
Encoding encoding)
{
var httpContext = context.HttpContext;
var request = httpContext.Request;
using var reader = new StreamReader(request.Body, encoding);
var csv = await reader.ReadToEndAsync();
try
{
var books = ParseCsv(csv);
// Якщо очікується один об'єкт, повертаємо перший
if (context.ModelType == typeof(Book))
{
return await InputFormatterResult.SuccessAsync(books.FirstOrDefault()!);
}
return await InputFormatterResult.SuccessAsync(books);
}
catch (Exception ex)
{
context.ModelState.AddModelError(string.Empty,
$"CSV parsing error: {ex.Message}");
return await InputFormatterResult.FailureAsync();
}
}
private static List<Book> ParseCsv(string csv)
{
var books = new List<Book>();
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
// Пропускаємо заголовок
for (int i = 1; i < lines.Length; i++)
{
var line = lines[i].Trim();
if (string.IsNullOrEmpty(line)) continue;
var values = ParseCsvLine(line);
if (values.Length < 9)
throw new FormatException($"Invalid CSV format at line {i + 1}");
var book = new Book
{
Id = int.Parse(values[0]),
Title = values[1],
Author = values[2],
ISBN = string.IsNullOrEmpty(values[3]) ? null : values[3],
PublicationYear = int.Parse(values[4]),
Pages = int.Parse(values[5]),
Genre = string.IsNullOrEmpty(values[6]) ? null : values[6],
Rating = double.Parse(values[7]),
IsAvailable = bool.Parse(values[8])
};
books.Add(book);
}
return books;
}
// Парсинг рядка CSV з урахуванням лапок
private static string[] ParseCsvLine(string line)
{
var values = new List<string>();
var currentValue = new StringBuilder();
bool inQuotes = false;
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (c == '"')
{
if (inQuotes && i + 1 < line.Length && line[i + 1] == '"')
{
// Подвійні лапки — екранування
currentValue.Append('"');
i++; // Пропускаємо наступну лапку
}
else
{
inQuotes = !inQuotes;
}
}
else if (c == ',' && !inQuotes)
{
values.Add(currentValue.ToString());
currentValue.Clear();
}
else
{
currentValue.Append(c);
}
}
values.Add(currentValue.ToString());
return values.ToArray();
}
}
builder.Services.AddControllers(options =>
{
options.ReturnHttpNotAcceptable = true;
// Output форматери
options.OutputFormatters.Add(new CsvOutputFormatter());
// Input форматери
options.InputFormatters.Add(new CsvInputFormatter());
})
.AddJsonOptions(/* ... */)
.AddXmlSerializerFormatters();
[HttpPost]
[Consumes("application/json", "application/xml", "text/csv")] // Додали CSV
[ProducesResponseType(typeof(Book), StatusCodes.Status201Created)]
public async Task<ActionResult<Book>> Create(Book book)
{
// Логіка залишається без змін!
}
YAML часто використовується для конфігураційних файлів. Використаємо бібліотеку YamlDotNet:
dotnet add package YamlDotNet
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Net.Http.Headers;
using System.Text;
using YamlDotNet.Serialization;
namespace BooksApi.Formatters;
public class YamlOutputFormatter : TextOutputFormatter
{
private readonly ISerializer _serializer;
public YamlOutputFormatter()
{
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/yaml"));
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/yaml"));
SupportedEncodings.Add(Encoding.UTF8);
_serializer = new SerializerBuilder()
.WithNamingConvention(YamlDotNet.Serialization.NamingConventions.CamelCaseNamingConvention.Instance)
.Build();
}
protected override bool CanWriteType(Type? type)
{
return type != null;
}
public override async Task WriteResponseBodyAsync(
OutputFormatterWriteContext context,
Encoding selectedEncoding)
{
var yaml = _serializer.Serialize(context.Object!);
await context.HttpContext.Response.WriteAsync(yaml, selectedEncoding);
}
}
Приклад відповіді:
id: 1
title: Clean Code
author: Robert C. Martin
isbn: 978-0132350884
publicationYear: 2008
pages: 464
genre: Programming
rating: 4.7
isAvailable: true
MessagePack — бінарний формат, що забезпечує високу продуктивність та компактність (до 10x менше за JSON):
dotnet add package MessagePack
dotnet add package MessagePack.AspNetCoreMvcFormatter
using MessagePack.AspNetCoreMvcFormatter;
using MessagePack.Resolvers;
builder.Services.AddControllers(options =>
{
// Додаємо MessagePack форматери
options.OutputFormatters.Add(new MessagePackOutputFormatter(ContractlessStandardResolver.Options));
options.InputFormatters.Add(new MessagePackInputFormatter(ContractlessStandardResolver.Options));
})
.AddJsonOptions(/* ... */);
Використання:
GET /api/books/1
Accept: application/x-msgpack
→ [бінарні дані MessagePack]
Атрибути [Produces] та [Consumes] дозволяють явно вказати підтримувані формати на рівні контролера або методу.
// На рівні контролера (для всіх методів)
[ApiController]
[Route("api/[controller]")]
[Produces("application/json", "application/xml")]
public class BooksController : ControllerBase
{
// Всі методи підтримують JSON та XML
}
// На рівні методу (перевизначає контролер)
[HttpGet("export")]
[Produces("text/csv", "application/yaml")]
public async Task<ActionResult<IEnumerable<Book>>> Export()
{
// Цей метод підтримує тільки CSV та YAML
}
[HttpPost]
[Consumes("application/json")] // Приймає тільки JSON
public async Task<ActionResult<Book>> CreateJson(Book book)
{
// ...
}
[HttpPost("xml")]
[Consumes("application/xml")] // Приймає тільки XML
public async Task<ActionResult<Book>> CreateXml(Book book)
{
// ...
}
[HttpPost]
[Consumes("application/json", "application/xml", "text/csv")]
[Produces("application/json", "application/xml")]
[ProducesResponseType(typeof(Book), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status415UnsupportedMediaType)]
public async Task<ActionResult<Book>> Create(Book book)
{
// Приймає JSON/XML/CSV, повертає JSON/XML
}
Який форматер буде обрано для кожного запиту?
Accept: application/jsonAccept: application/xmlAccept: text/csv, application/json;q=0.8Accept: application/yamlAddXmlSerializerFormatters())Які налаштування потрібні для наступної поведінки?
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.JsonSerializerOptions.WriteIndented = true;
});
Створіть форматер, що повертає JSON з обгорткою:
{
"success": true,
"data": { /* ваші дані */ },
"timestamp": "2024-01-15T10:30:00Z"
}
using Microsoft.AspNetCore.Mvc.Formatters;
using System.Text.Json;
public class WrappedJsonOutputFormatter : TextOutputFormatter
{
public WrappedJsonOutputFormatter()
{
SupportedMediaTypes.Add("application/vnd.myapi+json");
SupportedEncodings.Add(Encoding.UTF8);
}
protected override bool CanWriteType(Type? type)
{
return type != null;
}
public override async Task WriteResponseBodyAsync(
OutputFormatterWriteContext context,
Encoding selectedEncoding)
{
var wrapper = new
{
success = true,
data = context.Object,
timestamp = DateTime.UtcNow
};
var json = JsonSerializer.Serialize(wrapper, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
});
await context.HttpContext.Response.WriteAsync(json, selectedEncoding);
}
}
Реєстрація:
options.OutputFormatters.Insert(0, new WrappedJsonOutputFormatter());
Використання:
Accept: application/vnd.myapi+json
Реалізуйте endpoint, що повертає різні дані залежно від формату:
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var book = await _db.Books.FindAsync(id);
if (book is null) return NotFound();
var acceptHeader = Request.Headers.Accept.ToString();
if (acceptHeader.Contains("text/csv"))
{
// Повертаємо спрощену версію
var csv = $"Id,Title,Author\n{book.Id},\"{book.Title}\",\"{book.Author}\"";
return Content(csv, "text/csv");
}
if (acceptHeader.Contains("application/xml"))
{
// Додаємо метадані для XML
var enrichedBook = new
{
Book = book,
Metadata = new
{
ExportedAt = DateTime.UtcNow,
Version = "1.0"
}
};
return Ok(enrichedBook);
}
// JSON за замовчуванням
return Ok(book);
}
Створіть generic форматер, що працює з будь-яким типом:
public class GenericCsvOutputFormatter<T> : TextOutputFormatter
{
// Реалізуйте форматер, що автоматично серіалізує будь-який тип у CSV
// використовуючи рефлексію для отримання властивостей
}
using System.Reflection;
public class GenericCsvOutputFormatter<T> : TextOutputFormatter where T : class
{
public GenericCsvOutputFormatter()
{
SupportedMediaTypes.Add("text/csv");
SupportedEncodings.Add(Encoding.UTF8);
}
protected override bool CanWriteType(Type? type)
{
return type == typeof(T) || typeof(IEnumerable<T>).IsAssignableFrom(type);
}
public override async Task WriteResponseBodyAsync(
OutputFormatterWriteContext context,
Encoding selectedEncoding)
{
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
var csv = new StringBuilder();
// Заголовок
csv.AppendLine(string.Join(",", properties.Select(p => p.Name)));
// Дані
var items = context.Object is IEnumerable<T> collection
? collection
: new[] { (T)context.Object! };
foreach (var item in items)
{
var values = properties.Select(p =>
{
var value = p.GetValue(item)?.ToString() ?? "";
return $"\"{value.Replace("\"", "\"\"")}\"";
});
csv.AppendLine(string.Join(",", values));
}
await context.HttpContext.Response.WriteAsync(csv.ToString(), selectedEncoding);
}
}
У цій статті ми опанували Content Negotiation — потужний механізм, що дозволяє одному API обслуговувати різних клієнтів з різними потребами щодо формату даних. Ви навчилися не просто повертати JSON, а адаптувати відповіді до контексту використання.
Ключові висновки:
Accept header є фундаментальним принципом REST-архітектури, що забезпечує гнучкість та еволюцію API.AddXmlSerializerFormatters() займає один рядок коду.TextOutputFormatter та реалізації кількох методів.[Produces] та [Consumes] дозволяють явно документувати та обмежувати підтримувані формати на рівні контролера або методу.ReturnHttpNotAcceptable = true робить API більш строгим та передбачуваним, повертаючи 406 замість fallback на JSON.У наступній статті ми розглянемо API Versioning — як керувати еволюцією API без breaking changes для існуючих клієнтів.
ControllerBase, ActionResult<T> та Response Types
Глибоке занурення в ієрархію результатів ASP.NET Core Web API. Типи повернення, HTTP-коди, ProducesResponseType та патерни формування відповідей.
Версіонування API
Стратегії версіонування REST API для управління breaking changes. URL path, query string, HTTP headers, media type versioning. Пакет Asp.Versioning.Mvc, deprecation flow та migration guides.