Skip to content
Versionamento de API no Rails: comece em /api/v1 antes de achar que precisa

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.

Publicado em 13 min de leitura

Também disponível em English

Compartilhar

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égiaExemploEncaixe no RailsObservabilidadeVeredito
Path da URLGET /api/v1/postsNativo: namespace :v1 mapeia para Api::V1::PostsControllerVersão visível em cada linha de log, NRQL, access log, chave de CDNEscolha padrão
Header AcceptAccept: application/vnd.myapp.v1+jsonExige uma constraint de rota ou um dispatcher no nível da requestInvisível a menos que você adicione um atributo customizado; o curl precisa de uma flag extraSó se um mandato de design de API exigir
Query paramGET /api/posts?version=1Trivial de rotear, fácil de esquecerVisível, mas caches e proxies tratam de forma inconsistenteEvite
Header com data (estilo Stripe)Stripe-Version: 2026-10-20 por contaExige uma camada de transformação entre versõesExcelente, mas você está construindo um produto de API, não uma APISó 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:

  1. v1 significa 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.
  2. Comece em /api/v1 mesmo na primeira API. Uma rota /api/posts sem versão é uma v0 que 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ão ApplicationController. 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 a v2 chegar, ela ganha seu próprio base fino e herda o resto.
  • Serializers são explícitos. render json: @posts é o pecado original da era do respond_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.

AbordagemComoQuebra quandoVeredito
Copiar a árvore inteiraApi::V2::* duplicado a partir de V1Todo fix compartilhado precisa ser aplicado duas vezes; o drift é garantidoNão
Herdar entre versõesApi::V2::PostsController < Api::V1::PostsControllerUma mudança na v1 muda a v2 silenciosamente; cadeias de super escondem comportamentoSó para um delta de um endpoint, e mesmo assim prefira a linha seguinte
Versionar o contrato, não o códigoControllers finos, serializers e params versionados, endpoints inalterados roteados para a classe existenteRaramente; é mais estrutura antecipadaSim

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_schemer ou 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::PostsController sobrescrevendo uma única action. Trate como um smell a revisitar, não como um padrão.
  • Nunca deixe a v2 ser "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 na v2, 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:

  1. A data de Sunset passou.
  2. 30 dias de tráfego zero na v1 em produção, verificados pela query com facet, não por "ninguém reclamou".
  3. Todo cliente v1 foi contatado ao menos uma vez, com resposta ou bounce documentado.
  4. 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 :api com defaults: { format: :json }, começando em v1 desde o primeiro dia
  • Api::BaseController < ActionController::API é dono da auth, dos envelopes de erro e do tagging de transação
  • Um Api::VN::BaseController fino 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_version e api_client_id como atributos customizados em toda transação da API
  • Headers Deprecation e Sunset na 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

  1. ActionController::API (docs da API do Rails) api.rubyonrails.org
  2. Using Rails for API-only Applications (Rails Guides) guides.rubyonrails.org
  3. ActionController::HttpAuthentication::Token (docs da API do Rails) api.rubyonrails.org
  4. Doorkeeper: provedor OAuth 2 para Rails github.com
  5. Blueprinter: serializer JSON para Ruby github.com
  6. json_schemer: validador de JSON Schema para Ruby github.com
  7. RFC 8594: The Sunset HTTP Header Field www.rfc-editor.org
  8. RFC 9745: The Deprecation HTTP Response Header Field www.rfc-editor.org
  9. Guia da API do agente Ruby da New Relic (atributos customizados) docs.newrelic.com
  10. 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.