Web Api

Пагінація, фільтрація та сортування

Практична реалізація PagedList<T>, query-based фільтрація через DTO, dynamic сортування, X-Pagination headers, HATEOAS links та cursor-based пагінація для великих датасетів.

Пагінація, фільтрація та сортування

Вступ: Проблема великих колекцій

Уявіть, що ваш API повертає список продуктів:

[HttpGet]
public async Task<ActionResult<IEnumerable<Product>>> GetAll()
{
    var products = await _db.Products.ToListAsync();
    return Ok(products); // ❌ Проблема!
}

Що не так з цим кодом?

Якщо у базі даних 10,000 продуктів, цей endpoint:

  • ❌ Завантажить всі 10,000 записів у пам'ять
  • ❌ Серіалізує весь масив у JSON (~5-10 MB)
  • ❌ Передасть гігантську відповідь клієнту
  • ❌ Клієнт не зможе швидко відобразити таку кількість даних
  • Неможливо знайти потрібний продукт без пошуку на клієнті

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

GET /api/products
→ Response: 10 MB JSON з 10,000 продуктів
→ Час завантаження: 5-10 секунд
→ Користувач бачить: "Loading..." і чекає
→ Результат: погана UX, високе навантаження на сервер

Що потрібно користувачу насправді?

  • Показати 20 продуктів на сторінці
  • Фільтрувати за категорією та ціною
  • Сортувати за популярністю або ціною
  • Переходити між сторінками

РішенняПагінація, фільтрація та сортування — три ключові техніки для роботи з великими колекціями даних у REST API.

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

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

Ми побудуємо E-commerce Products API з професійною системою пагінації, фільтрації та сортування:

1. Offset-based пагінація:

GET /api/products?page=2&pageSize=20
→ Повертає продукти 21-40 з headers:
X-Pagination: {"currentPage":2,"totalPages":50,"totalCount":1000}

2. Query-based фільтрація:

GET /api/products?category=electronics&minPrice=100&maxPrice=500&inStock=true
→ Повертає тільки електроніку від $100 до $500 в наявності

3. Dynamic сортування:

GET /api/products?sort=price:desc,name:asc
→ Сортує за ціною (спадання), потім за назвою (зростання)

4. HATEOAS links:

{
  "data": [...],
  "_links": {
    "self": "/api/products?page=2",
    "first": "/api/products?page=1",
    "prev": "/api/products?page=1",
    "next": "/api/products?page=3",
    "last": "/api/products?page=50"
  }
}

5. Cursor-based пагінація (для real-time даних):

GET /api/products?cursor=eyJpZCI6MTAwfQ==&limit=20
→ Повертає 20 продуктів після курсора

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

  • Реалізувати PagedList<T> generic клас
  • Створювати query-based фільтри через DTO
  • Використовувати dynamic сортування
  • Додавати HATEOAS links до пагінованих відповідей
  • Вибирати між offset-based та cursor-based пагінацією

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

Три стовпи роботи з колекціями

Loading diagram...
graph TD
    A[Велика колекція даних<br/>10,000 продуктів] --> B[Фільтрація]
    B --> C[Сортування]
    C --> D[Пагінація]
    D --> E[Результат<br/>20 продуктів]
    
    B -.->|WHERE category = 'electronics'<br/>AND price BETWEEN 100 AND 500| B1[1,500 продуктів]
    C -.->|ORDER BY price DESC, name ASC| C1[1,500 відсортованих]
    D -.->|SKIP 20 TAKE 20| D1[20 продуктів<br/>сторінка 2]
    
    style A fill:#ef4444,stroke:#b91c1c,color:#ffffff
    style E fill:#10b981,stroke:#059669,color:#ffffff
    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style C fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style D fill:#8b5cf6,stroke:#6d28d9,color:#ffffff

Порядок виконання критично важливий:

  1. Фільтрація — зменшуємо датасет (WHERE у SQL)
  2. Сортування — впорядковуємо результати (ORDER BY у SQL)
  3. Пагінація — беремо частину (SKIP/TAKE у LINQ, OFFSET/LIMIT у SQL)
