Flexible API Versioning with Rails

rails, api, versioning

9 Feb 2025 ・ Featured at Ruby Weekly #738

Table of Content

1. Introduction

A well-defined API versioning strategy is crucial for any expected evolving API.

In this post, we’ll explore most common API versioning strategies and show an example of how it can go wrong regarding maintainability in Rails. We’ll then build a new API and implement a flexible approach to versioning that allows us to make breaking changes (new API versions) without much effort.

Feel free to jump to the 5. A flexible approach to see the solution.

2. Why we need API versioning

API versioning is crucial for maintaining backward compatibility and allowing clients to update their integrations at their own pace.

When we create an API, we establish (often) an unwritten contract with our clients: if they implement our API, it will function as expected, and they can rely on it without worrying about changes for a certain period, as outlined in our API Deprecation Strategy.

When we introduce a change that breaks this contract (a breaking change), we need to release a new version of the API. This approach ensures that the old version remains operational for clients who have not yet upgraded.

2.1. Common breaking changes

Here are a few examples of what can cause a breaking change in an API:

  • rename/remove attributes from requests/responses
  • changing error responses
  • adding required fields in the request (especially when we use OpenAPI specification for request body validation)
  • changing the type of the attribute value
  • removing/renaming endpoints
  • changing the HTTP response code (e.g., from 201 to 202) for a response
  • changing the HTTP request method (e.g., from PATCH to POST) for an endpoint
  • changing required headers (Accept, for example)
  • changing authentication and authorization
  • changing business logic that would lead to a different response with the same request body

From the list, we can see that most of the breaking changes occur in requests and responses.

2.2. Write down your API Strategy

Before building an API, we should always define the API strategy. It should include:

  • Versioning
    • How you name your versions (v1, 2025-02, etc.).
    • How you embed version information (paths, subdomains, headers, etc.).
  • Deprecation
    • How long you will support each version.
    • How and when you will notify clients about deprecations or new versions.
    • How you handle the end-of-life process.
  • Documentation
    • How the API is documented (e.g., OpenAPI specification, in-code documentation).
    • How clients access version-specific docs.
  • Monitoring
    • How you monitor performance and uptime.
    • Whether you maintain a public status page.
  • and more like security considerations, rate limiting, changelogs, guides, and any other relevant policies.

3. Common API versioning strategies

Let’s briefly explore the most common API versioning strategies.

In the code examples below, we operate in an existing Rails monolith, where we want to namespace the controllers and use a subdomain for our public API.

3.1. Path-Based

Rails most common/default approach is to version the API in the URL path. For example:

GET https://api.example.com/v1/posts

For it, we would have this in our routes.rb:

# config/routes.rb

scope constraints: {subdomain: "api"}, module: :api do
  namespace :v1 do
    resources :posts
  end
end

This will allow us to have controllers in the following location and have them separated by namespace (api) and version (v1):

app/controllers/api/v1/posts_controller.rb

A potential downside is that each new version introduces a new set of namespaced routes and controller files, which can lead to code duplication or complexity if not managed carefully (for example, by refactoring common logic into service objects).

3.2. Subdomain-Based

Instead of path, we use subdomains to version the API. For example:

GET https://v1.api.example.com/posts

In Rails, we can do it using the subdomain constraint:

# config/routes.rb

scope constraints: {subdomain: "v1.api"}, module: :api do
  scope module: :v1 do
    resources :posts
  end
end

We still keep our controllers in app/controllers/api/v1/, so the subdomain mainly serves as a routing constraint.

Subdomain-based versioning provides the potential advantage of routing requests to different servers or load balancers with greater ease.

However, configuring it for local development and testing can be more challenging.

3.3. Query-Based

This approach is not common, as it is not considered a valid RESTful API design and is not very user-friendly. However, it is a possible approach and can be seen in the wild.

Example:

GET https://api.example.com/posts?version=1

Although not usually regarded as RESTful, using query parameter-based versioning does make it easier to retrieve the version within the controller (e.g., params[:version]). It’s typically less self-documenting and may confuse clients.

3.4. Header-Based

A similar approach to the query-based, but with the version in, usually, the Accept header.

Example:

curl -H "Accept: application/vnd.v1+json" https://api.example.com/posts

