Evolving your RESTful APIs, a step-by-step approach

Evolving your RESTful APIs, a step-by-step approach

Designing an intuitive, user-friendly RESTful API is a tough job. It might already be a massive task if it’s your first attempt. Planning for the lifecycle management of your API is likely to be an afterthought. But it’s possible anyway: in this post, I’d like to propose a no-nonsense approach to evolving your APIs, even if it was not planned.

The initial situation

Let’s consider a sample application that says "Hello" when using it.

> curl http://org. apisix/hello
Hello world

> curl http://org. apisix/hello/Joe
Hello Joe

The underlying technology doesn’t matter; we shall focus on the API part.

Initial situation

Use an API Gateway

The first and most crucial step is to stop exposing the application directly to the Internet and set up an API Gateway between them. If you’re not familiar with the concept of an API Gateway, you can think of it as a souped-up reverse proxy. Wikipedia offers the following definition:

Gateway: a server that acts as an API front-end, receives API requests, enforces throttling and security policies, passes requests to the back-end service and then passes the response back to the requester. A gateway often includes a transformation engine to orchestrate and modify the requests and responses on the fly. A gateway can also provide functionality such as collecting analytics data and providing caching. The gateway can provide functionality to support authentication, authorization, security, audit and regulatory compliance.

-- API management

I'll use Apache APISIX in this post, but feel free to use the one you're most familiar with.

Exposing the gateway instead of the application requires you to update your DNS record(s) to point to the gateway instead of the application, and wait until it has been propagated worldwide. It can take some time. To follow up the propagation, you can use a site like dnschecker.

However, you first need to route your HTTP requests from the gateway to your application. With APISIX, you can create a route by sending an HTTP request to the gateway.

curl http://apisix:9080/apisix/admin/routes/1 -H 'X-API-KEY: xyz' -X PUT -d ' # 1-2
{
  "name": "Direct Route to Old API",               # 3
  "methods": ["GET"],                              # 4
  "uris": ["/hello", "/hello/", "/hello/*"],       # 5
  "upstream": {                                    # 6
    "type": "roundrobin",                          # 8
    "nodes": {
      "oldapi:8081": 1                             # 7
    }
  }
}'
  1. APISIX can assign an automatically-generated ID or use the one provided. In this case, we go for the latter, pass it in the URL - 1 and use the PUT verb
  2. To update the routes, we need to pass the API key
  3. Naming the route is not required, but it allows us to understand better what it does
  4. Array of HTTP methods to route
  5. Array of URLs to route
  6. An upstream is a back-end application. In our case, it's the Hello World API.
  7. Hashmap of nodes with their respective weight. The weight is only meaningful when there are multiple nodes, which is not the case in this simple scenario.
  8. The balancing algorithm to use when you configure multiple nodes

Use an API Gateway

At this stage, you can query the gateway and get the same results as before:

> curl http://org. apisix/hello
Hello world

> curl http://org. apisix/hello/Joe
Hello Joe

Version the API

Evolving an API means that multiple versions of the API will need to co-exist at some point. There are three options to version one's API:

TypeExample
Query parameter
curl org. apisix/hello?version=1
curl org. apisix/hello?version=2
Header
curl -H 'Version: 1' org. apisix/hello
curl -H 'Version: 2' org. apisix/hello
Path
curl org. apisix/v1/hello
curl org. apisix/v2/hello

Many articles have been written on what's the best option. In the scope of this post, we will use path-based versioning because it's the most widespread. APISIX supports the other options if you want to use them instead.

In the previous section, we created a route that wrapped an upstream. APISIX allows us to create an upstream with a dedicated ID to reuse it across several routes.

curl http://apisix:9080/apisix/admin/upstreams/1 -H 'X-API-KEY: xyz' -X PUT -d ' # 1
{
  "name": "Old API",                                                             # 2
  "type": "roundrobin",
  "nodes": {
    "oldapi:8081": 1
  }
}'
  1. Use the upstreams path
  2. Payload for the new upstream

