Web Api

HATEOAS та Resource Expansion

Hypermedia as the Engine of Application State, Richardson Maturity Model, HAL формат, LinkGenerator в ASP.NET Core, resource expansion через ?expand=author,comments, sparse fieldsets та self-discoverable API.

HATEOAS та Resource Expansion

Вступ: Проблема статичних API

Уявіть, що ви створили REST API для блогу:

GET /api/articles/5
{
  "id": 5,
  "title": "Introduction to ASP.NET Core",
  "authorId": 42,
  "categoryId": 3,
  "publishedAt": "2024-01-15T10:00:00Z"
}

Що має зробити клієнт далі?

Клієнт бачить authorId: 42, але не знає:

  • ❌ Як отримати інформацію про автора?
  • ❌ Який URL використовувати? /api/authors/42? /api/users/42?
  • ❌ Які дії доступні? Можна редагувати? Видалити?
  • ❌ Як отримати коментарі до статті?

Типовий підхід — документація:

Документація API:
- Отримати автора: GET /api/authors/{id}
- Отримати коментарі: GET /api/articles/{id}/comments
- Редагувати статтю: PUT /api/articles/{id} (потрібна роль Editor)
- Видалити статтю: DELETE /api/articles/{id} (потрібна роль Admin)

Проблеми цього підходу:

  • ❌ Клієнт має хардкодити URL-и
  • ❌ Документація може застаріти
  • ❌ Клієнт не знає, які дії доступні для конкретного користувача
  • ❌ Зміна URL-структури ламає всіх клієнтів

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

// Клієнтський код (хардкод URL)
const article = await fetch('/api/articles/5').then(r => r.json());
const author = await fetch(`/api/authors/${article.authorId}`).then(r => r.json());

// ❌ Що якщо API змінить URL на /api/users/{id}?
// ❌ Що якщо authorId більше не повертається?
// ❌ Клієнт зламається!

РішенняHATEOAS (Hypermedia as the Engine of Application State) — принцип REST, де API сам повідомляє клієнту, які дії та ресурси доступні через гіперпосилання.

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

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

Ми побудуємо Blog API з HATEOAS та Resource Expansion:

1. HATEOAS Links — Self-Discoverable API:

{
  "id": 5,
  "title": "Introduction to ASP.NET Core",
  "_links": {
    "self": { "href": "/api/articles/5" },
    "author": { "href": "/api/authors/42" },
    "comments": { "href": "/api/articles/5/comments" },
    "edit": { "href": "/api/articles/5", "method": "PUT" },
    "delete": { "href": "/api/articles/5", "method": "DELETE" }
  }
}

2. Resource Expansion — Embedded Resources:

GET /api/articles/5?expand=author,comments
{
  "id": 5,
  "title": "Introduction to ASP.NET Core",
  "_embedded": {
    "author": {
      "id": 42,
      "name": "John Doe",
      "email": "john@example.com"
    },
    "comments": [
      { "id": 1, "text": "Great article!", "author": "Jane" },
      { "id": 2, "text": "Thanks for sharing", "author": "Bob" }
    ]
  },
  "_links": { ... }
}

3. Sparse Fieldsets — Вибіркові поля:

GET /api/articles?fields=id,title,author

4. HAL (Hypertext Application Language) формат:

{
  "_links": { "self": { "href": "/api/articles" } },
  "_embedded": {
    "articles": [
      { "id": 1, "title": "Article 1", "_links": { ... } },
      { "id": 2, "title": "Article 2", "_links": { ... } }
    ]
  },
  "page": 1,
  "totalPages": 10
}

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

  • Реалізувати HATEOAS links через LinkGenerator
  • Створювати self-discoverable API
  • Використовувати resource expansion для зменшення кількості запитів
  • Реалізувати HAL формат
  • Застосовувати sparse fieldsets для оптимізації

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

Richardson Maturity Model

REST API має 4 рівні зрілості (Richardson Maturity Model):

Loading diagram...
graph TD
    A[Level 0: The Swamp of POX<br/>Один endpoint, POST для всього] --> B[Level 1: Resources<br/>Множина endpoints, але тільки POST]
    B --> C[Level 2: HTTP Verbs<br/>GET, POST, PUT, DELETE + статус-коди]
    C --> D[Level 3: Hypermedia Controls<br/>HATEOAS - links у відповідях]
    
    style A fill:#ef4444,stroke:#b91c1c,color:#ffffff
    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style C fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style D fill:#10b981,stroke:#059669,color:#ffffff

Level 0 — The Swamp of POX (Plain Old XML):

POST /api
{ "action": "getArticle", "id": 5 }

Level 1 — Resources:

POST /api/articles/5
{ "action": "get" }

Level 2 — HTTP Verbs (більшість сучасних API):

GET /api/articles/5
PUT /api/articles/5
DELETE /api/articles/5

Level 3 — Hypermedia Controls (HATEOAS):