Антипатерн: Завантажити всі дані у пам'ять, потім фільтрувати/сортувати/пагінувати на C#. Це призведе до OutOfMemoryException на великих датасетах. Завжди використовуйте LINQ для побудови SQL-запиту.

Пагінація: Offset-based vs Cursor-based

ХарактеристикаOffset-basedCursor-based
Синтаксис?page=2&pageSize=20?cursor=abc123&limit=20
SQLOFFSET 20 LIMIT 20WHERE id > 100 LIMIT 20
ПеревагиПростота, можна перейти на будь-яку сторінкуСтабільність, продуктивність
НедолікиПроблеми з real-time данимиНеможливо перейти на довільну сторінку
ВикористанняСтатичні списки, адмін-панеліСтрічки новин, чати, логи

Приклад проблеми offset-based:

Початковий стан (10 продуктів):
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Користувач на сторінці 1 (продукти 1-5):
[1, 2, 3, 4, 5]

Хтось видаляє продукт #3:
[1, 2, 4, 5, 6, 7, 8, 9, 10]

Користувач переходить на сторінку 2 (продукти 6-10):
[6, 7, 8, 9, 10]

❌ Продукти 4 та 5 пропущені!

Cursor-based вирішує це:

Користувач на сторінці 1 (після курсора 0):
[1, 2, 3, 4, 5] → курсор = 5

Хтось видаляє продукт #3:
[1, 2, 4, 5, 6, 7, 8, 9, 10]

Користувач переходить на наступну сторінку (після курсора 5):
[6, 7, 8, 9, 10]

✅ Жодних пропусків!

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

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

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

bash
$ dotnet new webapi -n ProductsPaginationApi
The template "ASP.NET Core Web API" was created successfully.
$ cd ProductsPaginationApi
$ dotnet add package Microsoft.EntityFrameworkCore.InMemory
info : PackageReference added successfully
$ dotnet add package System.Linq.Dynamic.Core
info : PackageReference added successfully
System.Linq.Dynamic.Core — бібліотека для dynamic сортування через строкові вирази ("price desc, name asc").

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

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

namespace ProductsPaginationApi.Models;

