Skip to content

Handlers and Context

A route handler takes one of two forms, and both register on the same router (see Routing):

// Both handler forms register on the same router.
// Plain: the standard library signature, passed through as is.
s.router.Get(s, "/ping", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
// DI-aware: gas.Context first, dependencies after, one error result.
s.router.Get(s, "/hello", func(ctx gas.Context) error {
return ctx.Text(http.StatusOK, "hello")
})
  • A plain handler is an http.HandlerFunc or a func(http.ResponseWriter, *http.Request). The router serves it as is, with one addition: a panic in it is recovered and sent to the error handler.
  • A DI-aware handler is a function whose first parameter is gas.Context, whose remaining parameters are dependencies, and which returns error. A returned error goes to the error handler, and so does a recovered panic.

The signature rules are checked when the route is registered. Handle panics when the handler:

  • is not a function (an http.Handler value included; pass its ServeHTTP method instead),
  • has no parameters, or a first parameter that is not exactly gas.Context,
  • does not return exactly one result whose type implements error (so func(gas.Context) *gas.Error is accepted).

Every parameter after the first is a dependency:

main.go
// Dependencies are parameters. Each is resolved from the request's scope when
// the request arrives: the singleton store is shared, the scoped audit log is
// built for this request alone.
func showNote(ctx gas.Context, store *NoteStore, audit *AuditLog) error {
id := ctx.Param("id")
audit.Record("read " + id)
note, ok := store.Get(id)
if !ok {
return gas.NotFound("note not found")
}
return ctx.JSON(http.StatusOK, map[string]string{"id": id, "note": note})
}
func wire() *gas.App {
app := gas.NewApp(
gas.WithSingletonService[*NoteStore](NewNoteStore),
gas.WithScopedService[*AuditLog](NewAuditLog),
)
app.Router().Get(nil, "/notes/{id}", showNote)
return app
}

On each request, the handler resolves its dependencies from that request’s scope by parameter type, in order, and then calls the handler. The lifetime of each registration decides what it gets: a singleton is the one shared instance, a scoped service is built once for the request and shared by everything that resolves it during that request, and a transient service is built fresh for each parameter. Scoped services that implement gas.Service are closed when the request ends.

Parameter types are matched against registrations by exact type. A parameter of type gas.CacheProvider needs a registration under gas.CacheProvider; a concrete type registered under its own type does not satisfy it. See Services and DI.

Start checks every DI-aware handler’s parameter types against the container after all services have run Init. If one is not registered, Start returns an error that names the route, its owner, and the type, and the app never serves traffic:

gas: handler GET /x (service "gas/router"): dependency *main.NoteStore is not registered in the container

The check confirms that each type is registered; it does not build anything. A dependency whose constructor fails at request time sends a wrapped gas: resolving <type>: ... error to the error handler, which the default handler renders as a 500.

The container always holds *gas.Router, *gas.EventBus, gas.HealthProvider, and gas.ReadyProvider, so a handler can take any of them without registering anything.

gas.Context embeds context.Context, so it goes straight into database calls and anything else that takes a context. It is also the request’s own context: ctx.Request().Context() returns the same value, so request-scoped values and cancellation are the request’s.

Because gas.Context is an interface, a test can embed it in a struct and override only the methods it needs.

func search(ctx gas.Context) error {
q := ctx.Query("q") // query string value
trace := ctx.Header("X-Trace-Id") // request header
agent := ctx.Request().UserAgent() // the *http.Request itself
ctx.SetHeader("Cache-Control", "no-store") // response header
return ctx.JSON(http.StatusOK, map[string]string{"q": q, "trace": trace, "agent": agent})
}
Method Returns
Param(name) A path parameter, through chi.URLParam
Query(name) The first value of a query string parameter
Header(name) The first value of a request header
Request() The *http.Request
ResponseWriter() The http.ResponseWriter

SetHeader(name, value) sets a response header. Set headers before writing the response.

type feed struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
}
s.router.Get(s, "/json", func(ctx gas.Context) error {
return ctx.JSON(http.StatusOK, map[string]int{"count": 3})
})
s.router.Get(s, "/feed.xml", func(ctx gas.Context) error {
body, err := xml.Marshal(feed{Version: "2.0"})
if err != nil {
return err
}
return ctx.Blob(http.StatusOK, "application/rss+xml; charset=utf-8", append([]byte(xml.Header), body...))
})
s.router.Get(s, "/page", func(ctx gas.Context) error {
return ctx.HTML(http.StatusOK, "<h1>Notes</h1>")
})
s.router.Delete(s, "/notes/{id}", func(ctx gas.Context) error {
return ctx.NoContent()
})
s.router.Get(s, "/old", func(ctx gas.Context) error {
ctx.Redirect(http.StatusMovedPermanently, "/new")
return nil
})
Method Status Content-Type
JSON(status, v) status application/json
XML(status, v) status application/xml; charset=utf-8
HTML(status, s) status text/html; charset=utf-8
Text(status, s) status text/plain; charset=utf-8
Blob(status, contentType, b) status contentType
NoContent() 204 none
Redirect(status, url) status set by http.Redirect

