Pular para o conteúdo

Trilha de Kotlin · parte 43 de 59

Design avançado de API: paginação, versionamento e idempotência

Paginação por deslocamento e por cursor, versões de API, Idempotency-Key e ETag em Spring Boot, com requisições reais e as falhas que cada escolha esconde.

avançadoPublicada em · 11 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Design avançado de API: paginação, versionamento e idempotência

Uma pizzaria com telefone instável ensina boa parte deste assunto. Se a linha cai depois do pedido, o cliente liga de novo, e a atendente precisa saber se o pedido já entrou. A analogia tem limite: a API só conhece o que chega na requisição.

Esta aula continua a API de pedidos de Spring Boot com controllers suspend e de Tratamento de erros com ProblemDetail, com os pedidos numa lista em memória. GraphQL, gRPC, rate limiting e HATEOAS ficam de fora.

o mapa da aula

As seções 1 a 3 tratam de paginação, a 4 de versões, a 5 de idempotência e a 6 de ETag.

Versões desta aula: Spring Boot 4.1.2, a versão padrão do Spring Initializr em 04/11/2026, com Spring Framework 7.0.10, Jackson 3.1.7, Kotlin 2.4.21 e Gradle 9.7.0. As aulas anteriores declaram Spring Boot 4.1.1 e Kotlin 2.3.21; a diferença não foi testada.

1. o problema de devolver tudo

Devolver a lista inteira obriga o servidor a montar um JSON cada vez maior. Paginar entrega um pedaço e um jeito de pedir o próximo. A técnica muda conforme a resposta descreve o próximo pedaço: pela posição na lista, ou por um item já recebido. O Spring Data tem Pageable, usado com persistência (fase 7).

2. paginação por deslocamento

No deslocamento, o cliente pede página e tamanho, e o servidor pula (página - 1) × tamanho itens. A resposta traz os itens, o total, a página e o tamanho. O defeito aparece quando a lista muda entre uma leitura e outra, porque a posição de cada item depende de quantos itens existem antes dele. O repositório ordena por id decrescente.

import org.springframework.stereotype.Component

data class Pedido(val id: Long, val cliente: String, val valor: Int, val versao: Int)

data class NovoPedido(val cliente: String, val valor: Int)

@Component
class RepositorioPedidos {
    private val lista = mutableListOf<Pedido>()
    private var ultimoId = 0L

    @Synchronized fun criar(novo: NovoPedido) = Pedido(++ultimoId, novo.cliente, novo.valor, 1).also { lista.add(it) }

    @Synchronized fun buscar(id: Long) = lista.find { it.id == id }

    @Synchronized fun remover(id: Long) = lista.removeIf { it.id == id }

    @Synchronized fun atualizarSe(id: Long, condicao: (Pedido) -> Boolean, novo: NovoPedido): Pedido? {
        val i = lista.indexOfFirst { it.id == id }
        if (i < 0 || !condicao(lista[i])) return null
        return lista[i].copy(cliente = novo.cliente, valor = novo.valor, versao = lista[i].versao + 1).also { lista[i] = it }
    }

    @Synchronized fun pagina(pagina: Int, tamanho: Int) = lista.sortedByDescending { it.id }.drop((pagina - 1) * tamanho).take(tamanho)

    @Synchronized fun depoisDe(cursor: Long, limite: Int) = lista.filter { it.id < cursor }.sortedByDescending { it.id }.take(limite)

    @Synchronized fun total() = lista.size
}
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.web.context.request.WebRequest
import java.util.concurrent.ConcurrentHashMap

data class PaginaPorDeslocamento(val itens: List<Pedido>, val total: Int, val pagina: Int, val tamanho: Int)
data class PaginaPorCursor(val itens: List<Pedido>, val proximoCursor: Long?)
data class PedidoV2(val id: Long, val comprador: String, val valor: Int)
private data class Registro(val entrada: NovoPedido, val pedido: Pedido)

fun etag(p: Pedido) = "\"v${p.versao}\""

@RestController
@RequestMapping("/pedidos")
class PedidoController(private val repo: RepositorioPedidos) {
    private val chaves = ConcurrentHashMap<String, Registro>()

    @GetMapping("/offset")
    fun porDeslocamento(@RequestParam(defaultValue = "1") pagina: Int, @RequestParam(defaultValue = "5") tamanho: Int) =
        PaginaPorDeslocamento(repo.pagina(pagina, tamanho), repo.total(), pagina, tamanho)

