Kulcare Docs

API Versioning Guide

Status: Guideline Date: 2026-01-06 Context: Reference domain implementation, header-based API versioning


Overview

This guide describes the header-based API versioning strategy used across all new schema domains. This approach provides clean, domain-focused URLs with version information in request headers.


Philosophy

Clean, domain-focused URLs with optional header-based versioning - Modern REST approach.

Key Principles

  1. URLs reflect domain model - /reference/countries not /api/v2/reference/countries
  2. Version in headers - Accept: application/vnd.kulcare.v2+json
  3. Simple namespace - Reference::CountriesController not Api::V2::Reference::CountriesController
  4. Easy evolution - Add new versions without changing URLs
  5. Industry standard - Used by GitHub, Stripe, AWS

Directory Structure

domains/
└── reference/
    ├── controllers/
    │   ├── base_controller.rb              # Reference::BaseController
    │   ├── countries_controller.rb         # Reference::CountriesController
    │   ├── states_controller.rb            # Reference::StatesController
    │   ├── cities_controller.rb            # Reference::CitiesController
    │   ├── languages_controller.rb         # Reference::LanguagesController
    │   ├── currencies_controller.rb        # Reference::CurrenciesController
    │   ├── specialties_controller.rb       # Reference::SpecialtiesController
    │   ├── facility_types_controller.rb    # Reference::FacilityTypesController
    │   ├── identifier_systems_controller.rb # Reference::IdentifierSystemsController
    │   ├── reference_data_controller.rb    # Reference::ReferenceDataController
    │   └── v2/  # Optional: version-specific overrides
    │       └── ...
    ├── models/
    │   ├── country.rb
    │   ├── state.rb
    │   └── ...
    ├── blueprints/
    │   ├── country_blueprint.rb            # Default blueprint
    │   ├── v2/
    │   │   └── country_blueprint.rb        # V2-specific blueprint
    │   └── v3/  # Future
    │       └── country_blueprint.rb        # V3-specific blueprint
    └── routes.rb

Zeitwerk Configuration

File: config/application.rb (lines 105-110)

# Load controllers directory with Reference namespace
controllers_path = File.join(domain_dir, 'controllers')
if File.directory?(controllers_path)
  loader.push_dir(controllers_path, namespace: namespace)
  # namespace = Reference, so:
  # controllers/countries_controller.rb → Reference::CountriesController
end

How It Works:

File: domains/reference/controllers/countries_controller.rb
                                        ↓
                    Directory structure defines namespace
                                        ↓
                          Reference::CountriesController

Routes

File: domains/reference/routes.rb

Rails.application.routes.draw do
  # Clean, domain-focused routes
  scope :reference do
    # Bulk data endpoints
    get 'all', to: 'reference_data#all'
    get 'geography', to: 'reference_data#geography'

    # Geographic data
    resources :countries, only: [:index, :show] do
      member do
        get :states
      end
    end

    resources :states, only: [:index, :show] do
      member do
        get :cities
      end
    end

    resources :cities, only: [:index, :show]

    # Localization data
    resources :languages, only: [:index, :show]
    resources :currencies, only: [:index, :show]

    # Healthcare data
    resources :specialties, only: [:index, :show] do
      collection do
        get :tree
      end
      member do
        get :children
      end
    end

    resources :facility_types, only: [:index, :show]

    # Identifier systems
    resources :identifier_systems, only: [:index, :show] do
      collection do
        get :available
      end
    end
  end
end

Version Detection Middleware

File: app/middleware/api_version_detection.rb

# frozen_string_literal: true

# Middleware to detect API version from request headers
class ApiVersionDetection
  def initialize(app)
    @app = app
  end

  def call(env)
    request = ActionDispatch::Request.new(env)
    version = detect_version(request)
    env['api.version'] = version if version
    @app.call(env)
  end

  private

  def detect_version(request)
    # Strategy 1: Accept header (preferred)
    accept_header = request.headers['Accept']
    return "v#{$1}" if accept_header&.match(/application\/vnd\.kulcare\.v(\d+)\+json/)

    # Strategy 2: Custom header
    custom_header = request.headers['X-API-Version']
    return custom_header if custom_header&.match(/^v\d+$/)

    # Strategy 3: Query parameter (testing)
    query_param = request.params['api_version']
    return query_param if query_param&.match(/^v\d+$/)

    nil # No version specified, use default
  end
