Kotlin, OpenApi, Spring & Spring Boot

API Versioning with Kotlin and Spring Boot

I wrote a short poem that explains the need for API versioning:

There was an API
And it was good
Then PM came
And said “you should
Change the field
From array to string”
How can I do it?
This is BC!
And there it comes
The magical V –
V like the version
Of your API.

Let’s get into the details!

Why and when to version an API?

I believe almost everyone has asked themselves the question “Should I version the API I created?”. Almost always, there’s no single answer. However, if you have already thought about it, it might be a good idea to pay attention to the problem before it becomes a real production issue.

From my experience, all APIs that work directly with the client (mobile apps, web applications) should be versioned. Imagine that you want to perform a breaking change like in the following scenario: the API supports multiple categories per item, but customers don’t like it for some reason. The business comes and asks you to change the API so it always accepts only a single category. If you change the API in place, you’ll break all the clients that already use the current solution. For the web application, there could probably be a short time while it stops working (for the time of deployment), but how can you force the users to upgrade all the mobile apps at once?

On the other hand, if your API is internal, you might not need full API versioning. Sometimes it’ll be easier to introduce a copy-paste endpoint, switch the usage on the client’s side, and then remove the old one. This approach requires you to know exactly how many clients the API has and align with all the consumers beforehand.

How to version the API?

Several approaches to API versioning can be used. The most common ones are described below.

Versioning via URL path param

http://api.example.com/v1/items

The idea is to introduce the API version as a part of the path for each request coming to your API. The main issue with this solution is that for each new version of the API, you need to modify your API specification for all endpoints to change the URL. This can be pretty messy in the long run.

Versioning via URL query param

http://api.example.com/items?version=1

The approach is similar, but this time your application will need to parse and look for specific query path parameters for each request coming to the application. It might not be a big issue on the server side, but from the mobile/frontend side, it might be a bit tricky to implement this solution, especially if there are endpoints that already use the query params to fetch data (like pagination). Usually, the clients use some kind of generator to not write all the code manually, and those will not support this kind of versioning without additional effort.

Versioning by extending the Accept header content

Accept: text/json; version=1

The idea is about appending a version string to the existing Accepts header. I don’t personally like this solution for similar reasons as with the URL query param. It might not be so straightforward on the client side to modify the Accept header for each request — they are usually automatically populated by the HTTP client based on the passed content type.

Versioning through the custom header

Accept-Version: 1.0

The idea is to add a custom header to every request. This is, in my opinion, the best and clearest approach that I’ll show you how to implement in the Spring Boot application.

Versioning through the custom header — Spring Boot configuration

To achieve this goal, we need to add several small classes to the project that will allow us to easily define the same endpoints across different controllers (or even the same) being identified by the custom annotation we’re going to create.

First of all, we need to define the custom annotation and enum with supported API versions:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
    ApiVersions value();
}

public enum ApiVersions {
    v1_0_0,
    v2_0_0,
    v3_0_0
}

Then we need to write the ApiVersionCondition class that implements the Spring RequestCondition interface. It requires implementing 3 methods:

  • combine defines the rules of overriding the annotations between classes and methods. In this case, method annotations take precedence over class annotations.
  • getMatchingCondition is responsible for reading the API version from the request and comparing it with the API version set in the annotation. We also fall back to version 1.0.0 if the request doesn’t contain the Accept-version header. Another possibility is to always require the header and return null when it’s not present.
  • compareTo is used to define version order. In our case, it’s an enum so its compareTo method is used.
public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {

    private final ApiVersions apiVersion;

    public ApiVersionCondition(ApiVersions apiVersion) {
        this.apiVersion = apiVersion;
    }

    @Override
    public ApiVersionCondition combine(ApiVersionCondition other) {
        return new ApiVersionCondition(other.apiVersion);
    }

    @Override
    public ApiVersionCondition getMatchingCondition(HttpServletRequest request) {
        String acceptVersion = request.getHeader("Accept-version");
        ApiVersions version = acceptVersion != null ? ApiVersions.valueOf(acceptVersion) : ApiVersions.v1_0_0;
        return version.equals(apiVersion) ? this : null;
    }

    @Override
    public int compareTo(ApiVersionCondition other, HttpServletRequest request) {
        return other.apiVersion.compareTo(apiVersion);
    }
}

The next step is to extend the Spring RequestMappingHandlerMapping class that will tell Spring how to create the ApiVersionCondition for classes and methods. This time we need to override 2 methods:

  • getCustomMethodCondition defines how to create the ApiVersionCondition from a Method
  • getCustomTypeCondition defines how to create the ApiVersionCondition from a Class
public class ApiVersionRequestMappingHandlerMapping extends RequestMappingHandlerMapping {

