Muzak logomuzak
v0.1.10

Routers

A Router groups related routes under a shared prefix, tag set and dependency chain. Routers are built independently and composed with Router.Include, which is the Go counterpart of FastAPI's APIRouter and include_router: 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.

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 before the handler runs, so %2F reaches you as / rather than as an escape you have to remember to decode.

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 form body limit for a route that binds form or file fields
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
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, 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. Calls made afterwards have no effect, because the routing tree and the dependency chains are resolved once.

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.
  • 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 and the standard error envelope.
  • 404. A path that matches nothing answers 404, unless a frontend is mounted and claims it.
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, Description, OperationID, Tags, 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
  • 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
  • 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 · sustained by the people who ship on it.Built on net/http, and nothing else.