We also need to rewrite the query that comes to the gateway before forwarding it to the upstream. The latter knows /hello, not /v1/hello.APISIX allows such transformations, filters, etc., via plugins. Let's create a plugin configuration to rewrite the path:

curl http://apisix:9080/apisix/admin/plugin_configs/1 -H 'X-API-KEY: xyz' -X PUT -d ' # 1
{
  "plugins": {
    "proxy-rewrite": {                                        # 2
      "regex_uri": ["/v1/(.*)", "/$1"]                        # 3
    }
  }
}'
  1. Use the plugin-configs path
  2. Use the proxy-rewrite plugin
  3. Remove the version prefix

We can now create the versioned route that references the newly-created upstream and plugin config:

curl http://apisix:9080/apisix/admin/routes/2 -H 'X-API-KEY: xyz' -X PUT -d '  # 1
{
  "name": "Versioned Route to Old API",
  "methods": ["GET"],
  "uris": ["/v1/hello", "/v1/hello/", "/v1/hello/*"],
  "upstream_id": 1,
  "plugin_config_id": 1
}'
  1. Look, ma, a new route!

Version the API

At this stage, we have configured two routes, one versioned and the other non-versioned:

> curl http://org. apisix/hello
Hello world

> curl http://org. apisix/v1/hello
Hello world

Migrate users from the non-versioned path to the versioned one

We have versioned our API, but our users probably still use the legacy non-versioned API. We want them to migrate, but we cannot just delete the legacy route as our users are unaware of it. Fortunately, the 301 HTTP status code is our friend:we can let users know that the resource has moved from \org. apisix/hello to \org. apisix/v1/hello. It requires configuring the redirect plugin on the initial route:

curl http://apisix:9080/apisix/admin/routes/1 -H 'X-API-KEY: xyz' -X PATCH -d '
{
  "plugins": {
    "redirect": {
      "uri": "/v1$uri",
      "ret_code": 301
    }
  }
}'

Migrate users to the versioned app

Results are interesting:

>curl http://apisix. org/hello

<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>openresty</center>
</body>
</html>

>curl -L apisix:9080/hello                     # 1
Hello world
  1. The -L option follows redirects

Either users will transparently use the new endpoint because they will follow, or their integration breaks and they will notice the 301 status and the new API location to use.

Know your users

You might have noticed that we had no clue who was using our API so far. When we had to introduce a change, we had to be creative not to break users' usage. Other changes might not be so easy to cope with. Hence, we should strive to know our users to contact them if necessary.

Let's be honest about it; most developers, including myself, don't like to register and give out contact details if we can avoid it. I guess it's the fault of marketing teams who do not understand our mindset - don't call me, I'll call you. Yet, in this particular case, it would be beneficial.

The "nuclear" option completely disallows users to call our API before registering in the system. I prefer another alternative:limit the number of calls unregistered users can make during a period. If they hit the limit, we will return the (in)famous 429 HTTP status and the message that invites them to register.

At the time of this writing, no out-of-the-box plugin can achieve this. But it's possible to write our own. APISIX sits on top of a Lua engine, and all provided plugins are written in Lua. Alternatively, you can write your plugins in Go, Python, WebAssembly, or any JVM-based language.

To keep things simple, I wrote a Lua plugin. As the goal of this post is not to understand Lua, I won't dive further. If you're interested in the code, it's available on GitHub.When the public is ready, we still have a couple of steps to complete:

  1. Configure APISIX to use the directory:

     apisix:
       extra_lua_path: "/opt/apisix/?.lua"      # 1
    
    1. APISIX can use any Lua script located in the /opt/apisix/ folder
  2. Load the plugin:

    APISIX can hot-reload itself. We don't need to restart it - and suffer downtime - to add additional plugins!

     curl http://apisix:9080/apisix/admin/plugins/reload -H 'X-API-KEY: xyz' -X PUT
    
  3. Patch the existing plugin config:

    Finally, we need to configure the plugin itself. Since we created a dedicated plugin config, we only have to update it with the new config:

     curl http://apisix:9080/apisix/admin/plugin_configs/1 -H 'X-API-KEY: xyz' -X PATCH -d '
     {
       "plugins": {
         "proxy-rewrite": {                                # 1
           "regex_uri": ["/v1/(.*)", "/$1"]
         },
         "unauth-limit": {                                 # 2
           "count": 1,                                     # 3
           "time_window": 60,                              # 3
           "key_type": "var",                              # 4
           "key": "consumer_name",                         # 4
           "rejected_code": 429,
           "rejected_msg": "Please register at https://apisix. org/register to get your API token and enjoy unlimited calls"
         }
       }
     }'
    
    1. Unfortunately, we need to repeat the existing plugin configuration. The APISIX team is working on a fix, so you can add a plugin to the config without knowing the existing one.
    2. Our plugin!
    3. If the user is authenticated, the plugin limits more than one call per 60 seconds. Otherwise, it doesn't limit anything.
    4. Explained in the next section

