You are reading the 0.3.0 documentation. The current release is 0.3.1. Read this page for it →

Routers

A Router groups related routes under a shared prefix, tag set and dependency chain. Routers are built independently and composed with Router.Include: a package exports its own routes and stays unaware of where they will be mounted.

App embeds *Router, so an application is a router that also knows how to serve. Everything on this page works the same on both.

Registering a route

r := muzak.NewRouter(muzak.WithTags("items"))

r.Get("/items/{item_id}", handlers.ReadItem)

The registration methods are Get, Post, Put, Patch, Delete and Head, plus Handle for any other method. Each takes a path template, a handler, and any number of route options. Each returns the *Route it registered.

r.Handle(http.MethodOptions, "/things", handlers.DescribeThings)

There is no Router.Options method. App.Options applies configuration to an application, and since App embeds *Router the two would shadow each other, so OPTIONS is registered with Handle. In practice you rarely need to: OPTIONS is answered automatically with an Allow header.

Router.WS, Router.SSE and Router.SSEHandle register real-time routes and are covered in WebSockets and Server-Sent Events. Router.Frontend and Router.Static serve files and are covered in Static Files and Frontends. Router.Mount serves any http.Handler at a prefix and is covered in Mounting Handlers, and Router.Implement registers an operation declared once for a server and its Go callers, covered in Typed Endpoints.

The handler shape

Every handler is func(ctx *muzak.Context, in In) (Out, error).

func ReadItem(ctx *muzak.Context, in schemas.ItemParams) (schemas.ItemOut, error) {
    store := muzak.From[*core.ItemStore](ctx)

    item, err := store.Get(in.ID)
    if err != nil {
        return schemas.ItemOut{}, muzak.NotFound("Item not found")
    }
    return schemas.ItemOut{ID: item.ID, Name: item.Name}, nil
}

In and Out are inferred from the handler literal or from the named function, so you never write the type arguments at the call site. A route that reads nothing declares muzak.Empty as its input:

r.Get("/healthz", func(ctx *muzak.Context, _ muzak.Empty) (schemas.HealthOut, error) {
    return schemas.HealthOut{Status: "ok"}, nil
})

Path templates

A path may contain {name} parameters and one trailing {name...} wildcard. Matching walks a segment-wise radix trie and allocates nothing.

r.Get("/users/{username}", handlers.ReadUser)          // one segment
r.Get("/files/{path...}", handlers.ServeFile)          // the rest of the path

Every parameter in the template must be read by a field of the input type. A template parameter nothing binds is reported when the application is built, because it is almost always a typo:

type ItemParams struct {
    ID string `path:"item_id" doc:"The item to operate on"`
}

r.Get("/items/{item_id}", handlers.ReadItem)

Captured values are percent-decoded once before the handler runs, so %2F reaches you as / rather than as an escape you have to remember to decode. That also means a wildcard's value can contain / and .., and any value can contain a NUL byte: /files/a%2F..%2Fb binds a/../b. Treat one as text a client wrote, and validate it before using it as a file name.

Static segments are compared percent-decoded too, as net/http.ServeMux does, for every kind of route, so GET /users/%61dmin is the route /users/admin and cannot slip round the guards it declares to be answered by a public /users/{id}. An encoded %2F never matches a static segment, and a lookup path that does not begin with / matches nothing. Two consequences are build errors:

  • A static segment that is not validly encoded (/50%off), or that encodes a / (/a%2Fb), could never match. Write %25 for a literal percent sign, and use a parameter for a value that may contain /.
  • Two spellings of one path (/a and /%61) conflict, as two routes at one path do.

A wildcard receives other spellings of the routes beside it. A path is matched as it arrives, and nothing normalises it first. With a guarded /admin/panel and a public /{rest...}, every one of //admin/panel, /admin//panel, /admin/panel/, /ADMIN/panel, /./admin/panel, /x/../admin/panel, /%2e/admin/panel and /admin%2Fpanel reaches the public wildcard, the last with rest set to admin/panel. The guarded route itself is never served without its guard, as with net/http.ServeMux; the danger is in what the wildcard's handler does with the value. One that serves a file, looks up a record or calls another service by it must check that the caller is allowed what the value refers to, rather than rely on the guards of a route the request did not match, and must never forward the value to a backend that cleans, decodes or case-folds paths, which may resolve it to the very resource the guard was protecting.

To hand everything beneath a prefix to a net/http handler, such as a legacy application or net/http/pprof, use Router.Mount rather than a wildcard route that calls one. The same rule holds there: nothing normalises the path, a request reaches the mount only through its exact prefix, and a handler that cleans or case-folds the path it is given needs guards of its own. See Mounting Handlers.

