Flexible API Versioning with Rails
rails, api, versioning
9 Feb 2025 ・ Featured at Ruby Weekly #738
Table of Content
- 1. Introduction
- 2. Why we need API versioning
- 3. Common API versioning strategies
- 4. How it can go wrong
- 5. A flexible approach
- 6. Conclusion
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
to202
) for a response - changing the HTTP request method (e.g., from
PATCH
toPOST
) 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.).
- How you name your versions (
- 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 concern
s
# 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:
app/request_validators/api/v2024_08/post_validator.rb
where the version would be a namespace.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:
- Add the version into the
Api::VersionConstraint::RELEASED_VERSIONS
. - Duplicate request tests related to the previous API version. Especially if we use it to generate the OpenAPI specification.
- Resolve breaking change(s). It may be only one serializer or request validator class.
- Remove deprecated attributes/endpoints.
- 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.