public class Product
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required string Category { get; set; }
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public bool InStock => Stock > 0;
    public double Rating { get; set; }
    public int ReviewCount { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

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

  • Id — первинний ключ для cursor-based пагінації
  • Category — для фільтрації за категорією
  • Price — для фільтрації за діапазоном цін та сортування
  • Stock — для фільтрації "в наявності"
  • InStock — computed property для зручності
  • Rating, ReviewCount — для сортування за популярністю
  • CreatedAt — для сортування за датою додавання

Крок 2: Pagination Infrastructure

1. PaginationFilter — Query Parameters DTO

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

namespace ProductsPaginationApi.Models;

public class PaginationFilter
{
    private const int MaxPageSize = 100;
    private int _pageSize = 20;

    public int Page { get; set; } = 1;

    public int PageSize
    {
        get => _pageSize;
        set => _pageSize = value > MaxPageSize ? MaxPageSize : value;
    }

    // Computed properties для LINQ
    public int Skip => (Page - 1) * PageSize;
    public int Take => PageSize;
}

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

  1. MaxPageSize — захист від зловживань (користувач не може запитати 1,000,000 записів)
  2. _pageSize — backing field для валідації
  3. PageSize setter — автоматично обмежує до MaxPageSize
  4. Skip — скільки записів пропустити (OFFSET у SQL)
  5. Take — скільки записів взяти (LIMIT у SQL)

Приклад:

Page = 3, PageSize = 20
→ Skip = (3 - 1) * 20 = 40
→ Take = 20
→ SQL: OFFSET 40 LIMIT 20

2. PagedList — Generic Wrapper

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

namespace ProductsPaginationApi.Models;

public class PagedList<T>
{
    public List<T> Items { get; }
    public int CurrentPage { get; }
    public int TotalPages { get; }
    public int PageSize { get; }
    public int TotalCount { get; }
    
    public bool HasPrevious => CurrentPage > 1;
    public bool HasNext => CurrentPage < TotalPages;

    public PagedList(List<T> items, int count, int page, int pageSize)
    {
        Items = items;
        TotalCount = count;
        CurrentPage = page;
        PageSize = pageSize;
        TotalPages = (int)Math.Ceiling(count / (double)pageSize);
    }

    public static PagedList<T> Create(IQueryable<T> source, PaginationFilter filter)
    {
        var count = source.Count(); // SQL: SELECT COUNT(*)
        var items = source
            .Skip(filter.Skip)
            .Take(filter.Take)
            .ToList(); // SQL: OFFSET X LIMIT Y

        return new PagedList<T>(items, count, filter.Page, filter.PageSize);
    }
}

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

  1. Items — фактичні дані сторінки
  2. TotalCount — загальна кількість записів (для UI: "Показано 21-40 з 1000")
  3. TotalPages — кількість сторінок (Math.Ceiling для округлення вгору)
  4. HasPrevious, HasNext — для UI кнопок навігації
  5. Create метод — factory для створення з IQueryable (виконує SQL)
Важливо:Create приймає IQueryable<T>, а не List<T>. Це дозволяє Entity Framework побудувати оптимальний SQL-запит з COUNT(*) та OFFSET/LIMIT.

3. PaginationMetadata — Response Headers

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

namespace ProductsPaginationApi.Models;

public class PaginationMetadata
{
    public int CurrentPage { get; set; }
    public int TotalPages { get; set; }
    public int PageSize { get; set; }
    public int TotalCount { get; set; }
    public bool HasPrevious { get; set; }
    public bool HasNext { get; set; }
}

Це DTO для серіалізації у X-Pagination header.


Крок 3: Filtering Infrastructure

ProductFilter — Query Parameters для фільтрації

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

namespace ProductsPaginationApi.Models;

public class ProductFilter : PaginationFilter
{
    // Фільтрація за категорією
    public string? Category { get; set; }

    // Фільтрація за ціною
    public decimal? MinPrice { get; set; }
    public decimal? MaxPrice { get; set; }

    // Фільтрація за наявністю
    public bool? InStock { get; set; }

    // Фільтрація за рейтингом
    public double? MinRating { get; set; }

    // Пошук за назвою
    public string? Search { get; set; }

    // Сортування
    public string? Sort { get; set; } = "id:asc"; // За замовчуванням
}

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

  1. Наслідування від PaginationFilter — отримуємо Page та PageSize
  2. Nullable типиnull означає "не фільтрувати за цим параметром"
  3. Category — точна відповідність (case-insensitive)
  4. MinPrice, MaxPrice — діапазон цін
  5. InStock — булева фільтрація
  6. Search — пошук за назвою (LIKE у SQL)
  7. Sort — строка формату "field:direction,field:direction"

Приклад запиту:

GET /api/products?category=electronics&minPrice=100&maxPrice=500&inStock=true&sort=price:desc&page=2&pageSize=20

Крок 4: Sorting Infrastructure

SortingHelper — Dynamic Sorting

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

using System.Linq.Dynamic.Core;

namespace ProductsPaginationApi.Helpers;

public static class SortingHelper
{
    public static IQueryable<T> ApplySort<T>(
        this IQueryable<T> source,
        string? sortExpression)
    {
        if (string.IsNullOrWhiteSpace(sortExpression))
            return source;

        // Парсимо "price:desc,name:asc" → ["price desc", "name asc"]
        var sortParts = sortExpression
            .Split(',', StringSplitOptions.RemoveEmptyEntries)
            .Select(part => part.Trim())
            .Select(part =>
            {
                var tokens = part.Split(':', StringSplitOptions.RemoveEmptyEntries);
                var field = tokens[0].Trim();
                var direction = tokens.Length > 1 && tokens[1].Trim().ToLower() == "desc"
                    ? "desc"
                    : "asc";
                return $"{field} {direction}";
            });

        var sortString = string.Join(", ", sortParts);

        // Використовуємо System.Linq.Dynamic.Core для dynamic OrderBy
        return source.OrderBy(sortString);
    }
}

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

  1. Extension method — викликається як query.ApplySort("price:desc")
  2. Парсинг — розбиваємо строку на частини
  3. Валідація напрямку — тільки asc або desc
  4. OrderBy(string) — з бібліотеки System.Linq.Dynamic.Core

Приклад:

var query = _db.Products.AsQueryable();
query = query.ApplySort("price:desc,name:asc");
// SQL: ORDER BY price DESC, name ASC
Безпека: У production додайте whitelist дозволених полів для сортування, щоб уникнути SQL injection через dynamic LINQ.

LinkGenerator Helper

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

using Microsoft.AspNetCore.WebUtilities;
using ProductsPaginationApi.Models;

namespace ProductsPaginationApi.Helpers;

public static class PaginationLinksHelper
{
    public static Dictionary<string, string> GenerateLinks(
        HttpRequest request,
        PagedList<object> pagedList)
    {
        var baseUrl = $"{request.Scheme}://{request.Host}{request.Path}";
        var queryParams = request.Query.ToDictionary(
            kvp => kvp.Key,
            kvp => kvp.Value.ToString());

        var links = new Dictionary<string, string>
        {
            ["self"] = BuildUrl(baseUrl, queryParams, pagedList.CurrentPage)
        };

        if (pagedList.HasPrevious)
        {
            links["first"] = BuildUrl(baseUrl, queryParams, 1);
            links["prev"] = BuildUrl(baseUrl, queryParams, pagedList.CurrentPage - 1);
        }

        if (pagedList.HasNext)
        {
            links["next"] = BuildUrl(baseUrl, queryParams, pagedList.CurrentPage + 1);
            links["last"] = BuildUrl(baseUrl, queryParams, pagedList.TotalPages);
        }

        return links;
    }

    private static string BuildUrl(
        string baseUrl,
        Dictionary<string, string> queryParams,
        int page)
    {
        queryParams["page"] = page.ToString();
        return QueryHelpers.AddQueryString(baseUrl, queryParams!);
    }
}

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

  1. baseUrl — схема + хост + шлях (https://api.example.com/api/products)
  2. queryParams — всі існуючі query параметри (фільтри, сортування)
  3. self — посилання на поточну сторінку
  4. first, prev — тільки якщо є попередня сторінка
  5. next, last — тільки якщо є наступна сторінка
  6. QueryHelpers.AddQueryString — правильно кодує URL

Результат:

{
  "_links": {
    "self": "/api/products?category=electronics&page=2&pageSize=20",
    "first": "/api/products?category=electronics&page=1&pageSize=20",
    "prev": "/api/products?category=electronics&page=1&pageSize=20",
    "next": "/api/products?category=electronics&page=3&pageSize=20",
    "last": "/api/products?category=electronics&page=50&pageSize=20"
  }
}

Крок 6: DbContext та Seed Data

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

using Microsoft.EntityFrameworkCore;
using ProductsPaginationApi.Models;

namespace ProductsPaginationApi.Data;

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

    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Seed 1000 продуктів для тестування
        var products = new List<Product>();
        var categories = new[] { "Electronics", "Clothing", "Books", "Home", "Sports" };
        var random = new Random(42); // Фіксований seed для відтворюваності

        for (int i = 1; i <= 1000; i++)
        {
            products.Add(new Product
            {
                Id = i,
                Name = $"Product {i}",
                Category = categories[random.Next(categories.Length)],
                Price = Math.Round((decimal)(random.NextDouble() * 1000 + 10), 2),
                Stock = random.Next(0, 100),
                Rating = Math.Round(random.NextDouble() * 5, 1),
                ReviewCount = random.Next(0, 500),
                CreatedAt = DateTime.UtcNow.AddDays(-random.Next(0, 365))
            });
        }

        modelBuilder.Entity<Product>().HasData(products);
    }
}

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

  1. 1000 продуктів — достатньо для тестування пагінації (50 сторінок по 20)
  2. 5 категорій — для тестування фільтрації
  3. Випадкові ціни — від $10 до $1010
  4. Випадковий stock — деякі продукти не в наявності
  5. Фіксований seed (42) — однакові дані при кожному запуску

