
Versionamento de API no Rails: comece em /api/v1 antes de achar que precisa
TL;DR
Coloque a versão na URL e comece em /api/v1 no primeiro dia. Dê à API um base controller próprio em ActionController::API, dono de auth, envelopes de erro e tagging de transação, e um base fino por versão. Versione serializers e params, não controllers. Aposente a v1 com headers Deprecation e Sunset, facets NRQL por cliente e um 410 ao remover.
A maioria das APIs Rails não é projetada: um branch format.json vira um contrato que ninguém escreveu. Aqui está a estrutura que resolve isso: versão na URL, hierarquia de controllers em três camadas, auth por token fora da árvore versionada, v2 sem copiar a v1 e NRQL para provar quando a v1 pode ser apagada.
O problema começa no respond_to
A maioria das APIs Rails não é projetada. Ela acontece. Um PostsController responde HTML, alguém adiciona um branch format.json para um protótipo mobile e, seis meses depois, há três parceiros chamando GET /posts.json com um formato que só existe porque uma view precisava dele daquele jeito.
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.published
respond_to do |format|
format.html
format.json { render json: @posts }
end
end
end
Não há nada de errado com isso no primeiro dia. O problema é que o branch JSON não tem contrato: ele serializa o que quer que o model exponha, passa pelos mesmos before-actions do app HTML (sessão, CSRF, flash, layouts) e, no momento em que você quer mudar o model Post para o app web, quebra um cliente que você não consegue ver.
No momento em que você tem um consumidor que não controla, você tem uma API, e uma API precisa de três coisas que o controller HTML não oferece: um contrato estável, autenticação própria e uma forma de aposentar formatos antigos. Este post é o setup que eu recomendo para apps Rails que chegam a esse ponto, e para apps novos que preferem não ter essa migração depois.
Coloque a versão no path: /api/v1
Use versionamento no path da URL. É o default certo para o Rails porque o router, o namespace do controller, os logs, os nomes de transação do APM e as chaves de cache do seu CDN enxergam a versão de graça. Há três opções que vale conhecer:
| Estratégia | Exemplo | Encaixe no Rails | Observabilidade | Veredito |
|---|---|---|---|---|
| Path da URL | GET /api/v1/posts | Nativo: namespace :v1 mapeia para Api::V1::PostsController | Versão visível em cada linha de log, NRQL, access log, chave de CDN | Escolha padrão |
| Header Accept | Accept: application/vnd.myapp.v1+json | Exige uma constraint de rota ou um dispatcher no nível da request | Invisível a menos que você adicione um atributo customizado; o curl precisa de uma flag extra | Só se um mandato de design de API exigir |
| Query param | GET /api/posts?version=1 | Trivial de rotear, fácil de esquecer | Visível, mas caches e proxies tratam de forma inconsistente | Evite |
| Header com data (estilo Stripe) | Stripe-Version: 2026-10-20 por conta | Exige uma camada de transformação entre versões | Excelente, mas você está construindo um produto de API, não uma API | Só na escala da Stripe |
O versionamento por header é mais "REST puro" porque a URI do recurso nunca muda. Na prática essa pureza custa depurabilidade: um parceiro cola uma URL num ticket e você não consegue dizer qual versão ele chamou. O versionamento por path torna a versão parte da identidade da request, que é exatamente o que você quer quando precisar contar quem ainda chama a v1.
Duas regras mantêm isso honesto:
v1significa o contrato, não o código. Mudanças aditivas (novos campos, novos endpoints, novos params opcionais) não sobem a versão. Só mudanças que quebram sobem: campos removidos, campos renomeados, tipos alterados, semântica alterada, auth alterada.- Comece em
/api/v1mesmo na primeira API. Uma rota/api/postssem versão é umav0que você vai ter que migrar depois, e até lá todo cliente terá feito hardcode dela.
Rotas e a hierarquia de controllers
A API ganha seu próprio namespace, seu próprio base controller e seu próprio formato default. Nada do lado HTML vaza para dentro.
# config/routes.rb
Rails.application.routes.draw do
resources :posts # HTML app stays where it is
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :posts, only: %i[index show create update destroy]
resources :comments, only: %i[index create]
end
end
end
defaults: { format: :json } significa que /api/v1/posts renderiza JSON sem .json e sem respond_to. Clientes que mandam Accept: text/html por engano continuam recebendo JSON, que é o que você quer de uma API.
A hierarquia tem três camadas, e cada uma tem um trabalho:
# app/controllers/api/base_controller.rb
module Api
class BaseController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :authenticate!
rescue_from ActiveRecord::RecordNotFound do
render json: { error: "not_found" }, status: :not_found
end
rescue_from ActionController::ParameterMissing do |e|
render json: { error: "bad_request", detail: e.message }, status: :bad_request
end
private
def authenticate!
# see the auth section below
end
def current_api_client
@current_api_client
end
end
end
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < Api::BaseController
before_action { response.set_header("X-Api-Version", "1") }
end
end
end
# app/controllers/api/v1/posts_controller.rb
module Api
module V1
class PostsController < Api::V1::BaseController
def index
posts = Post.published.order(created_at: :desc).limit(50)
render json: PostSerializer.render(posts)
end
def show
render json: PostSerializer.render(Post.find(params[:id]))
end
end
end
end
Três decisões nesse código importam:
ActionController::API, nãoApplicationController. Você abre mão de cookies, sessões, CSRF, flash, layouts e dos before-actions do HTML. Proteção CSRF num endpoint JSON autenticado por token é pura fricção e uma fonte comum de 422s que ninguém consegue explicar.Api::BaseControlleré dono das preocupações que atravessam versões: autenticação, envelopes de erro, rate limiting, request IDs, helpers de paginação.Api::V1::BaseControlleré dono apenas do que é específico do contrato v1. Quando av2chegar, ela ganha seu próprio base fino e herda o resto.- Serializers são explícitos.
render json: @postsé o pecado original da era dorespond_to: ele entrega seu schema ao mundo. Use Blueprinter, Alba, jbuilder ou um PORO simples. O serializer é o contrato, e o contrato é o que você versiona.
Apps Rails 7+ gerados com --api já entregam ActionController::API como base. Num app híbrido você mantém o ApplicationController para HTML e adiciona a árvore Api ao lado.
Autenticação: tokens primeiro, OAuth2 quando precisar de delegação
A auth mora em Api::BaseController, não na árvore versionada. Subir de versão nunca deveria mudar como um cliente prova quem ele é; se muda, você tem duas APIs, não duas versões.
Para a maioria das APIs first-party e de parceiros, bearer tokens opacos bastam:
# db/migrate/..._create_api_clients.rb
create_table :api_clients do |t|
t.string :name, null: false
t.string :token_digest, null: false, index: { unique: true }
t.datetime :last_used_at
t.datetime :revoked_at
t.timestamps
end
# app/models/api_client.rb
class ApiClient < ApplicationRecord
def self.authenticate(raw)
return if raw.blank?
find_by(token_digest: Digest::SHA256.hexdigest(raw), revoked_at: nil)
end
def regenerate_token!
raw = SecureRandom.base58(32)
update!(token_digest: Digest::SHA256.hexdigest(raw))
raw # shown once, never stored in plaintext
end
end
# app/controllers/api/base_controller.rb
def authenticate!
@current_api_client = authenticate_with_http_token do |token, _options|
ApiClient.authenticate(token)
end
return if @current_api_client
render json: { error: "unauthorized" }, status: :unauthorized
end
Guarde um digest, nunca o token bruto. O authenticate_with_http_token faz uma comparação em tempo constante no header, e a busca pelo digest significa que um dump vazado do banco não vaza credenciais. Atualize last_used_at de forma assíncrona (um job, ou um update_column com throttle) para que o caminho de auth continue sendo uma única leitura indexada.
Recorra ao OAuth2 apenas quando um terceiro precisar agir em nome de um dos seus usuários: uma integração com Zapier, um app mobile que você não distribui, um marketplace. A resposta padrão do Rails é o Doorkeeper:
# Gemfile
gem "doorkeeper"
# config/routes.rb
use_doorkeeper
# app/controllers/api/base_controller.rb
before_action :doorkeeper_authorize!
def current_user
@current_user ||= User.find(doorkeeper_token.resource_owner_id)
end
O Doorkeeper entrega authorization code + PKCE, client credentials, refresh tokens e scopes. O custo é real: uma UI de autorização, telas de consentimento, rotação de tokens e uma história de revogação. Não pague esse preço por uma API cujos únicos consumidores são o seu próprio frontend e dois parceiros com API keys. JWTs são uma terceira opção; fazem sentido quando um identity provider separado (Cognito, Auth0) os emite e seu app Rails apenas verifica a assinatura, e fazem menos sentido como substituto caseiro de um token no banco que você consegue revogar.
Seja qual for a escolha, exponha a identidade à observabilidade. current_api_client.id é o que você vai usar como facet quando chegar a hora de aposentar a v1.
Entregando a v2 sem copiar a árvore
Não faça cp -r app/controllers/api/v1 app/controllers/api/v2. Uma cópia completa parece segura e é a pior opção: todo bug fix agora cai duas vezes, os testes dobram e, em um ano, ninguém sabe qual cópia é a canônica. Existem três abordagens, e só uma delas escala.
| Abordagem | Como | Quebra quando | Veredito |
|---|---|---|---|
| Copiar a árvore inteira | Api::V2::* duplicado a partir de V1 | Todo fix compartilhado precisa ser aplicado duas vezes; o drift é garantido | Não |
| Herdar entre versões | Api::V2::PostsController < Api::V1::PostsController | Uma mudança na v1 muda a v2 silenciosamente; cadeias de super escondem comportamento | Só para um delta de um endpoint, e mesmo assim prefira a linha seguinte |
| Versionar o contrato, não o código | Controllers finos, serializers e params versionados, endpoints inalterados roteados para a classe existente | Raramente; é mais estrutura antecipada | Sim |
A terceira abordagem se apoia numa observação: uma mudança que quebra quase sempre vive no formato da resposta ou nos params aceitos, não na lógica do controller. Então versione esses, e deixe as rotas mapearem v2 para controllers v1 em tudo que não mudou.
# config/routes.rb
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :posts
resources :comments, only: %i[index create]
end
namespace :v2 do
resources :posts # Api::V2::PostsController, the thing that changed
# comments did not change: reuse the v1 controller under the v2 prefix
resources :comments, only: %i[index create], controller: "/api/v1/comments"
end
end
# app/serializers/api/v1/post_serializer.rb
module Api::V1
class PostSerializer < Blueprinter::Base
identifier :id
fields :title, :body, :created_at
field(:author) { |post| post.author.name } # v1 shipped a string
end
end
# app/serializers/api/v2/post_serializer.rb
module Api::V2
class PostSerializer < Blueprinter::Base
identifier :id
fields :title, :body, :created_at
association :author, blueprint: Api::V2::AuthorSerializer # v2 ships an object: breaking
end
end
# app/controllers/api/v2/posts_controller.rb
module Api::V2
class PostsController < Api::V2::BaseController
def index
posts = Posts::Query.new(current_api_client).published(limit: 50)
render json: PostSerializer.render(posts)
end
end
end
O corpo do controller é quase idêntico ao da v1 porque deveria ser: ele delega para um query object ou service que as duas versões compartilham. O que difere é o Api::V2::PostSerializer e, se a entrada aceita mudou, um objeto Api::V2::PostParams. A lógica de negócio fica em app/models e app/services, escrita uma vez.
Alguns guardrails:
- Mantenha um request spec por versão que afirme o schema JSON completo (
json_schemerou uma fixture commitada). O serializer é o contrato; o spec é o que torna verdadeiro o "v1 está congelada". - Se um endpoint realmente precisa de lógica diferente por versão, esse é o único lugar onde herança é aceitável:
Api::V2::PostsController < Api::V1::PostsControllersobrescrevendo uma única action. Trate como um smell a revisitar, não como um padrão. - Nunca deixe a
v2ser "v1 mais limpeza". Se você não está quebrando nada, adicione o campo na v1 e pule a versão.
Num app híbrido que ainda tem o PostsController HTML servindo JSON desde o início, aquele endpoint é efetivamente a v0. Mova-o para /api/v1 com um serializer que reproduza exatamente o formato antigo, redirecione ou mantenha a rota antiga como alias por um ciclo de deprecação, e trate a v1 como o primeiro contrato de verdade.
Aposentando a v1: headers Sunset e NRQL
Uma versão que você não consegue apagar é dívida técnica com URL pública. Apagar exige duas coisas: um sinal aos clientes de que ela vai sumir, e evidência de que eles pararam de chamá-la. Monitorar com NRQL é a forma certa de obter essa evidência, com uma ressalva: casar URLs cruas não basta. Marque a transação.
Primeiro, faça cada request da API carregar a versão e o cliente como atributos customizados:
# app/controllers/api/base_controller.rb
before_action :tag_transaction
private
def api_version
self.class.module_parent.name.demodulize.downcase # "v1", "v2"
end
def tag_transaction
NewRelic::Agent.add_custom_attributes(
api_version: api_version,
api_client_id: current_api_client&.id,
api_client_name: current_api_client&.name
)
end
module_parent é resolvido a partir da classe do controller, então um controller v1 reutilizado em /api/v2/comments continua reportando v1, que é a resposta honesta sobre qual código está rodando. Se o que importa para você é o prefixo da URL, leia de request.path.
Depois, marque a versão antiga como deprecada em toda resposta, conforme a RFC 9745 (Deprecation) e a RFC 8594 (Sunset). Repare nos dois formatos: Deprecation recebe uma data de Structured Field (@ mais um timestamp Unix), Sunset recebe uma data HTTP.
# app/controllers/api/v1/base_controller.rb
DEPRECATED_AT = Time.utc(2026, 10, 20)
SUNSET_AT = Time.utc(2027, 4, 1)
before_action do
response.set_header("Deprecation", "@#{DEPRECATED_AT.to_i}")
response.set_header("Sunset", SUNSET_AT.httpdate)
response.set_header("Link", '</docs/api/v2-migration>; rel="deprecation"')
end
Agora o NRQL. Quem ainda está na v1, e quanto:
SELECT count(*) FROM Transaction
WHERE appName = 'my-app (Production)' AND api_version = 'v1'
FACET api_client_name
SINCE 30 days ago
A migração está realmente acontecendo, ou o e-mail de deprecação foi para o spam:
SELECT count(*) FROM Transaction
WHERE appName = 'my-app (Production)' AND api_version IN ('v1', 'v2')
FACET api_version TIMESERIES 1 day
SINCE 90 days ago
Quais endpoints v1 podem ser apagados hoje, porque ninguém encostou neles:
SELECT count(*) FROM Transaction
WHERE appName = 'my-app (Production)' AND api_version = 'v1'
FACET name
SINCE 60 days ago
LIMIT MAX
Qualquer transação Api::V1::* ausente desse resultado teve zero chamadas em 60 dias. Apague essas primeiro; são as vitórias mais baratas e reduzem a superfície restante.
Dois alertas tornam o processo autônomo:
- Um alerta NRQL em
api_version = 'v1'cruzando zero por 30 dias consecutivos. Quando dispara, o endpoint está morto e o PR para removê-lo pode subir sem reunião. - Um alerta NRQL em
api_version = 'v1'vindo de um cliente que antes estava nav2, o que pega um parceiro fazendo deploy de um rollback e readotando o contrato antigo em silêncio.
Os critérios de remoção, em ordem:
- A data de
Sunsetpassou. - 30 dias de tráfego zero na v1 em produção, verificados pela query com facet, não por "ninguém reclamou".
- Todo cliente v1 foi contatado ao menos uma vez, com resposta ou bounce documentado.
- Os request specs da v1 são apagados no mesmo PR dos controllers e serializers, para que uma remoção pela metade não passe no CI.
Depois do passo 4, /api/v1/* deve retornar 410 Gone com um body apontando para o guia de migração, por pelo menos mais um ciclo. Um 404 parece bug; um 410 parece decisão.
O checklist
- Rotas da API vivem em
namespace :apicomdefaults: { format: :json }, começando emv1desde o primeiro dia Api::BaseController < ActionController::APIé dono da auth, dos envelopes de erro e do tagging de transação- Um
Api::VN::BaseControllerfino por versão; nada mais nele a menos que o contrato da versão exija - Bearer tokens guardados como digests; Doorkeeper só quando terceiros agem em nome de usuários
- Serializers explícitos por versão; lógica de negócio em models e services, escrita uma vez
- Nova versão só para mudanças que quebram; endpoints inalterados roteados para o controller existente
- Request specs que fixam o schema JSON de cada versão
api_versioneapi_client_idcomo atributos customizados em toda transação da API- Headers
DeprecationeSunsetna versão antiga, com um guia de migração datado - Facets NRQL por cliente e endpoint, um alerta de tráfego zero e um 410 após a remoção
Nada disso é esperto. É a estrutura chata que impede uma API Rails de virar aquilo que você tem medo de tocar. O retorno vem no dia em que um parceiro pergunta "dá para mudar o campo author?" e a resposta é um serializer novo, um bloco de rotas e um dashboard que diz quando a versão antiga pode ir embora.
Fontes
- ActionController::API (docs da API do Rails) api.rubyonrails.org
- Using Rails for API-only Applications (Rails Guides) guides.rubyonrails.org
- ActionController::HttpAuthentication::Token (docs da API do Rails) api.rubyonrails.org
- Doorkeeper: provedor OAuth 2 para Rails github.com
- Blueprinter: serializer JSON para Ruby github.com
- json_schemer: validador de JSON Schema para Ruby github.com
- RFC 8594: The Sunset HTTP Header Field www.rfc-editor.org
- RFC 9745: The Deprecation HTTP Response Header Field www.rfc-editor.org
- Guia da API do agente Ruby da New Relic (atributos customizados) docs.newrelic.com
- Versionamento da API da Stripe docs.stripe.com
Perguntas frequentes
Devo versionar pelo path da URL ou pelo header Accept no Rails?
Pelo path. `namespace :v1` mapeia direto para `Api::V1::*`, e a versão aparece em logs, nomes de transação do APM, access logs e chaves de cache do CDN sem trabalho extra. Versionar por header mantém a URI estável, mas esconde a versão de toda ferramenta que lê URLs, incluindo o parceiro que cola um link num ticket.
Quando uma mudança exige uma nova versão da API?
Só quando ela quebra um cliente existente: um campo removido ou renomeado, um tipo alterado, semântica alterada ou auth alterada. Adicionar campos, endpoints ou params opcionais é aditivo e sai na versão atual. Se a v2 seria "v1 mais limpeza", coloque a limpeza na v1.
Por que ActionController::API em vez de ApplicationController na árvore da API?
Ele descarta cookies, sessões, CSRF, flash e layouts. Um endpoint JSON autenticado por token não ganha nada com proteção CSRF além de 422s inexplicáveis, e os before-actions do app HTML nunca deveriam rodar em requests da API. Num app híbrido, mantenha o `ApplicationController` para HTML e adicione a árvore `Api` ao lado.
Bearer tokens ou Doorkeeper?
Bearer tokens opacos guardados como digests SHA-256 cobrem APIs first-party e de parceiros. Recorra ao Doorkeeper (OAuth2) só quando um terceiro precisar agir em nome de um dos seus usuários, porque ele traz UI de consentimento, rotação de tokens e fluxos de revogação. JWTs se encaixam quando um identity provider externo os emite e o Rails só verifica assinaturas.
Como entregar a v2 sem duplicar os controllers da v1?
Versione o contrato, não o código: um `Api::V2::BaseController` fino, serializers e objetos de params versionados, e query objects ou services compartilhados. Nas rotas, aponte os endpoints v2 inalterados para o controller v1 existente com `controller: "/api/v1/comments"`, para que só os endpoints que realmente mudaram ganhem uma classe nova.
Como saber quando a v1 pode ser apagada?
Marque toda transação da API com `api_version` e `api_client_id`, depois use facets no NRQL por cliente e por endpoint. Envie headers `Deprecation` e `Sunset` com um link de migração, espere a data de Sunset mais 30 dias de tráfego zero na v1, contate cada cliente restante e apague controllers, serializers e specs num único PR. Retorne 410 Gone por mais um ciclo.
Comentários
Faça login com GitHub para comentar.
Ainda não há comentários.