You retrieve the version from the request headers (often via middleware or a before_action). This approach avoids polluting the URL with version details and keeps the API more REST-compliant than query-parameter versioning. However, it can be more cumbersome to test and debug, requiring clients to properly set and manage custom headers.

4. How it can go wrong

Let’s examine the lifecycle of a typical Rails API that adheres to a simple path-based versioning scheme.

To shorten the article, let’s assume we use Jbuilder for responses and our controllers call a service object that handles the business logic.

4.1. Let’s build a new API

Our API will be very simple: we need to allow our users to manage users, pages and posts. Based on the path-based API versioning, we will create a few controllers and set routes.

# config/routes.rb

scope constraints: {subdomain: "api"}, module: :api do
  namespace :v1 do
    resources :users
    resources :posts
    resources :pages
  end
end

Everyone is happy. We have our API, and we are adding new features, enhancing responses, and introducing new endpoints.

4.2. First breaking change

The day has arrived. We have a breaking change, but we are prepared. We can finally utilize the API versioning that we have had from the beginning.

Suppose we need to rename users as authors, which illustrates an example of a breaking change. Any user_id will be changed to author_id (for responses/requests), and we also want to rename the endpoint.

# config/routes.rb

scope constraints: {subdomain: "api"}, module: :api do
  namespace :v1 do
    resources :users
    resources :posts
    resources :pages
  end

  namespace :v2 do
    resources :posts
    resources :pages
    resources :authors # endpoint rename
  end
end

Or we can use a concern for resources that are shared by both versions.

# config/routes.rb

scope constraints: {subdomain: "api"}, module: :api do
  concern :api_base do
    resources :posts
    resources :pages
  end

  namespace :v1 do
    concerns :api_base

    resources :users
  end

  namespace :v2 do
    concerns :api_base

    resources :authors # endpoint rename
  end
end

This may assist in adding new features to both versions, but it may make maintenance more difficult in the long term.

Controllers

Regardless of the routes.rb options, we need to duplicate all the controllers (we now have the new v2 namespace).

We can choose to reuse the v1 controllers in v2 through inheritance (class Api::V2::PostsController < Api::V1::PostsController), but we still have to create the files.

Views

We can reuse views when we don’t need to update the user_id reference or duplicate them in any case.

Integrating new attributes into the response will be somewhat easier with the former since we will need to update it only once. However, it may become more challenging to navigate. Utilizing partials adds further complexity.

rswag greatly assists in ensuring that our requests and responses remain intact, as we can easily leverage the OpenAPI specification within our test suite.

4.3. Second breaking change

Imagine we have tens of resources in our API versions now. Unfortunately, we have another breaking change and are forced to create a new API version.

It might be something “silly,” like removing an attribute in one response or adding a new required attribute in the request due to new legislation.

# config/routes.rb

scope constraints: {subdomain: "api"}, module: :api do
  namespace :v1 do
    resources :users
    resources :posts
    resources :pages

    # ... more resources
  end

  namespace :v2 do
    resources :posts
    resources :pages
    resources :authors

    # ... more resources
  end

  namespace :v3 do
    resources :posts
    resources :pages
    resources :authors

    # ... more resources
  end
end

or with concerns

# config/routes.rb

scope constraints: {subdomain: "api"}, module: :api do
  concern :api_base do
    resources :posts
    resources :pages

    # ... more resources
  end
  
  concern :api_v2 do
    concerns :api_base

    resources :authors
  end

  namespace :v1 do  
    concerns :api_base

    resources :users
  end

  namespace :v2 do  
    concerns :api_v2
  end

  namesapce :v3 do
    concerns :api_v2
  end
end

We now have many versions and controllers. Even if we use inheritance to minimize code duplication, version-specific differences can lead to branching or partial overrides. The number of files increases, and the mental burden of ensuring each version remains accurate rises significantly.

With all of this (reusing views, inheritance, and so on), how easy can it be to remove the first version?

5. A flexible approach

One of the main burdens in the example above is the number of controllers we are forced to have.

To solve this, we need to move the version from namespaces to a variable, which will allow us to reuse one set of controllers.

We could have it for “free” with the Query-Based approach, but that isn’t the right way to go about it. Fortunately, Rails makes it very easy for us to use the Path-Based approach to API versioning.