Крок 7: Products Controller

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

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductsPaginationApi.Data;
using ProductsPaginationApi.Models;
using ProductsPaginationApi.Helpers;
using System.Text.Json;

namespace ProductsPaginationApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly ProductDbContext _db;
    private readonly ILogger<ProductsController> _logger;

    public ProductsController(ProductDbContext db, ILogger<ProductsController> logger)
    {
        _db = db;
        _logger = logger;
    }

    /// <summary>
    /// Отримати список продуктів з пагінацією, фільтрацією та сортуванням
    /// </summary>
    /// <param name="filter">Параметри фільтрації, сортування та пагінації</param>
    [HttpGet]
    [ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
    public async Task<IActionResult> GetAll([FromQuery] ProductFilter filter)
    {
        _logger.LogInformation(
            "Fetching products: Page={Page}, PageSize={PageSize}, Category={Category}, Sort={Sort}",
            filter.Page,
            filter.PageSize,
            filter.Category ?? "all",
            filter.Sort);

        // Починаємо з базового query
        var query = _db.Products.AsQueryable();

        // 1. ФІЛЬТРАЦІЯ
        query = ApplyFilters(query, filter);

        // 2. СОРТУВАННЯ
        query = query.ApplySort(filter.Sort);

        // 3. ПАГІНАЦІЯ
        var pagedList = PagedList<Product>.Create(query, filter);

        // 4. METADATA у headers
        var metadata = new PaginationMetadata
        {
            CurrentPage = pagedList.CurrentPage,
            TotalPages = pagedList.TotalPages,
            PageSize = pagedList.PageSize,
            TotalCount = pagedList.TotalCount,
            HasPrevious = pagedList.HasPrevious,
            HasNext = pagedList.HasNext
        };

        Response.Headers.Append("X-Pagination", JsonSerializer.Serialize(metadata));

        // 5. HATEOAS LINKS
        var pagedListAsObject = new PagedList<object>(
            pagedList.Items.Cast<object>().ToList(),
            pagedList.TotalCount,
            pagedList.CurrentPage,
            pagedList.PageSize);

        var links = PaginationLinksHelper.GenerateLinks(Request, pagedListAsObject);

        // 6. RESPONSE
        var response = new
        {
            data = pagedList.Items,
            pagination = metadata,
            _links = links
        };

        return Ok(response);
    }

    private IQueryable<Product> ApplyFilters(IQueryable<Product> query, ProductFilter filter)
    {
        // Фільтр за категорією
        if (!string.IsNullOrWhiteSpace(filter.Category))
        {
            query = query.Where(p => p.Category.ToLower() == filter.Category.ToLower());
        }

        // Фільтр за ціною (діапазон)
        if (filter.MinPrice.HasValue)
        {
            query = query.Where(p => p.Price >= filter.MinPrice.Value);
        }

        if (filter.MaxPrice.HasValue)
        {
            query = query.Where(p => p.Price <= filter.MaxPrice.Value);
        }

        // Фільтр за наявністю
        if (filter.InStock.HasValue)
        {
            if (filter.InStock.Value)
            {
                query = query.Where(p => p.Stock > 0);
            }
            else
            {
                query = query.Where(p => p.Stock == 0);
            }
        }

        // Фільтр за рейтингом
        if (filter.MinRating.HasValue)
        {
            query = query.Where(p => p.Rating >= filter.MinRating.Value);
        }

        // Пошук за назвою
        if (!string.IsNullOrWhiteSpace(filter.Search))
        {
            query = query.Where(p => p.Name.ToLower().Contains(filter.Search.ToLower()));
        }

        return query;
    }

    /// <summary>
    /// Отримати продукт за ID
    /// </summary>
    [HttpGet("{id:int}")]
    [ProducesResponseType(typeof(Product), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<Product>> GetById(int id)
    {
        var product = await _db.Products.FindAsync(id);

        if (product is null)
            return NotFound();

        return Ok(product);
    }
}

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

  1. [FromQuery] — автоматично біндить query параметри до ProductFilter
  2. Порядок операцій — фільтрація → сортування → пагінація
  3. X-Pagination header — метадані для клієнта
  4. HATEOAS links — навігаційні посилання
  5. Structured responsedata, pagination, _links
  6. ApplyFilters — окремий метод для читабельності
Best Practice: Завжди логуйте параметри пагінації та фільтрації для моніторингу та дебагу.

Крок 8: Program.cs Configuration

using Microsoft.EntityFrameworkCore;
using ProductsPaginationApi.Data;

var builder = WebApplication.CreateBuilder(args);

// DbContext
builder.Services.AddDbContext<ProductDbContext>(options =>
    options.UseInMemoryDatabase("ProductsDb"));

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<ProductDbContext>();
    db.Database.EnsureCreated();
}

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

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

app.Run();

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

bash
$ dotnet run
info: Now listening on: https://localhost:5001
# Тест 1: Базова пагінація
$ curl "https://localhost:5001/api/products?page=1&pageSize=5"
HTTP/1.1 200 OK
X-Pagination: {"currentPage":1,"totalPages":200,"pageSize":5,"totalCount":1000}
{
"data": [
{ "id": 1, "name": "Product 1", "price": 123.45 },
{ "id": 2, "name": "Product 2", "price": 234.56 },
...
],
"_links": {
"self": "/api/products?page=1&pageSize=5",
"next": "/api/products?page=2&pageSize=5",
"last": "/api/products?page=200&pageSize=5"
}
}
# Тест 2: Фільтрація за категорією
$ curl "https://localhost:5001/api/products?category=electronics&page=1&pageSize=10"
HTTP/1.1 200 OK
X-Pagination: {"currentPage":1,"totalPages":20,"pageSize":10,"totalCount":200}
# Тест 3: Фільтрація за ціною + сортування
$ curl "https://localhost:5001/api/products?minPrice=100&maxPrice=500&sort=price:desc"
HTTP/1.1 200 OK
# Повертає продукти від $100 до $500, відсортовані за ціною (спадання)
# Тест 4: Комбінована фільтрація
$ curl "https://localhost:5001/api/products?category=electronics&minPrice=200&inStock=true&sort=rating:desc,price:asc"
HTTP/1.1 200 OK
# Електроніка від $200, в наявності, сортування: рейтинг↓, ціна↑

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

1. Cursor-Based Pagination

Для real-time даних (чати, стрічки новин) offset-based пагінація не підходить. Використовуйте cursor-based:

CursorPaginationFilter

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

namespace ProductsPaginationApi.Models;

public class CursorPaginationFilter
{
    public string? Cursor { get; set; } // Base64-encoded ID
    public int Limit { get; set; } = 20;
    private const int MaxLimit = 100;

    public int GetLimit() => Limit > MaxLimit ? MaxLimit : Limit;

    public int? DecodeCursor()
    {
        if (string.IsNullOrWhiteSpace(Cursor))
            return null;

        try
        {
            var bytes = Convert.FromBase64String(Cursor);
            var json = System.Text.Encoding.UTF8.GetString(bytes);
            var data = System.Text.Json.JsonSerializer.Deserialize<CursorData>(json);
            return data?.Id;
        }
        catch
        {
            return null;
        }
    }

    public static string EncodeCursor(int id)
    {
        var data = new CursorData { Id = id };
        var json = System.Text.Json.JsonSerializer.Serialize(data);
        var bytes = System.Text.Encoding.UTF8.GetBytes(json);
        return Convert.ToBase64String(bytes);
    }

    private record CursorData
    {
        public int Id { get; init; }
    }
}

Controller Method

[HttpGet("cursor")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
public async Task<IActionResult> GetWithCursor([FromQuery] CursorPaginationFilter filter)
{
    var query = _db.Products.AsQueryable();

    // Застосовуємо курсор (якщо є)
    var cursorId = filter.DecodeCursor();
    if (cursorId.HasValue)
    {
        query = query.Where(p => p.Id > cursorId.Value);
    }

    // Беремо limit + 1 для визначення hasNext
    var limit = filter.GetLimit();
    var products = await query
        .OrderBy(p => p.Id) // Важливо: стабільне сортування
        .Take(limit + 1)
        .ToListAsync();

    var hasNext = products.Count > limit;
    if (hasNext)
    {
        products = products.Take(limit).ToList();
    }

    var nextCursor = hasNext && products.Any()
        ? CursorPaginationFilter.EncodeCursor(products.Last().Id)
        : null;

    var response = new
    {
        data = products,
        pagination = new
        {
            limit,
            hasNext,
            nextCursor
        }
    };

    return Ok(response);
}

Приклад використання:

GET /api/products/cursor?limit=20
→ Повертає перші 20 продуктів + nextCursor

GET /api/products/cursor?cursor=eyJpZCI6MjB9&limit=20
→ Повертає наступні 20 продуктів після ID=20

Переваги:

✅ Стабільність при змінах даних
✅ Висока продуктивність (індекс на ID)
✅ Підходить для infinite scroll

Недоліки:

❌ Неможливо перейти на довільну сторінку
❌ Складніша реалізація


2. Field Selection (Sparse Fieldsets)

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

GET /api/products?fields=id,name,price
→ Повертає тільки id, name, price (без category, stock, rating)

Implementation

public class FieldSelectionFilter
{
    public string? Fields { get; set; }

    public List<string> GetFields()
    {
        if (string.IsNullOrWhiteSpace(Fields))
            return new List<string>();

        return Fields
            .Split(',', StringSplitOptions.RemoveEmptyEntries)
            .Select(f => f.Trim().ToLower())
            .ToList();
    }
}
[HttpGet("sparse")]
public async Task<IActionResult> GetWithFieldSelection(
    [FromQuery] ProductFilter filter,
    [FromQuery] FieldSelectionFilter fieldFilter)
{
    var query = _db.Products.AsQueryable();
    query = ApplyFilters(query, filter);
    query = query.ApplySort(filter.Sort);

    var pagedList = PagedList<Product>.Create(query, filter);

    // Застосовуємо field selection
    var fields = fieldFilter.GetFields();
    if (fields.Any())
    {
        var selectedData = pagedList.Items.Select(p => SelectFields(p, fields));
        return Ok(new { data = selectedData });
    }

    return Ok(new { data = pagedList.Items });
}

private object SelectFields(Product product, List<string> fields)
{
    var result = new Dictionary<string, object?>();

    foreach (var field in fields)
    {
        switch (field)
        {
            case "id": result["id"] = product.Id; break;
            case "name": result["name"] = product.Name; break;
            case "price": result["price"] = product.Price; break;
            case "category": result["category"] = product.Category; break;
            case "stock": result["stock"] = product.Stock; break;
            case "rating": result["rating"] = product.Rating; break;
        }
    }

    return result;
}
У production використовуйте бібліотеки типу AutoMapper або System.Linq.Dynamic.Core для dynamic projection.

3. Aggregations та Facets

Додайте агрегації для фільтрів (як у e-commerce):

[HttpGet("facets")]
public async Task<IActionResult> GetFacets()
{
    var facets = new
    {
        categories = await _db.Products
            .GroupBy(p => p.Category)
            .Select(g => new { category = g.Key, count = g.Count() })
            .ToListAsync(),

        priceRanges = new[]
        {
            new { range = "0-100", count = await _db.Products.CountAsync(p => p.Price < 100) },
            new { range = "100-500", count = await _db.Products.CountAsync(p => p.Price >= 100 && p.Price < 500) },
            new { range = "500+", count = await _db.Products.CountAsync(p => p.Price >= 500) }
        },

        availability = new
        {
            inStock = await _db.Products.CountAsync(p => p.Stock > 0),
            outOfStock = await _db.Products.CountAsync(p => p.Stock == 0)
        }
    };

    return Ok(facets);
}

Результат:

{
  "categories": [
    { "category": "Electronics", "count": 200 },
    { "category": "Clothing", "count": 180 },
    { "category": "Books", "count": 220 }
  ],
  "priceRanges": [
    { "range": "0-100", "count": 150 },
    { "range": "100-500", "count": 600 },
    { "range": "500+", "count": 250 }
  ],
  "availability": {
    "inStock": 850,
    "outOfStock": 150
  }
}

Це дозволяє клієнту показувати UI фільтрів з кількістю результатів.


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

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

Завдання 1.1: Розрахунок пагінації

Дано: 1,247 продуктів, pageSize = 25. Розрахуйте:

  1. Скільки буде сторінок?
  2. Скільки продуктів на останній сторінці?
  3. Які значення Skip та Take для сторінки 3?

Завдання 1.2: Порядок операцій

У якому порядку мають виконуватися операції для оптимальної продуктивності?

A) Пагінація → Фільтрація → Сортування
B) Сортування → Фільтрація → Пагінація
C) Фільтрація → Сортування → Пагінація
D) Фільтрація → Пагінація → Сортування


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

