Filtragem, ordenação e paginação

Os bugs de endpoints de listagem que ninguém nota até a página 3.

Um endpoint de listagem parece simples: você pede produtos e recebe produtos. Aí a lista cresce além do que cabe numa resposta, os usuários querem ordenar por preço, filtrar por categoria e buscar pelo nome, e cada uma dessas funcionalidades é um pedacinho de código com os próprios casos extremos. Bugs de listagem são traiçoeiros porque a resposta sempre parece plausível. Uma página com os dez produtos errados continua sendo uma página com dez produtos.

Esta lição usa /api/Products, que tem 20 produtos nos dados iniciais, e os endpoints de demonstração em /api/paginationdocs. Os mesmos parâmetros funcionam em toda listagem desta API, e a referência da API os descreve.

ParâmetroO que faz aqui
limitTamanho da página, de 1 a 100. Sem ele, você recebe tudo.
pageNúmero da página começando em 1, usado com limit.
offsetPula esse número de registros, como alternativa ao page.
sort, orderOrdena por qualquer campo, asc (o padrão) ou desc.
qBusca sem diferenciar maiúsculas em campos de texto como title e description.
qualquer nome de campoFiltro exato sem diferenciar maiúsculas, como ?category=books.

Paginação

Peça três produtos por página e olhe os cabeçalhos além do corpo:

GET/api/Products?limit=3&page=1

Vinte produtos em páginas de três dão sete páginas, e a última tem dois. A última página é o primeiro lugar para procurar bugs, porque é o único caso em que a página não está cheia:

Terminal
$ curl -i "https://api.testingapis.com/api/Products?limit=3&page=7"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
x-page: 7
x-per-page: 3
x-total-count: 20
x-total-pages: 7

[{"id":19,"title":"Non-Slip Yoga Mat",...},{"id":20,"title":"Resistance Bands Set",...}]

Correto: sete páginas, e a última tem os produtos 19 e 20. Um bug clássico aqui é calcular o número de páginas com divisão inteira (20 / 3 = 6), o que esconde os dois últimos produtos de quem vai passando as páginas.

Valores fora da faixa

Em seguida, envie valores que a documentação diz serem inválidos, ou que ela nem menciona. É isto que a API faz com eles:

Resultados
?limit=3&page=8    200, x-page: 8, body []
?limit=3&page=0    200, x-page: 1, same as page 1
?limit=0           200, x-per-page: 1
?limit=-1          200, x-per-page: 1
?limit=101         200, x-per-page: 100
?limit=abc         200, x-per-page: 20 (limit ignored, all 20 returned)

O servidor nunca recusa. Ele ajusta um valor ruim para o válido mais próximo, ou o ignora. É um projeto legítimo, e amigável com os clientes, mas um teste deve registrá-lo, porque a alternativa (um 400 que explica a faixa válida) é igualmente comum. Os casos perigosos são aqueles em que o ajuste esconde um bug do cliente. Um cliente que manda limit=abc por causa de um erro de template recebe aqui todos os 20 registros. Numa tabela com dois milhões de linhas, o mesmo comportamento significa que o servidor tenta devolver todas.

Uma página além do fim devolve uma lista vazia com 200. É o que a maioria das APIs faz, e é o que os clientes esperam quando repetem até não receber mais nada. Um 404 para uma página vazia também seria defensável. Um 500 não.

Sem buracos, sem duplicados

O teste de paginação mais importante também é o mais simples de descrever: percorra todas as páginas, junte os IDs e confira se recebeu cada registro exatamente uma vez. Compare a quantidade que juntou com o X-Total-Count.

JavaScript
async function allPages(path, limit) {
  const seen = [];
  for (let page = 1; ; page++) {
    const res = await fetch(`https://api.testingapis.com${path}?limit=${limit}&page=${page}`);
    const items = await res.json();
    if (items.length === 0) break;
    seen.push(...items.map((item) => item.id));
  }
  return seen;
}

const ids = await allPages("/api/Products", 3);
const total = 20; // from X-Total-Count
console.log(ids.length === total, new Set(ids).size === ids.length);

Rode com vários tamanhos de página: 1, 3, 7 e um maior que a lista inteira. Erros de um a mais ou a menos nos cálculos de deslocamento costumam aparecer só em tamanhos específicos. Se a API aceita tanto page quanto offset, confira se limit=5&page=3 e limit=5&offset=10 devolvem os mesmos registros.

Este teste só passa de forma confiável se ninguém mudar os dados enquanto ele roda. Num servidor compartilhado como este, alguém pode mudar. Isso leva à principal fraqueza dos números de página, tratada mais abaixo.

Ordenação

Ordene por preço, do maior para o menor, e confira a ordem do resultado inteiro em vez de só o primeiro item:

GET/api/Products?sort=price&order=desc&limit=5