end

Register Middleware: config/application.rb

config.middleware.use ApiVersionDetection

Controller Concern

File: domains/shared/concerns/api_versioning.rb

# frozen_string_literal: true

module Shared
  module Concerns
    module ApiVersioning
      extend ActiveSupport::Concern

      included do
        before_action :set_api_version
      end

      private

      def set_api_version
        @api_version = request.env['api.version'] || 'v2' # Default to v2
      end

      def current_api_version
        @api_version
      end

      def api_version?(version)
        @api_version == version.to_s
      end
    end
  end
end

Include in Base Controller: domains/shared/base_controller.rb

class BaseController < ApiController
  include Shared::Concerns::ApiVersioning
  # ...
end

Example Controller

File: domains/reference/controllers/countries_controller.rb

# frozen_string_literal: true

module Reference
  class CountriesController < BaseController
    # GET /reference/countries
    def index
      countries = ::Reference::Country.alphabetical
      countries = apply_active_filter(countries)
      countries = apply_search_filter(countries)

      # Version-specific blueprint
      blueprint = blueprint_for_version(:country)

      render_blueprint_collection(countries, blueprint)
    end

    # GET /reference/countries/:id
    def show
      country = ::Reference::Country.find(params[:id])
      render_blueprint(country, ::Reference::CountryBlueprint, view: :detailed)
    end

    # GET /reference/countries/:id/states
    def states
      country = ::Reference::Country.find(params[:id])
      states = country.states.active.alphabetical

      render_blueprint_collection(states, ::Reference::StateBlueprint)
    end

    private

    # Select blueprint based on API version
    def blueprint_for_version(resource_name)
      version = current_api_version # 'v2', 'v3', etc.

      # Try to load version-specific blueprint
      blueprint_class = "::Reference::#{version.camelize}::#{resource_name.camelize}Blueprint".safe_constantize

      # Fall back to default blueprint
      blueprint_class || "::Reference::#{resource_name.camelize}Blueprint".constantize
    end
  end
end

Base Controller

File: domains/reference/controllers/base_controller.rb

# frozen_string_literal: true

module Reference
  class BaseController < Shared::BaseController
    # Skip authentication for reference endpoints - data is public
    skip_before_action :authenticate_user, raise: false

    # Skip tenant context for reference endpoints - data is global
    skip_before_action :set_tenant_from_jwt, raise: false
    skip_before_action :set_rls_context, raise: false
    skip_before_action :clear_rls_context, raise: false

    protected

    # ... filter methods (apply_active_filter, apply_search_filter, etc.)
  end
end

API Endpoints

All versions use the same URLs, version specified in headers:

# V2 with Accept header (preferred)
GET /reference/countries
Accept: application/vnd.kulcare.v2+json

# V2 with custom header
GET /reference/countries
X-API-Version: v2

# V2 with query parameter (testing only)
GET /reference/countries?api_version=v2

# No version specified (uses default: v2)
GET /reference/countries

Available Endpoints:

GET /reference/countries
GET /reference/countries/:id
GET /reference/countries/:id/states
GET /reference/states
GET /reference/states/:id
GET /reference/states/:id/cities
GET /reference/cities
GET /reference/languages
GET /reference/currencies
GET /reference/specialties
GET /reference/specialties/tree
GET /reference/facility_types
GET /reference/identifier_systems
GET /reference/all
GET /reference/geography

Version-Specific Blueprints

When V3 requires different response format:

domains/reference/blueprints/
├── country_blueprint.rb              # Default (V2)
├── v2/
│   └── country_blueprint.rb          # Explicit V2 (same as default)
└── v3/
    └── country_blueprint.rb          # V3 with different fields

V2 Blueprint:

module Reference
  class CountryBlueprint < Blueprinter::Base
    identifier :id

    fields :name, :iso_code, :phone_code
  end
end

V3 Blueprint:

module Reference
  module V3
    class CountryBlueprint < Blueprinter::Base
      identifier :id

      fields :name, :iso_code, :phone_code, :continent, :currency_code
    end
  end
end