We can now check if it behaves as expected:

>curl apisix:9080/v1/hello
Hello world

>curl apisix:9080/v1/hello
{"error_msg":"Please register at https:\/\/apisix. org\/register to get your API token and enjoy unlimited calls"}

Indeed, it does.

Creating users

You should probably start to see your users visit the register page, depending on how much you limit unauthenticated usage. Registration has many facets; it can be:

  • Automated or require as many manual validation steps as you wish
  • Free or paying
  • As simple as asking an email with no further validation, or as complex as requiring many more data
  • etc.

It depends on your specific context.

Regarding APISIX, in the end, it translates to a new consumer. To create such a consumer, we need to configure a plugin that specifies authenticating. A couple of authentication plugins are available out-of-the-box:basic, API key, JWT, OpenId, LDAP, Keycloak, etc.

In the scope of this post, the key-auth plugin is sufficient. Let's configure a consumer object that is authenticated by an API key:

curl http://apisix:9080/apisix/admin/consumers -H 'X-API-KEY: xyz' -X PUT -d '
{
  "username": "johndoe",                 # 1
  "plugins": {
    "key-auth": {                        # 2
      "key": "mykey"                     # 3
    }
  }
}'
  1. ID of the consumer
  2. Plugin to use
  3. The valid token is mykey

Note that the default header is apikey. It's possible to configure another one: please check the key-auth plugin documentation.

We can now test our set-up and verify that it works according to our requirements:

>curl -H 'apikey: mykey' apisix:9080/v1/hello
Hello world

>curl -H 'apikey: mykey' apisix:9080/v1/hello
Hello world

Testing in production

At this stage, we are now ready to let users know about the improved version of our Hello world API. I assume our team tested it thoroughly, but new code is always a risk. Deploying a new bug-ridden version of an existing application can negatively impact an API provider's image (and the revenue!).

To minimize risks, an agreed-upon strategy is to do a canary release:

Canary release is a technique to reduce the risk of introducing a new software version in production by slowly rolling out the change to a small subset of users before rolling it out to the entire infrastructure and making it available to everybody.

-- CanaryRelease

If something fails, it will impact only a fraction of the user base, and we will be able to revert the change without too much impact. However, with an API gateway, we can introduce a step before the canary release:we will duplicate the production traffic to the new API endpoint. Although the gateway will discard the response, we can uncover additional bugs with zero impact on users.

APISIX offers the proxy-mirror plugin to duplicate the production traffic toward other nodes. Let's update our plugin configuration:

curl http://apisix:9080/apisix/admin/plugin_configs/1 -H 'X-API-KEY: xyz' -X PATCH -d '
{
 "plugins": {
    "proxy-rewrite": {
      "regex_uri": ["/v1/(.*)", "/$1"]
    },
    "unauth-limit": {
      "count": 1,
      "time_window": 60,
      "key_type": "var",
      "key": "consumer_name",
      "rejected_code": 429,
      "rejected_msg": "Please register at https://apisix. org/register to get your API token and enjoy unlimited calls"
    },
    "proxy-mirror": {
      "host": "http://new. api:8082"                             # 1
    }
  }
}'
  1. APISIX will also send traffic to this host

Test in production

We can monitor both the new and the old endpoints to ensure that no more errors happen on the former than on the latter. If not, we can fix the bugs and redeploy again until it's the case. We are now ready to do the canary release.