A template that declares the same parameter name twice is a build error as well. /orgs/{id}/users/{id} used to build, and a handler binding path:"id" silently received the first segment, so a check on the user's id was made against the organisation's. Give each parameter its own name.

Paths are matched exactly. /items/ and /items are two different templates, and nothing redirects between them, so register the one you mean.

Registering two routes for the same method and path is an error, with one exception: an application with versioning enabled may register several at one path as long as the versions they answer never overlap. See Versioning.

Route options

Options passed after the handler configure that route alone.

r.Post("/items/", handlers.CreateItem,
    muzak.Summary("Create an item"),
    muzak.Description("Creates an item under the identifier the caller chooses."),
    muzak.Status(http.StatusCreated),
    muzak.OperationID("createItem"),
    muzak.WithResponseDoc(http.StatusConflict, "An item with that identifier already exists"))
OptionWhat it does
Summary(s)The one-line description shown beside the operation
Description(s)The long-form description, rendered as CommonMark
OperationID(id)The operation's identifier, which client generators use to name methods
Status(code)The status written when the handler returns without an error
Deprecated()Marks the operation deprecated in the document. Changes nothing at runtime
Hidden()Leaves the route fully routable but out of the document and the docs page
SkipValidation()Stops the input model's Validate method from running
AllowUnknownFields()Accepts JSON members with no matching field instead of rejecting them
MaxBodySize(n)Overrides the request body limit for this route
MaxUploadSize(n)Overrides the body limit for a route that declares a file field. A route that binds only form values is bounded by MaxBodySize
MaxFileSize(n)Bounds any single uploaded file
WithResponseDoc(code, desc)Documents an outcome the handler produces through an error
WithResponseModel[T](code, desc)Documents an outcome and the model its body carries
WithTags(...)Adds OpenAPI tags
WithCategory(name)Files a router, or one route, under a single category in the documentation. See OpenAPI
Title(s)A short human name the documentation lists the route by, in place of its path
WithSecurity(...) / Public()Says which declared security scheme a route needs, or that it needs none. Enforced for a scheme built by JWTBearer or APIKeyVerifier; a descriptive scheme is documentation only. See Authentication
WithDependencies(...)Attaches guards
Needs(provider)Declares a request-scoped value dependency
Singleton(provider) / WithSingleton(v)Publishes a value shared by every request
RateLimit(...) / SkipRateLimit() / WithRateLimit(...)Narrow the rate limit policy
WithWebSocket(...) / WithSSE(...)Configure a real-time route's connection or stream
WithVersion(...)Declare the API version or versions the route answers

Most of these are SharedOption, meaning they are equally valid on a router, where they apply to every route beneath it. Summary, Title, Description, OperationID, Status and SkipValidation belong to a single route; WithPrefix belongs to a router.

Grouping routes into a router

A router is created with the options every route beneath it should inherit.

package routers

import (
    "net/http"

    "awesome-api/core"
    "awesome-api/handlers"
    "muzak.dev/framework"
)

// Items returns the items router.
func Items() *muzak.Router {
    r := muzak.NewRouter(muzak.WithTags("items"))

    r.Get("/items/", handlers.ListItems,
        muzak.Summary("List items"))

    r.Get("/items/{item_id}", handlers.ReadItem,
        muzak.Summary("Read an item"),
        muzak.WithResponseDoc(http.StatusNotFound, "The item does not exist"),
        // Only this route resolves the caller, so only this route pays for it.
        muzak.Needs(core.GetCurrentUser))

    r.Post("/items/", handlers.CreateItem,
        muzak.Summary("Create an item"),
        muzak.Status(http.StatusCreated),
        muzak.WithResponseDoc(http.StatusConflict, "An item with that identifier already exists"))

    r.Put("/items/{item_id}", handlers.RenameItem,
        muzak.Summary("Rename an item"),
        muzak.WithResponseDoc(http.StatusNotFound, "The item does not exist"))

    return r
}

Notice what the router does not decide: it has no prefix, no authentication and no knowledge of the application it belongs to. Those are decisions made where it is included.

Including a router

app := muzak.New(muzak.AppOptions{
    Title:   "Awesome API",
    Version: "1.0.0",
    Addr:    settings.Addr,
},
    muzak.WithDependencies(core.GetQueryToken),
    muzak.WithSingleton(settings),
)

app.Include(routers.Users())
app.Include(routers.Items())