{
  "id": 5,
  "title": "Article",
  "_links": {
    "self": { "href": "/api/articles/5" },
    "edit": { "href": "/api/articles/5", "method": "PUT" },
    "delete": { "href": "/api/articles/5", "method": "DELETE" }
  }
}
Більшість API знаходяться на Level 2. Level 3 (HATEOAS) — це ідеал REST, але він вимагає більше зусиль і не завжди виправданий для простих API.

HATEOAS: Переваги та недоліки

Переваги:

Decoupling — клієнт не хардкодить URL-и
Evolvability — можна змінювати URL-структуру без ламання клієнтів
Discoverability — API самодокументується
Dynamic behavior — сервер контролює, які дії доступні
Versioning — легше підтримувати кілька версій API

Недоліки:

Складність — більше коду на сервері та клієнті
Розмір відповіді — links збільшують payload
Підтримка клієнтів — не всі клієнти вміють працювати з HATEOAS
Overkill — для простих CRUD API може бути надмірним

HAL (Hypertext Application Language)

HAL — стандартний формат для HATEOAS API:

{
  "_links": {
    "self": { "href": "/api/articles/5" },
    "author": { "href": "/api/authors/42", "title": "John Doe" }
  },
  "_embedded": {
    "comments": [
      {
        "id": 1,
        "text": "Great!",
        "_links": { "self": { "href": "/api/comments/1" } }
      }
    ]
  },
  "id": 5,
  "title": "Article Title"
}

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

  • _links — навігаційні посилання
  • _embedded — вбудовані ресурси (для зменшення кількості запитів)
  • self — обов'язкове посилання на сам ресурс
  • href — URL ресурсу
  • title — опис посилання (опціонально)

Практична реалізація: Blog API з HATEOAS

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

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

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

Створення моделей

Створіть файл Models/Article.cs:

namespace BlogHateoasApi.Models;

public class Article
{
    public int Id { get; set; }
    public required string Title { get; set; }
    public required string Content { get; set; }
    public int AuthorId { get; set; }
    public Author? Author { get; set; }
    public int CategoryId { get; set; }
    public Category? Category { get; set; }
    public List<Comment> Comments { get; set; } = new();
    public DateTime PublishedAt { get; set; } = DateTime.UtcNow;
    public DateTime? UpdatedAt { get; set; }
    public ArticleStatus Status { get; set; } = ArticleStatus.Draft;
}

public enum ArticleStatus
{
    Draft,
    Published,
    Archived
}

public class Author
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required string Email { get; set; }
    public string? Bio { get; set; }
    public List<Article> Articles { get; set; } = new();
}

public class Category
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public string? Description { get; set; }
    public List<Article> Articles { get; set; } = new();
}

