Core

Routing

Register routes, use path patterns, group them, and attach metadata.

Routing is Go's net/http.ServeMux. ezz registers METHOD /pattern entries on a mux it owns, so pattern syntax, precedence, and conflict rules are the standard library's.

Register a route

Each HTTP method has a matching function on the app. They all take a pattern and an http.Handler, and return a *RouteBuilder you can chain metadata onto.

app := ezz.New()

app.GET("/users", listUsers)
app.POST("/users", createUser)
app.PUT("/users/{id}", replaceUser)
app.PATCH("/users/{id}", updateUser)
app.DELETE("/users/{id}", deleteUser)
app.HEAD("/health", healthHead)
app.OPTIONS("/users", usersOptions)

// any other method
app.Handle("TRACE", "/debug", traceHandler)

The handler can be an ezz.H typed handler, an http.HandlerFunc, or anything else implementing http.Handler:

app.GET("/legacy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("plain net/http still works"))
})).Public()

Typed handlers additionally expose their request and response types, which is what the OpenAPI generator reads. A plain http.Handler still routes and still respects auth rules, it just has no schema to document.

Match path parameters

Patterns use {name} for a single segment and {name...} for a trailing wildcard.

app.GET("/users/{id}", handler)             // /users/42
app.GET("/files/{path...}", handler)        // /files/a/b/c.txt
app.GET("/orgs/{org}/repos/{repo}", handler)

Read them through the request type, which is the usual case:

type GetUserRequest struct {
    ID string `path:"id" validate:"required"`
}

Or read them from the context when you have no request struct:

app.GET("/users/{id}", ezz.HFunc(func(ctx *ezz.Context) (*User, error) {
    return lookup(ctx.Param("id"))
}))

app.Group(prefix) returns a *RouteGroup that shares a path prefix, and optionally middleware and an auth rule, with every route registered on it.

api := app.Group("/api/v1")

api.GET("", listRoot)          // /api/v1
api.GET("/users", listUsers)   // /api/v1/users

Groups nest, and a child copies the parent's middleware and auth rule at the moment it is created:

api := app.Group("/api/v1").Use(middleware.Gzip())
admin := api.Group("/admin").Auth(ezz.Role("admin"))

admin.GET("/stats", stats)   // /api/v1/admin/stats, gzipped, admin only

Trailing slashes on the prefix are trimmed, so Group("/api/") and Group("/api") behave the same.

A group snapshots its middleware and auth rule when a route is registered, and a child group snapshots the parent when the child is created. Call Use and Auth on a group before registering routes on it or creating children from it.

Override auth per route

Precedence runs from broad to narrow: the app default, then the group rule, then the route rule.

app := ezz.New().Auth(provider)      // default: any valid token

app.GET("/health", health).Public()               // no auth
app.GET("/admin", panel).Auth(ezz.Role("admin"))  // admin only
app.GET("/me", profile)                            // inherits the default

To make routes public by default and opt into auth instead, pass nil:

app := ezz.New().Auth(provider).DefaultAuth(nil)

app.GET("/open", handler)                          // public
app.GET("/closed", handler).Auth(ezz.Authenticated())

See Auth rules for the full set of rules.

Attach metadata for the docs

Every registration returns a *RouteBuilder:

app.GET("/users/{id}", getUser).
    Tags("Users").
    Description("Returns a single user by ID.").
    Summary("Get user").
    OperationID("getUser").
    Response(404, "User not found").
    Deprecated()
MethodEffect
Auth(rule)Sets the auth rule for this route
Public()Shorthand for Auth(ezz.Public())
Tags(...)OpenAPI tags, also used to group operations in the docs UI
Description(text)OpenAPI operation description
Summary(text)Stored on the route
OperationID(id)Stored on the route
Deprecated()Stored on the route
Response(status, description, example...)OpenAPI response for that status
Tags, description, and responses are the fields the current OpenAPI generator emits. Summary, operation ID, and deprecation are recorded on the route but not yet written into the spec; operation IDs are generated from the method and path instead. See OpenAPI.

Start serving

// the convenience path
log.Fatal(app.Listen(":8080"))

// or drive the server yourself, since App is an http.Handler
srv := &http.Server{
    Addr:              ":8080",
    Handler:           app,
    ReadHeaderTimeout: 5 * time.Second,
}
log.Fatal(srv.ListenAndServeTLS(certFile, keyFile))

Use the second form when you need timeouts or TLS configuration. Listen calls http.ListenAndServe with no timeouts set.

Shut down gracefully

ListenWithContext serves until the context is cancelled, then drains in-flight requests before returning. Pair it with signal.NotifyContext and the deferred cleanup in main actually runs:

func main() {
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    pool := mustConnect()
    defer pool.Close()

    app := buildApp(pool)
    if err := app.ListenWithContext(ctx, ":8080"); err != nil {
        log.Fatal(err)
    }
}

In-flight requests get up to 30 seconds to finish; app.ShutdownTimeout(d) changes that. Call app.Shutdown(ctx) directly if you drive the lifecycle yourself — it is safe before Listen starts and safe to call twice, and it makes Listen return nil rather than http.ErrServerClosed, so an intentional stop is not mistaken for a crash.

app.Server() returns the underlying *http.Server once Listen has started it, for reading or tuning fields you would otherwise have no handle on.

Inspect registered routes

app.Routes() returns the *ezz.Route values, which is what the OpenAPI generator walks. It is also handy for a startup log or a route table in tests.

for _, r := range app.Routes() {
    log.Printf("%-6s %-30s public=%t", r.Method, r.Pattern, r.AuthRule == nil || r.AuthRule.IsPublic())
}

Next steps

  • Handlers for what happens inside a route.
  • Middleware for the wrapping order and the built-in set.
Copyright © 2026