    @GetMapping("/cursor")
    fun porCursor(@RequestParam(required = false) apos: Long?, @RequestParam(defaultValue = "5") limite: Int): ResponseEntity<PaginaPorCursor> {
        val itens = repo.depoisDe(apos ?: Long.MAX_VALUE, limite)
        val proximo = itens.lastOrNull()?.id?.takeIf { itens.size == limite }
        val resposta = ResponseEntity.ok()
        if (proximo != null) resposta.header(HttpHeaders.LINK, "</pedidos/cursor?apos=$proximo&limite=$limite>; rel=\"next\"")
        return resposta.body(PaginaPorCursor(itens, proximo))
    }

    @PostMapping
    fun criar(@RequestHeader("Idempotency-Key", required = false) chave: String?, @RequestBody entrada: NovoPedido): ResponseEntity<Any> {
        val anterior = chave?.let { chaves[it] }
        if (anterior != null && anterior.entrada != entrada) {
            val erro = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, "Idempotency-Key já usada com outro corpo")
            return ResponseEntity.status(HttpStatus.UNPROCESSABLE_CONTENT).body(erro)
        }
        if (anterior != null) return ResponseEntity.status(HttpStatus.CREATED).body(anterior.pedido)
        val pedido = repo.criar(entrada)
        if (chave != null) chaves[chave] = Registro(entrada, pedido)
        return ResponseEntity.status(HttpStatus.CREATED).body(pedido)
    }

    @GetMapping("/{id}")
    fun buscar(@PathVariable id: Long, requisicao: WebRequest): ResponseEntity<Pedido> {
        val pedido = repo.buscar(id) ?: return ResponseEntity.notFound().build()
        if (requisicao.checkNotModified(etag(pedido))) return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build()
        return ResponseEntity.ok().eTag(etag(pedido)).body(pedido)
    }

    @GetMapping("/{id}", version = "2.0+")
    fun buscarV2(@PathVariable id: Long): ResponseEntity<PedidoV2> {
        val p = repo.buscar(id) ?: return ResponseEntity.notFound().build()
        return ResponseEntity.ok(PedidoV2(p.id, p.cliente, p.valor))
    }

    @PutMapping("/{id}")
    fun atualizar(@PathVariable id: Long, @RequestHeader(HttpHeaders.IF_MATCH, required = false) seAtender: String?, @RequestBody entrada: NovoPedido): ResponseEntity<Any> {
        if (repo.buscar(id) == null) return ResponseEntity.notFound().build()
        val novo = repo.atualizarSe(id, { seAtender == null || seAtender == etag(it) }, entrada)
            ?: return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build()
        return ResponseEntity.ok().eTag(etag(novo)).body(novo)
    }

    @DeleteMapping("/{id}")
    fun remover(@PathVariable id: Long): ResponseEntity<Void> =
        if (repo.remover(id)) ResponseEntity.noContent().build() else ResponseEntity.notFound().build()
}

O script roda contra um servidor recém-iniciado.