public class Comment
{
    public int Id { get; set; }
    public required string Text { get; set; }
    public required string AuthorName { get; set; }
    public int ArticleId { get; set; }
    public Article? Article { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

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

  • Article — основна сутність з навігаційними властивостями
  • Author, Category — пов'язані ресурси для expansion
  • Comment — вкладений ресурс (один-до-багатьох)
  • ArticleStatus — для демонстрації conditional links (draft можна редагувати, archived — ні)

Крок 2: HATEOAS Infrastructure

Створіть файл Models/Link.cs:

namespace BlogHateoasApi.Models;

public class Link
{
    public required string Href { get; set; }
    public string? Rel { get; set; }
    public string? Method { get; set; } = "GET";
    public string? Title { get; set; }
}

public class ResourceWithLinks
{
    public Dictionary<string, Link> Links { get; set; } = new();
}

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

  • Href — URL ресурсу
  • Rel — тип зв'язку (self, author, edit, delete)
  • Method — HTTP метод (GET, POST, PUT, DELETE)
  • Title — опис посилання (для UI)

2. LinkGenerator Helper

Створіть файл Helpers/LinkGeneratorHelper.cs:

using BlogHateoasApi.Models;
using Microsoft.AspNetCore.Mvc;

namespace BlogHateoasApi.Helpers;

public static class LinkGeneratorHelper
{
    public static Dictionary<string, Link> GenerateArticleLinks(
        int articleId,
        ArticleStatus status,
        IUrlHelper urlHelper,
        bool isOwner = false)
    {
        var links = new Dictionary<string, Link>
        {
            ["self"] = new Link
            {
                Href = urlHelper.Action("GetById", "Articles", new { id = articleId })!,
                Rel = "self",
                Method = "GET",
                Title = "Get article details"
            },
            ["author"] = new Link
            {
                Href = urlHelper.Action("GetById", "Authors", new { id = "{authorId}" })!,
                Rel = "author",
                Method = "GET",
                Title = "Get article author"
            },
            ["comments"] = new Link
            {
                Href = urlHelper.Action("GetComments", "Articles", new { id = articleId })!,
                Rel = "comments",
                Method = "GET",
                Title = "Get article comments"
            },
            ["category"] = new Link
            {
                Href = urlHelper.Action("GetById", "Categories", new { id = "{categoryId}" })!,
                Rel = "category",
                Method = "GET",
                Title = "Get article category"
            }
        };

        // Conditional links based on status and ownership
        if (status == ArticleStatus.Draft && isOwner)
        {
            links["publish"] = new Link
            {
                Href = urlHelper.Action("Publish", "Articles", new { id = articleId })!,
                Rel = "publish",
                Method = "POST",
                Title = "Publish article"
            };
        }

        if (status != ArticleStatus.Archived && isOwner)
        {
            links["edit"] = new Link
            {
                Href = urlHelper.Action("Update", "Articles", new { id = articleId })!,
                Rel = "edit",
                Method = "PUT",
                Title = "Update article"
            };

            links["delete"] = new Link
            {
                Href = urlHelper.Action("Delete", "Articles", new { id = articleId })!,
                Rel = "delete",
                Method = "DELETE",
                Title = "Delete article"
            };
        }

        if (status == ArticleStatus.Published)
        {
            links["archive"] = new Link
            {
                Href = urlHelper.Action("Archive", "Articles", new { id = articleId })!,
                Rel = "archive",
                Method = "POST",
                Title = "Archive article"
            };
        }

        return links;
    }

    public static Dictionary<string, Link> GenerateCollectionLinks(
        IUrlHelper urlHelper,
        string action,
        string controller,
        int page,
        int totalPages)
    {
        var links = new Dictionary<string, Link>
        {
            ["self"] = new Link
            {
                Href = urlHelper.Action(action, controller, new { page })!,
                Rel = "self"
            }
        };

        if (page > 1)
        {
            links["first"] = new Link
            {
                Href = urlHelper.Action(action, controller, new { page = 1 })!,
                Rel = "first"
            };

            links["prev"] = new Link
            {
                Href = urlHelper.Action(action, controller, new { page = page - 1 })!,
                Rel = "prev"
            };
        }

        if (page < totalPages)
        {
            links["next"] = new Link
            {
                Href = urlHelper.Action(action, controller, new { page = page + 1 })!,
                Rel = "next"
            };

            links["last"] = new Link
            {
                Href = urlHelper.Action(action, controller, new { page = totalPages })!,
                Rel = "last"
            };
        }

        return links;
    }
}

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

  1. IUrlHelper — ASP.NET Core сервіс для генерації URL-ів
  2. Conditional links — різні links залежно від статусу та прав
  3. {authorId} placeholder — буде замінено на фактичний ID
  4. Collection links — для пагінованих списків
IUrlHelper автоматично генерує правильні URL-и з урахуванням routing конфігурації. Якщо змінити маршрут, links оновляться автоматично.

Крок 3: Resource DTOs з HATEOAS

Створіть файл Models/DTOs/ArticleDto.cs:

using System.Text.Json.Serialization;

namespace BlogHateoasApi.Models.DTOs;

public class ArticleDto
{
    public int Id { get; set; }
    public required string Title { get; set; }
    public required string Content { get; set; }
    public int AuthorId { get; set; }
    public int CategoryId { get; set; }
    public DateTime PublishedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
    public string Status { get; set; } = "";

    [JsonPropertyName("_links")]
    public Dictionary<string, Link>? Links { get; set; }

    [JsonPropertyName("_embedded")]
    public EmbeddedResources? Embedded { get; set; }
}

public class EmbeddedResources
{
    public AuthorDto? Author { get; set; }
    public CategoryDto? Category { get; set; }
    public List<CommentDto>? Comments { get; set; }
}

public class AuthorDto
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required string Email { get; set; }
    public string? Bio { get; set; }

    [JsonPropertyName("_links")]
    public Dictionary<string, Link>? Links { get; set; }
}

public class CategoryDto
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public string? Description { get; set; }

    [JsonPropertyName("_links")]
    public Dictionary<string, Link>? Links { get; set; }
}

public class CommentDto
{
    public int Id { get; set; }
    public required string Text { get; set; }
    public required string AuthorName { get; set; }
    public DateTime CreatedAt { get; set; }

    [JsonPropertyName("_links")]
    public Dictionary<string, Link>? Links { get; set; }
}

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

  1. [JsonPropertyName("_links")] — HAL стандарт використовує _links
  2. _embedded — вбудовані ресурси (для expansion)
  3. Nullable Links — можна вимкнути HATEOAS для певних endpoints
  4. Окремі DTO — для кожного типу ресурсу

Крок 4: Resource Expansion Infrastructure

ExpansionHelper

Створіть файл Helpers/ExpansionHelper.cs:

namespace BlogHateoasApi.Helpers;

public class ExpansionOptions
{
    public bool IncludeAuthor { get; set; }
    public bool IncludeCategory { get; set; }
    public bool IncludeComments { get; set; }

    public static ExpansionOptions Parse(string? expand)
    {
        if (string.IsNullOrWhiteSpace(expand))
            return new ExpansionOptions();

        var parts = expand.Split(',', StringSplitOptions.RemoveEmptyEntries)
            .Select(p => p.Trim().ToLower())
            .ToHashSet();

        return new ExpansionOptions
        {
            IncludeAuthor = parts.Contains("author"),
            IncludeCategory = parts.Contains("category"),
            IncludeComments = parts.Contains("comments")
        };
    }
}

