Skip to content

Routing

*gas.Router wraps a chi router and adds two things: every route records the service that owns it, and a handler can declare its dependencies as parameters (see Handlers and Context). gas.NewApp creates one router per app and registers it in the container under *gas.Router, so a service takes it as a constructor parameter. Outside a service, app.Router() returns the same router.

notes/service.go
// Service owns the notes routes. It receives the router from the container,
// which NewApp registers under *gas.Router.
type Service struct {
router *gas.Router
}
func New(router *gas.Router) *Service {
return &Service{router: router}
}
func (s *Service) Name() string { return "notes" }
func (s *Service) Close(context.Context) error { return nil }
main.go
func main() {
app := gas.NewApp(
gas.WithSingletonService[*Service](New),
)
if err := app.Run(); err != nil {
log.Fatal(err)
}
}
notes/service.go
// Init registers the routes. The first argument of every registration is the
// owning service, so the router records which service each route belongs to.
func (s *Service) Init(context.Context) error {
s.router.Handle(s, http.MethodGet, "/notes", s.list)
// The method shorthands call Handle. Get also serves HEAD on the same path.
s.router.Get(s, "/notes/{id}", s.show)
s.router.Post(s, "/notes", s.create)
s.router.Delete(s, "/notes/{id}", s.remove)
return nil
}

Handle(owner, method, path, handler, middleware...) is the general form. Get, Head, Post, Put, Patch, and Delete call it with the method filled in. A route registered for GET, through Get or Handle, also serves HEAD on the same path.

The handler is either a plain handler (http.HandlerFunc or func(http.ResponseWriter, *http.Request)) or a DI-aware handler (func(gas.Context, deps...) error). Anything else panics. On the app’s router the panic comes from the Handle call itself; inside a Group or Route callback, which runs when the router builds its tree, it comes from Start for a block registered before Start. Handlers and Context covers both forms.

The trailing arguments are per-route middleware, gas.MiddlewareByName(...) or gas.MiddlewareFunc(...); see Middleware.

The router collects registrations and builds its chi tree when the app starts: Start seals the router after every singleton service has run Init. Until then, the order of Use and Handle calls on the app’s router does not matter, so services can add middleware with Use and register routes in any order during Init. Named middleware is the exception: a name must be registered before a call refers to it (see Named middleware). Global middleware is always applied before the routes, which is the order chi requires.

A registration made after Start takes effect immediately. The router builds a fresh tree from everything registered so far and swaps it in atomically; requests already in flight finish on the previous tree.

Patterns use chi’s syntax: {name} matches one path segment, {name:regexp} adds a regular expression, and a trailing * matches the rest of the path.

// Path parameters use chi's pattern syntax. A parameter can carry a regular
// expression, and a trailing * matches the rest of the path.
s.router.Get(s, "/notes/{id}/revisions/{rev:[0-9]+}", s.revision)
s.router.Get(s, "/files/*", s.file)
func (s *Site) revision(ctx gas.Context) error {
id := ctx.Param("id")
rev := ctx.Param("rev")
return ctx.JSON(http.StatusOK, map[string]string{"id": id, "rev": rev})
}
func (s *Site) file(ctx gas.Context) error {
rest := ctx.Param("*") // everything after /files/
return ctx.Text(http.StatusOK, rest)
}

ctx.Param(name) returns a path parameter, and ctx.Param("*") the part matched by the wildcard. A plain handler reads the same values with chi.URLParam:

// A plain handler reads path parameters with chi directly.
func plainShow(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(id))
}

Group scopes middleware to a set of routes without changing their paths:

// Group scopes middleware to a set of routes without changing their paths.
s.router.Group(func(sub *gas.Router) {
sub.UseMiddlewareByName("require-auth")
sub.Get(s, "/admin/dashboard", s.list)
sub.Get(s, "/admin/settings", s.list)
})

Route mounts a path prefix. Several services may mount the same prefix, which is how independent features share /api:

// Route mounts a path prefix. Several services can call it with the same
// pattern; later calls attach to the mount the first one created.
func (s *Notes) Init(context.Context) error {
s.router.Route("/api", func(sub *gas.Router) {
sub.Use(gas.MiddlewareByName("require-auth"))
sub.Get(s, "/notes", listNotes) // GET /api/notes, guarded
})
return nil
}
// Billing mounts the same prefix. Its block is unaffected by the Use above.
func (s *Billing) Init(context.Context) error {
s.router.Route("/api", func(sub *gas.Router) {
sub.Get(s, "/plans", listPlans) // GET /api/plans, not guarded
})
return nil
}

Calling Route again with a pattern that is already mounted attaches to the existing mount instead of failing. Each Route body runs in its own group, so Use inside one block applies only to the handlers registered in that block. The second block’s routes above are not behind require-auth, even though both hang off /api.

Route nests, and the prefixes accumulate:

// Route nests. Prefixes accumulate, so this registers GET /api/v1/notes.
s.router.Route("/api", func(api *gas.Router) {
api.Route("/v1", func(v1 *gas.Router) {
v1.Get(s, "/notes", s.list)
})
})

Handle accepts functions, not http.Handler values. Pass the handler’s ServeHTTP method, which has the plain handler signature:

// Handle accepts functions only. To serve an http.Handler, pass its ServeHTTP
// method, which has the plain handler signature.
files := http.StripPrefix("/static/", http.FileServer(http.Dir("public")))
s.router.Get(s, "/static/*", files.ServeHTTP)

A request that matches no route gets chi’s default response: 404 with the plain-text body 404 page not found. A request whose path matches but whose method does not gets 405 with an empty body and an Allow header listing the methods the path serves, including the implicit HEAD of a GET route (GET and HEAD for a path with only a GET route). The methods are listed in no fixed order.

NotFound(owner, handler) replaces the 404 handler. It takes either handler form, and a DI-aware not-found handler has its dependencies checked at startup like any route.

// NotFound replaces chi's default 404. A router accepts one not-found handler;
// a second call panics.
s.router.NotFound(s, func(ctx gas.Context) error {
return gas.NotFound("no route for " + ctx.Request().URL.Path)
})

A router accepts one not-found handler. A second NotFound call panics, even from the same service.

The first argument of Handle, Get, NotFound, and the other registration methods is the owning service. The router records each route under the owner’s Name(). Passing nil makes the router itself the owner: those routes are recorded under the name gas/router, including routes registered inside a Group or Route block.

Ownership is what the kill switch acts on. CloseService replaces every route the closed service owns with a 503 handler and disables the named middleware it registered wherever that middleware is used; RestartService brings them back. See Ownership and teardown.

Routes() returns a snapshot of the registered routes, grouped by owner name. It leaves out the implicit HEAD routes that GET registrations add. Each route lists the named middleware that applies to it, with one exception: a route registered directly on the app’s router before a global Use of a named middleware is listed without that name, even though the middleware runs on it. This is tracked as KI-056 in Known issues. NamedMiddleware() maps each registered middleware name to the name of the service that owns it.

// Routes reports every registered route grouped by the name of its owner.
func printRoutes(router *gas.Router) {
for owner, routes := range router.Routes() {
for _, rt := range routes {
log.Printf("%s: %s %s %v", owner, rt.Method, rt.Path, rt.Middleware)
}
}
}

App.Run logs both right after Start, when at least one route is registered. When the environment is development, it logs one table of routes and named middleware; otherwise it logs one line per route and one per named middleware. With no routes registered it logs nothing, not even the named middleware. When nothing sets the environment, for example when no ConfigProvider is registered, it is empty and the per-line form is used. Calling Start and Serve yourself logs no route map.

Mux() returns the most recently built chi tree. Changes made to it directly are not recorded by the router, so they are lost the next time the tree is rebuilt: Start builds a fresh tree, and so does every registration or teardown after it. Register routes and middleware through the *gas.Router methods instead.