RUBY ON RAILS · 18 MIN READ ·

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.

Rails API Versiebeheer: URL Namespaces, Header Routing en Nette Deprecatie

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.

#rails-api-versioning #rails-api-namespace-routing #accept-header-api-versioning-rails #rails-api-deprecation-sunset-header #rails-api-v2-migration #rails-versioned-api-controller

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software