Context
Every handler receives an *ezz.Context. It embeds context.Context, holds the request and response writer, and caches the metadata most services end up reading on every request.
type Context struct {
context.Context
Request *http.Request
Response http.ResponseWriter
StartTime time.Time
// ...
}
Because it embeds context.Context, you can pass ctx straight to anything expecting one:
rows, err := db.QueryContext(ctx, "SELECT ...")
Read request metadata
These are extracted once when the context is created, so calling them repeatedly costs nothing.
| Method | Source |
|---|---|
GetRequestID() | X-Request-ID, then X-Correlation-ID, otherwise a generated value |
GetClientIP() | First hop of X-Forwarded-For, then X-Real-IP, otherwise RemoteAddr |
GetUserAgent() | User-Agent |
GetTraceID() | X-Trace-ID, then X-B3-TraceId, otherwise empty |
StartTime | When the context was created |
logger.InfoContext(ctx, "processing order",
"request_id", ctx.GetRequestID(),
"trace_id", ctx.GetTraceID(),
"client_ip", ctx.GetClientIP(),
)
GetClientIP trusts X-Forwarded-For unconditionally, so a direct client can set it to anything. Do not use it for rate limiting, lockouts, or audit records unless a proxy you control always overwrites the header. The GoTrue proxy resolves the client IP differently, walking only trusted hops; see Client IP and rate limits.Read the request
| Method | Returns |
|---|---|
Param(name) | A path parameter, falling back to Request.PathValue |
Query(name) | The first value of a query parameter |
QueryValues(name) | All values of a query parameter |
QueryAll() | The full url.Values |
RequestHeader(name) | A request header value |
Body() | The raw io.ReadCloser |
The query string is parsed the first time you ask for it and cached for the rest of the request.
app.GET("/search", ezz.HFunc(func(ctx *ezz.Context) (*Results, error) {
term := ctx.Query("q")
tags := ctx.QueryValues("tag")
if term == "" {
return nil, ezz.BadRequest("q is required")
}
return search(ctx, term, tags)
}))
For anything structured, prefer a request struct and let binding do this work.
Write a response
| Method | Behaviour |
|---|---|
JSON(status, data) | Sets Content-Type, writes the status, encodes data |
Status(code) | Writes the status header and nothing else |
SetHeader(key, value) | Sets a response header, before the status is written |
NoContent() | 204 with no body |
Redirect(url, status) | Delegates to http.Redirect |
ServeFile(path) | Delegates to http.ServeFile |
Error(err) | Writes an error response, mapping *HTTPError to its status |
Written() | Whether a response has been written |
StatusCode() | The status that was written, or 0 |
Set headers before writing the status, since the status flushes the header map:
app.GET("/files/{name}", ezz.H(func(ctx *ezz.Context, req FileRequest) (any, error) {
etag := etagFor(req.Name)
ctx.SetHeader("ETag", etag)
ctx.SetHeader("Cache-Control", "max-age=300")
if ctx.RequestHeader("If-None-Match") == etag {
ctx.Status(http.StatusNotModified)
return nil, nil
}
return nil, ctx.ServeFile("/var/files/" + req.Name)
}))
JSON encodes with HTML escaping turned off, so &, <, and > survive intact in strings.
Stream server-sent events
Stream sets the streaming headers and hands back a small writer.
app.GET("/events", ezz.HFunc(func(ctx *ezz.Context) (any, error) {
stream := ctx.Stream("text/event-stream")
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done(): // client disconnected
return nil, nil
case t := <-ticker.C:
if err := stream.WriteJSON("tick", map[string]any{"at": t}); err != nil {
return nil, nil
}
stream.Flush()
}
}
}))
Stream sets Content-Type to the value you pass, plus Cache-Control: no-cache and Connection: keep-alive, and marks the response as written. The returned *StreamWriter has WriteEvent(event, data), WriteJSON(event, value), Write(bytes), Flush(), and Close(). Call Flush after each message, otherwise events sit in the buffer.
Watch ctx.Done() so a disconnected client does not leave the handler spinning.
Reach the authenticated user
| Method | Behaviour |
|---|---|
User() | The user value from the auth provider, or nil |
MustUser() | The same value, panicking when there is none |
SetUser(user) | Replaces the user, mostly useful in tests |
The value is whatever your auth provider returned, so assert it to the type you expect:
app.GET("/me", ezz.HFunc(func(ctx *ezz.Context) (*Profile, error) {
user, ok := ctx.User().(auth.User)
if !ok {
return nil, ezz.Unauthorized("no user on request")
}
claims := user.GetClaims()
return loadProfile(ctx, claims.Subject)
}))
ezz.UserFromContext(ctx) reads the same value from a plain context.Context, which is handy inside middleware or a service layer that does not take the ezz context.
Respect the context lifetime
Contexts are pooled. When your handler returns, the context is reset and put back for the next request, so a reference kept beyond that point will observe another request's data.
// wrong: ctx is recycled as soon as the handler returns
go func() {
report(ctx.GetRequestID())
}()
// right: copy what you need, and use a context that outlives the request
requestID := ctx.GetRequestID()
go func() {
report(requestID)
}()
The same applies to context.Context cancellation: ctx is derived from the request, so it is cancelled when the response completes. Background work that must outlive the response needs context.WithoutCancel or a fresh context.
Build a context in tests
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/users/42", nil)
r.SetPathValue("id", "42")
ctx := ezz.NewTestContext(w, r)
resp, err := getUser(ctx, GetUserRequest{ID: "42"})
See Testing for the rest of the helpers.
Next steps
- Errors for how returned errors become responses.
- Middleware for work that happens around the handler.