Завдання 2.1: Реалізація Search Filter

Додайте повнотекстовий пошук, що шукає у Name та Category:

::

Завдання 2.2: Whitelist для сортування

Додайте валідацію дозволених полів для сортування (безпека):

Завдання 2.3: Default Sorting

Додайте сортування за замовчуванням, якщо клієнт не вказав sort:

::


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

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

Створіть generic сервіс для пагінації будь-яких сутностей:

Завдання 3.2: Advanced Filtering з Expression Trees

Створіть систему динамічної фільтрації через expression trees:

Завдання 3.3: Pagination Response Filter

Створіть Result Filter для автоматичного додавання пагінаційних headers та HATEOAS links:


Резюме

У цій статті ви навчилися реалізовувати пагінацію, фільтрацію та сортування для Web API:

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

1. Три стовпи роботи з колекціями:

  • Фільтрація — зменшення датасету (WHERE)
  • Сортування — впорядкування результатів (ORDER BY)
  • Пагінація — розбиття на сторінки (OFFSET/LIMIT)

2. Типи пагінації:

  • Offset-based?page=2&pageSize=20 (простота, довільні сторінки)
  • Cursor-based?cursor=abc&limit=20 (стабільність, продуктивність)

3. Infrastructure компоненти:

  • PaginationFilter — query parameters DTO
  • PagedList<T> — generic wrapper з метаданими
  • SortingHelper — dynamic сортування через LINQ
  • PaginationLinksHelper — HATEOAS links генератор

