DEV Community

Cover image for How we handle breaking changes in Whoz API thanks to Spring Cloud Gateway
martin-thomas-tm
martin-thomas-tm

Posted on

How we handle breaking changes in Whoz API thanks to Spring Cloud Gateway

At Whoz, like most SaaS services, we expose an API so our clients can import or export their data and interconnect all their software. And at Whoz, like most API providers, we had to face the difficulty of API versioning and managing breaking changes.

In this article, I’ll present the solution we came up with, based on Spring Cloud Gateway and a bit of our self-cooked sauce. Of course, it has its limitations and has been subject to a lot of internal debates, but this is the best way we found, knowing that at the end of the day API versioning has no “right” way.

API versioning really?

“Why ?” is always a good question

Before getting into the details, you could legitimately wonder why API versions are needed? Until a few months ago, we were dealing with breaking changes without any versioning. Breaking changes were avoided unless absolutely needed, and when they were, the deprecated properties were kept with some in-app code to maintain them by translating the new properties into the old ones. Because our API is consumed by customer clients but also and mainly our front-end app, things were getting tricky.

Developers were not happy with all this backward compatibility code mixed with the new shiny code of their latest feature. Product owners were not happy with developers telling them that their awesome feature is “impossible” or costs three times the usual cost. Customers would not have been happy if the app was getting slower because payload sizes were always increasing because of deprecated fields. Basically, technical debt was increasing without much we could do about it without versioning.

Design goals

Once we decided to implement an API versioning system, we settled on several goals to reach.

  • Keep It Simple
  • Keep the business code free from backward compatibility considerations
  • Handle all the back-end microservices
  • Be seamless for current API customers
  • Have a “private” API that is subject to breaking changes every other week (we work in two-weeks-long sprints)

We also had issues to tackle about monitoring deprecated versions usage, documentation, release cycle, or communication but that’s for another story.


The “Accept-Version” header

Clients choose the API version thanks to the Accept-Version header.

It is assumed to mean “V1” version when no header is passed. That way, clients implemented prior to versioning still work.

To avoid splitting our API into a private and a public one, increasing the maintaining cost, we use a special API version that is unstable and is called latest. latest is translated by the versioning system into the next version.

For instance, if the current stable version is “V3”, we have the following API versions:

  • V1 (default if no Accept-Version header)
  • V2
  • V3
  • V4 (unstable, also targetted if Accept-Version="latest")
  • Note that “unstable” here does not mean beta, it is a fully functioning version used by the front-end. However, it may introduce breaking changes without notice.

When the release date of V4 is reached, we simply need to update the documentation and introduce a V5 API version into the system, becoming de facto the new unstable version.

Successive transformations

Step by step

The general idea of the versioning system is to transform requests and responses from one version to the next one, reaching step by step the latest API version which is the current state of the back-end services.

That means that we only have to transform payloads from the last stable version to the unstable one when introducing breaking changes.

Transformations are handled by Spring beans implementing the VersionTransformer interface, which the Kotlin code is:

interface VersionTransformer {    
    fun transformResponseObjectNode(responseBody: ObjectNode): ObjectNode
    fun transformRequestObjectNode(requestBody: ObjectNode): ObjectNode
    fun sourceVersionNumber(): ApiVersion
    fun accept(exchange: ServerWebExchange): Boolean
}
Enter fullscreen mode Exit fullscreen mode

The first two methods are the obvious transformation methods for requests and responses.

The last two methods will be called by the ApiVersioningManager to determine if the transformer must be applied or not. The accept() method is a condition to apply the transformer, typically on the beginning of the path which indicates the business object managed by the endpoint. The sourceVersionNumber() method defines which API version the input of the transformer uses. Since transformers handle only the transformations to the next version, it is unnecessary to specify the target version. The same is true for the name of the transformer classes. For instance, the TaskV2Transformer handles call to all endpoints under the path /api/task with a V2 API version.

@Service
class V2TaskTransformer : VersionTransformer {

    override fun sourceVersionNumber(): ApiVersion = ApiVersion.V2

    override fun accept(exchange: ServerWebExchange): Boolean = exchange.request.uri.path.startsWith("/api/task")
Enter fullscreen mode Exit fullscreen mode

Note that the TaskV2Transformer would also be called in the case of a request with Accept-Version="V1" because the transformers are called successively. Let’s pretend that there is a breaking change on task endpoints between V2 and V3, and another one between V3 and V4, the transformers would be applied in that order by the ApiVersionManager:

  1. TaskV2Transformer.transformRequestObjectNode()
  2. TaskV3Transformer.transformRequestObjectNode()
  3. The back-end service
  4. TaskV3Transformer.transformResponseObjectNode()
  5. TaskV2Transformer.transformResponseObjectNode()

TaskV1Transformer does not exist because there is no breaking change between V1 and V2<br>

For now, this covers all our use cases.

The accept() method is quite generic and future-proof, allowing other eligibility criteria, such as payload patterns or precise endpoint names. Breaking changes are thankfully sparse, so one transformer for each version and business object is manageable, but the interface is generic enough to have one transformer per modified field.

One more questionable design choice is the fact that transformers handle request and response transformation. What about read-only fields? Just do nothing in the request part? As long as we don’t encounter such issues, we’ll keep the interface as is for simplicity's sake, but I can sense we’ll have to change it sooner or later.

The gateway

Gateway — source: [Laila Gebhard](https://unsplash.com/@lailagebhard?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) on Unsplash

We naturally have a gateway between clients and back-end services to intercept the requests and responses. Historically, that gateway was based on Zuul. Since then, Spring has released the great Spring Cloud Gateway and has deprecated Zuul. Our stack is based on Spring Boot, so we decided to port the gateway to that new technology.

Like Zuul, Spring Cloud Gateway allows creating filters to be applied pre and post-backend calls. There are two filters to handle the transformations : ApiVersioningInput and ApiVersioningOutput.

In the following groovy code, you can see that we configure a ModifyRequestBodyGatewayFilterFactoryto call our ApiVersioningManager into a Mono, because Spring Cloud Gateway is based on Spring Web Flux that is itself built upon the Reactor reactive library. This is right for our API, though I’m not sure a Mono would work in the case of web sockets.

class APIVersioningInputGatewayFilterFactory extends AbstractGatewayFilterFactory<Config> {
    @Inject
    ApiVersioningManager apiVersioningManager
    ModifyRequestBodyGatewayFilterFactory modifyRequestBodyGatewayFilterFactoryDelegate

    APIVersioningInputGatewayFilterFactory() {
        super(Config.class)
        modifyRequestBodyGatewayFilterFactoryDelegate = new ModifyRequestBodyGatewayFilterFactory()
    }

    @Override
    GatewayFilter apply(Config config) {
        GatewayFilter modifyRequestBodyGatewayFilterDelegate = modifyRequestBodyGatewayFilterFactoryDelegate.apply(new ModifyRequestBodyGatewayFilterFactory.Config(
            inClass: byte[].class,
            outClass: byte[].class,
            rewriteFunction: new RewriteFunction<byte[], byte[]>() {
                @Override
                Mono<byte[]> apply(ServerWebExchange exchange, byte[] originalBody) {
                    if (originalBody != null) {
                        return Mono.just(apiVersioningManager.applyRequestTransformations(exchange, new String(originalBody)).bytes)
                    } else {
                        return Mono.empty()
                    }

                }
            }
        ))

        return (ServerWebExchange sourceExchange, GatewayFilterChain sourceChain) -> {
            if (apiVersioningManager.checkNeedForRequestTransformation(sourceExchange)) {
                return modifyRequestBodyGatewayFilterDelegate.filter(sourceExchange, sourceChain)
            } else {
                return applyNoChange(sourceExchange, sourceChain)
            }
        }
    }

    static Mono<Void> applyNoChange(ServerWebExchange sourceExchange, GatewayFilterChain sourceChain) {
        return sourceChain.filter(sourceExchange)
    }

}
Enter fullscreen mode Exit fullscreen mode

In the configuration, the list of filters will have an automatically assigned order (1 for the first one, 2 for the second, etc.). The filters will be applied in ascending order in the request processing, and in descending order in the response processing. We have this configuration:

spring:
  cloud:
    gateway:
      default-filters:
        - SomethingAuthRelated
        - SomethingUnrelatedToVersioning
        - APIVersioningInput
        - APIVersioningOutput
Enter fullscreen mode Exit fullscreen mode

This works well for the request part, but not the response part. Indeed, the filter writing the response has the order, and we need to transform the payload before that. To do so, we set the order ourselves with an OrderedGatewayFilter.


    @Override
    GatewayFilter apply(Config config) {

        // about the same code as the request part ...

        // filter must be executed after WRITE_RESPONSE_FILTER_ORDER so the response rewriting is done AFTER the backend call
        // this order is the one of the ModifyResponseBodyGatewayFilterFactory
        return new OrderedGatewayFilter(
            (ServerWebExchange sourceExchange, GatewayFilterChain sourceChain) -> {
                if (apiVersioningManager.checkNeedForTransformation(sourceExchange)) {
                    return modifyResponseBodyFilterDelegate.filter(sourceExchange, sourceChain)
                } else {
                    return applyNoChange(sourceExchange, sourceChain)
                }
            }, NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1)
    }
Enter fullscreen mode Exit fullscreen mode

Transformers

Sorry but I had to :) — source: Netflix

Interface of VersionTransformer is fairly simple, so are their implementations. As I write those lines, all they do is manipulating JSON payloads thanks to Jackson's ObjectNode and its subclasses.

We don’t do serialization to POJOs because:

that would become painful to maintain to have a different POJO for each version of a business object
the less overhead the better. Keep in mind that the transformers are part of a gateway that is passed through at least twice for each API call, so we want them to be as fast as possible.
For example, look at the code to rename the aphotecary property to drugstore.

   override fun transformRequestObjectNode(requestBody: ObjectNode): ObjectNode {
        val apothecary = requestBody["apothecary"]

        requestBody.set<JsonNode?>("drugstore", apothecary)
        requestBody.remove("apothecary")
        return requestBody
    }

    override fun transformResponseObjectNode(responseBody: ObjectNode): ObjectNode {
        val drugstore = responseBody["drugstore"]
        responseBody.remove("drugstore")

        responseBody.set<JsonNode?>("apothecary", drugstore)

        return responseBody
    }
Enter fullscreen mode Exit fullscreen mode

We have not needed anything more than JSON transformations for the first few months. We’ll probably have to do trickier things in the future like endpoint splitting, but for as long as this simple design will work, we’ll keep it simple.


API versioning can be headache-inducing. I hope you’ve enjoyed reading our adventure with tackling it as much as I enjoyed writing it and that it will be helpful for your own journey. And if you already have gotten through this, feel free to share your experience too!

Pictures credits: Chris Lawton on Unsplash,Harold & Kumar Go to White Castle, my Instagram, Laila Gebhard on Unsplash, Netflix

Top comments (0)