Content Negotiation - JSON, XML та власні форматери
Content Negotiation: JSON, XML та власні форматери
Вступ: Один API — багато форматів
Уявіть, що ви керуєте рестораном, де кожен гість має свої уподобання щодо подачі страв. Один хоче їжу на тарілці, інший — у контейнері на винос, третій — у вакуумній упаковці для довгого зберігання. Страва (дані) залишається тією самою, але формат подачі змінюється залежно від потреб клієнта. Саме це робить Content Negotiation (узгодження контенту) у веб-API.
Content Negotiation — це механізм, що дозволяє одному endpoint повертати дані у різних форматах залежно від того, що запитує клієнт через HTTP-заголовок Accept. Це не просто технічна можливість — це фундаментальний принцип REST-архітектури, що забезпечує гнучкість та сумісність API з різними типами клієнтів:
- Веб-браузери зазвичай очікують JSON
- Legacy-системи можуть вимагати XML
- Excel/аналітика потребують CSV
- Мобільні додатки віддають перевагу компактному MessagePack
- Конфігураційні файли використовують YAML
Замість створення окремих endpoints для кожного формату (/api/products.json, /api/products.xml, /api/products.csv), ви створюєте один універсальний endpoint, що автоматично адаптується до потреб клієнта.
Що ви створите в цій статті
Ми побудуємо Books API — сервіс управління бібліотекою, що підтримує 5 форматів відповідей:
- JSON (System.Text.Json) — за замовчуванням
- XML — для legacy-систем
- CSV — для експорту в Excel
- YAML — для конфігураційних файлів
- MessagePack — для високопродуктивних мобільних додатків
Один запит — різні формати:
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
До кінця статті ви зможете:
- Налаштовувати System.Text.Json для різних сценаріїв
- Додавати підтримку XML-серіалізації
- Створювати власні
OutputFormatterтаInputFormatter - Використовувати
[Produces]та[Consumes]атрибути - Обирати між System.Text.Json та Newtonsoft.Json обґрунтовано
Фундаментальні концепції: Як працює Content Negotiation
Механізм узгодження формату
Content Negotiation базується на HTTP-заголовках, що передаються між клієнтом та сервером:
Заголовки запиту (Request Headers)
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" }
Заголовки відповіді (Response Headers)
Content-Type — сервер повідомляє, який формат він повернув:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{ "id": 1, "title": "Clean Code" }
Процес узгодження: Крок за кроком
Алгоритм вибору форматера:
- ASP.NET Core аналізує заголовок
Acceptз запиту - Перевіряє список зареєстрованих
OutputFormatter-ів - Знаходить форматер, що підтримує запитаний MIME-тип
- Якщо кілька форматерів підходять — обирає за пріоритетом (q-factor)
- Якщо жоден не підходить — повертає 406 Not Acceptable (або fallback на JSON)
- Серіалізує об'єкт через обраний форматер
- Встановлює заголовок
Content-Typeу відповіді
ReturnHttpNotAcceptable = true у налаштуваннях.Форматери: Input vs Output
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().Практична реалізація: Books API з множинними форматами
Настав час створити реальний API, що підтримує різні формати відповідей.
Крок 1: Налаштування проєкту
Створення проєкту
Створення моделі Book
Створіть файл 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-елемента (за замовчуванням збігається з назвою властивості)- Ці атрибути не впливають на JSON-серіалізацію, лише на XML
Створення DbContext
Створіть файл 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
}
);
});
}
}
Налаштування форматерів у Program.cs
Тепер найважливіша частина — налаштування 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-серіалізатор (підтримує більше типів)
Крок 2: Створення BooksController
Створіть файл 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
Крок 3: Тестування Content Negotiation
Запустіть проєкт та протестуйте різні формати:
System.Text.Json vs Newtonsoft.Json
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+ | Всі версії |
| Розмір бібліотеки | ✅ Вбудована | Зовнішній пакет |
Коли використовувати System.Text.Json
✅ Використовуйте за замовчуванням, якщо:
- Новий проєкт на .NET 6+
- Потрібна максимальна продуктивність
- Базова серіалізація (POCO-об'єкти)
- Хочете уникнути зовнішніх залежностей
Приклад налаштування:
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
✅ Перейдіть на Newtonsoft.Json, якщо:
- Legacy-проєкт з існуючим кодом
- Потрібна серіалізація складних типів (DataTable, DataSet)
- Використовуєте
[JsonProperty]атрибути - Потрібні розширені можливості (conditional serialization, custom converters)
Міграція на 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.
Крок 1: Реалізація CsvOutputFormatter
Створіть файл 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()— екранування спеціальних символів (лапки, коми)
Крок 2: Реєстрація форматера
Додайте форматер у Program.cs:
using BooksApi.Formatters;
builder.Services.AddControllers(options =>
{
options.ReturnHttpNotAcceptable = true;
// Додаємо наш кастомний CSV форматер
options.OutputFormatters.Add(new CsvOutputFormatter());
})
.AddJsonOptions(/* ... */)
.AddXmlSerializerFormatters();
Крок 3: Оновлення контролера
Додайте підтримку CSV у BooksController:
[ApiController]
[Route("api/[controller]")]
[Produces("application/json", "application/xml", "text/csv")] // Додали CSV
public class BooksController : ControllerBase
{
// Методи залишаються без змін!
}
Крок 4: Тестування CSV
Content-Disposition:response.Headers.Append("Content-Disposition",
$"attachment; filename=books_{DateTime.UtcNow:yyyyMMdd}.csv");
Створення InputFormatter для CSV
Тепер створимо форматер для прийому CSV-даних у POST/PUT запитах.
CsvInputFormatter
Створіть файл 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();
}
}
Реєстрація InputFormatter
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)
{
// Логіка залишається без змін!
}
Тестування CSV Input
Інші формати: YAML та MessagePack
YAML OutputFormatter
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 Formatter
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]
- Мобільні додатки з обмеженим трафіком
- Високонавантажені системи (мікросервіси)
- Real-time додатки (ігри, чати)
- IoT-пристрої з обмеженою пам'яттю
Атрибути Produces та Consumes
Атрибути [Produces] та [Consumes] дозволяють явно вказати підтримувані формати на рівні контролера або методу.
Produces — Формати відповіді
// На рівні контролера (для всіх методів)
[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
}
Consumes — Формати запиту
[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
}
Практичні завдання
Рівень 1: Базове розуміння
Завдання 1.1: Вибір форматера
Який форматер буде обрано для кожного запиту?
Accept: application/jsonAccept: application/xmlAccept: text/csv, application/json;q=0.8Accept: application/yaml
- SystemTextJsonOutputFormatter (JSON за замовчуванням)
- XmlSerializerOutputFormatter (якщо додано через
AddXmlSerializerFormatters()) - CsvOutputFormatter (CSV має вищий пріоритет q=1.0 vs q=0.8)
- YamlOutputFormatter (якщо зареєстровано) або 406 Not Acceptable
Завдання 1.2: Налаштування System.Text.Json
Які налаштування потрібні для наступної поведінки?
- Властивості у camelCase
- Enum як рядки
- Ігнорувати null-значення
- Форматований JSON (з відступами)
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;
});
Рівень 2: Логіка та розширення
Завдання 2.1: Кастомний JSON OutputFormatter
Створіть форматер, що повертає 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
Завдання 2.2: Conditional Content Negotiation
Реалізуйте endpoint, що повертає різні дані залежно від формату:
- JSON: повні дані книги
- CSV: тільки Id, Title, Author
- XML: повні дані + додаткові метадані
[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);
}
Рівень 3: Архітектура та створення
Завдання 3.1: Універсальний Generic OutputFormatter
Створіть 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, а адаптувати відповіді до контексту використання.
Ключові висновки:
- Content Negotiation — це REST: Механізм узгодження формату через
Acceptheader є фундаментальним принципом REST-архітектури, що забезпечує гнучкість та еволюцію API. - System.Text.Json за замовчуванням: Для нових проєктів використовуйте вбудований серіалізатор — він швидший, ефективніший та не вимагає зовнішніх залежностей. Newtonsoft.Json залишається актуальним для legacy-проєктів та складних сценаріїв.
- XML не мертвий: Багато enterprise-систем та legacy-додатків все ще використовують XML. Додавання підтримки через
AddXmlSerializerFormatters()займає один рядок коду. - Кастомні форматери — просто: Створення власного форматера (CSV, YAML, MessagePack) вимагає лише успадкування від
TextOutputFormatterта реалізації кількох методів. - Атрибути для контролю:
[Produces]та[Consumes]дозволяють явно документувати та обмежувати підтримувані формати на рівні контролера або методу. - 406 vs Fallback: Налаштування
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.