5.1. Routing

We still want to keep our API on a subdomain to maintain the subdomain constraint in our config/routes.rb file, but we will add a new scope.

scope constraints: {subdomain: "api"} do
  scope "/:api_version", as: :api, module: :api do
    resources :users
    resources :posts
    resources :pages
  end
end

If we run rails routes we will get this example:

api_post GET    /:api_version/posts/:id(.:format)   api/posts#show {:subdomain=>"api"}

This confirms that our new path for API-related controllers is under the app/controllers/api/ folder.

It also indicates that we now have the api_version variable in the path, which will be accessible under params[:api_version] in our controllers. We can name the variable as we wish, but the most straightforward name, version, might conflict with request bodies.

Supported API versions

With the scope definition above, we allow everything to be an API version. To limit it, we can use another constraint.

We will reuse it in a few other cases, so we will have support for defining the minimum and last (supported) versions.

To make it easier for our users, we will also use versioning based on YYYY-MM. That will make it easier to know (based on our API strategy) when a version will be deprecated.

For the first breaking change in our example above, we have two API versions: 2024-08 (initial) and 2024-11 (latest).

# app/constraints/api/version_constraint.rb

module Api
  class VersionConstraint
    RELEASED_VERSIONS = %w[2024-08 2024-11]

    def initialize(min: nil, last: nil)
      @min = parse(min)
      @last = parse(last)
    end

    def matches?(request)
      current_version = request.params[:api_version]
      parsed_version = parse(current_version)

      return false unless parsed_version
      return false if RELEASED_VERSIONS.exclude?(current_version)
      return false if lower_than_min_version?(parsed_version)
      return false if higher_than_last_version?(parsed_version)

      true
    end

    private

    attr_reader :min, :last

    def parse(version)
      return unless version

      version.tr("-", ".").to_f
    end

    def lower_than_min_version?(parsed_version)
      return false unless min

      parsed_version < min
    end

    def higher_than_last_version?(parsed_version)
      return false unless last

      parsed_version > last
    end
  end
end

And updated config/routes.rb file:

scope constraints: {subdomain: "api"} do
  scope "/:api_version", as: :api, module: :api, constraints: Api::VersionConstraint.new do
    resources :authors, constraints: Api::VersionConstraint.new(min: "2024-11")
    resources :pages
    resources :posts
    resources :users, controller: "authors", constraints: Api::VersionConstraint.new(last: "2024-08")
  end
end
  • The Api::VersionConstraint used on line 2 will ensure that only supported versions (Api::VersionConstraint::RELEASED_VERSIONS) are allowed.
  • The usage on line 3 makes the authors resource accessible only for our second version and any subsequent versions.
  • The usage on line 6 makes the users resource available only for our first version (2024-08).

We can reuse the same controller for users (line 6) depending on how we handle requests and responses in controllers. If we cannot, we can leave the Api::UsersController.

5.2. Controllers

Here is a brief example of how we can define our controllers.

# app/controllers/api/base_controller.rb

module Api
  class BaseController < ActionController::API
    # authentication
    # error handling
    # setting the `Current.api_version` if we use the `Current` object
    # other helper modules ...
  end
end
# app/controllers/api/posts_controller.rb

module Api
  class PostsController < BaseController
    # resource authorization
  end
end

5.3. Handling request params

OK, we now use the same set of controllers, so we need to manage the breaking changes as easily as possible.

The easiest is to move the request body validation into the service object, where we can pass the params, api_version and anything else we want.

Inside the service object, we can decide how to handle the request for params validation.

For example, we might reuse namespaces/class naming using the API version and create classes that handle the request parameters. Since we know which versions are supported, we can determine the latest one for the request. The validator class would return standardized parameters that the service object will use.

Example

Let’s expect that the Api::AvailableApiVersions class is responsible for returning the appropriate API versions. For instance, when asked for the latest version, it will return all versions. For the first version, it will return only the first one.

The folder structure might be one of the following options for a post resource:

  1. app/request_validators/api/v2024_08/post_validator.rb where the version would be a namespace.
  2. app/request_validators/api/posts/v2024_08_validator.rb where the version would be a class name. This could make it easier to maintain, as we can easily see our versions for the resource in one folder.

