API versioning
Once other people's code depends on your API, you cannot change it freely. A
renamed field or a new required value breaks every client that has not been
updated. Versioning is the way out: leave the old behaviour in place as
v1, publish the change as v2, and let clients move when they are ready.
howdy/version does this with a version group. You give it the
controllers for each version and say how a request names the version it
wants. The group works out the version, then routes the request to that
version's controllers.
It sits beside middleware because it is set up in the same place, on the app, and works hand in hand with it. It is not middleware itself: it chooses which routes a request can reach, which happens before any middleware runs.
A version group
import howdy
import howdy/version
pub fn app() {
let api =
version.new(version.path())
|> version.default("v1")
|> version.add("v1", [users_v1.controller(), orders.controller()])
|> version.add("v2", [users_v2.controller()])
howdy.new()
|> howdy.controller(health.controller())
|> howdy.versions(api)
}
version.new takes the strategy for reading the version, here the path.
version.add declares a version and its controllers, oldest first. The
controllers themselves are ordinary ones and know nothing about versions:
both user controllers are just controller.new("users").
With that app:
-
GET /v2/usersreaches thev2user controller. -
GET /v1/usersreaches thev1one. -
GET /usersnames no version, so it gets the default,v1. -
GET /healthreaches the health controller, which is not versioned.
Falling back
v2 above declares only a user controller, yet GET /v2/orders works. A
version falls back to the ones declared before it for any route it does not
answer itself, newest first. So a new version only has to contain what
changed, and everything else carries forward.
Fallback is per route, not per controller. If the v2 user controller
declares only GET /, then POST /v2/users falls back to v1's POST /.
That lets you override a single route: make a v2 controller with just the
route that changed.
When that is not what you want, version.no_fallback makes every version
answer only the routes it declares:
version.new(version.path())
|> version.no_fallback
|> version.add("v1", [users_v1.controller(), orders.controller()])
|> version.add("v2", [users_v2.controller(), orders.controller()])
Now v2 must list everything it supports, and GET /v2/orders would be a
404 without the orders controller there. Choose this when versions should
be complete, separate things, and a route missing from v2 means it was
removed on purpose.
Choosing how clients name the version
One strategy applies to the whole group:
-
version.path()reads the first segment of the path, as in/v2/users. -
version.header("x-api-version")reads a request header. -
version.accept("vnd.howdy")reads a vendor media type from theacceptheader, as inapplication/vnd.howdy.v2+json. -
version.custom(fn)runs your own function from the request to anOption(String).
Use the path unless you have a reason not to. The version is visible in the URL, so a link can be shared, pasted into a browser, cached and tested without any special tooling. Headers keep URLs the same between versions, which some API designs prefer, at the price of being invisible.
A custom resolver can read the version from anywhere, such as a query parameter:
import gleam/http/request
import gleam/list
import gleam/option
import gleam/result
version.custom(fn(req) {
request.get_query(req)
|> result.try(list.key_find(_, "api"))
|> option.from_result
})
Mixing strategies, such as a header that overrides the path, is left out on purpose, because it makes it unclear which version a request is asking for. If you truly need it, a custom resolver can express any rule you like.
Missing and unknown versions
What happens when the version is missing or wrong depends on the strategy.
With the path strategy, the first segment is only treated as a version if
it is one you declared. Anything else is left alone, which is how /health
still reaches an unversioned controller. So an unknown version such as
/v9/users is simply a path nothing matches, and answers 404. Without a
default, so does /users.
With the header, accept and custom strategies there is a definite answer to
"which version?", so a bad one is the client's mistake and answers 400:
{"error":"unknown API version v9"}
A request with no version gets the default. If the group has no default it
answers 400 with {"error":"missing API version"}. That applies to every
path the unversioned controllers do not answer, including ones with no
route at all, so without a default a mistyped URL reports a missing version
rather than 404.
A default is a convenience for clients and a commitment for you: clients that never name a version are pinned to it forever, and you can never move the default without breaking them. Leaving it out forces every client to choose, which is stricter but keeps you free.
Caching
The header and accept strategies serve different responses from the same
URL. The group adds a vary header naming the header it read, vary:
x-api-version or vary: accept, to every response, so that caches keep
the versions apart. A custom resolver gets no vary header, because Howdy
cannot know what it reads; add your own in middleware if responses may be
cached.
Unversioned controllers
Controllers mounted with howdy.controller are matched first and never see
a version. The group answers whatever they do not. Use them for things that
are not part of the API contract: health checks, the home page, static
files, webhooks whose shape someone else decides.
They are not available under a version prefix: with the app above,
/v2/health is a 404. An app can have one version group, and mounting a
second panics.
Reading the version
The resolved version is on the context as ctx.version, an
Option(String). It is None on unversioned routes. After a fallback it
is still the version the client asked for, so an orders handler reached
through /v2/orders sees Some("v2").
That lets one handler serve several versions where the difference is small, which can be simpler than a whole controller per version:
import gleam/option.{Some}
fn show_user(ctx: Context) {
use id <- param.int(ctx, "id")
let encode = case ctx.version {
Some("v1") -> user_to_json_v1
_ -> user_to_json
}
user_service.find(id) |> service.respond(ctx, encode)
}
The path strategy strips the version segment before routing, which is why
the controllers are written without it. The request itself is left alone,
so ctx.request.path is still /v2/users.
Versions and middleware
Middleware wraps versioned routes like any
others, and can read ctx.version. That makes it the place for rules about
versions as a whole, such as warning clients that an old one is going away:
import gleam/http/response
import gleam/option.{Some}
import howdy/controller.{type Context}
import howdy/middleware.{type Next}
pub fn deprecation(ctx: Context, next: Next) {
let res = next(ctx)
case ctx.version {
Some("v1") ->
res
|> response.set_header("deprecation", "true")
|> response.set_header("sunset", "Wed, 30 Jun 2027 00:00:00 GMT")
_ -> res
}
}
Added with howdy.middleware, this marks every v1 response, including
ones from the default version and from routes v1 clients reach, without
touching a single handler. The same idea works for a stricter
rate limit on an old version, or for
logging which versions are still in use before you remove one.
Naming versions
A version name is any string, and Howdy gives the text no meaning. v1 and
v2 are common, and dates such as 2026-09-14 work as well. What matters
is the order you call version.add in, because that is the fallback order:
oldest first.
Names are matched exactly, so v2 and V2 are different versions. With
the path strategy, avoid a name that is also the first segment of a real
route.
Mistakes in the group are configuration errors, and panic when the app is built rather than when a request arrives: an empty name, a name added twice, or a default that was never added.
When to make a new version
A new version is a promise to keep the old one working, so make one only for changes that would break a client:
- removing or renaming a field, a route or a parameter
- changing the type or meaning of a value
- making something required that was optional
Additions are safe within a version, as long as clients ignore what they do not recognise: a new route, a new optional parameter, a new field in a response. Most changes to an API are additions, so new versions should be rare.
Testing versions
Ask for each version the way a client would. With the path strategy that is just the URL:
import howdy/testing
pub fn v2_falls_back_to_v1_orders_test() {
let res = testing.get("/v2/orders") |> testing.send(app())
assert res.status == 200
}
pub fn unknown_version_is_not_found_test() {
let res = testing.get("/v9/users") |> testing.send(app())
assert res.status == 404
}
With a header strategy, set the header, and check the rejection too:
pub fn unknown_version_is_rejected_test() {
let res =
testing.get("/users")
|> testing.header("x-api-version", "v9")
|> testing.send(app())
assert res.status == 400
assert testing.error(res) == Ok("unknown API version v9")
}
Keep a test for each old version for as long as you support it. They are what tells you a change to shared code has broken a client you promised not to break.
Documenting versions
howdy_openapi builds an OpenAPI document for each version, showing how
clients choose it. See OpenAPI documents.