#!/bin/bash
B="$1"
P() { curl -s -X POST "$B/pedidos" -H 'Content-Type: application/json' -d "$1"; }
ids() { jq -c '[.itens[].id]'; }
for i in $(seq 1 10); do P "{\"cliente\":\"C$i\",\"valor\":$((i*100))}" > /dev/null; done
echo "1. deslocamento, pagina 1:"; curl -s "$B/pedidos/offset?pagina=1&tamanho=4" | ids
echo "2. cursor, pagina 1:"; curl -s -D h.txt "$B/pedidos/cursor?limite=4" | ids; grep -i '^link' h.txt
P '{"cliente":"C11","valor":1100}' > /dev/null
echo "3. entra o 11; deslocamento, pagina 2:"; curl -s "$B/pedidos/offset?pagina=2&tamanho=4" | ids
echo "4. cursor depois do 7:"; curl -s "$B/pedidos/cursor?apos=7&limite=4" | ids
echo "5. le a pagina 1, remove o 10, le de novo:"; curl -s "$B/pedidos/offset?pagina=1&tamanho=4" | ids
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE "$B/pedidos/10"
curl -s "$B/pedidos/offset?pagina=2&tamanho=4" | ids; curl -s "$B/pedidos/cursor?apos=8&limite=4" | ids
echo "6. mesmo POST sem chave, duas vezes:"
P '{"cliente":"Dani","valor":500}' | jq -c .id; P '{"cliente":"Dani","valor":500}' | jq -c .id
K='Idempotency-Key: "pedido-eva-1"'
echo "7. mesma chave e mesmo corpo:"
for v in 700 700; do curl -s -X POST "$B/pedidos" -H "$K" -H 'Content-Type: application/json' -d "{\"cliente\":\"Eva\",\"valor\":$v}" -w ' %{http_code}\n'; done
echo "8. mesma chave, outro corpo:"
curl -s -X POST "$B/pedidos" -H "$K" -H 'Content-Type: application/json' -d '{"cliente":"Eva","valor":900}' -w ' %{http_code}\n'
echo "9. condicional sobre o pedido 3, sem e com If-None-Match:"
curl -s -o /dev/null -w "%{http_code}\n" "$B/pedidos/3"
curl -s -o /dev/null -w "%{http_code}\n" -H 'If-None-Match: "v1"' "$B/pedidos/3"
echo "10. gravacoes a partir da versao 1:"
curl -s -o /dev/null -w "Ana: %{http_code}\n" -X PUT -H 'If-Match: "v1"' -H 'Content-Type: application/json' -d '{"cliente":"Ana","valor":310}' "$B/pedidos/3"
curl -s -o /dev/null -w "Bia: %{http_code}\n" -X PUT -H 'If-Match: "v1"' -H 'Content-Type: application/json' -d '{"cliente":"Bia","valor":320}' "$B/pedidos/3"
curl -s -o /dev/null -w "Caio: %{http_code}\n" -X PUT -H 'Content-Type: application/json' -d '{"cliente":"Caio","valor":330}' "$B/pedidos/3"
curl -s "$B/pedidos/3"; echo
echo "11. versoes do pedido 3:"
curl -s "$B/pedidos/3"; echo
curl -s -H 'API-Version: 2.0' "$B/pedidos/3"; echo
curl -s -w ' %{http_code}\n' -H 'API-Version: 3.0' "$B/pedidos/3"
1. deslocamento, pagina 1:
[10,9,8,7]
2. cursor, pagina 1:
[10,9,8,7]
Link: </pedidos/cursor?apos=7&limite=4>; rel="next"
3. entra o 11; deslocamento, pagina 2:
[7,6,5,4]
4. cursor depois do 7:
[6,5,4,3]
5. le a pagina 1, remove o 10, le de novo:
[11,10,9,8]
204
[6,5,4,3]
[7,6,5,4]
6. mesmo POST sem chave, duas vezes:
12
13
7. mesma chave e mesmo corpo:
{"id":14,"cliente":"Eva","valor":700,"versao":1} 201
{"id":14,"cliente":"Eva","valor":700,"versao":1} 201
8. mesma chave, outro corpo:
{"detail":"Idempotency-Key já usada com outro corpo","instance":"/pedidos","status":422,"title":"Unprocessable Content"} 422
9. condicional sobre o pedido 3, sem e com If-None-Match:
200
304
10. gravacoes a partir da versao 1:
Ana: 200
Bia: 412
Caio: 200
{"id":3,"cliente":"Caio","valor":330,"versao":3}
11. versoes do pedido 3:
{"id":3,"cliente":"Caio","valor":330,"versao":3}
{"id":3,"comprador":"Caio","valor":330}
{"timestamp":"2026-11-04T23:16:48.133Z","status":400,"error":"Bad Request","path":"/pedidos/3"} 400

A página 2 começou com o 7, que a página 1 já tinha mostrado. O pedido 11 entrou no topo, e a posição 4 passou a ser o 7. Na remoção, o 10 saiu de cima. A página 1 tinha sido lida antes, e a página 2 lida depois começou no 6. O 7 mudou de posição e ficou de fora das duas leituras: esse é o pulo do deslocamento. Com cursor, nada se repetiu nem pulou. O mesmo script em JDK 25 deu saída idêntica.

Primeira leitura de 10 a 7, um POST que entra o pedido 11 e duas leituras depois: deslocamento repete o 7, cursor começa depois do 7
figura 1 · Duas leituras da mesma lista depois de um POST

Com um escritor em paralelo, três execuções (servidor novo, 400 inserções). O código desse escritor não está nesta nota:

