Web Api

Content Negotiation - JSON, XML та власні форматери

Механізм узгодження формату відповіді між клієнтом та сервером. System.Text.Json vs Newtonsoft.Json, XML-серіалізація та створення кастомних форматерів для CSV, YAML, MessagePack.

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, що автоматично адаптується до потреб клієнта.

Передумови: Ця стаття базується на знаннях з попередніх статей (01-02 Web API Controllers), а також на розумінні HTTP-заголовків з курсу API Design (стаття 03).

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

Ми побудуємо Books API — сервіс управління бібліотекою, що підтримує 5 форматів відповідей:

  1. JSON (System.Text.Json) — за замовчуванням
  2. XML — для legacy-систем
  3. CSV — для експорту в Excel
  4. YAML — для конфігураційних файлів
  5. 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" }

Процес узгодження: Крок за кроком

Loading diagram...
sequenceDiagram
    participant Client
    participant ASP.NET Core
    participant Formatters
    participant Controller
    
    Client->>ASP.NET Core: GET /api/books/1<br/>Accept: application/xml
    ASP.NET Core->>Formatters: Які форматери підтримують XML?
    Formatters-->>ASP.NET Core: XmlSerializerOutputFormatter
    ASP.NET Core->>Controller: Виконати GetById(1)
    Controller-->>ASP.NET Core: Book object
    ASP.NET Core->>Formatters: Серіалізувати у XML
    Formatters-->>ASP.NET Core: XML string
    ASP.NET Core->>Client: HTTP 200<br/>Content-Type: application/xml<br/><Book>...</Book>
    
    style Client fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style ASP.NET Core fill:#10b981,stroke:#059669,color:#ffffff
    style Formatters fill:#f59e0b,stroke:#b45309,color:#ffffff
    style Controller fill:#3b82f6,stroke:#1d4ed8,color:#ffffff

Алгоритм вибору форматера:

  1. ASP.NET Core аналізує заголовок Accept з запиту
  2. Перевіряє список зареєстрованих OutputFormatter-ів
  3. Знаходить форматер, що підтримує запитаний MIME-тип
  4. Якщо кілька форматерів підходять — обирає за пріоритетом (q-factor)
  5. Якщо жоден не підходить — повертає 406 Not Acceptable (або fallback на JSON)
  6. Серіалізує об'єкт через обраний форматер
  7. Встановлює заголовок Content-Type у відповіді
Fallback поведінка: За замовчуванням ASP.NET Core не повертає 406, а використовує перший доступний форматер (зазвичай JSON). Це можна змінити через 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)
Loading diagram...
graph LR
    A[HTTP Request] -->|Content-Type: application/json| B[InputFormatter]
    B --> C[C# Object]
    C --> D[Controller Action]
    D --> E[C# Object]
    E -->|Accept: application/xml| F[OutputFormatter]
    F --> G[HTTP Response]
    
    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style C fill:#10b981,stroke:#059669,color:#ffffff
    style D fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style E fill:#10b981,stroke:#059669,color:#ffffff
    style F fill:#f59e0b,stroke:#b45309,color:#ffffff
    style G fill:#3b82f6,stroke:#1d4ed8,color:#ffffff

Вбудовані форматери в ASP.NET Core:

ФорматерMIME-типInputOutputЗа замовчуванням
SystemTextJsonInputFormatterapplication/json
SystemTextJsonOutputFormatterapplication/json
XmlSerializerInputFormatterapplication/xml, text/xml
XmlSerializerOutputFormatterapplication/xml, text/xml
XmlDataContractSerializerInputFormatterapplication/xml
StringOutputFormattertext/plain
Важливо: XML-форматери не включені за замовчуванням. Їх потрібно додавати явно через AddXmlSerializerFormatters() або AddXmlDataContractSerializerFormatters().

Практична реалізація: Books API з множинними форматами

Настав час створити реальний API, що підтримує різні формати відповідей.

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

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

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

Створення моделі 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();

Декомпозиція налаштувань:

  1. ReturnHttpNotAcceptable = true — якщо клієнт запитує непідтримуваний формат, повертати 406 замість fallback на JSON
  2. AddJsonOptions() — налаштування System.Text.Json:
    • PropertyNamingPolicy.CamelCase — властивості у camelCase (замість PascalCase)
    • WriteIndented = true — форматований JSON (для читабельності)
    • DefaultIgnoreCondition.WhenWritingNull — не серіалізувати null-значення
    • JsonStringEnumConverter — enum як рядки замість чисел
  3. AddXmlSerializerFormatters() — додає XmlSerializerInputFormatter та XmlSerializerOutputFormatter
  4. AddXmlDataContractSerializerFormatters() — альтернативний 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();
    }
}

Ключові атрибути:

  1. [Produces("application/json", "application/xml")] на рівні класу — вказує, які формати підтримує контролер
  2. [Consumes("application/json", "application/xml")] на методі — вказує, які формати приймає метод у тілі запиту
  3. StatusCodes.Status406NotAcceptable — документуємо можливість повернення 406
  4. StatusCodes.Status415UnsupportedMediaType — документуємо можливість повернення 415 для непідтримуваного Content-Type

Крок 3: Тестування Content Negotiation

Запустіть проєкт та протестуйте різні формати:

bash
$ dotnet run
info: Now listening on: https://localhost:5001
# Тест 1: JSON (за замовчуванням)
$ curl -H "Accept: application/json" https://localhost:5001/api/books/1
{
"id": 1,
"title": "Clean Code",
"author": "Robert C. Martin"
}
# Тест 2: XML
$ curl -H "Accept: application/xml" https://localhost:5001/api/books/1
<Book>
<Id>1</Id>
<Title>Clean Code</Title>
<Author>Robert C. Martin</Author>
</Book>
# Тест 3: Непідтримуваний формат
$ curl -H "Accept: text/csv" https://localhost:5001/api/books/1
HTTP/1.1 406 Not Acceptable

System.Text.Json vs Newtonsoft.Json

ASP.NET Core за замовчуванням використовує System.Text.Json (з .NET Core 3.0+), але багато проєктів все ще використовують Newtonsoft.Json (Json.NET). Розглянемо відмінності та коли використовувати кожен.

Порівняльна таблиця

АспектSystem.Text.JsonNewtonsoft.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;
    });
Важливо: Не можна використовувати обидва серіалізатори одночасно для JSON. Вибір між 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("\"", "\"\"");
    }
}

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

  1. TextOutputFormatter — базовий клас для текстових форматерів (альтернатива: OutputFormatter для бінарних)
  2. SupportedMediaTypes — список MIME-типів, що обробляє форматер
  3. SupportedEncodings — підтримувані кодування (UTF-8, Unicode)
  4. CanWriteType() — перевірка, чи може форматер серіалізувати цей тип
  5. WriteResponseBodyAsync() — основна логіка серіалізації
  6. 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

bash
$ curl -H "Accept: text/csv" https://localhost:5001/api/books
Id,Title,Author,ISBN,PublicationYear,Pages,Genre,Rating,IsAvailable
1,"Clean Code","Robert C. Martin","978-0132350884",2008,464,"Programming",4.7,True
2,"Design Patterns","Gang of Four","978-0201633610",1994,395,"Programming",4.6,True
3,"The Pragmatic Programmer","Andrew Hunt, David Thomas","978-0135957059",2019,352,"Programming",4.8,True
Автоматичне завантаження: Браузери автоматично пропонують завантажити CSV-файл. Для кращого UX додайте заголовок 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

bash
$ curl -X POST https://localhost:5001/api/books \
-H "Content-Type: text/csv" \
-d 'Id,Title,Author,ISBN,PublicationYear,Pages,Genre,Rating,IsAvailable
0,"Refactoring","Martin Fowler","978-0134757599",2018,448,"Programming",4.9,True'
HTTP/1.1 201 Created
Location: https://localhost:5001/api/books/4

Інші формати: 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]
Коли використовувати 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: Вибір форматера

Який форматер буде обрано для кожного запиту?

  1. Accept: application/json
  2. Accept: application/xml
  3. Accept: text/csv, application/json;q=0.8
  4. Accept: application/yaml

Завдання 1.2: Налаштування System.Text.Json

Які налаштування потрібні для наступної поведінки?

  • Властивості у camelCase
  • Enum як рядки
  • Ігнорувати null-значення
  • Форматований JSON (з відступами)

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

Завдання 2.1: Кастомний JSON OutputFormatter

Створіть форматер, що повертає JSON з обгорткою:

{
  "success": true,
  "data": { /* ваші дані */ },
  "timestamp": "2024-01-15T10:30:00Z"
}

Завдання 2.2: Conditional Content Negotiation

Реалізуйте endpoint, що повертає різні дані залежно від формату:

  • JSON: повні дані книги
  • CSV: тільки Id, Title, Author
  • XML: повні дані + додаткові метадані

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

Завдання 3.1: Універсальний Generic OutputFormatter

Створіть generic форматер, що працює з будь-яким типом:

public class GenericCsvOutputFormatter<T> : TextOutputFormatter
{
    // Реалізуйте форматер, що автоматично серіалізує будь-який тип у CSV
    // використовуючи рефлексію для отримання властивостей
}

Підсумок

У цій статті ми опанували Content Negotiation — потужний механізм, що дозволяє одному API обслуговувати різних клієнтів з різними потребами щодо формату даних. Ви навчилися не просто повертати JSON, а адаптувати відповіді до контексту використання.

Ключові висновки:

  1. Content Negotiation — це REST: Механізм узгодження формату через Accept header є фундаментальним принципом REST-архітектури, що забезпечує гнучкість та еволюцію API.
  2. System.Text.Json за замовчуванням: Для нових проєктів використовуйте вбудований серіалізатор — він швидший, ефективніший та не вимагає зовнішніх залежностей. Newtonsoft.Json залишається актуальним для legacy-проєктів та складних сценаріїв.
  3. XML не мертвий: Багато enterprise-систем та legacy-додатків все ще використовують XML. Додавання підтримки через AddXmlSerializerFormatters() займає один рядок коду.
  4. Кастомні форматери — просто: Створення власного форматера (CSV, YAML, MessagePack) вимагає лише успадкування від TextOutputFormatter та реалізації кількох методів.
  5. Атрибути для контролю: [Produces] та [Consumes] дозволяють явно документувати та обмежувати підтримувані формати на рівні контролера або методу.
  6. 406 vs Fallback: Налаштування ReturnHttpNotAcceptable = true робить API більш строгим та передбачуваним, повертаючи 406 замість fallback на JSON.

У наступній статті ми розглянемо API Versioning — як керувати еволюцією API без breaking changes для існуючих клієнтів.


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

Content Negotiation

Офіційна документація про форматування відповідей

System.Text.Json

Повний гайд по System.Text.Json

Custom Formatters

Створення власних форматерів

MessagePack

Офіційний сайт MessagePack

Наступна стаття:API Versioning — стратегії версіонування API (URL path, query string, headers, media type) та управління breaking changes.