Versioning API requests
rails, api, versioning
23 Jun 2025
Table of Content
- 1. Introduction
- 2. Why do we need request versioning
- 3. Requirements
- 4. Introducing the
verquest
gem - 5. In conclusion
1. Introduction
In my previous article, Flexible API Versioning with Rails, I presented a solution that utilizes the same controllers for all API versions and provided simple examples of how to version request parameters and responses.
By using the standard approach, where controllers are duplicated for each version, we do not need to handle it that strongly, as we can use Rails Strong Parameters and define what parameters are expected and in what structure.
In this article, I will go deeper into versioning requests for the Flexible API versioning.
2. Why do we need request versioning
To properly handle breaking changes without causing issues to existing clients.
Typical breaking changes in requests are:
- changing the structure
- moving fields
- renaming fields
- removing fields
- making existing fields required
We usually do not need to bump a version in case:
- adding new optional fields
- making a field optional (from required)
3. Requirements
Before we delve into the solution, let’s examine what it should cover.
3.1. OpenAPI support
It does not matter whether the API is public or internal; it should be documented. OpenAPI can help with that, and many tools can utilize it.
We can, for example, use it for:
- Generate API documentation (e.g., redoc)
- Handle request validations before the request gets into the controller (committee or openapi_first can help with that)
- Generate API clients (e.g., openapi-generator)
- and more, see openapi.tools
Typically, components for OpenAPI, which are based on JSON Schema, must be created manually. This can introduce some complexity in keeping the actual usage and the OpenAPI in sync.
3.2. DRY
We don’t want to repeat the request definitions for each new version, especially when there are no changes for the specific request.
The goal is to define what is different from the previous version and not define it at all for versions where there are no changes.
3.3. Hide internal implementation
There are several reasons why we may need to name models or attributes differently from the internal naming. We may have things stored differently, and providing better naming would make the API more user-friendly.
The typical example is an address associated with the user or contact. It may be stored in the same table as the user. It is better to provide the address inside an object to make the request more user-friendly.
Example of the user-facing (external) request structure:
{
first_name: "John",
last_name: "Doe",
email: "john@doe.com",
address: {
line_1: "123 Main St",
line_2: nil,
city: "Springfield",
postal_code: "12345"
}
}
Example of the internal structure:
{
first_name: "John",
last_name: "Doe",
email: "john@doe.com",
address_line_1: "123 Main St",
address_line_2: nil,
address_city: "Springfield",
address_postal_code: "12345"
}
3.4. Map internal errors back to the external structure
To help users understand what they need to fix when they encounter an error response, we should show them where they made the mistake or which value is incorrect.
When we maintain the external structure to match the internal one, we can efficiently utilize the ActiveModel
errors (in the case of Rails) that contain internal attribute names. However, if we decide to change the structure, we should also update the attribute names in error responses to align with the new structure.
4. Introducing the verquest
gem
I did not find a suitable existing solution, so I decided to develop one that met the requirements.
The verquest gem covers almost all that I mentioned above, except the support for error mapping, which I plan to add later.
4.1. Defining schemas
Verquest supports referencing other classes that inherit from it. That allows us to separate shared parts and version them individually. For an example of this feature, please refer to the gem’s README.md.
The DSL is designed to easily define the JSON Schema, which is then used for generating OpenAPI components and for validation.
The following is an example of the request body that we will cover in the UserCreateRequest
below.
{
first_name: "John",
last_name: "Doe",
email: "john@doe.com",
address: {
line_1: "123 Main St",
line_2: nil,
city: "Springfield",
postal_code: "12345",
country: "US"
}
}
Request Schema:
class UserCreateRequest < Verquest::Base
description "User Create Request"
schema_options additional_properties: false
version "2025-06" do
with_options type: :string, required: true do
field :first_name, description: "The first name of the user", min_length: 1, max_length: 50
field :last_name, description: "The last name of the user", min_length: 1, max_length: 50
field :email, format: "email", description: "The email address of the user", pattern: "^\\S+@\\S+\\.\\S+$"
end
object :address, additional_properties: false do
with_options type: :string do
field :line_1, description: "Line 1", map: "/address_line_1"
field :line_2, description: "Line 2", map: "/address_line_2"
field :city, description: "City of residence", map: "/address_city"
field :postal_code, description: "Postal code", map: "/address_zip"
field :country, description: "Country of residence", map: "/address_country_alpha2"
end
end
end
end
We can check the JSON Schema by calling to_validation_schema
:
UserCreateRequest.to_validation_schema(version: "2025-06")
Output:
{
type: :object,
description: "User Create Request",
required: [:first_name, :last_name, :email],
properties: {
first_name: {type: :string, description: "The first name of the user", minLength: 1, maxLength: 50},
last_name: {type: :string, description: "The last name of the user", minLength: 1, maxLength: 50},
email: {type: :string, format: "email", description: "The email address of the user", pattern: "^\\S+@\\S+\\.\\S+$"},
address: {
type: :object,
required: [],
properties: {
line_1: {type: :string, description: "Line 1"},
line_2: {type: :string, description: "Line 2"},
city: {type: :string, description: "City of residence"},
postal_code: {type: :string, description: "Postal code"},
country: {type: :string, description: "Country of residence"}
},
additionalProperties: false
}
},
additionalProperties: false
}
This will also set the mapping (with the map
keys), altering the structure as follows:
{
first_name: "John",
last_name: "Doe",
email: "john@doe.com",
address_line_1: "123 Main St",
address_line_2: nil,
address_city: "Springfield",
address_zip: "12345",
address_country_alpha2: "US"
}
4.2. Example usage in Rails
The process
method is used below to validate the provided params against the JSON Schema and map them to the internal structure.
class UsersController < ApplicationController
rescue_from Verquest::InvalidParamsError, with: :handle_invalid_params
def create
user = User.new(user_params) # service object to handle the creation logic
if user.save
# render success response
else
# render error response
end
end
private
def user_params
UserCreateRequest.process(params, version: params[:api_version])
end
end
You can see that we set the passed version from the parameters using the params[:api_version]
.
Error example when invalid params are provided:
params = {
last_name: "", # empty
email: "johndoe.com", # wrong format
address: {
line_1: 1234, # wrong type
city: true, # wrong type
extra: "param" # undefined field
}
}
begin
UserCreateRequest.process(params, version: "2025-06")
rescue Verquest::InvalidParamsError => e
e.errors
end
Will output:
[
"The property '#/' did not contain a required property of 'first_name' in schema #/components/schemas/UserCreateRequest",
"The property '#/last_name' was not of a minimum string length of 1 in schema #/components/schemas/UserCreateRequest",
"The property '#/email' value \"johndoe.com\" did not match the regex '^\\S+@\\S+\\.\\S+$' in schema #/components/schemas/UserCreateRequest",
"The property '#/address/line_1' of type integer did not match the following type: string in schema #/components/schemas/UserCreateRequest",
"The property '#/address/city' of type boolean did not match the following type: string in schema #/components/schemas/UserCreateRequest",
"The property '#/address' contains additional properties [\"extra\"] outside of the schema when none are allowed in schema #/components/schemas/UserCreateRequest"
]
The gem can also be set to use the result pattern instead of exceptions.
4.3. Handling breaking changes
We do not need to add a new version to the UserCreateRequest
until we have a breaking change.
Let’s add a new version, 2025-08, with a breaking change: adding a new required field, phone_number
.
We will also add it to the previous version as an optional field, making it available to users who are using the older API version.
Updated class:
class UserCreateRequest < Verquest::Base
description "User Create Request"
schema_options additional_properties: false
version "2025-06" do
with_options type: :string, required: true do
field :first_name, description: "The first name of the user", min_length: 1, max_length: 50
field :last_name, description: "The last name of the user", min_length: 1, max_length: 50
field :email, format: "email", description: "The email address of the user", pattern: "^\\S+@\\S+\\.\\S+$"
end
# added as optional field
field :phone_number, type: :string, description: "Phone number, mandatory in newer API versions"
object :address, additional_properties: false do
with_options type: :string do
field :line_1, description: "Line 1", map: "/address_line_1"
field :line_2, description: "Line 2", map: "/address_line_1"
field :city, description: "City of residence", map: "/address_city"
field :postal_code, description: "Postal code", map: "/address_zip"
field :country, description: "Country of residence", map: "/address_country_alpha2"
end
end
end
# We omit the `phone_number` from the previous version (using the `exclude_properties`) and redefine it as a required field.
# Thanks to inheritance, all other fields from the previous version will also be available in this version.
version "2025-08", exclude_properties: %i[phone_number] do
field :phone_number, type: :string, required: true, description: "Phone number"
end
end
We can also disable the default inheritance from the previous version or set the exact version using the inherit
option on the version
definition (see the gem documentation).
The gem implements a “downgrading” strategy - when an exact version match isn’t found, it returns the closest earlier version.
Here is an example with requested => resolved
versions:
2025-06 => 2025-06
2025-07 => 2025-06
2025-08 => 2025-08
2025-09 => 2025-08
4.4. OpenAPI support
Verquest has a few methods that should help with building the OpenAPI documentation with, for example, the rswag gem.
UserCreateRequest.component_name # => "UserCreateRequest"
UserCreateRequest.to_ref # => "#/components/schemas/UserCreateRequest"
component_schema = UserCreateRequest.to_schema(version: "2025-06")
The difference between a component and a validation schema is in how references to other request classes are handled. Within the component schema,
to_ref
is used. The validation schema will have the reference replaced with the validation schema from the referenced class.
5. In conclusion
As mentioned in the gem’s README, verquest is not ready for production use.
There are several valid concerns to consider before trying it, such as performance (which will be interesting to investigate), the complexity versus benefits trade-off (including the learning curve and an additional layer of abstraction), missing features (such as error mapping), and possibly more.
The reason I wrote this article is to get feedback. If you try it, please let me know about your experience. If you notice something is wrong and know how to improve it, please let me know (or better yet, open a pull request). I am open to suggestions and improvements.
I am available to reach out by email (in the header), on Mastodon, Bluesky, or in GitHub Discussions.
Thanks!
Do you like it? You can subscribe to RSS (you know how), or follow me on Mastodon.