execução 1: deslocamento repetiu 8 ids, cursor repetiu 0
execução 2: deslocamento repetiu 9 ids, cursor repetiu 0
execução 3: deslocamento repetiu 9 ids, cursor repetiu 0

3. paginação por cursor

Na paginação por cursor, a resposta traz o último id entregue, e a chamada seguinte pede os itens com id menor que ele. Uma inserção no topo não empurra a página seguinte, porque o servidor não conta posições. Em troca, o cliente não pula para a página 50, e o total não sai de graça: o exemplo devolve proximoCursor. A chave de ordenação precisa ser única e estável, e o id é as duas coisas.

O cabeçalho Link (RFC 8288) traz a próxima página com rel="next", como no passo 2.

4. versionamento

Na URL, como em /v2/pedidos, a versão aparece em qualquer log, mas duplica rotas. No cabeçalho, como API-Version: 2.0, a URL fica estável, mas quem esquece o cabeçalho cai na versão padrão. No tipo de mídia, como Accept: application/vnd.loja.v2+json, a versão entra na negociação de conteúdo. Só o cabeçalho foi rodado aqui, com suporte nativo do Spring Framework 7.

O atributo version marca o mapeamento, e configureApiVersioning define o cabeçalho e a versão padrão. Um mapeamento sem versão casa com qualquer versão, mas perde para um mais específico. 2.0+ casa com a 2.0 e com as suportadas acima dela.

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@SpringBootApplication
class LojaApplication : WebMvcConfigurer {
    override fun configureApiVersioning(configurer: ApiVersionConfigurer) {
        configurer.useRequestHeader("API-Version").setDefaultVersion("1.0").addSupportedVersions("1.0")
    }
}

fun main(args: Array<String>) {
    runApplication<LojaApplication>(*args)
}

O passo 11 mostra o efeito: sem cabeçalho, a forma antiga; com API-Version: 2.0, a forma nova, em que cliente virou comprador; com 3.0, 400.

Um detalhe pegou. As versões suportadas vêm, por padrão, das versões declaradas nos mapeamentos, e 2.0+ não acrescenta 2.5 a elas. Por isso a 2.5 respondeu 400 nesta configuração. O exercício 2 corrige isso.

Mudança compatível não pede versão nova. Adicionar um campo opcional não quebra quem não o conhece. Renomear cliente para comprador quebra, e aí a versão nova se justifica.

Pergunta de entrevistaPaginação por offset ou por cursor: como você escolhe?

Offset é simples e permite pular para uma página específica, mas repete ou pula itens quando a lista muda entre uma leitura e outra. Cursor usa o último item recebido como ponto de partida, então inserções no topo não causam repetição. Em troca, o cliente não salta para a página 50, e o total custa mais para calcular. Para feeds e listas que mudam, uso cursor. Para uma tela administrativa com poucas linhas, offset basta.

5. idempotência

Segundo a RFC 9110, o POST não é idempotente, e o cliente não deve repeti-lo automaticamente, a menos que saiba que a original nunca foi aplicada. Por isso um POST que cria pedido, repetido depois de um timeout, pode duplicar o pedido. O passo 6 mostra isso: o mesmo corpo, sem chave, gerou os pedidos 12 e 13.

A correção usual é o cliente enviar uma chave em Idempotency-Key. O servidor guarda chave, corpo e resposta. A mesma chave com o mesmo corpo recebe a resposta guardada, como no passo 7, em que as duas chamadas devolveram o id 14. A mesma chave com outro corpo recebe 422, como no passo 8. O código guarda isso num ConcurrentHashMap em memória.

Cliente envia POST com chave: chave nova cria o pedido, chave conhecida com mesmo corpo devolve a resposta guardada, corpo diferente gera 422
figura 2 · Pedido com Idempotency-Key: primeira tentativa, retry e chave reutilizada

A figura mostra as duas perguntas do servidor: a chave já foi vista, e o corpo é igual.

O rascunho draft-ietf-httpapi-idempotency-key-header ainda não é RFC. Na revisão 08, de 22 de outubro de 2026, é um Active Internet-Draft do grupo httpapi, válido até 25 de abril de 2027. Ele recomenda (SHOULD) 409 para requisição concorrente com a mesma chave. O exemplo não implementa esse 409, e a corrida entre chamadas simultâneas não foi testada.

6. requisições condicionais