// The admin router is written without a prefix or a guard. Both are applied
// here, which keeps that router reusable and puts the security decision
// somewhere a reviewer will find it.
app.Include(routers.Admin(),
    muzak.WithPrefix("/admin"),
    muzak.WithTags("admin"),
    muzak.WithDependencies(core.GetTokenHeader(settings)),
    muzak.WithResponseDoc(http.StatusTeapot, "I'm a teapot"),
)

The child keeps its own configuration and inherits everything from the parent, with the include's options layered in between. Guards contributed by the parent run before those contributed at the include, which run before the child's own.

A router may be included only once. Including it twice is reported as an error when the application is built, because a route has a single resolved path.

Prefixes

WithPrefix must begin with / and must not end with one. Prefixes nest, so including a router that is itself included concatenates both.

muzak.WithPrefix("/admin")     // "/" becomes "/admin/", "/reports" becomes "/admin/reports"
v1 := muzak.NewRouter()
v1.Include(routers.Users())
v1.Include(routers.Items())

app.Include(v1, muzak.WithPrefix("/v1"))

Options applied after creation

App.Options adds router options to an application after New has returned, which is how a dependency discovered later is published.

models := core.NewModelRegistry()
app.Options(muzak.WithSingleton(models))

It must be called before the application is built. A call made afterwards panics, naming it, because the routing tree and the dependency chains are resolved once and the call would have no effect. That is the loud failure the mistake needs: a guard added late used to be dropped without a word and left routes open. The same holds for Use, Include, route registration, WebSocket and event stream registration, and Frontend, Static and Mount after Build, ServeHTTP, Document or a run method. Make every configuration call before the first of those.

What the router answers on its own

  • HEAD. A GET route answers HEAD automatically by running the handler and letting net/http discard the body. Registering an explicit Head route takes precedence. Such a request is a call to the GET route, with its guards and security, and is named after it: the access log's route, RouteFromContext, the span and the request observer all report the GET route's template.
  • OPTIONS. A path with no OPTIONS route answers 204 with an Allow header listing every method it serves, including the automatic HEAD and OPTIONS.
  • 405. A path that exists but does not serve the method answers 405 with the same Allow header, rendered by the error renderer like any other error.
  • 404. A path that matches nothing answers 404, unless a frontend is mounted and claims it. A path that is not valid UTF-8 gets its 404 too, with the path quoted in its escaped form, and one longer than 1 KiB is cut in the message and in the log.

The OPTIONS and 405 answers come from the route table alone, as net/http.ServeMux answers them, so they skip a route's guards and rate limit. An unauthenticated client can therefore learn which paths exist and which methods they take, and never a route's data. Middleware installed with App.Use still covers them: put a guard or a limit there when the existence of a path is itself a secret.

curl -i -X OPTIONS http://localhost:8080/items/
HTTP/1.1 204 No Content
Allow: GET, POST, HEAD, OPTIONS

Inspecting registered routes

Router.Routes returns the routes registered directly on a router, excluding those of any included router. Before the application is built the paths are the ones supplied at registration, without inherited prefixes.

if err := app.Build(); err != nil {
    log.Fatal(err)
}
for _, route := range app.Routes() {
    log.Printf("%s %s (%s)", route.Method, route.Path, route.OperationID)
}

A *Route carries Method, Path, Summary, Title, Description, OperationID, Tags, Category, Deprecated, Hidden and Status. Its fields are filled in when the application is built and are read-only from that point on. The route being executed is available inside a handler as ctx.Route().

Errors reported at build time

These are collected while the application is built and reported together, so a first run in a new environment lists everything wrong at once instead of one thing per attempt:

  • two routes registered for the same method and path, unless versioning tells them apart
  • an input or output type that cannot be bound or described
  • a path parameter no field binds, or a template that declares one name twice
  • a static segment that is not validly encoded, or two spellings of one path
  • a duplicate operation identifier
  • a prefix that does not begin with /, or that ends with one
  • a router included more than once
  • a MaxConnections or MaxStreams set anywhere but on the application
  • a static or frontend mount whose directory does not exist, or two mounts at one path (a second Static or Frontend used to lose to the first without a word, guards included)
  • a path, query, header, cookie, form or file tag on a field the binder cannot reach, and a Nested that would validate nothing (see Request Data and Validation)
  • a certificate without a key, or a key without a certificate
  • a category, a title or a security declaration that cannot be written to the document (see OpenAPI), and, unless DisableDocs is set, a document that cannot be encoded as JSON, which a doc, summary, description or example that is not valid UTF-8 causes (it used to be logged, and /openapi.json silently left unserved)
  • a version declared while versioning is off, or a versioning policy missing the field its type depends on

Where to go next

Request Data covers how the input type is filled in from the request, and Responses covers what happens to the value you return.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.