Використання:

var options = ExpansionOptions.Parse("author,comments");
// options.IncludeAuthor = true
// options.IncludeComments = true
// options.IncludeCategory = false

Крок 5: DbContext та Seed Data

Створіть файл Data/BlogDbContext.cs:

using Microsoft.EntityFrameworkCore;
using BlogHateoasApi.Models;

namespace BlogHateoasApi.Data;

public class BlogDbContext : DbContext
{
    public BlogDbContext(DbContextOptions<BlogDbContext> options) : base(options) { }

    public DbSet<Article> Articles => Set<Article>();
    public DbSet<Author> Authors => Set<Author>();
    public DbSet<Category> Categories => Set<Category>();
    public DbSet<Comment> Comments => Set<Comment>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Seed Authors
        modelBuilder.Entity<Author>().HasData(
            new Author { Id = 1, Name = "John Doe", Email = "john@example.com", Bio = "Senior Developer" },
            new Author { Id = 2, Name = "Jane Smith", Email = "jane@example.com", Bio = "Tech Writer" }
        );

        // Seed Categories
        modelBuilder.Entity<Category>().HasData(
            new Category { Id = 1, Name = "Technology", Description = "Tech articles" },
            new Category { Id = 2, Name = "Programming", Description = "Programming tutorials" }
        );

        // Seed Articles
        modelBuilder.Entity<Article>().HasData(
            new Article
            {
                Id = 1,
                Title = "Introduction to ASP.NET Core",
                Content = "ASP.NET Core is a cross-platform framework...",
                AuthorId = 1,
                CategoryId = 2,
                Status = ArticleStatus.Published,
                PublishedAt = DateTime.UtcNow.AddDays(-10)
            },
            new Article
            {
                Id = 2,
                Title = "Getting Started with HATEOAS",
                Content = "HATEOAS is a constraint of REST...",
                AuthorId = 2,
                CategoryId = 1,
                Status = ArticleStatus.Draft,
                PublishedAt = DateTime.UtcNow.AddDays(-5)
            },
            new Article
            {
                Id = 3,
                Title = "REST API Best Practices",
                Content = "Building great APIs requires...",
                AuthorId = 1,
                CategoryId = 2,
                Status = ArticleStatus.Published,
                PublishedAt = DateTime.UtcNow.AddDays(-3)
            }
        );

        // Seed Comments
        modelBuilder.Entity<Comment>().HasData(
            new Comment { Id = 1, ArticleId = 1, Text = "Great article!", AuthorName = "Bob", CreatedAt = DateTime.UtcNow.AddDays(-9) },
            new Comment { Id = 2, ArticleId = 1, Text = "Very helpful, thanks!", AuthorName = "Alice", CreatedAt = DateTime.UtcNow.AddDays(-8) },
            new Comment { Id = 3, ArticleId = 3, Text = "Looking forward to more!", AuthorName = "Charlie", CreatedAt = DateTime.UtcNow.AddDays(-2) }
        );
    }
}

Крок 6: Articles Controller з HATEOAS

Створіть файл Controllers/ArticlesController.cs:

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using BlogHateoasApi.Data;
using BlogHateoasApi.Models;
using BlogHateoasApi.Models.DTOs;
using BlogHateoasApi.Helpers;

namespace BlogHateoasApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ArticlesController : ControllerBase
{
    private readonly BlogDbContext _db;
    private readonly ILogger<ArticlesController> _logger;
    private readonly IUrlHelper _urlHelper;

    public ArticlesController(
        BlogDbContext db,
        ILogger<ArticlesController> logger,
        IUrlHelperFactory urlHelperFactory,
        IActionContextAccessor actionContextAccessor)
    {
        _db = db;
        _logger = logger;
        _urlHelper = urlHelperFactory.GetUrlHelper(actionContextAccessor.ActionContext!);
    }