O ETag é um validador opaco da versão de uma representação, aqui "v1" (RFC 9110, seção 8.8.3). If-None-Match com o ETag atual faz o GET responder 304 sem corpo (seção 15.4.5), o que poupa banda. If-Match faz a gravação valer só se a versão ainda for a que o cliente leu; senão, a resposta é 412 (seção 15.5.13). O Spring cobre o 304 com checkNotModified. A checagem do If-Match fica no atualizarSe, dentro do mesmo bloqueio que troca a versão.

Os passos 9 e 10 mostram o problema: Ana e Bia partiram da versão 1. Ana gravou e subiu para a versão 2, e Bia recebeu 412. Caio gravou sem If-Match, e sobrescreveu a Ana sem aviso. Essa é a atualização perdida.

Pergunta de entrevistaComo você garante que um POST não crie o mesmo pedido duas vezes?

O cliente envia uma chave de idempotência no cabeçalho, e o servidor guarda a chave, o corpo e a resposta. Se a mesma chave chegar com o mesmo corpo, devolve a resposta guardada, sem criar outro pedido. Se chegar com um corpo diferente, recusa com 422. A chave precisa de expiração, e a gravação da operação e o registro da chave precisam ser atômicos, senão duas requisições simultâneas podem passar.

erros comuns e boas práticas

O primeiro erro é usar deslocamento em lista que muda. O segundo é criar versão para cada mudança, quando um campo opcional não quebra ninguém. O terceiro é aceitar a chave sem comparar o corpo, porque a chave reutilizada devolveria a resposta de outro pedido. O quarto é tratar 412 como erro do servidor, quando é o retorno normal de uma gravação concorrente.

exercícios

  1. Guiado. Depois do script de exemplo, o servidor tem os pedidos 1 a 9, 11, 12, 13 e 14. Antes de rodar, preveja a página 2 por deslocamento (tamanho 4) se o pedido 14 for removido. A resposta é [8,7,6,5]: o 9 cai na posição 3, que a página 1 já cobria. A saída real, depois do DELETE do pedido 14 e da leitura da página 2:
estado antes:
[14,13,12,11,9,8,7,6,5,4,3,2,1]
DELETE /pedidos/14 -> 204
pagina 2 de 4 por deslocamento:
[8,7,6,5]
  1. Mudança de versão. Em LojaApplication, troque addSupportedVersions("1.0") por addSupportedVersions("1.0", "2.5"), suba de novo e leia com API-Version: 2.5. A resposta é 200 com a forma PedidoV2, porque 2.5 passa a ser suportada e 2.0+ a casa. A saída:
== exercicio 2: addSupportedVersions("1.0", "2.5")
POST /pedidos (sem chave) -> 201
API-Version 1.5 -> {"timestamp":"2026-11-04T23:07:22.053Z","status":400,"error":"Bad Request","path":"/pedidos/1"}  HTTP 400
API-Version 2.0 -> {"id":1,"comprador":"Ana","valor":10}  HTTP 200
API-Version 2.5 -> {"id":1,"comprador":"Ana","valor":10}  HTTP 200
API-Version 3.0 -> {"timestamp":"2026-11-04T23:07:22.148Z","status":400,"error":"Bad Request","path":"/pedidos/1"}  HTTP 400

glossário

Paginação por deslocamento: o cliente pede uma página por número e tamanho, e o servidor conta as posições.

Método idempotente: várias requisições iguais têm o efeito de uma só (RFC 9110, seção 9.2.2).

Idempotency-Key: cabeçalho com uma chave que o cliente gera para identificar uma escrita.

o que não foi confirmado ou medido

O custo de OFFSET em banco não foi medido, pois não há banco. A corrida da chave, o 409 e a expiração das chaves não foram testados. Versão por caminho e por tipo de mídia não foram rodadas. As repetições vêm de três execuções, para a ordem de grandeza, e a economia de bytes do 304 não foi medida.

Os trechos não trazem package. No projeto, o código ficou no pacote loja, porque o pacote padrão impediu o Spring de subir. Nenhuma saída mostrada tem posição de código.

Fontes

Consultadas em 04/11/2026:

Verificações locais, em 04/11/2026: compilação com Gradle 9.7.0 e Kotlin 2.4.21, e jar iniciado com java -jar em localhost, em JDK 21.0.8 e JDK 25.

Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?