Skip to content

Route requests

The router is chi underneath, with two additions: every route records the service that owns it, and handlers may declare their dependencies as parameters.

notes/service.go
// Handle takes the owning service name, a method, a path, and either a plain
// http.HandlerFunc or a DI-aware handler. Both forms coexist on one router.
func (s *Service) Init() error {
s.router.Handle(s.Name(), http.MethodGet, "/notes", s.list)
s.router.Handle(s.Name(), http.MethodPost, "/notes", s.create,
gas.MiddlewareByName("require-auth"),
)
s.router.Handle(s.Name(), http.MethodGet, "/healthz",
func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
return nil
}

The first argument is the owning service name, which is what makes teardown and route attribution possible. Passing s.Name() keeps it honest.

Middleware is attached per route with gas.MiddlewareByName(...), resolved from the router’s registry, or gas.MiddlewareFunc(...) for an inline one.

Both work on the same router. A plain http.HandlerFunc is passed through untouched. A DI-aware handler takes gas.Context first, dependencies in the middle, and returns error:

func (s *Service) createUser(ctx gas.Context, db gas.DatabaseProvider, mailer gas.EmailProvider) error

Each dependency is resolved from that request’s scope. At startup the router checks that every declared parameter is registered, so a missing dependency fails before the server accepts traffic rather than on the first request that hits the route.

Returning an error renders it through the error handler, and panics are recovered and routed the same way.

Group scopes middleware without changing paths:

// Group scopes middleware to a set of routes without changing their paths.
func groups(router *gas.Router, s *Service) {
router.Group(func(sub *gas.Router) {
sub.UseMiddlewareByName("require-auth")
sub.Handle("admin", http.MethodGet, "/admin/dashboard", s.list)
sub.Handle("admin", http.MethodGet, "/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 mounts(router *gas.Router, s *Service) {
router.Route("/api", func(sub *gas.Router) {
sub.Use(gas.MiddlewareByName("require-auth"))
sub.Handle("notes", http.MethodGet, "/notes", s.list) // guarded
})
// Registered by a different service, same mount, unaffected by the Use above.
router.Route("/api", func(sub *gas.Router) {
sub.Handle("billing", http.MethodGet, "/plans", s.list) // not guarded
})
}
func (s *Service) show(ctx gas.Context) error {
id := ctx.Param("id") // path parameter
verbose := ctx.Query("verbose") // query string
trace := ctx.Header("X-Trace-Id") // request header
ctx.SetHeader("Cache-Control", "no-store")
return ctx.JSON(http.StatusOK, map[string]string{"id": id, "v": verbose, "t": trace})
}

gas.Context embeds context.Context, so it goes straight into database calls, gRPC clients, and anything else taking a context, with no unwrapping. It also carries the response helpers: JSON, XML, RSS, HTML, Text, NoContent, Redirect, Error, and ErrorJSON, plus BindJSON and BindForm for request bodies. The full method list is on pkg.go.dev.

Because it is an interface, tests can implement only the parts they need by embedding gas.Context in a struct and overriding a method or two.

router.NotFound(service, handler) sets the fallback. router.Mux() returns the underlying chi router when you need something Gas does not wrap. router.Routes() reports every registered route with its owner, and router.NamedMiddleware() does the same for middleware, which is what the startup route-map log prints in development.