First, we create an upstream that points to the new API:

curl http://apisix:9080/apisix/admin/upstreams/2 -H 'X-API-KEY: xyz' -X PUT -d '
{
  "name": "New API",
  "type": "roundrobin",
  "nodes": {
    "newapi:8082": 1
  }
}'

Then, we can replace the proxy-mirror plugin with the traffic-split:

curl http://apisix:9080/apisix/admin/plugin_configs/1 -H 'X-API-KEY: xyz' -X PATCH -d '
{
 "plugins": {
    "proxy-rewrite": {
      "regex_uri": ["/v1/(.*)", "/$1"]
    },
    "unauth-limit": {
      "count": 1,
      "time_window": 60,
      "key_type": "var",
      "key": "consumer_name",
      "rejected_code": 429,
      "rejected_msg": "Please register at https://apisix. org/register to get your API token and enjoy unlimited calls"
    },
    "traffic-split": {
      "rules": [
        {
          "weighted_upstreams": [      # 1
            {
              "upstream_id": 2,
              "weight": 1
            },
            {
              "weight": 1
            }
          ]
        }
      ]
    }
  }
}'
  1. Send 50% of the traffic to the new API for demo purposes. In real life, you'd probably start much lower or even configure only internal users to the new endpoint.
curl -L -H 'apikey: mykey' apisix:9080/hello
Hello world

curl -L -H 'apikey: mykey' apisix:9080/hello
Hello world (souped-up version!)

If everything works fine, we can gradually increase the percentage of traffic sent to the new API until we reach 100%. We can now remove the traffic split and redirect from the default endpoint to the v2 instead of the v1.

Deprecating the legacy version

Most users will probably migrate to the new version to benefit from it, but a fraction of them will stay on the v1. There can be a variety of reasons for that:no the right time (hint: it never is), too expensive, not enough incentive to migrate, you name it. But as an API provider, every deployed version has a definite cost. You'll probably need to retire the v1 at some point.

REST is not a standard, but the IETF has a draft specification about it. For more details, please read The Deprecation HTTP Header Field.As its name implies, it's based on a specific HTTP response header.

With the help of the API gateway, we can configure the route to communicate about its future deprecation and its replacement. For that, APISIX offers the response-rewrite.While it can rewrite any part of the response, we will use it to add additional deprecation headers:

curl -v http://apisix:9080/apisix/admin/plugin_configs/1 -H 'X-API-KEY: xyz' -X PATCH -d '
{
 "plugins": {
    "proxy-rewrite": {
      "regex_uri": ["/v1/(.*)", "/$1"]
    },
    "unauth-limit": {
      "count": 1,
      "time_window": 60,
      "key_type": "var",
      "key": "consumer_name",
      "rejected_code": 429,
      "rejected_msg": "Please register at https://apisix. org/register to get your API token and enjoy unlimited calls"
    },
    "response-rewrite": {
      "headers": {
        "Deprecation": "true",
        "Link": "<$scheme://apisix:$server_port/v2/hello>; rel=\"successor-version\""
      }
    }
  }
}'
curl -v -H 'apikey: mykey' apisix:9080/v1/hello

< HTTP/1. 1 200 
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 11
< Connection: keep-alive
< Date: Fri, 18 Feb 2022 16:33:30 GMT
< Server: APISIX/2. 12. 0
< Link: <http://apisix:9080/v2/hello>; rel="successor-version"
< Deprecation: true
< 
Hello world

Conclusion

In this post, we have described a simple step-by-step process to manage the lifecycle of your APIs:

  1. Don't expose your APIs directly; set up an API gateway in front
  2. Version the existing API using either the path, a query param, or a request header
  3. Migrate users from the unversioned endpoint to the versioned one with the 301 status code
  4. Gently push your users to register
  5. Test in production, first by duplicating the traffic, then by moving a small fraction of users to the new version
  6. Officially release the new version
  7. Communicate the deprecation of the old version via standard response headers

To go further:

Originally published at A Java Geek on February 27th, 2022