Next, we just need to locate the first class that corresponds to the requested version (the latest in the example below) in params[:api_version] and the post resource (in by the resource variable).

api_versions = AvailableApiVersions.for(params[:api_version]) # => ["2024-11", "2024-08"]
validator = api_versions.each do |version|
  klass = "Api::#{resource.camelize.pluralize}::v#{version.underscore}Validator".safe_constantize # => Api::Posts::V2024_11Validator
  break klass if klass
end
result = validator.call(params)

5.4. Responses

Jbuilder

With Jbuilder, we can use prepend_view_path to modify how Rails searches for view files. We have defined versions, allowing us to easily add them and enable Rails to perform its tasks effectively.

# app/controllers/api/base_controller.rb

module Api
  class BaseController < ActionController::API
    before_action :prepend_api_view_paths
    
    private

    def prepend_api_view_paths
      api_versions = AvailableApiVersions.for(params[:api_version])

      api_versions.each do |version|
        prepend_view_path(Rails.root.join("app", "views", "api", version))
      end
    end
  end
end

The prepend_view_path method will allow Rails to search for views in the app/views/api/2024-11 and app/views/api/2024-08 directories (for the latest version) and only for the app/views/api/2024-08 for the first version. If the view was not found in the newest version, Rails would search for it in the previous versions.

Example

# app/controllers/api/posts_controller.rb

module Api
  class PostsController < BaseController
    def show
      post = Post.find(params[:id])

      render :show, locals: {post: post}
    end
  end
end

With the prepend_api_view_paths defined on the BaseController, Rails will search for the show.json.jbuilder view in the app/views/api/2024-11/posts and app/views/api/2024-08/posts directories for the latest (2024-11) version, if we would not have it in the latest version directory, Rails would use the view for the previous version.

Class-Based serializers

If we use alternatives like alba, we might implement namespaces/naming to decide what to use (such as the example for request body validations).

5.5. New API version release

If we simplify the process, launching a new version would involve these steps:

  1. Add the version into the Api::VersionConstraint::RELEASED_VERSIONS.
  2. Duplicate request tests related to the previous API version. Especially if we use it to generate the OpenAPI specification.
  3. Resolve breaking change(s). It may be only one serializer or request validator class.
  4. Remove deprecated attributes/endpoints.
  5. Deploy.

Extreme business logic change

If necessary, we can move some controllers from the main routes to facilitate maintenance of the main version.

scope constraints: {subdomain: "api"} do
  scope "/2024-08", module: "api/v2024_08", defaults: {api_version: "2024-08"} do
    resources :users
  end

  scope "/:api_version", as: :api, module: :api, constraints: Api::VersionConstraint.new do
    resources :authors, constraints: Api::VersionConstraint.new(min: "2024-11")
    resources :pages
    resources :posts
  end
end

This will allow us to move the UsersController to another namespace and ensure it is used solely for the chosen version:

app/controllers/api/v2024_08/users_controller.rb

5.6. Special versions

This approach should allow us to handle some non-standard version names or the case of a client (usually a big one) who refuses to use our current version strategy and requires fixed paths.

A few examples:

  • /beta/posts
    • Special version for beta endpoints.
  • /unreleased/posts
    • For easier testing in an environment where we do not yet know the final version name.
  • /my/posts
    • We can allow clients to set it in their account.
  • /api/posts
    • As mentioned above, we can use this as a fixed path and let clients specify the version using another method (likely Header-Based).

Do these make sense? Probably not. It depends. However, the solution won’t hold us back, and supporting for them shouldn’t be too difficult.

6. Conclusion

We’ve seen how rapidly a naive approach to versioning can escalate, resulting in numerous controllers, duplicated logic, and complicated routes.

By utilizing a dynamic version parameter, streamlined controllers, and version-aware request and response handling, we can greatly minimize this overhead.

This flexible strategy facilitates new version rollouts by concentrating changes where they are most impactful rather than duplicating entire folders, and it simplifies the eventual deprecation of older versions. Ultimately, it maintains your API’s maintainability, clarity, and capacity to evolve with your application, ensuring that developers and clients have a smoother experience as requirements change.


Do you like it? You can subscribe to RSS (you know how), or follow me on Mastodon.