Versioning Strategy

Detection Priority

  1. Accept header (preferred) - application/vnd.kulcare.v2+json
  2. Custom header - X-API-Version: v2
  3. Query parameter (testing) - ?api_version=v2
  4. Default - v2 if no version specified

Version Format

  • Pattern: v{number} where number is 2, 3, 4, etc.
  • Examples: v2, v3, v4
  • Invalid: v2.1, 2.0, latest

Default Version

  • Current default: v2
  • Changed by updating ApiVersionDetection middleware
  • Announce default version changes in API changelog

Creating New Versions

Step 1: Create Blueprint

# Create version-specific blueprint directory
mkdir -p domains/reference/blueprints/v3

# Create V3 blueprint
touch domains/reference/blueprints/v3/country_blueprint.rb

Step 2: Implement Changes

# domains/reference/blueprints/v3/country_blueprint.rb
module Reference
  module V3
    class CountryBlueprint < Blueprinter::Base
      identifier :id

      # Add/remove fields as needed
      fields :name, :iso_code, :phone_code, :continent
    end
  end
end

Step 3: Update Documentation

  • Document breaking changes
  • Provide migration guide
  • Set deprecation timeline for old version

Step 4: Update Default (Optional)

When ready to make V3 default:

# app/middleware/api_version_detection.rb
def set_api_version
  @api_version = request.env['api.version'] || 'v3' # Change default
end

Benefits

✅ Clean URLs: /reference/countries instead of /api/v2/reference/countries ✅ Domain-Focused: URL structure reflects domain model ✅ Simple Namespace: Reference::CountriesController instead of Api::V2::Reference::CountriesController ✅ Easy to Evolve: Add versions without URL changes ✅ Industry Standard: Used by GitHub, Stripe, AWS ✅ Flexible: Can mix versioning strategies ✅ Stable URLs: URLs don't change between versions ✅ Backwards Compatible: Old clients continue working


Drawbacks

❌ Version Hidden: Version not obvious from URL ❌ Header Required: Clients must send version header ❌ Harder to Test: Can't just use browser (need header extensions) ❌ Documentation Required: Must document versioning strategy ❌ Less Familiar: Some developers find it confusing initially


Best Practices

1. Version Incrementing

  • Major changes: Increment version number (v2 → v3)
  • Breaking changes: Always increment version
  • Non-breaking additions: Keep same version
  • Bug fixes: Keep same version

2. Deprecation

  • Announce deprecation at least 6 months in advance
  • Add deprecation warnings to old versions
  • Document migration path clearly
  • Provide sunset date for old versions

3. Documentation

  • Document all version differences
  • Provide request/response examples for each version
  • Include migration guides
  • Maintain changelog

4. Testing

  • Test all supported versions
  • Include version headers in integration tests
  • Test backwards compatibility
  • Test default version behavior

References

  1. GitHub API Versioning
  2. Stripe API Versioning
  3. AWS API Versioning Best Practices
  4. REST API Versioning

Examples

Request with Version Header

curl -X GET http://localhost:5000/reference/countries \
  -H "Accept: application/vnd.kulcare.v2+json"

Response (V2)

{
  "data": [
    {
      "id": "019ba2c4-606b-7c80-8ecb-5564fcb61816",
      "name": "India",
      "iso_code": "IN",
      "phone_code": "+91"
    }
  ],
  "meta": {
    "pagination": {
      "total_entries": 1,
      "current_page": 1,
      "per_page": 50
    }
  }
}

Request with V3 Header (Future)

curl -X GET http://localhost:5000/reference/countries \
  -H "Accept: application/vnd.kulcare.v3+json"

Response (V3 - with additional fields)

{
  "data": [
    {
      "id": "019ba2c4-606b-7c80-8ecb-5564fcb61816",
      "name": "India",
      "iso_code": "IN",
      "phone_code": "+91",
      "continent": "Asia",
      "currency_code": "INR"
    }
  ],
  "meta": {
    "pagination": {
      "total_entries": 1,
      "current_page": 1,
      "per_page": 50
    }
  }
}

Summary: This header-based versioning approach provides clean URLs, simple namespaces, and easy evolution while following industry best practices from GitHub, Stripe, and AWS.

On this page