Rails API Versiebeheer: URL Namespaces, Header Routing en Nette Deprecatie
Rails API versiebeheer goed aanpakken: URL namespaces, Accept header routing, controller overerving en Sunset headers. Volledige codevoorbeelden included.
De klant belde op een dinsdagmiddag. Hun mobiele app was live in driehonderdduizend handen en ze hadden net ontdekt dat het datumformaat dat we teruggaven voor created_at in de orders-API fout was — niet fout volgens onze definitie, maar fout volgens die van het iOS-team, dat ISO 8601 met milliseconden verwachtte terwijl wij gewoon ISO 8601 zonder milliseconden hadden opgeleverd. Een fix in onze serializer zou de bestaande app breken. De fix niet uitvoeren zou de nieuwe versie blijven breken.
We hadden geen versiebeheer. Elke client zat vastgepind aan één enkel endpoint. De fix kostte vier maanden om te onderhandelen, te coördineren en uiteindelijk uit te rollen — releasecycli van de client, gefaseerde uitrol, feature flags aan de mobiele kant, en zes weken lang beide serializer-formaten tegelijk draaien via een request-header die iemand van ons op een vrijdagavond om elf uur had toegevoegd.
Na negentien jaar Rails heb ik API’s op beide manieren opgeleverd: met versiebeheer vanaf dag één en zonder. De kosten van het achteraf toevoegen van versiebeheer zijn altijd hoger dan de kosten van het meteen goed aanpakken. Dit is hoe ik het doe.
Waarom URL-gebaseerd Rails API Versiebeheer de Juiste Standaard Is
Er zijn drie hoofdbenaderingen voor Rails API versiebeheer: URL-pad (/api/v1/), Accept-header en een custom header (X-API-Version). Ik heb alle drie gebruikt. URL-versiebeheer is de juiste standaard voor vrijwel elke API die ik bouw, en de argumenten voor de alternatieven overleven de confrontatie met productie zelden.
URL-versiebeheer is expliciet, cachebaar en debugbaar. Een URL als https://api.example.com/v2/orders/123 vertelt iedereen — je logs, je load balancer, je browserdevtools, de curl-regel van de klant — precies welke versie ze raken. Accept-header-versiebeheer verstopt die informatie in een request-header die de meeste loggingconfiguraties standaard niet vastleggen, en die bijna iedere front-endontwikkelaar minstens één keer vergeet in te stellen.
Het bezwaar dat ik het vaakst hoor: “URL’s moeten permanent zijn; een versienummer toevoegen is fout.” Dat geldt voor voor mensen leesbare webpagina’s. API’s zijn contracten tussen machines. Als je het contract breekt, is het versienummer in de URL een feature, geen vervuiling.
Custom header-versiebeheer (X-API-Version: 2) deelt alle debugproblemen van Accept-header-versiebeheer, zonder de semantische legitimiteit. Ik heb het in de praktijk nooit beter zien werken dan de alternatieven.
URL-gebaseerd API Versiebeheer Opzetten in Rails
De routingstructuur voor een geversioneerde Rails API:
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :orders, only: [:index, :show, :create]
resources :customers, only: [:index, :show]
end
namespace :v2 do
resources :orders, only: [:index, :show, :create, :update]
resources :customers
end
end
end
Dit levert routes op als GET /api/v1/orders en GET /api/v2/orders/123. De bijbehorende controllerlocaties:
app/controllers/api/v1/orders_controller.rb → Api::V1::OrdersController
app/controllers/api/v2/orders_controller.rb → Api::V2::OrdersController
Maak een gedeelde basiscontroller voor authenticatie en algemene foutafhandeling, plus een dunne basis per versie:
# app/controllers/api/base_controller.rb
module Api
class BaseController < ApplicationController
protect_from_forgery with: :null_session
before_action :authenticate_api_token!
respond_to :json
rescue_from ActiveRecord::RecordNotFound do |e|
render json: { error: "Not found", message: e.message }, status: :not_found
end
rescue_from ActiveRecord::RecordInvalid do |e|
render json: { error: "Validation failed", errors: e.record.errors.full_messages },
status: :unprocessable_entity
end
private
def authenticate_api_token!
token = request.headers["Authorization"]&.delete_prefix("Bearer ")
@current_api_user = ApiToken.active.find_by(token: token)&.user
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_api_user
end
end
end
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < Api::BaseController
end
end
end
# app/controllers/api/v2/base_controller.rb
module Api
module V2
class BaseController < Api::BaseController
end
end
end
De controllers van elke versie erven van hun eigen basiscontroller, die erft van de gedeelde Api::BaseController. Authenticatie, foutafhandeling en alle cross-cutting concerns leven in de gedeelde basis en gelden automatisch voor alle versies.
Logica Delen Tussen Versies via Controller Overerving
De meest gemaakte fout bij Rails API versiebeheer is de V1-controller integraal kopiëren naar de V2-map en dan aanpassen. Dan heb je twee kopieën te onderhouden, twee plekken waar bugs kunnen schuilen, en een diff die de volgende engineer verward achterlaat.
Betere aanpak: V2 overschrijft alleen wat veranderd is.
# app/controllers/api/v1/orders_controller.rb
module Api
module V1
class OrdersController < V1::BaseController
def index
orders = current_api_user.orders.order(created_at: :desc).page(params[:page])
render json: orders.map { |o| serialize_order(o) }
end
def show
order = current_api_user.orders.find(params[:id])
render json: serialize_order(order)
end
def create
order = current_api_user.orders.create!(order_params)
render json: serialize_order(order), status: :created
end
private
def serialize_order(order)
{
id: order.id,
status: order.status,
total: order.total.to_f,
created_at: order.created_at.iso8601 # V1: zonder milliseconden
}
end
def order_params
params.require(:order).permit(:customer_id, :notes)
end
end
end
end
# app/controllers/api/v2/orders_controller.rb
module Api
module V2
class OrdersController < V1::OrdersController # erft van V1
def update
order = current_api_user.orders.find(params[:id])
order.update!(order_params)
render json: serialize_order(order)
end
private
def serialize_order(order)
super.merge(
created_at: order.created_at.iso8601(3), # V2: millisecondeprecisie
updated_at: order.updated_at.iso8601(3),
line_items: order.line_items.map { |li|
{ id: li.id, sku: li.sku, quantity: li.quantity }
}
)
end
def order_params
super.merge(params.require(:order).permit(:status, :priority))
end
end
end
end
V2’s OrdersController erft van V1::OrdersController, overschrijft serialize_order om milliseconde-tijdstempels en regelitems toe te voegen, en voegt de update-action toe. Alle overige actions — index, show, create, authenticatie, foutafhandeling — komen gratis uit V1. Als je een bug fixet in V1’s index, krijgt V2 die fix automatisch, tenzij V2 die action heeft overschreven.
Dit patroon werkt alleen wanneer V2 een stricte superset is van V1. Als V2 gedrag verandert dat V1-clients zou breken bij toepassing op V1, moeten de controllers uiteen lopen. Accepteer die duplicatie en beschouw het als de prijs van de breaking change.
Accept-header Versiebeheer: Wanneer Het Zinvol Is
Accept-header versiebeheer (Accept: application/vnd.myapp.v2+json) is de REST-puristische keuze. URL’s blijven stabiel tussen versies en routing verloopt via content negotiation.
Voor implementatie in Rails heb je een routing constraint nodig:
# app/constraints/api_version_constraint.rb
class ApiVersionConstraint
def initialize(version:, default: false)
@version = version
@default = default
end
def matches?(request)
@default || request.headers["Accept"].to_s.include?("application/vnd.myapp.v#{@version}+json")
end
end
# config/routes.rb
Rails.application.routes.draw do
namespace :api, defaults: { format: :json } do
scope module: :v2, constraints: ApiVersionConstraint.new(version: 2) do
resources :orders
end
scope module: :v1, constraints: ApiVersionConstraint.new(version: 1, default: true) do
resources :orders
end
end
end
Ik gebruik dit patroon wanneer ik een API bouw voor clients die ik zelf beheer — een mobiele app en een web-SPA van mijn eigen team, waarbij ik er zeker van kan zijn dat de Accept-header correct wordt ingesteld. Voor publieke API’s waarbij ik niet alle clients kan controleren, kies ik standaard voor URL-versiebeheer. De Accept-header ontbreekt in de meeste curl-aanroepen en API-playground-tools, waardoor derde partijen de standaardversie raken of ze dat nou willen of niet.
Serializer Versiebeheer
Het controller-overervingspatroon hierboven bevat serialisatie in de controller, wat werkt voor kleine API’s. Voor iets dat meer dan een handvol resources omvat, extraheer je de serializers naar eigen klassen.
Met de alba-gem (snel, minimalistisch, geen DSL-magie):
# app/serializers/api/v1/order_serializer.rb
module Api
module V1
class OrderSerializer
include Alba::Resource
attributes :id, :status
attribute(:total) { |o| o.total.to_f }
attribute(:created_at) { |o| o.created_at.iso8601 }
end
end
end
# app/serializers/api/v2/order_serializer.rb
module Api
module V2
class OrderSerializer < V1::OrderSerializer
attribute(:created_at) { |o| o.created_at.iso8601(3) }
attribute(:updated_at) { |o| o.updated_at.iso8601(3) }
association :line_items, serializer: V2::LineItemSerializer
end
end
end
De V2-controller wordt dan:
module Api
module V2
class OrdersController < V1::OrdersController
def show
order = current_api_user.orders.find(params[:id])
render json: V2::OrderSerializer.new(order).serialize
end
end
end
end
Duidelijke scheiding van verantwoordelijkheden: de controller regelt authenticatie, autorisatie en inputvalidatie. De serializer regelt de outputvorm. Elke versie overschrijft alleen de attributen die veranderd zijn.
Oude API Versies Afschrijven met Sunset Headers
Een API-versie verwijderen is het moeilijke deel. Het technische werk kost een dag; de afstemming met clients kost maanden. Dit is het raamwerk dat ik gebruik om dat beheersbaar te maken.
De IETF Sunset-header (Sunset: Sat, 31 Dec 2026 23:59:59 GMT) signaleert aan API-clients dat een endpoint op een bepaalde datum buiten gebruik wordt gesteld. Gecombineerd met een Deprecation-header en een Link naar migratiedocumentatie, creëert dit een machine-leesbaar afschrijvingsspoor.
Voeg dit toe aan de basiscontroller van V1 zodra de shutdowndatum bekend is:
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < Api::BaseController
SUNSET_DATE = Time.utc(2026, 12, 31, 23, 59, 59).freeze
before_action :add_deprecation_headers
private
def add_deprecation_headers
response.headers["Sunset"] = SUNSET_DATE.httpdate
response.headers["Deprecation"] = "true"
response.headers["Link"] = \
'<https://api.example.com/docs/migration-v1-v2>; rel="deprecation"'
end
end
end
end
Log V1-gebruik om bij te houden welke clients nog actief zijn:
before_action :log_v1_usage
def log_v1_usage
Rails.logger.warn(
event: "api_v1_usage",
path: request.path,
client_ip: request.remote_ip,
user_agent: request.user_agent,
api_user_id: @current_api_user&.id,
days_until_sunset: ((SUNSET_DATE - Time.current) / 86_400).ceil
)
end
Met gestructureerde logs in je observability-stack — zie de OpenTelemetry en Rails 8 gids voor de opzet — bouw je een dashboard dat V1-requestvolume over de tijd laat zien en identificeert welke API-gebruikers nog niet zijn gemigreerd. Benader ze proactief per e-mail. Verschuif de sunsetdatum als grote clients meer tijd nodig hebben. Verwijder V1 volledig zodra de logs twee weken geen verkeer meer tonen; één maand bij voorkeur.
Versioned APIs Testen
Test de controller van elke versie onafhankelijk in request-specs. Deel geen specs tussen versies — elk spec-bestand documenteert het contract voor die specifieke versie en moet breken als dat contract verandert.
# spec/requests/api/v2/orders_spec.rb
RSpec.describe "GET /api/v2/orders/:id", type: :request do
let(:user) { create(:user) }
let(:token) { create(:api_token, user: user) }
let(:order) { create(:order, customer: user, line_items: create_list(:line_item, 2)) }
before do
get "/api/v2/orders/#{order.id}",
headers: { "Authorization" => "Bearer #{token.token}" }
end
it "geeft de order terug met milliseconde-tijdstempels en regelitems" do
expect(response).to have_http_status(:ok)
body = JSON.parse(response.body)
expect(body["created_at"]).to match(/\.\d{3}Z$/)
expect(body["line_items"]).to be_an(Array).and have(2).items
end
end
# spec/requests/api/v1/orders_spec.rb
RSpec.describe "GET /api/v1/orders/:id", type: :request do
let(:user) { create(:user) }
let(:token) { create(:api_token, user: user) }
let(:order) { create(:order, customer: user) }
before do
get "/api/v1/orders/#{order.id}",
headers: { "Authorization" => "Bearer #{token.token}" }
end
it "geeft de order terug zonder milliseconden en zonder line_items sleutel" do
expect(response).to have_http_status(:ok)
body = JSON.parse(response.body)
expect(body["created_at"]).not_to match(/\.\d{3}Z$/)
expect(body).not_to have_key("line_items")
end
end
De RSpec en Factory Bot patronen post behandelt het opnemen en afspelen van HTTP-fixtures voor externe afhankelijkheden — nuttig wanneer je API een third-party service aanroept die per versie verschilt.
Wanneer Je Versiebeheer Kunt Overslaan
Niet elke API heeft versiebeheer nodig. Als je alle clients beheert — een Rails-app die praat met zijn eigen API-laag, een interne microservice met gecoördineerde deployments — voegt versiebeheer rompslomp toe zonder waarde. Verander gewoon het endpoint. Deploye de client en server samen.
Versiebeheer is voor API’s waarbij je clientupdates niet kunt afstemmen op serverdeployments. Publieke API’s. Mobiele apps met releasecycli buiten jouw controle. Third-party integraties waarbij de clientcode in een externe repository leeft. Als dat allemaal ontbreekt, sla de namespace-overhead over en houd de routes eenvoudig.
Veelgestelde Vragen
Moet ik Rails API versiebeheer vanaf dag één toevoegen?
Als je een publieke API bouwt of een API die wordt gebruikt door mobiele apps die je niet beheert, ja — voeg het /v1/-voorvoegsel toe voordat je het eerste endpoint uitbrengt. De kosten van het achteraf toevoegen ervan, wanneer clients al in productie zijn, zijn precies de situatie uit het openingsverhaal van dit artikel. Als je een interne API bouwt met gecoördineerde deployments, is versiebeheer optionele overhead.
Wat is de beste gem voor Rails API versiebeheer?
Er is geen gem die Rails API-versiebeheer beter afhandelt dan de ingebouwde routing-namespace. Third-party gems zoals versionist bestaan, maar voegen abstractie toe terwijl Rails’ routing-DSL al expressief genoeg is. Het routing-constraint-patroon voor header-gebaseerd versiebeheer is twintig regels gewone Ruby. Ik heb geen gem gevonden die de aanpak genoeg verbetert om de dependency te rechtvaardigen.
Hoe ga ik om met breaking changes zonder de versie te verhogen?
Dat doe je niet. Elke wijziging die bestaande clients breekt, is per definitie een nieuwe versie. Het toevoegen van velden en nieuwe optionele parameters is achterwaarts compatibel en vereist geen versiesprong. Het wijzigen van het type van een veld, het verwijderen van een veld of het aanpassen van de vorm van foutresponsen zijn breaking changes die een nieuwe versie vereisen. Twijfel je? Lever dan zowel het oude als het nieuwe veld tegelijk in de huidige versie, markeer het oude veld als verouderd in de documentatie, en verwijder het in de volgende versie.
Hoe werkt Rails API versiebeheer samen met Kamal deployments?
Helemaal niet, en dat is precies wat je wilt. Versiebeheer is een routing- en controllerprobleem; het deploymentmechanisme is er blind voor. Een zero-downtime Kamal 2 deploy die containers één voor één doorloopt, bedient V1- en V2-verkeer correct gedurende de hele uitrol, omdat beide versie-namespaces in dezelfde gedeployde codebase bestaan. Je hoeft deployments alleen te coördineren wanneer je een versie verwijdert — en zelfs dan is de coördinatie met API-clients, niet met de deploymentpipeline.
Onderhoud je een Rails API die moeilijk uitbreidbaar is geworden omdat versiebeheer achteraf werd toegevoegd? TTB Software helpt teams API-contracten te herontwerpen, nette versiestrategieën te implementeren en clients te migreren zonder downtime. We doen dit al negentien jaar.
Related Articles
Solid Queue Recurring Jobs: Vervang Whenever en Sidekiq-Cron in Rails 8
Solid Queue recurring jobs vervangen whenever en sidekiq-cron in Rails 8. Leer recurring task configuratie, dispatche...
Rails Turbo Morphing: Realtime DOM-updates met broadcasts_refreshes
Rails Turbo Morphing patcht de DOM chirurgisch bij een paginavernieuwing. Leer broadcasts_refreshes, scroll-anchoring...
RSpec Rails: Factory Bot, VCR en de Testpatronen die Echt Schalen
RSpec Rails goed opgezet — Factory Bot associaties, VCR voor externe APIs, gedeelde voorbeelden en CI-trucs die je Ra...