Yükleniyor...

ASP.NET Core Web API’de Temiz Pagination (Header + Link)

Response body’de yalnızca öğeleri döndürün, pagination metadatasını header’lara taşıyın. RFC 5988 Link header ile next/prev sayfalarını yönetin.

Bir REST API geliştirirken sayfalama (pagination) bilgileri çoğu zaman response body’nin içine eklenir. Bu yöntem çalışır; ancak veri ile gezinti metadatasını aynı payload içinde karıştırır. Daha temiz bir yaklaşım, response body’yi yalnızca öğelere odaklamak ve sayfalama bilgisini HTTP header’larına taşımaktır.

Bu örnekte API, müşterileri basit bir JSON dizi olarak döndürürken sayfalama metadatasını X-Total-Count, X-Page, X-Page-Size, X-Total-Pages ve next/prev gezinimi için RFC 5988 Link header’ı ile sunar.


Amaç


Controller Örneği (Body’de Öğeler, Header’da Metadata)


using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;

[HttpGet("api/customers")]
public async Task<ActionResult<IEnumerable<Customer>>> GetCustomers(
    [FromQuery] int page = 1,
    [FromQuery] int pageSize = 20)
{
    // Sunucu tarafı limitler (kötüye kullanımı önler)
    page = Math.Max(1, page);
    pageSize = Math.Clamp(pageSize, 1, 100);

    // Servis: items + totalCount döndürür
    var (items, totalCount) = await _customerService.ListPagedAsync(page, pageSize);

    // toplam sayfa (totalCount = 0 olduğunda 0 olabilir)
    var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);

    // Sayfalama metadatası header'larda
    Response.Headers["X-Total-Count"] = totalCount.ToString();
    Response.Headers["X-Page"] = page.ToString();
    Response.Headers["X-Page-Size"] = pageSize.ToString();
    Response.Headers["X-Total-Pages"] = totalPages.ToString();

    // Link header: next/prev (RFC 5988)
    var links = BuildLinkHeader(page, pageSize, totalPages);

    if (!string.IsNullOrWhiteSpace(links))
        Response.Headers["Link"] = links;

    return Ok(items);
}

// Şöyle Link header üretir:
// <https://host/api/customers?page=1&pageSize=20&sort=name>; rel="prev", <...>; rel="next"
private string BuildLinkHeader(int page, int pageSize, int totalPages)
{
    // Sayfa yoksa (totalCount = 0) veya tek sayfaysa gezinim linki gerekmez.
    if (totalPages <= 1)
        return string.Empty;

    var baseUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}";

    // Var olan query parametrelerini (filter/sort/search...) koru
    var query = Request.Query
        .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToString());

    // Mevcut pageSize'ı zorunlu/uyumlu tut
    query["pageSize"] = pageSize.ToString();

    var parts = new List<string>();

    if (page > 1)
    {
        query["page"] = (page - 1).ToString();
        var prevUrl = QueryHelpers.AddQueryString(baseUrl, query);
        parts.Add($"<{prevUrl}>; rel=\"prev\"");
    }

    if (page < totalPages)
    {
        query["page"] = (page + 1).ToString();
        var nextUrl = QueryHelpers.AddQueryString(baseUrl, query);
        parts.Add($"<{nextUrl}>; rel=\"next\"");
    }

    return string.Join(", ", parts);
}

// Opsiyonel (örneğin copy/paste ile çalışması için)
public sealed record Customer(string Name);

Response body minimal kalır (sadece items), istemci ise sayfalama metadatasını header’lardan okur. Ayrıca sayfalama linkleri, mevcut query parametrelerini (sort, filter, search) koruduğu için gezinim her zaman tutarlı olur.


Örnek Response Body


[
  { "name": "John" },
  { "name": "Hans" },
  { "name": "Alex" },
  { "name": "Pearl" }
]

Örnek Response Header’ları


X-Total-Count: 542
X-Page: 2
X-Page-Size: 20
X-Total-Pages: 28
Link: <https://api.site.com/api/customers?page=1&pageSize=20&sort=name>; rel="prev",
      <https://api.site.com/api/customers?page=3&pageSize=20&sort=name>; rel="next"

Bu örnekte sort=name (ve diğer query parametreleri) üretilen Link URL’lerinde korunur.


Fetch Örneği (JavaScript)


const response = await fetch(`/api/customers?page=${page}&pageSize=${pageSize}&sort=name`);

if (!response.ok) {
  throw new Error(`HTTP error! Status: ${response.status}`);
}

const items = await response.json();

// Header'lardan sayfalama metadatası
const totalCount  = Number(response.headers.get("X-Total-Count"));
const currentPage = Number(response.headers.get("X-Page"));
const pageSize    = Number(response.headers.get("X-Page-Size"));
const totalPages  = Number(response.headers.get("X-Total-Pages"));

// Link header ile gezinim (next/prev)
const linkHeader = response.headers.get("Link");

// Link header'ı { next, prev } şeklinde parse edin
const links = parseLinkHeader(linkHeader);
console.log(links.next, links.prev);

function parseLinkHeader(value) {
  if (!value) return {};

  // Örnek:
  // <https://...page=1&pageSize=20>; rel="prev", <https://...page=3&pageSize=20>; rel="next"
  const result = {};
  const parts = value.split(",");

  for (const part of parts) {
    const section = part.trim();
    const urlMatch = section.match(/<([^>]+)>/);
    const relMatch = section.match(/rel="([^"]+)"/);

    if (!urlMatch || !relMatch) continue;

    const url = urlMatch[1];
    const rel = relMatch[1];

    result[rel] = url;
  }

  return result;
}

Küçük bir parseLinkHeader yardımcı fonksiyonu sayesinde UI tarafında, response body’ye metadata eklemeden links.next ve links.prev üzerinden sayfa gezintisi yapılabilir.


CORS Notu (Özel Header’ları Okumak)

API ve frontend farklı domainlerde çalışıyorsa, tarayıcı CORS nedeniyle JavaScript tarafında tüm response header’larını okumaya izin vermez. Bu durumda response.headers.get("X-Total-Count") gibi çağrılar null dönebilir.

Bu header’ları okuyabilmek için API tarafında CORS ayarına Access-Control-Expose-Headers eklenmelidir. Aşağıdaki örnek, ASP.NET Core Program.cs içinde CORS policy tanımlar ve uygular:


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCors(options =>
{
    options.AddPolicy("Frontend", policy =>
        policy.WithOrigins("https://frontend.site.com")
              .AllowAnyMethod()
              .AllowAnyHeader()
              .WithExposedHeaders(
                  "X-Total-Count",
                  "X-Page",
                  "X-Page-Size",
                  "X-Total-Pages",
                  "Link"));
});

var app = builder.Build();

// ... diğer middleware'ler (ör. UseRouting)

// CORS'u endpoint'lerden önce uygula
app.UseCors("Frontend");

// ... UseAuthentication / UseAuthorization vb.

app.MapControllers();
app.Run();

Böylece pagination metadatası header’ları JavaScript tarafında okunabilir hale gelir.


Neden Link Header?

Link header, sayfalama gezinimini standart bir şekilde keşfedilebilir hale getirir:


Yaygın İyileştirmeler


TL;DR

  • Response body’de sadece öğeleri döndürün.
  • Sayfalama metadatasını header’larda sunun: X-Total-Count, X-Page, X-Page-Size.
  • next/prev için RFC 5988 Link header ekleyin.
  • Mevcut query parametrelerini koruyarak sayfalama linklerinin doğru kalmasını sağlayın.