Versionamento de API
Como evoluir um contrato sem quebrar quem já o usa, e quais mudanças quebram de verdade.
Os clientes desta página foram executados nas cinco linguagens contra as duas respostas do teste do nível Conceito: uma com o campo novo prazoEntrega e outra com o status novo em_separacao.
Um cliente tolerante, em cada linguagem
Cada versão ignora campos desconhecidos e transforma um status desconhecido em desconhecido, em vez de lançar exceção.
import { z } from "zod";
export const Pedido = z.object({
id: z.string(),
// valores novos viram "desconhecido" em vez de quebrar a leitura
status: z
.enum(["aberto", "pago", "enviado", "desconhecido"])
.catch("desconhecido"),
}); // campos desconhecidos são descartados por padrão (não use .strict())
export type Pedido = z.infer<typeof Pedido>;
export async function buscarPedido(id: string): Promise<Pedido> {
const resposta = await fetch(`https://api.loja.com.br/v1/pedidos/${id}`);
if (!resposta.ok) throw new Error(`HTTP ${resposta.status}`);
return Pedido.parse(await resposta.json());
}from enum import Enum
import httpx
from pydantic import BaseModel
class Status(str, Enum):
ABERTO = "aberto"
PAGO = "pago"
ENVIADO = "enviado"
DESCONHECIDO = "desconhecido"
@classmethod
def _missing_(cls, valor):
return cls.DESCONHECIDO # valores novos não quebram a leitura
class Pedido(BaseModel): # campos desconhecidos são ignorados por padrão
id: str
status: Status
def buscar_pedido(pedido_id: str) -> Pedido:
resposta = httpx.get(f"https://api.loja.com.br/v1/pedidos/{pedido_id}", timeout=3)
resposta.raise_for_status()
return Pedido.model_validate(resposta.json())package loja
import (
"encoding/json"
"fmt"
"net/http"
)
type Status string
const (
Aberto Status = "aberto"
Pago Status = "pago"
Enviado Status = "enviado"
)
// Conhecido diz se o cliente sabe tratar o status; valores novos chegam intactos.
func (s Status) Conhecido() bool {
switch s {
case Aberto, Pago, Enviado:
return true
}
return false
}
type Pedido struct {
ID string `json:"id"`
Status Status `json:"status"`
}
func BuscarPedido(cliente *http.Client, id string) (Pedido, error) {
var p Pedido
resposta, err := cliente.Get("https://api.loja.com.br/v1/pedidos/" + id)
if err != nil {
return p, err
}
defer resposta.Body.Close()
if resposta.StatusCode != http.StatusOK {
return p, fmt.Errorf("HTTP %d", resposta.StatusCode)
}
// encoding/json ignora campos desconhecidos (não use DisallowUnknownFields aqui)
return p, json.NewDecoder(resposta.Body).Decode(&p)
}import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
public class ClienteDePedidos {
enum Status { aberto, pago, enviado, @JsonEnumDefaultValue desconhecido }
@JsonIgnoreProperties(ignoreUnknown = true)
record Pedido(String id, Status status) {}
// Valores de enumeração desconhecidos viram o valor marcado como padrão
private static final JsonMapper MAPPER = JsonMapper.builder()
.enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
static Pedido ler(String json) throws Exception {
return MAPPER.readValue(json, Pedido.class);
}
}using System.Text.Json;
using System.Text.Json.Serialization;
public static class ClienteDePedidos
{
// Campos desconhecidos já são ignorados por padrão no System.Text.Json
private static readonly JsonSerializerOptions Opcoes = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new EnumTolerante<Status>(Status.Desconhecido) },
};
public static Pedido? Ler(string json) => JsonSerializer.Deserialize<Pedido>(json, Opcoes);
}
// Lê valores novos como um valor padrão, em vez de lançar exceção
public sealed class EnumTolerante<T>(T desconhecido) : JsonConverter<T> where T : struct, Enum
{
public override T Read(ref Utf8JsonReader leitor, Type tipo, JsonSerializerOptions opcoes) =>
Enum.TryParse<T>(leitor.GetString()?.Replace("_", ""), ignoreCase: true, out var valor)
&& Enum.IsDefined(valor) ? valor : desconhecido;
public override void Write(Utf8JsonWriter escritor, T valor, JsonSerializerOptions opcoes) =>
escritor.WriteStringValue(valor.ToString().ToLowerInvariant());
}
public enum Status { Desconhecido, Aberto, Pago, Enviado }
public record Pedido(string Id, Status Status);O resultado, nas cinco linguagens
| Cliente tolerante | Resposta com campo novo | Resposta com status novo |
|---|---|---|
TypeScript (Zod com .catch) | Leu; campo ignorado | Leu; status desconhecido |
Python (Pydantic com _missing_) | Leu; campo ignorado | Leu; status desconhecido |
Go (encoding/json) | Leu; campo ignorado | Leu; status em_separacao, com Conhecido() falso |
| Java (Jackson configurado) | Leu; campo ignorado | Leu; status desconhecido |
| C# (conversor próprio) | Leu; campo ignorado | Leu; status Desconhecido |
O que muda de uma linguagem para outra
| Campos desconhecidos por padrão | Enumeração desconhecida por padrão | Como tolerar | |
|---|---|---|---|
| TypeScript (Zod) | Descartados | Erro | .catch() com um valor padrão |
| Python (Pydantic) | Ignorados | Erro | _missing_ na enumeração |
| Go | Ignorados | Não se aplica: o status é texto | Tratar o valor desconhecido no switch |
| Java (Jackson) | Erro | Erro | ignoreUnknown, @JsonEnumDefaultValue e a opção do mapeador |
C# (System.Text.Json) | Ignorados | Erro | Um conversor próprio, como o do exemplo |
Duas observações sobre a tabela:
- No Go, o valor novo chega intacto, o que é ótimo para registrar em log e repassar, mas o código que decide algo pelo status precisa de um caso para valores que não conhece, e não pode assumir que o
switchcobre tudo. - No C#, o
JsonStringEnumConverterpadrão lança exceção com valores desconhecidos, e não há opção para mudar isso. O conversor do exemplo resolve com poucas linhas. Uma alternativa simples é modelar o status comostringno cliente.
Tolerar não é ignorar: o cliente deve registrar quando recebe um valor desconhecido. Esse registro avisa que a API evoluiu e que o cliente precisa ser atualizado para tratar o caso novo.
Acompanhar o uso das versões no servidor
Para desligar uma versão antiga com segurança, é preciso saber quem ainda a usa. Registre a versão e o cliente em cada pedido:
// Middleware: registra a versão usada e marca as rotas obsoletas
app.use((req, res, next) => {
const versao = req.path.startsWith("/v1/") ? "v1" : "v2";
metricas.contador("api_pedidos_total", { versao, cliente: req.clienteId ?? "anonimo" }).inc();
if (versao === "v1") {
res.setHeader("Deprecation", "@1767225600");
res.setHeader("Sunset", "Thu, 01 Oct 2026 00:00:00 GMT");
res.setHeader("Link", '<https://docs.loja.com.br/migracao-v2>; rel="deprecation"');
}
next();
});Com essa métrica por cliente, a etapa de aviso deixa de ser um e-mail para todos e passa a ser uma conversa com quem realmente ainda depende da versão antiga.
Comparar o contrato no CI
# Falha se o OpenAPI novo tiver mudanças incompatíveis em relação ao da main
oasdiff breaking openapi-main.yaml openapi.yaml --fail-on ERRFerramentas assim apontam campos removidos, tipos alterados, parâmetros que passaram a ser obrigatórios e valores removidos de enumerações. Acrescentar valores a uma enumeração da resposta nem sempre é apontado como incompatível, e, como o teste deste tópico mostrou, para clientes tipados ele é. Trate essa mudança com o mesmo cuidado.
Lista de revisão
Servidor
- A mudança pode ser feita de forma compatível, com expandir e contrair?
- Campos, enumerações e códigos de status existentes continuam iguais?
- As enumerações que podem crescer estão documentadas como abertas?
- Há comparação de contrato no CI?
- Versões obsoletas devolvem
DeprecationeSunset, e o uso delas é medido por cliente?
Cliente
- O cliente ignora campos desconhecidos?
- Valores desconhecidos de enumerações viram um valor padrão, registrado em log?
- Modos estritos de leitura estão desligados para respostas da API?
- A versão da API está fixada nas chamadas?
Prompts para aprofundar
Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.
Testar meu cliente contra a evolução da API
Quero testar se o cliente da minha API sobrevive à evolução do servidor. Pergunte qual linguagem e biblioteca de JSON eu uso. Vamos em etapas: 1. Escrever um modelo tipado para a resposta de um pedido, com o status como enumeração. 2. Ler uma resposta com um campo novo e ver o que acontece na configuração padrão. 3. Ler uma resposta com um status novo e ver o que acontece. 4. Ativar o modo estrito da biblioteca e repetir. 5. Tornar o cliente tolerante aos dois casos, registrando em log o que não conhece. 6. Transformar tudo em testes automatizados. Em cada etapa, peça que eu preveja o resultado antes de rodar.
Comparar contratos no CI
Quero que o CI do meu projeto detecte mudanças incompatíveis na API antes do deploy. Pergunte se o projeto gera um documento OpenAPI e como, e qual plataforma de CI eu uso. Explique, com a configuração para o meu caso: 1. Como gerar o OpenAPI da branch atual e obter o da branch principal. 2. Como usar uma ferramenta de comparação, como o oasdiff, para apontar mudanças incompatíveis. 3. O que a ferramenta detecta e o que ela pode deixar passar, como valores novos em enumerações da resposta. 4. Como aprovar uma mudança incompatível intencional.
Revisar uma mudança na API
Vou colar abaixo a diferença entre a versão atual e a nova de um documento OpenAPI, ou o código de uma rota antes e depois de uma mudança. Avalie o impacto nos clientes. Para cada mudança encontrada, diga: 1. Se é compatível ou incompatível, e para que tipo de cliente. 2. Se poderia ser feita de forma compatível, e como. 3. Se exige versão nova, e como conduzir a migração. No final, diga se a mudança pode ir para produção como está. Diferença: [cole aqui]