4. Best Practices:

  • Завжди виконуйте фільтрацію/сортування/пагінацію на рівні БД (IQueryable)
  • Додавайте X-Pagination header з метаданими
  • Використовуйте HATEOAS links для навігації
  • Валідуйте pageSize (max limit)
  • Whitelist дозволених полів для сортування
  • Логуйте параметри пагінації для моніторингу

Порівняння підходів

ХарактеристикаOffset-basedCursor-based
СкладністьПростаСередня
ПродуктивністьПогіршується на великих offsetСтабільна
СтабільністьПроблеми з real-time данимиСтабільна
НавігаціяДовільні сторінкиТільки next/prev
ВикористанняАдмін-панелі, каталогиСтрічки, чати, логи

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

СценарійРішення
Каталог продуктівOffset-based + фільтри + сортування
Стрічка новинCursor-based
Адмін-панельOffset-based + пошук
Чат/коментаріCursor-based
Логи/подіїCursor-based
ЗвітиOffset-based + експорт
Production Checklist:
  • ✅ Валідація pageSize (max 100)
  • ✅ Whitelist для сортування
  • ✅ Індекси на поля фільтрації та сортування
  • ✅ Кешування для популярних запитів
  • ✅ Rate limiting для захисту від зловживань
  • ✅ Логування параметрів пагінації

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

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

REST API Best Practices

Cursor Pagination


Наступна стаття:HATEOAS та Resource Expansion — Hypermedia as the Engine of Application State, HAL формат, LinkGenerator, resource expansion через ?expand=author,comments та sparse fieldsets.