API Versioning
Versioning is off until AppOptions.Versioning names a type. Once it is on, a route says
which version or versions it answers, and a route that says nothing answers nothing.
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
Version: "1.0.0",
Versioning: muzak.VersioningOptions{Type: muzak.VersioningURI},
})
app.Get("/cats", handlers.ListCatsV1, muzak.WithVersion("1"))
app.Get("/cats", handlers.ListCatsV2, muzak.WithVersion("2"))
app.Get("/health", handlers.Health, muzak.WithVersion(muzak.VersionNeutral))
curl http://localhost:8080/v1/cats # ListCatsV1
curl http://localhost:8080/v2/cats # ListCatsV2
curl http://localhost:8080/health # Health, at its plain path
curl http://localhost:8080/v3/cats # 404
AppOptions.Version and AppOptions.Versioning are different things. The first is the
API's own version string in the OpenAPI document; the second is how a request says which
version of the API it wants.
Choosing a type
muzak.VersioningOptions{Type: muzak.VersioningURI}
| Type | The version comes from | Needs |
|---|---|---|
VersioningNone | nowhere. Versioning is off, and WithVersion may not be used anywhere | |
VersioningURI | the request path, such as /v1/cats | |
VersioningHeader | a request header | Header |
VersioningMediaType | a parameter of the Accept header, such as application/json;v=2 | Key |
VersioningCustom | a function of your own | Extractor |
VersioningURI is the one a new application should reach for first. It is the only type
where the version is part of routing rather than something read off a request, which is
what makes it visible in a URL, cacheable by anything in front of the service, and
representable in the generated document.
Declaring what a route answers
WithVersion is a shared option, so it works on a route, on a router, and at the point one
router is included into another.
func Cats() *muzak.Router {
// Every route below answers version 1 unless it says otherwise.
r := muzak.NewRouter(muzak.WithTags("cats"), muzak.WithVersion("1"))
r.Get("/cats", handlers.ListCats)
r.Get("/cats/{id}", handlers.ReadCat)
// This one is only in version 2.
r.Post("/cats/{id}/adopt", handlers.AdoptCat, muzak.WithVersion("2"))
return r
}
A route's own declaration replaces what it inherited rather than adding to it, exactly
as Status replaces a router's declared default. AdoptCat above answers version 2 and
not version 1.
Several versions at once
// One handler, two versions. Nothing changed between them.
r.Get("/cats", handlers.ListCats, muzak.WithVersion("1", "2"))
Calling WithVersion with no arguments at all is a build error, since there would be
nothing left for the declaration to mean.
Version-neutral routes
r.Get("/health", handlers.Health, muzak.WithVersion(muzak.VersionNeutral))
VersionNeutral answers every request regardless of the version it names, including a
request that names none. Under VersioningURI a neutral route keeps its plain path, with
no version segment inserted, so the route above is reached at /health and not at
/v1/health.
It cannot be combined with another version in the same call, because it already answers every request the narrower version would.
The strict default
A route that resolves no version at all answers no request while versioning is enabled. It
is not a build error, and it is not served unversioned either: an application has to opt a
route into being version-independent deliberately, with VersionNeutral, rather than by
forgetting.
The build says so rather than leaving you to find out:
14:32:07.488 WARN [Router] GET /cats declares no version and no default version applies; it will answer no request while versioning is enabled
Where most routes share one version, say so once instead of on every route:
muzak.VersioningOptions{
Type: muzak.VersioningURI,
DefaultVersion: []muzak.Version{"1"},
}
DefaultVersion applies to any route or router that declares none of its own. Setting it
while Type is still VersioningNone is a build error, because it could not mean
anything.
URI versioning
The version is inserted into the path of every route that declares one.
muzak.VersioningOptions{Type: muzak.VersioningURI}
| Declaration | Path |
|---|---|
r.Get("/cats", h, muzak.WithVersion("1")) | /v1/cats |
r.Get("/cats", h, muzak.WithVersion("1", "2")) | /v1/cats and /v2/cats |
r.Get("/health", h, muzak.WithVersion(muzak.VersionNeutral)) | /health |
A route naming more than one version is registered once per version, each at its own path, because here the version is part of routing rather than something matched once a request arrives.
The prefix
v by default, so version 1 is reached at /v1. Prefix is a *string, which is what
lets an empty prefix be told from an unset one.
prefix := "ver-"
muzak.VersioningOptions{Type: muzak.VersioningURI, Prefix: &prefix}
// version "1" is reached at /ver-1/cats
none := ""
muzak.VersioningOptions{Type: muzak.VersioningURI, Prefix: &none}
// version "1" is reached at /1/cats
Versions come before router prefixes
The version segment is prepended to the route's fully resolved path, after every
WithPrefix has already been applied.
r := muzak.NewRouter(muzak.WithVersion("1"))
r.Get("/cats", handlers.ListCats)
app.Include(r, muzak.WithPrefix("/api"))
/v1/api/cats
Not /api/v1/cats. If you want the version inside a prefix, put the prefix in the route
paths themselves and leave the include unprefixed, or use one of the header-based types
where the path is left alone entirely.
Header versioning
muzak.VersioningOptions{
Type: muzak.VersioningHeader,
Header: "X-API-Version",
}
app.Get("/cats", handlers.ListCatsV1, muzak.WithVersion("1"))
app.Get("/cats", handlers.ListCatsV2, muzak.WithVersion("2"))
curl http://localhost:8080/cats -H 'X-API-Version: 1' # ListCatsV1
curl http://localhost:8080/cats -H 'X-API-Version: 2' # ListCatsV2
curl http://localhost:8080/cats -H 'X-API-Version: 3' # 404
curl http://localhost:8080/cats # 404, no version named
The path is left alone, so more than one route may share it, as long as their versions
never overlap. Two routes at one method and path that both answer version 1 are reported
as registered twice when the application is built.
A request naming no version at all is answered only by a route registered VersionNeutral.
Media type versioning
muzak.VersioningOptions{
Type: muzak.VersioningMediaType,
Key: "v=",
}
curl http://localhost:8080/cats -H 'Accept: application/json;v=2'
Key is the literal prefix to look for among the parameters of the Accept header,
trailing = included. The header may name several media ranges separated by commas, each
with its own parameters separated by semicolons, and the first parameter carrying the key
wins.
An Accept header with no such parameter names no version, so only a VersionNeutral
route answers it.
Custom versioning
muzak.VersioningOptions{
Type: muzak.VersioningCustom,
Extractor: core.VersionsFromHeader,
}
// VersionsFromHeader reads a comma-separated list of acceptable versions,
// most preferred first, so a client can say "2 if you have it, otherwise 1".
func VersionsFromHeader(r *http.Request) []string {
raw := r.Header.Get("X-Versions")
if raw == "" {
return nil
}
return strings.Split(raw, ",")
}
curl http://localhost:8080/cats -H 'X-Versions: 3,2,1' # answered by version 2
The extractor returns versions in order from most to least preferred, and the first one some route actually answers wins. Empty strings are dropped, and returning nil or an empty slice reports a request that named no version at all.
VersioningCustom is the only type that can offer several versions in one request. It is
also where a version that is not a plain string belongs: a date, a build number, or a value
read from a query parameter or a cookie.
How a request is matched
- The path and method are resolved as they always are. For
VersioningURIthat has already settled the version, since it is part of the path. - For every other type, the version or versions the request declares are extracted.
- Each requested version is tried in order, from most to least preferred, against every route registered for that method and path.
- If nothing matched, a route registered
VersionNeutralanswers. - If nothing answers, the request gets the same
404a path that matches nothing gets.
{
"error": {
"code": "not_found",
"message": "no route matches GET /cats",
"status": 404
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
A version nobody serves is a 404 rather than a 406, because as far as routing is
concerned the request named something that does not exist.
An application that never enables versioning pays nothing for any of this: there is exactly one route per method and path, and it is dispatched without a version ever being resolved.
Errors reported when the application is built
| Problem | Reported as |
|---|---|
WithVersion used while Type is VersioningNone | a version is declared but versioning is not enabled |
WithVersion() with no versions | WithVersion was called with no versions |
VersionNeutral combined with another version | VersionNeutral cannot be combined with another version |
| Two routes at one method and path with overlapping versions | is registered twice |
DefaultVersion set while Type is VersioningNone | DefaultVersion is set but Type is not |
VersioningHeader with no Header | Header must name a header for header versioning |
VersioningMediaType with no Key | Key must be set for media type versioning |
VersioningCustom with no Extractor | Extractor must be set for custom versioning |
All of them arrive together, with everything else Build reports.
Versioning and the generated document
Operation identifiers stay unique, which is what a client generator needs.
| Scheme | Declaration | Identifier |
|---|---|---|
| URI | r.Get("/cats", h, WithVersion("1")) | get_v1_cats, derived from the versioned path |
| URI | with OperationID("listCats") and versions 1, 2 | listCats_1 and listCats_2 |
| Header, media type, custom | r.Get("/cats", h, WithVersion("1")) | get_cats_1 |
| Any | WithVersion(muzak.VersionNeutral) | no suffix |
URI versioning describes every version. Each one is a distinct path, so /v1/cats and
/v2/cats are two entries in the document and both appear in the documentation UI.
The other schemes describe one. Several routes then share one path and method, and an OpenAPI path item has a single slot per method, so the last registration wins and the earlier versions are absent from the document. Nothing about routing changes; only the description does.
That is the practical argument for VersioningURI whenever the generated document
matters. Where a header-based scheme is required anyway, the options are to keep the
document to the current version and describe the older ones elsewhere, or to generate one
document per version by building an application per version:
for _, version := range []muzak.Version{"1", "2"} {
app := buildApp(version) // includes only the routers for that version
document, err := app.Document()
// ... write it to openapi-v{version}.json
}
Inspecting what was registered
Route.Versions holds the versions a route resolved to, from WithVersion or from
DefaultVersion. It is empty for an application that never enables versioning, and also,
deliberately, for a route that answers nothing because neither it nor anything it was
declared under named a version.
The fields are filled in when the application is built, so read them after Build.
cats := routers.CatsV1()
app.Include(cats)
if err := app.Build(); err != nil {
log.Fatal(err)
}
for _, route := range cats.Routes() {
log.Printf("%s %s versions=%v", route.Method, route.Path, route.Versions)
}
GET /cats versions=[1]
GET /cats/{id} versions=[1]
Routes() reports the routes registered directly on the router it is called on, excluding
those of any router included into it, so hold on to the routers you want to inspect rather
than asking the application for everything.
Two details are worth knowing when reading those paths. They carry every inherited prefix
once the application is built, and under VersioningURI they are the paths before the
version segment is inserted: the versioned clones the router actually dispatches are the
framework's, not the ones this list holds.
A version per package
Once two versions differ in more than a handler, give each its own router and let main
mount both. The schemas stay separate, which is the point: a version exists so that the
old shape can keep working while the new one changes.
func CatsV1() *muzak.Router {
r := muzak.NewRouter(muzak.WithTags("cats"), muzak.WithVersion("1"), muzak.Deprecated())
r.Get("/cats", handlers.ListCatsV1, muzak.Summary("List cats"))
r.Get("/cats/{id}", handlers.ReadCatV1, muzak.Summary("Read a cat"))
return r
}
Deprecated() on the version 1 router marks every operation beneath it as deprecated in
the document and changes nothing at runtime, which is the honest way to announce a removal
before making it.
Retiring a version is then deleting one Include and one package.
Testing versioned routes
func TestCatsByVersion(t *testing.T) {
client := testclient.New(t, buildApp())
client.Get("/v1/cats").
AssertStatus(http.StatusOK).
AssertJSON(`{"cats":[{"name":"Plumbus"}]}`)
client.Get("/v2/cats").
AssertStatus(http.StatusOK).
AssertJSON(`{"cats":[{"name":"Plumbus","adopted":false}]}`)
// A version nobody serves is a 404, the same as any unmatched path.
client.Get("/v3/cats").AssertStatus(http.StatusNotFound)
// A version-neutral route keeps its plain path under URI versioning.
client.Get("/healthz").AssertStatus(http.StatusOK)
}
func TestCatsByHeader(t *testing.T) {
client := testclient.New(t, buildHeaderVersionedApp())
client.Get("/cats", testclient.Header("X-API-Version", "1")).AssertStatus(http.StatusOK)
client.Get("/cats", testclient.Header("X-API-Version", "9")).AssertStatus(http.StatusNotFound)
// No version named at all, and no neutral route to fall back to.
client.Get("/cats").AssertStatus(http.StatusNotFound)
}
A test that every route answers something is worth more than usual here, because a route that answers nothing is a log line rather than a build error:
func TestEveryRouteAnswersSomething(t *testing.T) {
app := muzak.New(appOptions())
// Hold on to each router, because Routes() reports only what was
// registered directly on the router it is called on.
all := []*muzak.Router{routers.CatsV1(), routers.CatsV2(), routers.Meta()}
app.Include(all[0])
app.Include(all[1])
app.Include(all[2], muzak.WithVersion(muzak.VersionNeutral))
if err := app.Build(); err != nil {
t.Fatalf("Build = %v", err)
}
for _, r := range all {
for _, route := range r.Routes() {
if len(route.Versions) == 0 {
t.Errorf("%s %s answers no version", route.Method, route.Path)
}
}
}
}
Where to go next
Routers covers the prefixes and options a version declaration layers on top of, and OpenAPI covers the document each version produces.