Muzak logomuzak
v0.2.00
You are reading the 0.1.1 documentation. The current release is 0.2.0. Read this page for it →

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}
TypeThe version comes fromNeeds
VersioningNonenowhere. Versioning is off, and WithVersion may not be used anywhere
VersioningURIthe request path, such as /v1/cats
VersioningHeadera request headerHeader
VersioningMediaTypea parameter of the Accept header, such as application/json;v=2Key
VersioningCustoma function of your ownExtractor

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}
DeclarationPath
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

  1. The path and method are resolved as they always are. For VersioningURI that has already settled the version, since it is part of the path.
  2. For every other type, the version or versions the request declares are extracted.
  3. Each requested version is tried in order, from most to least preferred, against every route registered for that method and path.
  4. If nothing matched, a route registered VersionNeutral answers.
  5. If nothing answers, the request gets the same 404 a 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

ProblemReported as
WithVersion used while Type is VersioningNonea version is declared but versioning is not enabled
WithVersion() with no versionsWithVersion was called with no versions
VersionNeutral combined with another versionVersionNeutral cannot be combined with another version
Two routes at one method and path with overlapping versionsis registered twice
DefaultVersion set while Type is VersioningNoneDefaultVersion is set but Type is not
VersioningHeader with no HeaderHeader must name a header for header versioning
VersioningMediaType with no KeyKey must be set for media type versioning
VersioningCustom with no ExtractorExtractor 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.

SchemeDeclarationIdentifier
URIr.Get("/cats", h, WithVersion("1"))get_v1_cats, derived from the versioned path
URIwith OperationID("listCats") and versions 1, 2listCats_1 and listCats_2
Header, media type, customr.Get("/cats", h, WithVersion("1"))get_cats_1
AnyWithVersion(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.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.