    @Override
    protected RequestCondition<ApiVersionCondition> getCustomTypeCondition(Class<?> handlerType) {
        ApiVersion apiVersion = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class);
        return apiVersion != null ? new ApiVersionCondition(apiVersion.value()) : null;
    }

    @Override
    protected RequestCondition<ApiVersionCondition> getCustomMethodCondition(Method method) {
        ApiVersion apiVersion = AnnotationUtils.findAnnotation(method, ApiVersion.class);
        return apiVersion != null ? new ApiVersionCondition(apiVersion.value()) : null;
    }
}

The last step before we can start using the annotation in the controllers is to add the new ApiVersionRequestMappingHandlerMapping class to the Spring Boot Configuration:

@Configuration
public class ApiVersionConfiguration implements WebMvcConfigurer {

    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        configurer.addPathPrefix("/api/{version}", c -> true);
        configurer.setPatternParser(new PathPatternParser());
        configurer.addMapping(new ApiVersionRequestMappingHandlerMapping());
    }
}

Using the new annotation in controllers

Let’s create 3 classes for testing purposes. We have 1 API interface and 2 implementations for different API versions. We create the 1.0.0 version annotation on the V1 controller and the 2.0.0 annotation on the V2 controller with the 2.1.0 annotation of one of its methods.

// Color API interface
public interface ColorApi {
    String getColor();
}

// V1.0.0 controller
@RestController
@RequestMapping("/colors")
@ApiVersion(ApiVersions.v1_0_0)
public class V1ColorController implements ColorApi {

    @Override
    public String getColor() {
        return "red";
    }
}

// V2.0.0 controller with one V2.1.0 method
@RestController
@RequestMapping("/colors")
@ApiVersion(ApiVersions.v2_0_0)
public class V2ColorController implements ColorApi {

    @GetMapping
    public String getColor() {
        return "blue";
    }

    @GetMapping("/shade")
    @ApiVersion(ApiVersions.v2_1_0)
    public String getColorShade() {
        return "light blue";
    }
}

Results for different curl calls

Calling the list endpoint without a version header or with the 1.0.0 version returns the V1.0.0 controller response:

curl --location --request GET 'http://localhost:8080/colors'
curl --location --request GET 'http://localhost:8080/colors' --header 'Accept-version: 1.0.0'

Response:

[
    {
        "tag": "V1.0.0 list color",
        "hex": "#FF0000"
    }
]

Calling the list endpoint with 2.0.0 or 2.1.0 version header returns the V2.0.0 controller response:

curl --location --request GET 'http://localhost:8484/colors' --header 'Accept-version: 2.0.0'
curl --location --request GET 'http://localhost:8484/colors' --header 'Accept-version: 2.1.0'

Response:

{
    "colors": [
        {
            "tag": "V2.0.0 list color",
            "hex": "#f0f0f0"
        }
    ]
}

Calling the single item endpoint without a version header or with 1.0.0/2.0.0 returns the V1.0.0 controller value:

curl --location --request GET 'http://localhost:8484/color'
curl --location --request GET 'http://localhost:8484/color' --header 'Accept-version: 1.0.0'
curl --location --request GET 'http://localhost:8484/color' --header 'Accept-version: 2.0.0'

Response:

{
    "tag": "V1.0.0 single color",
    "hex": "#FF0000"
}

Finally, calling the single item endpoint with the 2.1.0 version header returns the V2.1.0 controller method value:

curl --location --request GET 'http://localhost:8484/color' --header 'Accept-version: 2.1.0'

Response:

{
    "tag": "V2.1.0 single color",
    "hex": "#f0f0f0"
}

Performance impact

The last thing to check is the impact on application performance. For that purpose, I used Apache Benchmark. I ran a test that calls the endpoint 10000 times with no concurrent users:

ab -n 100000 -c 1 -H "Accept-version: 2.0.0" http://localhost:8484/colors

The first test I ran with the @ApiVersion annotation processor enabled:

Concurrency Level:      1
Time taken for tests:   95.117 seconds
Complete requests:      100000
Failed requests:        0
Total transferred:      16800000 bytes
HTML transferred:       4000000 bytes
Requests per second:    1051.33 [#/sec] (mean)
Time per request:       0.951 [ms] (mean)
Time per request:       0.951 [ms] (mean, across all concurrent requests)
Transfer rate:          172.48 [Kbytes/sec] received

Then I removed all the configs and annotations we created and I left only one controller, which I tested with the same command:

Concurrency Level:      1
Time taken for tests:   82.624 seconds
Complete requests:      100000
Failed requests:        0
Total transferred:      16800000 bytes
HTML transferred:       4000000 bytes
Requests per second:    1210.30 [#/sec] (mean)
Time per request:       0.826 [ms] (mean)
Time per request:       0.826 [ms] (mean, across all concurrent requests)
Transfer rate:          198.57 [Kbytes/sec] received

The result is that the annotation processor has some impact on the requests, but the mean impact per request is only 1/8 ms. I think this is an acceptable value and will only matter in performance-critical environments.


I hope this will help you to manage the API versions in your code easier and will make your code architecture cleaner. I’m happy to hear any feedback from you!

Credits

Leave a Reply