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.Status,ctx.Redirect,ctx.ServeFile,ctx.Stream, or a direct write), ezz leaves it alone. - Otherwise, if the returned value is not the zero value for its type, it is encoded as JSON with status
200. - If the returned value is the zero value, nothing is written, and the server sends
200with an empty body.
That last 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
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.JSON with the status you want, then return nil:
app.POST("/users", ezz.H(func(ctx *ezz.Context, req CreateUserRequest) (any, error) {
user := store.Create(req)
ctx.SetHeader("Location", "/users/"+user.ID)
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.