Chargement...

Pagination propre en ASP.NET Core Web API (Headers + Link)

Gardez uniquement les éléments dans le body et placez les métadonnées de pagination dans les headers. Ajoutez un Link header RFC 5988 (next/prev).

Lors de la création d’une API REST, les métadonnées de pagination finissent souvent dans le body de la réponse. Cela fonctionne, mais cela mélange les données et les métadonnées de navigation dans le même payload. Une approche plus propre consiste à garder le body concentré sur les éléments et à déplacer les informations de pagination dans les en-têtes HTTP.

Dans cet exemple, l’API renvoie des clients sous forme d’un simple tableau JSON, tandis que les métadonnées de pagination sont exposées via X-Total-Count, X-Page, X-Page-Size, X-Total-Pages et un en-tête RFC 5988 Link pour la navigation next/prev.


Objectif


Exemple de Controller (Éléments dans le body, métadonnées dans les en-têtes)


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)
{
    // Limites côté serveur (évite les abus)
    page = Math.Max(1, page);
    pageSize = Math.Clamp(pageSize, 1, 100);

    // Le service renvoie : items + totalCount
    var (items, totalCount) = await _customerService.ListPagedAsync(page, pageSize);

    // Nombre total de pages (0 est valide si totalCount = 0)
    var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);

    // Métadonnées de pagination dans les en-têtes
    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();

    // En-tête Link : next/prev (RFC 5988)
    var links = BuildLinkHeader(page, pageSize, totalPages);

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

    return Ok(items);
}

// Génère un en-tête Link comme :
// <https://host/api/customers?page=1&pageSize=20&sort=name>; rel="prev", <...>; rel="next"
private string BuildLinkHeader(int page, int pageSize, int totalPages)
{
    // S'il n'y a pas de pages (totalCount = 0) ou une seule page, aucun lien de navigation n'est nécessaire.
    if (totalPages <= 1)
        return string.Empty;

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

    // Conserver les paramètres existants (filter/sort/search...)
    var query = Request.Query
        .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToString());

    // Forcer une valeur cohérente de pageSize
    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);
}

// Optionnel (pour un exemple copy/paste complet)
public sealed record Customer(string Name);

Le body de la réponse reste minimal (uniquement items), tandis que le client lit les métadonnées de pagination dans les en-têtes. Les liens de pagination restent fiables, car les paramètres de requête existants (sort, filter, search) sont conservés dans les URL générées.


Exemple de body de réponse


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

Exemple d’en-têtes de réponse


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"

Dans cet exemple, sort=name (et les autres paramètres de requête) est conservé dans les URL Link générées.


Exemple Fetch (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();

// métadonnées de pagination depuis les en-têtes
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"));

// navigation via l’en-tête Link (next/prev)
const linkHeader = response.headers.get("Link");

// parser l’en-tête Link en { next, prev }
const links = parseLinkHeader(linkHeader);
console.log(links.next, links.prev);

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

  // Exemple :
  // <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;
}

Avec un petit helper parseLinkHeader, l’UI côté client peut naviguer via links.next et links.prev sans intégrer les métadonnées de pagination dans le body de la réponse.


Note CORS (lire des headers personnalisés)

Si l’API et le frontend tournent sur des domaines différents, la politique CORS du navigateur peut empêcher JavaScript de lire certains headers de réponse. Dans ce cas, des appels comme response.headers.get("X-Total-Count") peuvent retourner null.

Pour rendre ces headers lisibles, l’API doit les exposer via Access-Control-Expose-Headers. L’exemple ci-dessous définit et applique une policy CORS dans ASP.NET Core Program.cs :


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();

// ... autres middlewares (ex. UseRouting)

// Appliquer CORS avant les endpoints
app.UseCors("Frontend");

// ... UseAuthentication / UseAuthorization etc.

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

Ensuite, les headers de métadonnées de pagination deviennent lisibles depuis JavaScript.


Pourquoi un en-tête Link ?

L’en-tête Link rend la navigation de pagination découvrable de manière standard :


Améliorations courantes


TL;DR

  • Ne renvoyer que les éléments dans le body de la réponse.
  • Exposer les métadonnées de pagination via les en-têtes : X-Total-Count, X-Page, X-Page-Size.
  • Ajouter un en-tête RFC 5988 Link pour la navigation next/prev.
  • Conserver les paramètres de requête existants afin que les liens de pagination restent corrects.