Evolving HTTP APIs
Tuesday, 4 December 2012
One of the most vexing problems that still seems to be facing people when I talk to them about HTTP APIs is how to handle versioning and extensibility – i.e., how they evolve.
I tend to think about this a lot and talk to quite a few people about it, since I’m intimately familiar with the approaches to versioning that the HTTP protocol itself takes, and with the general attitude taken to it in the broader Internet architecture by the IETF.
So, I was quite interested to come across Tom Preston-Werner’s effort to define Semantic Versioning. If you haven’t seen it yet, go have a read; it’s a very sensible explanation of how to evolve software.
Let’s apply his “Firetruck” example to services. If you depend on a Ladder service, you have many of the same concerns; you need an instance of it that supports the semantics you understand (major version) and the additional features that you need on top (the minor version). You might also be interested in the patch level, in case you need to do some debugging.
The interesting, confusing and contentious part of applying this common sense to HTTP services is figuring out what you’re versioning, and how to communicate the version. One viewpoint is to say that the service is being versioned, the service is identified by the URL, and therefore the version goes in the URL, like this:
However, there are (at least) two issues to consider with this approach.
First of all, it’s coarse-grained, in that you can’t evolve parts of the system independently. For example, introducing a new format for the “ladder” resource - by the rules, that means a minor version, which means that the URL should now become:
which, as discussed previously in the API Versioning Smackdown, creates a whole new tree of resources, and tightly couples the client and server.
Second, it’s also intermingling the version into identifiers. Because URIs are used in the Web as the fundamental identifier by so many things – caches, spiders, forms, and so on – embedding information that’s likely to change into them make them unstable, thereby reducing their value. For example, a cache invalidates content associated with a URL when a POST request flows through it; if the URL is different because there are different versions floating around, it doesn’t work.
This is actually very similar to the discussion about X- and names for HTTP headers; putting a flag into the name to signify “experimental” doesn’t make much sense when the experiment ends and it gets used “for real.”
With that in mind, what should HTTP APIs do? My current thinking (based on the thinking of a lot of other people ;) is below.
Keep Compatible Changes Out of Names
As per above, names should be stable over time, and should identify a known set of semantics – corresponding to the major version number in Semantic Versioning. By “names,” I mean everything that’s used as an identifier, whether it’s a URL, a media type, a link relation name, HTTP header, whatever.
From what I see, most HTTP APIs are already moving in this direction, with structures like this:
Here, only the major version number is put in the URL; the minor and patch versions don’t go in, because backwards-compatible changes don’t need to be signified by changes in the name. Of course, it’d be equally valid to do:
because the hostname is just as much an identifier as anything else.
Even with this approach, it’s worth noting that letting clients infer that it’s a v2 API just from that path segment is a dodgy thing to do; however, the deeper reasons for this are the subject of a different blog post.
Likewise, minor and patch versions shouldn’t go into other names, such as media types or link relations. There is a train of thought that you don’t need to have numeric major versions at all, since you can call the first one “foo” and the second one “bar” – but that’s just a matter of taste.
Avoid New Major Versions
This is also pretty widely agreed to. Every time you release a new major version, people have to look at it, understand it, write new software to it, debug it, and so on. This is a huge investment on both sides, since you also have to support two (or more) major versions concurrently for some sort of sunset period. So, new major versions should be few and far between. In a perfect world, there would be none, but the reality is that every once in a while, you need to clean up a messy API or otherwise make breaking changes. Just make them last as long as you can.
Make Changes Backwards-Compatible
The biggest way to avoid new major versions is to make as many of your changes backwards-compatible as possible. For example, if you want to add support for a new HTTP method, or add a new resource to the mix, this doesn’t necessitate a new version. Likewise, adding support for a new format can be achieved through the miracle of content negotiation. Need to change the meaning of an existing query argument? Don’t – instead, introduce a new one.
Surprisingly, removing support for something can be considered backwards-compatible too. Think about it; if you remove support for, say, the “foo” resource and return a 410 Gone, clients will break. However, it will only break those clients that use it; those that don’t can still smoothly interoperate. Introducing a new major version to say “I don’t support the foo resource” effectively breaks everybody, so it doesn’t do any good.
That naturally leads to…
Think about Forwards-Compatibility
Fundamentally, evolution is about figuring out how to limit the breakage that your changes incurs on clients. As such, you need to place some sorts of expectations and boundaries on how your clients should behave when they encounter certain circumstances. In other words, if a client is hardcoded to only work when “foo” is there, “foo” will always have to be there to avoid breaking it. More subtly, if clients don’t expect an extra HTTP header, or an extra member on a JSON object, that can cause problems too, and restrict your options down the road.
Expressing these expectations clearly is something we as an industry needs to get a lot better about. Unlike Web browsers – which are extremely forgiving of unexpected input – most API clients are incredibly brittle. For example, XML Schema got this pretty fundamentally wrong, making it very difficult to express a forwards-compatible schema (in 1.0). JSON is better, mostly because implementations are tied pretty closely to dynamic programming language data structures, rather than a schema language.
That’s not to say that you want everything to be extensible. Sometimes it’s a good idea to explicitly NOT allow forward compatibility. For example, in the JSON Patch format, we realised that any extensions were likely to be a fundamental change in the document semantics, thus requiring a new major version (in this case, identifying it with a new media type). After all, an old client that doesn’t respect a new patch operation is going to come up with a different result than the new one that does understand it. So, we disallowed most extensions in that format.
The trick is to think about it and document your expectations carefully – whether you’re designing a format, a link relation type, or a HTTP header. Tell clients to expect and handle (as gracefully as they can) responses like 410 Gone, 405 Method Not Allowed, and 415 Unsupported Media Type.
Version at Appropriate Granularities
Going back to the example above:
a natural question to ask is “what is that ‘v2’ versioning?”
To me, the most natural reading is that it’s a collection of resources that represents a set of functionality, hierarchical URLs have collection semantics, by putting them under a single path segment (“directory”).
This is an important distinction; v2 is identifying NOT just the ladder, but the whole interface, as a collection of resources (i.e., everything under that “v2”) that works together.
Another conversation that I have sometimes is about how to relate format changes to API versions. In short, they should be completely separate; formats can have lives of their own, and to get the most value out of them, they should do so. It’s fine to say “Version 2 of the API requires the foo resource to support version 5 of the bar format,” of course.
Make Minor and Patch Information Discoverable
There are legitimate needs for minor and patch information; just because we say “don’t put them into names” doesn’t mean that they shouldn’t exist. However, I am fairly skeptical that they do much good “on the wire” as they are.
For example, consider our Ladder service, version 1.2.3. Maybe we added support for a new HTTP method in version 1.2, and fixed a few bugs in patch level 3.
A self-describing service will make it completely evident (e.g., using the Allow response header) that the new method is supported; if it needs to be known about ahead of time (for example, to reflect it in a UI), you can advertise support for that method directly (e.g., with something like this embryonic mechanism).
Tying this information up in a version number only makes the client go and look up a chart of version numbers to see whether the feature they want is supported by the given version; instead, if they can directly interrogate the interface to see if it supports the fancy new “ladder cover” feature (or whatever), it’s a lot more flexible and useful. The same goes for new resources, new formats, and so on.
Aside from that, using linear, numeric minor versions for negotiating new features is really, really limiting; complex APIs will find this especially impractical.
So, where should the minor and patch version numbers go? Easy – it’s useful for the release notes, and a few other forms of documentation. It’s useful for marketing. In the case of a more complex API (as with most standards – whether they come out of a standards body or an open source project), it’s useful for packaging up an agreed-to set of functionality and calling that a spec release.
Mind you, there’s a strong case for including this information about the implementation of the API – server or client side – but that goes in the Server or User-Agent header respectively, and is completely separate from API versioning (i.e., you might have version 0.2.1 of the client accessing version 3.2.3 of the server’s implementation of the API, which itself might have a version of 1.0.3).