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.HandlerFuncor afunc(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 returnserror. A returned error goes to the error handler, and so does a recovered panic.
DI-aware handlers
Section titled “DI-aware handlers”The signature rules are checked when the route is registered. Handle panics
when the handler:
- is not a function (an
http.Handlervalue included; pass itsServeHTTPmethod instead), - has no parameters, or a first parameter that is not exactly
gas.Context, - does not return exactly one result whose type implements
error(sofunc(gas.Context) *gas.Erroris accepted).
Every parameter after the first is a dependency:
// 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.
Missing dependencies fail at startup
Section titled “Missing dependencies fail at startup”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 containerThe 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
Section titled “gas.Context”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.
Reading the request
Section titled “Reading the request”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.
Writing responses
Section titled “Writing responses”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.
Binding request bodies
Section titled “Binding request bodies”// 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.
The authenticated principal
Section titled “The authenticated principal”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.
Plain handlers
Section titled “Plain handlers”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.