Os cinco primeiros voltam como 249.99, 199.99, 89.99, 89.99, 59.99. Os produtos 3 e 8 custam o mesmo. Empates importam para a paginação: se o servidor não desempata de um jeito fixo, o mesmo produto pode aparecer no fim da página 1 e no começo da página 2, ou em nenhuma das duas. A maioria dos bancos de dados não promete nada sobre a ordem de linhas iguais, a não ser que a consulta acrescente uma segunda chave de ordenação, geralmente o ID.

Ordenar por categoria mostra como esta API lida com isso. Dentro de cada categoria, os produtos ficam em ordem de ID, tanto crescente quanto decrescente:

Resultados (id:categoria)
?sort=category
10:books 11:books 12:books 13:books 6:clothing 7:clothing 8:clothing 9:clothing
1:electronics 2:electronics 3:electronics 4:electronics 5:electronics 14:home ...

?sort=category&order=desc
18:sports 19:sports 20:sports 14:home 15:home 16:home 17:home 1:electronics ...

Isso é estável, que é o que você quer. Para testar, ordene por um campo com muitos valores repetidos, busque a lista duas vezes (e percorra-a por páginas com um limite pequeno) e confira se a ordem nunca muda.

Mais casos de ordenação para testar em qualquer API:

  • Strings com maiúsculas e minúsculas misturadas. "apple" vem antes ou depois de "Banana"?
  • Números guardados como strings, em que "10" vem antes de "9".
  • Registros em que o campo de ordenação é null ou está ausente. Eles vão para o início, para o fim, ou somem?
  • Datas em fusos horários diferentes.

Parâmetros que são ignorados

O que acontece quando você erra um parâmetro dá mais problema do que qualquer uma das funcionalidades em si:

Resultados
?sort=colour&limit=5            200, ids 1,2,3,4,5 (unsorted)
?sort=price&order=sideways      200, sorted ascending
?categry=books                  200, all 20 products
?rating.rate=4.3                200, all 20 products

Todos devolvem 200 e uma lista que parece razoável. Uma ordenação por um campo que não existe é descartada. Uma ordem sideways vira crescente. Um filtro escrito errado, categry, é ignorado, então o cliente pediu livros e recebeu todos os 20 produtos. Filtrar por um campo aninhado com notação de pontos não é suportado, e isso também é ignorado.

Na prática, o filtro escrito errado é o pior deles. Pense numa tela que mostra "pedidos do cliente 42", construída sobre um parâmetro de filtro que alguém renomeou. Depois da renomeação, todo cliente vê todos os pedidos, e a API devolve 200 o tempo todo. Ao testar filtros, inclua sempre um caso em que o filtro deveria remover a maioria dos registros, e confira se removeu. Um teste que só confere que "todo produto devolvido é da categoria books" também passa com uma lista vazia, então confira a contagem também.

Nesta API os filtros são exatos e não diferenciam maiúsculas: ?category=Electronics devolve os cinco produtos de eletrônicos, e ?category=electron devolve uma lista vazia. Campos booleanos funcionam do mesmo jeito:

GET/api/Products?inStock=false

A busca com qprocura trechos do texto. É aqui que a codificação começa a importar, porque termos de busca têm espaços, &, sinais de porcentagem e aspas, e todos eles têm significado especial numa URL:

Resultados (ids)
?q=YOGA              19
?q=yoga+mat          19
?q=yoga%20mat        19
?q=100%25            6   (description contains "100% organic cotton")
?q=%27               11,13   (titles and descriptions with an apostrophe)
?q=                  all 20
?q=a&q=b             []
?category=books&category=home    []

Espaços funcionam escritos como + ou como %20. Um % literal precisa ser enviado como %25, e ele encontra corretamente a camiseta de "100% organic cotton". Uma busca por um apóstrofo funciona e devolve dois livros, um pequeno sinal de que a busca não monta SQL colando strings (a lição de segurança explica por que isso importa). Se você mandar um espaço sem codificar, o curl se recusa a enviar a requisição.

As duas últimas linhas merecem uma nota. Repetir um parâmetro é permitido numa URL, e as APIs tratam isso de jeitos diferentes: o primeiro valor vence, o último valor vence, os dois valores são combinados com OR, ou são unidos numa string só. Aqui, repetir q ou category devolve uma lista vazia, o que sugere que os valores foram unidos em algo como books,home, que não combina com nada. Se a interface do seu produto um dia deixar as pessoas escolherem duas categorias, há um bug esperando para ser encontrado aqui.

Combinando tudo

Filtre, ordene e pagine numa requisição só, e confira se tudo é aplicado na ordem certa: primeiro o filtro, depois a ordenação, depois a página.

GET/api/Products?category=electronics&sort=price&order=desc&limit=2&page=2

A página 2 tem os produtos 3 e 4 (89.99 e 59.99), e o X-Total-Counté 5, o número de produtos de eletrônicos, não 20. Se o total ignorasse o filtro, um cliente mostraria "página 1 de 10" para uma lista de três páginas. Esse erro é comum, porque os totais muitas vezes são contados por uma consulta separada que alguém esqueceu de atualizar.

Por que os números de página quebram