XML writes the standard <?xml ...?> header line before the encoded value. Blob writes bytes you have already encoded, such as the RSS feed above. With an empty contentType it leaves Content-Type as it is, and when none is set, net/http detects one from the start of the body. The helpers write the status line immediately and return an error only when encoding or writing the body fails.

For errors, return a *gas.Error and let the error handler write it. The Context also carries constructors such as ctx.NotFound(msg) and ctx.NotFoundErr(err, msg), and WriteError and WriteErrorJSON for writing an error directly. All of them are covered in Errors.

// CreateNote is decoded from a JSON body and validated by its validate tags.
type CreateNote struct {
Title string `json:"title" validate:"required,max=120"`
Body string `json:"body"`
}
// SignupForm is decoded from a form body by its form tags.
type SignupForm struct {
Email string `form:"email" validate:"required,email"`
Name string `form:"name"`
}
func createNote(ctx gas.Context) error {
var req CreateNote
if err := ctx.BindJSON(&req); err != nil {
return err // already a *gas.Error: 400, 413, or 422
}
return ctx.JSON(http.StatusCreated, req)
}
func signup(ctx gas.Context) error {
var form SignupForm
if err := ctx.BindForm(&form); err != nil {
return err // already a *gas.Error: 400 or 422
}
ctx.Redirect(http.StatusSeeOther, "/welcome")
return nil
}

BindJSON(dest) decodes the request body as JSON into dest, then validates it. It reads at most Server.MaxJSONBodyBytes bytes, 1 MiB by default; zero or a negative value means no limit (see Deployment). BindForm(dest) parses the request form, decodes the form values from the body (r.PostForm, not the query string) into dest by form struct tags, ignoring keys with no matching field, then validates it.

Validation uses go-playground/validator and the struct’s validate tags. Both methods return a *gas.Error on failure, so the handler returns it unchanged:

Failure Status Code
BindJSON: body over the limit 413 payload_too_large
BindJSON: body is not valid JSON 400 invalid_json
BindForm: form cannot be parsed or decoded 400 invalid_form
Validation fails 422 validation_failed, one entry in fields per failed rule

Errors shows the response body and how fields are named.

ctx.Validator() returns the *validator.Validate in use, for validating values yourself, and ctx.FormDecoder() returns the *schema.Decoder used by BindForm.

func profile(ctx gas.Context) error {
p, err := ctx.Principal()
if err != nil {
return err // a 401 *gas.Error when no principal is attached
}
return ctx.JSON(http.StatusOK, map[string]string{"subject": p.Subject()})
}

ctx.Principal() returns the gas.Principal an authentication middleware attached to the request. When there is none, it returns a 401 *gas.Error with code unauthorized, so the handler can return it directly. Authentication itself comes from gas/auth.

A plain handler gets no injected dependencies, but it can resolve them from the request scope with gas.ResolveFromRequestScope:

// A plain handler resolves from the request scope itself.
func plainWithDeps(w http.ResponseWriter, r *http.Request) {
store, err := gas.ResolveFromRequestScope[*NoteStore](r)
if err != nil {
_ = gas.WriteError(w, r, err)
return
}
note, _ := store.Get("1")
_, _ = w.Write([]byte(note))
}

gas.NewContext(r.Context(), w, r) builds a gas.Context around a plain handler’s writer and request, for the response helpers:

// A plain handler can build a Context to use the response helpers. It does not
// get the router's form decoder or body limit unless it passes them.
func plainWithHelpers(w http.ResponseWriter, r *http.Request) {
ctx := gas.NewContext(r.Context(), w, r)
if err := ctx.JSON(http.StatusOK, map[string]bool{"ok": true}); err != nil {
log.Printf("write response: %v", err)
}
}

A Context built this way does not use the router’s form decoder. Its default decoder does not read form tags and rejects form keys with no matching field, so BindForm behaves differently than in a DI-aware handler. Pass gas.WithFormDecoder, gas.WithValidate, or gas.WithMaxJSONBodyBytes to NewContext to configure it; without WithMaxJSONBodyBytes, its BindJSON limit is 1 MiB.