Handlers
A handler is a function with this shape:
func(ctx *ezz.Context, req Req) (Resp, error)
ezz.H wraps it into a *Handler[Req, Resp], which implements http.Handler and reports its own request and response types so the OpenAPI generator can document it.
Write a handler with a request type
type GetUserRequest struct {
ID string `path:"id" validate:"required"`
Expand bool `query:"expand"`
}
app.GET("/users/{id}", ezz.H(func(ctx *ezz.Context, req GetUserRequest) (*User, error) {
user, ok := store[req.ID]
if !ok {
return nil, ezz.NotFound("user not found")
}
if req.Expand {
user.Orders = loadOrders(ctx, user.ID)
}
return user, nil
}))
Before your function runs, ezz binds req from the request and validates it. A binding or validation failure returns 400 and your function is never called.
Write a handler without a request type
When there is nothing to bind, ezz.HFunc drops the second parameter:
app.GET("/health", ezz.HFunc(func(ctx *ezz.Context) (*HealthResponse, error) {
return &HealthResponse{Status: "ok"}, nil
}))
ezz.H with a struct{} request type does the same thing, which is why ezz.HFunc(fn) has the type *Handler[struct{}, Resp].
Understand when the response is written
After your function returns, ezz decides what to send:
- If the function returned an error, the error is written and nothing else happens. See Errors.
- If you already wrote to the response yourself (
ctx.JSON,ctx.Redirect,ctx.ServeFile,ctx.Stream, or a direct write), ezz leaves it alone.ctx.Statusis deliberately not in this list — it sets a status without ending the response. - If the returned value is a slice or a map, it is encoded as JSON, even when nil.
- Otherwise, if the returned value is not the zero value for its type, it is encoded as JSON.
- If the returned value is the zero value, nothing is written and the server sends an empty body.
The status is whatever ctx.Status asked for, or 200 if the handler did not ask.
That fifth rule is worth internalising:
// writes {"status":"ok"}
return &HealthResponse{Status: "ok"}, nil
// writes {} because the pointer is non-nil
return &HealthResponse{}, nil
// writes nothing: a nil pointer is the zero value
var h *HealthResponse
return h, nil
// writes nothing: an empty struct value is the zero value
return HealthResponse{}, nil
Collections are the exception, because an empty collection is a real answer rather than an
absent one. A nil slice serialises as [] and a nil map as {}, so the ordinary "no rows"
path gives the client JSON it can parse:
// writes [] — no need for make([]User, 0, n)
var users []User
return users, nil
Prefer pointer response types (*User) or types that are never meaningfully empty. If you genuinely want an empty success, be explicit:
app.DELETE("/users/{id}", ezz.H(func(ctx *ezz.Context, req DeleteUserRequest) (any, error) {
store.Delete(req.ID)
return nil, ctx.NoContent() // 204, no body
}))
Take over the response
The context gives you the raw http.ResponseWriter, so a handler can write anything. Return nil afterwards and ezz will not touch the response.
app.GET("/reports/{id}.csv", ezz.H(func(ctx *ezz.Context, req ReportRequest) (any, error) {
ctx.SetHeader("Content-Type", "text/csv")
ctx.SetHeader("Content-Disposition", `attachment; filename="report.csv"`)
ctx.Status(http.StatusOK)
w := csv.NewWriter(ctx.Response)
defer w.Flush()
for _, row := range rows(req.ID) {
if err := w.Write(row); err != nil {
return nil, err
}
}
return nil, nil
}))
Choose a response status other than 200
Call ctx.Status and return the value as usual. The status you set is the one the response is
sent with:
app.POST("/users", ezz.H(func(ctx *ezz.Context, req CreateUserRequest) (*User, error) {
user := store.Create(req)
ctx.SetHeader("Location", "/users/"+user.ID)
ctx.Status(http.StatusCreated)
return user, nil
}))
ctx.JSON(status, value) does the same thing in one call, for when you want to write and
return nil instead:
return nil, ctx.JSON(http.StatusCreated, user)
Build handlers once, at startup
ezz.H inspects the request type and compiles a binder and a validator when you call it. Do that at registration time, which is what the examples above do, and the request path stays reflection-free.
// good: compiled once at startup
app.POST("/users", ezz.H(createUser))
// bad: builds a new binder and validator on every request
app.POST("/users", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ezz.H(createUser).ServeHTTP(w, r)
}))
Reuse handler functions
Handlers are ordinary functions, so the usual Go patterns apply. Closures are the simplest way to inject dependencies:
func getUser(store *Store) func(*ezz.Context, GetUserRequest) (*User, error) {
return func(ctx *ezz.Context, req GetUserRequest) (*User, error) {
user, err := store.Find(ctx, req.ID)
if errors.Is(err, ErrNotFound) {
return nil, ezz.NotFound("user not found")
}
return user, err
}
}
app.GET("/users/{id}", ezz.H(getUser(store)))
Methods on a service struct work equally well:
type UserAPI struct{ store *Store }
func (a *UserAPI) Get(ctx *ezz.Context, req GetUserRequest) (*User, error) { ... }
api := &UserAPI{store: store}
app.GET("/users/{id}", ezz.H(api.Get))
Inspect a handler's types
*Handler[Req, Resp] satisfies ezz.TypedHandler:
type TypedHandler interface {
http.Handler
RequestType() reflect.Type
ResponseType() reflect.Type
}
The app calls this at registration and stores the result on the route, which is how /openapi.json learns your schemas without any code generation.
Next steps
- Request binding for the tags that fill
req. - Validation for the rules that guard it.
- Context for everything on
ctx.