    /// <summary>
    /// Отримати статтю за ID з HATEOAS links та опціональним expansion
    /// </summary>
    /// <param name="id">ID статті</param>
    /// <param name="expand">Ресурси для expansion (author,category,comments)</param>
    [HttpGet("{id:int}", Name = "GetArticleById")]
    [ProducesResponseType(typeof(ArticleDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<ArticleDto>> GetById(
        int id,
        [FromQuery] string? expand = null)
    {
        var expansionOptions = ExpansionOptions.Parse(expand);

        // Базовий query
        var query = _db.Articles.AsQueryable();

        // Eager loading залежно від expansion
        if (expansionOptions.IncludeAuthor)
            query = query.Include(a => a.Author);

        if (expansionOptions.IncludeCategory)
            query = query.Include(a => a.Category);

        if (expansionOptions.IncludeComments)
            query = query.Include(a => a.Comments);

        var article = await query.FirstOrDefaultAsync(a => a.Id == id);

        if (article is null)
            return NotFound();

        // Мапінг до DTO
        var dto = MapToDto(article, expansionOptions);

        // Генерація HATEOAS links
        dto.Links = LinkGeneratorHelper.GenerateArticleLinks(
            article.Id,
            article.Status,
            _urlHelper,
            isOwner: true); // У production перевіряти реального користувача

        // Заміна placeholders у links
        dto.Links["author"].Href = dto.Links["author"].Href.Replace("{authorId}", article.AuthorId.ToString());
        dto.Links["category"].Href = dto.Links["category"].Href.Replace("{categoryId}", article.CategoryId.ToString());

        return Ok(dto);
    }

    /// <summary>
    /// Отримати всі статті з пагінацією та HATEOAS
    /// </summary>
    [HttpGet(Name = "GetArticles")]
    [ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
    public async Task<IActionResult> GetAll(
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 10)
    {
        var totalCount = await _db.Articles.CountAsync();
        var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);

        var articles = await _db.Articles
            .OrderByDescending(a => a.PublishedAt)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync();

        var dtos = articles.Select(a =>
        {
            var dto = MapToDto(a, new ExpansionOptions());
            dto.Links = LinkGeneratorHelper.GenerateArticleLinks(
                a.Id,
                a.Status,
                _urlHelper,
                isOwner: true);
            return dto;
        }).ToList();

        var collectionLinks = LinkGeneratorHelper.GenerateCollectionLinks(
            _urlHelper,
            "GetArticles",
            "Articles",
            page,
            totalPages);

        var response = new
        {
            _links = collectionLinks,
            _embedded = new { articles = dtos },
            page,
            pageSize,
            totalCount,
            totalPages
        };

        return Ok(response);
    }

    /// <summary>
    /// Отримати коментарі до статті
    /// </summary>
    [HttpGet("{id:int}/comments", Name = "GetArticleComments")]
    [ProducesResponseType(typeof(List<CommentDto>), StatusCodes.Status200OK)]
    public async Task<ActionResult<List<CommentDto>>> GetComments(int id)
    {
        var article = await _db.Articles
            .Include(a => a.Comments)
            .FirstOrDefaultAsync(a => a.Id == id);

        if (article is null)
            return NotFound();

        var dtos = article.Comments.Select(c => new CommentDto
        {
            Id = c.Id,
            Text = c.Text,
            AuthorName = c.AuthorName,
            CreatedAt = c.CreatedAt,
            Links = new Dictionary<string, Link>
            {
                ["self"] = new Link
                {
                    Href = _urlHelper.Action("GetCommentById", "Comments", new { id = c.Id })!,
                    Rel = "self"
                },
                ["article"] = new Link
                {
                    Href = _urlHelper.Action("GetById", "Articles", new { id = article.Id })!,
                    Rel = "article"
                }
            }
        }).ToList();

        return Ok(dtos);
    }

    /// <summary>
    /// Створити нову статтю
    /// </summary>
    [HttpPost(Name = "CreateArticle")]
    [ProducesResponseType(typeof(ArticleDto), StatusCodes.Status201Created)]
    public async Task<ActionResult<ArticleDto>> Create([FromBody] CreateArticleDto dto)
    {
        var article = new Article
        {
            Title = dto.Title,
            Content = dto.Content,
            AuthorId = dto.AuthorId,
            CategoryId = dto.CategoryId,
            Status = ArticleStatus.Draft
        };

        _db.Articles.Add(article);
        await _db.SaveChangesAsync();

        var resultDto = MapToDto(article, new ExpansionOptions());
        resultDto.Links = LinkGeneratorHelper.GenerateArticleLinks(
            article.Id,
            article.Status,
            _urlHelper,
            isOwner: true);

        return CreatedAtAction(
            nameof(GetById),
            new { id = article.Id },
            resultDto);
    }

    /// <summary>
    /// Опублікувати статтю (змінити статус на Published)
    /// </summary>
    [HttpPost("{id:int}/publish", Name = "PublishArticle")]
    [ProducesResponseType(typeof(ArticleDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<ActionResult<ArticleDto>> Publish(int id)
    {
        var article = await _db.Articles.FindAsync(id);

        if (article is null)
            return NotFound();

        if (article.Status != ArticleStatus.Draft)
            return BadRequest(new ProblemDetails
            {
                Title = "Invalid Operation",
                Detail = "Only draft articles can be published"
            });

        article.Status = ArticleStatus.Published;
        article.PublishedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync();

        var dto = MapToDto(article, new ExpansionOptions());
        dto.Links = LinkGeneratorHelper.GenerateArticleLinks(
            article.Id,
            article.Status,
            _urlHelper,
            isOwner: true);

        return Ok(dto);
    }

    /// <summary>
    /// Архівувати статтю
    /// </summary>
    [HttpPost("{id:int}/archive", Name = "ArchiveArticle")]
    [ProducesResponseType(typeof(ArticleDto), StatusCodes.Status200OK)]
    public async Task<ActionResult<ArticleDto>> Archive(int id)
    {
        var article = await _db.Articles.FindAsync(id);

        if (article is null)
            return NotFound();

        article.Status = ArticleStatus.Archived;
        await _db.SaveChangesAsync();

        var dto = MapToDto(article, new ExpansionOptions());
        dto.Links = LinkGeneratorHelper.GenerateArticleLinks(
            article.Id,
            article.Status,
            _urlHelper,
            isOwner: true);

        return Ok(dto);
    }

    /// <summary>
    /// Оновити статтю
    /// </summary>
    [HttpPut("{id:int}", Name = "UpdateArticle")]
    [ProducesResponseType(typeof(ArticleDto), StatusCodes.Status200OK)]
    public async Task<ActionResult<ArticleDto>> Update(int id, [FromBody] UpdateArticleDto dto)
    {
        var article = await _db.Articles.FindAsync(id);

        if (article is null)
            return NotFound();

        article.Title = dto.Title;
        article.Content = dto.Content;
        article.UpdatedAt = DateTime.UtcNow;

        await _db.SaveChangesAsync();

        var resultDto = MapToDto(article, new ExpansionOptions());
        resultDto.Links = LinkGeneratorHelper.GenerateArticleLinks(
            article.Id,
            article.Status,
            _urlHelper,
            isOwner: true);

        return Ok(resultDto);
    }

    /// <summary>
    /// Видалити статтю
    /// </summary>
    [HttpDelete("{id:int}", Name = "DeleteArticle")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    public async Task<IActionResult> Delete(int id)
    {
        var article = await _db.Articles.FindAsync(id);

        if (article is null)
            return NotFound();

        _db.Articles.Remove(article);
        await _db.SaveChangesAsync();

        return NoContent();
    }

    private ArticleDto MapToDto(Article article, ExpansionOptions options)
    {
        var dto = new ArticleDto
        {
            Id = article.Id,
            Title = article.Title,
            Content = article.Content,
            AuthorId = article.AuthorId,
            CategoryId = article.CategoryId,
            PublishedAt = article.PublishedAt,
            UpdatedAt = article.UpdatedAt,
            Status = article.Status.ToString()
        };

        // Resource expansion
        if (options.IncludeAuthor || options.IncludeCategory || options.IncludeComments)
        {
            dto.Embedded = new EmbeddedResources();

            if (options.IncludeAuthor && article.Author != null)
            {
                dto.Embedded.Author = new AuthorDto
                {
                    Id = article.Author.Id,
                    Name = article.Author.Name,
                    Email = article.Author.Email,
                    Bio = article.Author.Bio,
                    Links = new Dictionary<string, Link>
                    {
                        ["self"] = new Link
                        {
                            Href = _urlHelper.Action("GetById", "Authors", new { id = article.Author.Id })!,
                            Rel = "self"
                        }
                    }
                };
            }

            if (options.IncludeCategory && article.Category != null)
            {
                dto.Embedded.Category = new CategoryDto
                {
                    Id = article.Category.Id,
                    Name = article.Category.Name,
                    Description = article.Category.Description,
                    Links = new Dictionary<string, Link>
                    {
                        ["self"] = new Link
                        {
                            Href = _urlHelper.Action("GetById", "Categories", new { id = article.Category.Id })!,
                            Rel = "self"
                        }
                    }
                };
            }

            if (options.IncludeComments && article.Comments.Any())
            {
                dto.Embedded.Comments = article.Comments.Select(c => new CommentDto
                {
                    Id = c.Id,
                    Text = c.Text,
                    AuthorName = c.AuthorName,
                    CreatedAt = c.CreatedAt
                }).ToList();
            }
        }

        return dto;
    }
}

// DTOs для створення/оновлення
public record CreateArticleDto
{
    public required string Title { get; init; }
    public required string Content { get; init; }
    public int AuthorId { get; init; }
    public int CategoryId { get; init; }
}

public record UpdateArticleDto
{
    public required string Title { get; init; }
    public required string Content { get; init; }
}

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

  1. IUrlHelper injection — через IUrlHelperFactory та IActionContextAccessor
  2. Conditional eager loading — завантажуємо пов'язані дані тільки якщо потрібно
  3. MapToDto — централізована логіка мапінгу з expansion
  4. Conditional links — різні links для draft/published/archived
  5. HAL формат_links та _embedded у відповідях

Крок 7: Program.cs Configuration

using Microsoft.EntityFrameworkCore;
using BlogHateoasApi.Data;

var builder = WebApplication.CreateBuilder(args);

// DbContext
builder.Services.AddDbContext<BlogDbContext>(options =>
    options.UseInMemoryDatabase("BlogDb"));

// IUrlHelper dependencies
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<IActionContextAccessor, ActionContextAccessor>();

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Seed database
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<BlogDbContext>();
    db.Database.EnsureCreated();
}

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Важливо: Реєструємо IActionContextAccessor для доступу до IUrlHelper.


Крок 8: Тестування HATEOAS

bash
$ dotnet run
info: Now listening on: https://localhost:5001
# Тест 1: Базовий запит (без expansion)
$ curl https://localhost:5001/api/articles/1
HTTP/1.1 200 OK
{
"id": 1,
"title": "Introduction to ASP.NET Core",
"authorId": 1,
"_links": {
"self": { "href": "/api/articles/1", "method": "GET" },
"author": { "href": "/api/authors/1", "method": "GET" },
"comments": { "href": "/api/articles/1/comments" },
"archive": { "href": "/api/articles/1/archive", "method": "POST" }
}
}
# Тест 2: Resource expansion (author + comments)
$ curl "https://localhost:5001/api/articles/1?expand=author,comments"
HTTP/1.1 200 OK
{
"id": 1,
"title": "Introduction to ASP.NET Core",
"_embedded": {
"author": {
"id": 1,
"name": "John Doe",
"email": "john@example.com"
},
"comments": [
{ "id": 1, "text": "Great article!", "authorName": "Bob" },
{ "id": 2, "text": "Very helpful!", "authorName": "Alice" }
]
},
"_links": { ... }
}
# Тест 3: Draft article (різні links)
$ curl https://localhost:5001/api/articles/2
HTTP/1.1 200 OK
{
"id": 2,
"status": "Draft",
"_links": {
"self": { "href": "/api/articles/2" },
"publish": { "href": "/api/articles/2/publish", "method": "POST" },
"edit": { "href": "/api/articles/2", "method": "PUT" },
"delete": { "href": "/api/articles/2", "method": "DELETE" }
}
}
# Тест 4: Колекція з пагінацією
$ curl "https://localhost:5001/api/articles?page=1&pageSize=2"
HTTP/1.1 200 OK
{
"_links": {
"self": { "href": "/api/articles?page=1" },
"next": { "href": "/api/articles?page=2" },
"last": { "href": "/api/articles?page=2" }
},
"_embedded": {
"articles": [...]
}
}

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

1. Sparse Fieldsets — Вибіркові поля

Дозволяє клієнту вибирати, які поля повертати:

GET /api/articles/1?fields=id,title,author
→ Повертає тільки id, title, author (без content, publishedAt)

Implementation

public class FieldSelectionHelper
{
    public static object SelectFields<T>(T source, string? fields)
    {
        if (string.IsNullOrWhiteSpace(fields))
            return source!;

        var fieldList = fields.Split(',', StringSplitOptions.RemoveEmptyEntries)
            .Select(f => f.Trim().ToLower())
            .ToHashSet();

        var result = new Dictionary<string, object?>();
        var properties = typeof(T).GetProperties();

        foreach (var prop in properties)
        {
            if (fieldList.Contains(prop.Name.ToLower()))
            {
                result[ToCamelCase(prop.Name)] = prop.GetValue(source);
            }
        }

        return result;
    }

    private static string ToCamelCase(string str)
    {
        if (string.IsNullOrEmpty(str) || char.IsLower(str[0]))
            return str;

        return char.ToLower(str[0]) + str.Substring(1);
    }
}

Використання у контролері

[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(
    int id,
    [FromQuery] string? expand = null,
    [FromQuery] string? fields = null)
{
    var article = await _db.Articles.FindAsync(id);
    if (article is null) return NotFound();

    var dto = MapToDto(article, ExpansionOptions.Parse(expand));
    dto.Links = LinkGeneratorHelper.GenerateArticleLinks(article.Id, article.Status, _urlHelper, true);

    // Застосовуємо field selection
    var result = FieldSelectionHelper.SelectFields(dto, fields);

    return Ok(result);
}

Приклад:

GET /api/articles/1?fields=id,title,_links
{
  "id": 1,
  "title": "Introduction to ASP.NET Core",
  "_links": { ... }
}

Генеруйте links залежно від прав користувача:

public static Dictionary<string, Link> GenerateArticleLinks(
    int articleId,
    ArticleStatus status,
    IUrlHelper urlHelper,
    ClaimsPrincipal user) // Замість bool isOwner
{
    var links = new Dictionary<string, Link>
    {
        ["self"] = new Link { Href = urlHelper.Action("GetById", "Articles", new { id = articleId })! }
    };

    // Тільки автор може редагувати
    if (user.HasClaim("ArticleOwner", articleId.ToString()))
    {
        links["edit"] = new Link
        {
            Href = urlHelper.Action("Update", "Articles", new { id = articleId })!,
            Method = "PUT"
        };
    }

    // Тільки адміни можуть видаляти
    if (user.IsInRole("Admin"))
    {
        links["delete"] = new Link
        {
            Href = urlHelper.Action("Delete", "Articles", new { id = articleId })!,
            Method = "DELETE"
        };
    }

    return links;
}

Результат для звичайного користувача:

{
  "_links": {
    "self": { "href": "/api/articles/1" }
  }
}

Результат для автора:

{
  "_links": {
    "self": { "href": "/api/articles/1" },
    "edit": { "href": "/api/articles/1", "method": "PUT" }
  }
}

Для параметризованих links використовуйте URI Templates:

links["search"] = new Link
{
    Href = "/api/articles{?category,author,page}",
    Templated = true,
    Title = "Search articles"
};
{
  "_links": {
    "search": {
      "href": "/api/articles{?category,author,page}",
      "templated": true,
      "title": "Search articles"
    }
  }
}

Клієнт може підставити параметри:

/api/articles?category=tech&author=john&page=2

4. Curies (Compact URIs)

Для документації links використовуйте curies:

{
  "_links": {
    "self": { "href": "/api/articles/1" },
    "curies": [{
      "name": "doc",
      "href": "https://api.example.com/docs/rels/{rel}",
      "templated": true
    }],
    "doc:author": { "href": "/api/authors/1" },
    "doc:comments": { "href": "/api/articles/1/comments" }
  }
}

Клієнт може отримати документацію:

https://api.example.com/docs/rels/author
→ Документація про rel "author"

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

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

Завдання 1.1: Richardson Maturity Model

Визначте рівень зрілості для кожного API:

API A:

POST /api
{ "action": "getUser", "userId": 5 }

API B:

GET /api/users/5
PUT /api/users/5
DELETE /api/users/5

API C:

{
  "id": 5,
  "name": "John",
  "_links": {
    "self": { "href": "/api/users/5" },
    "edit": { "href": "/api/users/5", "method": "PUT" }
  }
}

Завдання 1.2: HAL формат

Який з цих JSON є валідним HAL форматом?

Варіант A:

{
  "id": 1,
  "links": {
    "self": "/api/articles/1"
  }
}

Варіант B:

{
  "id": 1,
  "_links": {
    "self": { "href": "/api/articles/1" }
  }
}

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

Завдання 2.1: Nested Resource Expansion

Реалізуйте вкладений expansion: ?expand=author.articles:

Завдання 2.2: Expansion Depth Limit

Додайте обмеження глибини expansion для захисту від зловживань:

Завдання 2.3: Conditional Expansion

Дозволяйте expansion тільки для певних ролей:


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

Завдання 3.1: Generic HATEOAS Service

Створіть generic сервіс для додавання HATEOAS links до будь-яких ресурсів:

Завдання 3.2: HATEOAS Action Filter

Створіть Action Filter для автоматичного додавання HATEOAS links:

Завдання 3.3: HAL Browser Integration

Створіть endpoint для HAL Browser (інтерактивна документація):


Резюме

У цій статті ви навчилися реалізовувати HATEOAS та Resource Expansion для Web API:

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

1. Richardson Maturity Model:

  • Level 0 — один endpoint, POST для всього
  • Level 1 — множина ресурсів
  • Level 2 — HTTP verbs (GET, POST, PUT, DELETE)
  • Level 3 — Hypermedia Controls (HATEOAS)

2. HATEOAS переваги:

  • ✅ Decoupling — клієнт не хардкодить URL-и
  • ✅ Evolvability — можна змінювати структуру без ламання клієнтів
  • ✅ Discoverability — API самодокументується
  • ✅ Dynamic behavior — сервер контролює доступні дії

3. HAL (Hypertext Application Language):

  • _links — навігаційні посилання
  • _embedded — вбудовані ресурси
  • self — обов'язкове посилання на ресурс

4. Resource Expansion:

  • Зменшує кількість HTTP-запитів
  • ?expand=author,comments — вбудовує пов'язані ресурси
  • Потребує eager loading для продуктивності

5. Sparse Fieldsets:

  • ?fields=id,title,author — повертає тільки вказані поля
  • Зменшує розмір payload
  • Оптимізує мобільні додатки

Коли використовувати HATEOAS

СценарійРекомендація
Публічний API для сторонніх розробників✅ Так
Складний workflow з багатьма станами✅ Так
API що часто змінюється✅ Так
Простий CRUD API❌ Overkill
Internal API з одним клієнтом❌ Не обов'язково
Mobile-first API (розмір payload критичний)⚠️ З обережністю

Best Practices

Завжди включайте self link
Використовуйте conditional links (залежно від прав та стану)
Документуйте rel types (через curies або окрему документацію)
Обмежуйте глибину expansion (max 2-3 рівні)
Кешуйте згенеровані links
Використовуйте IUrlHelper замість хардкоду URL-ів
Тестуйте links (перевіряйте, що всі URL-и валідні)

Production Checklist:
  • ✅ Conditional links based on permissions
  • ✅ Expansion depth limit (max 2-3)
  • ✅ Sparse fieldsets для оптимізації
  • ✅ Кешування згенерованих links
  • ✅ Документація rel types
  • ✅ HAL Browser для розробників

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


Наступна стаття:Гібридна архітектура: Minimal API + Controllers — як комбінувати Minimal API та Controllers у одному проєкті, vertical slice architecture, feature folders та Carter library.