Números de página e offsets descrevem uma posição na lista: "pule 20, me dê 10". Isso funciona para dados que não mudam. Quando mudam, as posições se deslocam:

  1. Você busca a página 1 (itens 1 a 10) de uma lista ordenada do mais novo para o mais antigo.
  2. Alguém cria um item novo. Ele vai para o topo, e tudo desce uma posição.
  3. Você busca a página 2 (posições 11 a 20). A posição 11 agora é o item que era o 10, então você o vê duas vezes.

Apagar um item faz o contrário: tudo sobe uma posição e um item nunca é mostrado. Num feed de notícias, é um pequeno incômodo. Num processo que exporta todas as faturas percorrendo páginas, significa que uma fatura falta na exportação e ninguém percebe.

A paginação por cursor evita isso descrevendo onde você parou em vez de quanto já andou. Cada resposta entrega um token para a próxima requisição:

GET/api/pagination/cursor?limit=3
Esta requisição usa um endpoint para membros conectados.EntrarCriar uma conta gratuita
Resposta
{
  "object": "list",
  "url": "/api/pagination/cursor",
  "data": [
    { "id": 1, "name": "Smart Headset", "category": "electronics", "price": 42.01, "createdAt": "2026-01-01T21:00:00Z" },
    { "id": 2, "name": "Compact Speaker", "category": "outdoors", "price": 79.02, "createdAt": "2026-01-02T10:00:00Z" },
    { "id": 3, "name": "Deluxe Blender", "category": "office", "price": 116.03, "createdAt": "2026-01-02T23:00:00Z" }
  ],
  "has_more": true,
  "next_cursor": "eyJsYXN0SWQiOjN9"
}
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

Você devolve o next_cursor como ?cursor= e continua até has_moreser false. Um cursor inválido recebe um 400 claro: "Invalid cursor. Pass the 'next_cursor' value from a previous response unchanged."

A paginação keyset é a mesma ideia sem o embrulho. O cliente envia o último ID que viu, e o servidor devolve os registros depois dele:

GET/api/pagination/keyset?after_id=10&limit=3
Esta requisição usa um endpoint para membros conectados.EntrarCriar uma conta gratuita
Resposta
{
  "data": [
    { "id": 11, "name": "Smart Lamp", "category": "electronics", "price": 12.11, "createdAt": "2026-01-07T07:00:00Z" },
    { "id": 12, "name": "Compact Headset", "category": "outdoors", "price": 49.12, "createdAt": "2026-01-07T20:00:00Z" },
    { "id": 13, "name": "Deluxe Speaker", "category": "office", "price": 86.13, "createdAt": "2026-01-08T09:00:00Z" }
  ],
  "has_more": true,
  "next_after_id": 13
}

O terceiro estilo coloca os links de página num cabeçalho Link, como faz a API do GitHub. O corpo continua sendo um array simples:

Terminal
$ curl -i "https://api.testingapis.com/api/pagination/link?page=2&per_page=3"
HTTP/1.1 200 OK
link: <http://api.testingapis.com/api/pagination/link?page=3&per_page=3>; rel="next",
      <http://api.testingapis.com/api/pagination/link?page=34&per_page=3>; rel="last",
      <http://api.testingapis.com/api/pagination/link?page=1&per_page=3>; rel="first",
      <http://api.testingapis.com/api/pagination/link?page=1&per_page=3>; rel="prev"
x-total-count: 100
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

O número de páginas está certo (100 itens em páginas de 3 dão 34 páginas). Mas olhe as URLs. A requisição foi para https, e todos os links apontam para http. Esse é um bug real, e comum: a aplicação roda atrás de um proxy que cuida do HTTPS, ela mesma vê HTTP simples e monta os links a partir do que vê. Um cliente que segue esses links ou é redirecionado ou, numa configuração mais rígida, manda a próxima requisição sem criptografia. Conferir o esquema e o host de toda URL que uma API gera é um teste barato que pega isso.

Para cada estilo de paginação, os testes centrais são os mesmos: percorra até o fim, junte os IDs, procure buracos e duplicados, e confira o que acontece na última página. Para cursores e keysets, adicione um registro enquanto percorre e confirme que continua vendo cada registro original exatamente uma vez.

Exercício

Escolha /api/Booksentre para ver e anote os resultados de cada passo.

  1. Descubra quantos livros existem pelo X-Total-Count e percorra a lista com limit=4. Você recebeu cada ID uma vez?
  2. Ordene por genre. Busque duas vezes e com limit=3 pelas páginas. A ordem dentro de cada gênero é estável?
  3. Filtre por um gênero, ordene por price do maior para o menor e peça a página 2. O X-Total-Count é a contagem filtrada?
  4. Escreva errado o nome do filtro. O que volta?
  5. Percorra /api/pagination/offsetentre para ver e /api/pagination/linkentre para ver até o fim. Onde eles sinalizam a última página, e os dois concordam no total?

Para cada surpresa, decida se é um bug, uma lacuna na documentação ou uma escolha de projeto que você ainda questionaria.

Pratique e vá além