Core

Context

Read request data, write responses, stream events, and reach the authenticated user.

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.

MethodSource
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
StartTimeWhen 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

MethodReturns
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

MethodBehaviour
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

MethodBehaviour
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.
Copyright © 2026