Getting Started

Quick Start

Build an authenticated JSON API with generated OpenAPI docs in one file.

This walks through a small user API with a login endpoint, protected routes, an admin-only route, and interactive documentation. Everything fits in one file.

Create the app

ezz.New() returns an app with recovery, request ID, and request logging middleware already installed. Auth registers the provider that turns an incoming request into a user, and after that every route requires a valid token until you say otherwise.

main.go
package main

import (
    "log"
    "time"

    "github.com/sulv-io/ezz"
    "github.com/sulv-io/ezz/auth"
    "github.com/sulv-io/ezz/openapi"
)

var jwt = auth.NewHMACProvider("dev-secret-change-me", "quickstart")

func main() {
    app := ezz.New().Auth(auth.NewJWTAuthProvider(jwt))

    // routes go here

    log.Fatal(app.Listen(":8080"))
}

Describe your requests and responses

Request types carry the binding rules in their tags. Response types are plain structs; ezz serializes whatever you return.

main.go
type LoginRequest struct {
    Email    string `json:"email" validate:"required,email"`
    Password string `json:"password" validate:"required,min=8"`
}

type LoginResponse struct {
    Token     string    `json:"token"`
    ExpiresAt time.Time `json:"expires_at"`
}

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

type User struct {
    ID    string `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
    Role  string `json:"role"`
}

Add a public login route

.Public() opts a route out of authentication, which you need for login. Roles you want to check later go into the token's custom claims.

main.go
app.POST("/login", ezz.H(func(ctx *ezz.Context, req LoginRequest) (*LoginResponse, error) {
    user, ok := authenticate(req.Email, req.Password)
    if !ok {
        return nil, ezz.Unauthorized("invalid email or password")
    }

    expires := time.Now().Add(24 * time.Hour)
    token, err := jwt.GenerateToken(&auth.Claims{
        Subject:   user.ID,
        ExpiresAt: expires.Unix(),
        Custom:    map[string]any{"role": user.Role},
    })
    if err != nil {
        return nil, ezz.InternalServerError("could not issue token")
    }

    return &LoginResponse{Token: token, ExpiresAt: expires}, nil
})).Public().Tags("Auth")

Group the protected routes

A group shares a path prefix, and optionally middleware and an auth rule. Routes inside it inherit the app default, which is "any valid token".

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

api.GET("/me", ezz.HFunc(func(ctx *ezz.Context) (*User, error) {
    user := ctx.User().(auth.User)
    return lookup(user.ID())
})).Tags("Users")

api.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")
    }
    return user, nil
})).Tags("Users")

Restrict a route to admins

Auth rules can be set per route with .Auth(...) or on a whole group. ezz.Role checks the user value returned by your provider, which for JWT tokens reads the role and roles claims unless you configure a resolver.

main.go
admin := api.Group("/admin").Auth(ezz.Role("admin"))

admin.DELETE("/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")
    }
    delete(store, req.ID)
    return user, nil
})).Tags("Users", "Admin")

Serve the documentation

This registers /openapi.json and a Scalar UI at /docs, both public.

main.go
openapi.SetupDocsEndpoints(app, openapi.ScalarConfig{
    Title:    "Quick Start API",
    Layout:   "modern",
    DarkMode: true,
})

Try it

Terminal
# no token, so this is rejected
curl -i localhost:8080/api/v1/me

# log in and keep the token
TOKEN=$(curl -s localhost:8080/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"password123"}' | jq -r .token)

# now the protected route answers
curl -s localhost:8080/api/v1/me -H "Authorization: Bearer $TOKEN"

# validation runs before your handler
curl -s localhost:8080/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"nope","password":"short"}'

The last call returns a 400 and a body like:

{
  "error": true,
  "message": "field 'Email' must be a valid email; field 'Password' must be at least 8",
  "status": 400
}

Open http://localhost:8080/docs and you will find every route, with path parameters, request bodies, response schemas, and the min/max/email constraints carried over from the validate tags.

If you want toRead
Understand patterns, groups, and route metadataRouting
Bind headers, forms, or repeated query parametersRequest binding
Load roles and permissions from your databaseRoles and permissions
Put GoTrue behind your APIGoTrue proxy
Write tests against the appTesting
Copyright © 2026