Skip to content

APIs

Tutorial: a JSON API

Build a token-protected JSON API step by step — users, bearer tokens, typed task endpoints with filters and pages, rate limiting, an OpenAPI document, a generated Go client and tests.

This tutorial builds a small JSON API from an empty folder. Users exchange their password for a bearer token, then create, list, update and delete their own private tasks. Along the way you use most of what the other API pages describe, in the order a real project needs it. The finished code is examples/api in the repository, and CI runs its tests.

You will learn:

  • How the --api starter differs from a web app, and what it ships.
  • How to issue and use bearer tokens.
  • How to declare typed inputs and outputs so validation and documentation come for free.
  • How to scope every query to the signed-in user, so another user's task is a 404.
  • How to rate limit by token, export an OpenAPI 3.1 document and generate a typed client.
  • How to test the API against its own document.

What you need#

  • Go 1.27 or newer (install Go).
  • curl, to call the API by hand.
  • The Vukem CLI:
Terminal
go install github.com/AlexGalhardo/vukem/cmd/vukem@latest

If Go is new to you, the Tour of Go covers structs, methods and errors, which every step below uses.

Build it#

Create the app

Terminal
vukem new api --api
cd api
vukem migrate
vukem serve

--api writes the JSON starter: no views, sessions or CSRF, vukem.APIMiddleware, problem+json errors, and the same routes vukem install:api adds to other apps: POST /api/v1/tokens, GET /api/v1/me, DELETE /api/v1/tokens/current, CORS from CORS_ALLOWED_ORIGINS and rate limits. It also ships a public /api/v1/posts resource to learn from. See the REST APIs overview.

Add a command that creates users

The API has no sign-up page, so add a command:

Terminal
vukem make:command CreateUser

It registers user:create, hashes the password with the app's hasher and reads it from standard input, so the password stays out of your shell history:

app/commands/create_user.go
return vukem.CommandFunc{
	CommandName: "user:create",
	Desc: "Create a user (password on stdin): " +
		"vukem user:create -name Ada ada@example.com",
	DefineFlags: func(fs *flag.FlagSet) {
		name = fs.String("name", "API User", "display name")
	},
	Fn: func(ctx context.Context, app *vukem.App, args []string) error {
		// ...
		u := models.User{
			Name:     *name,
			Email:    strings.ToLower(args[0]),
			Password: hash,
		}
		if err := orm.Create(ctx, acc.DB, &u); err != nil {
			return fmt.Errorf("user:create: %w", err)
		}
		// ...
	},
}
Terminal
echo "correct horse battery" | vukem user:create -name Ada ada@example.com
# created user 1 <ada@example.com>

Get a token

Terminal
curl -s -X POST localhost:3000/api/v1/tokens \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -d '{"email":"ada@example.com","password":"correct horse battery","device_name":"curl"}'
JSON
{"expires_at":"2026-11-16T09:29:28.3005936Z","token":"<id>.<secret>","type":"Bearer"}

Tokens are opaque, stored hashed and expire after 30 days; five token requests per minute per IP are allowed (API tokens). Keep the token for the next steps:

Terminal
TOKEN='<id>.<secret>'   # the token from the response

Create the Task model

Terminal
vukem make:model Task -m -f
vukem make:resource Task
vukem migrate

Every task belongs to a user:

database/migrations/..._create_tasks_table.go
return s.Create("tasks", func(t *schema.Table) {
	t.ID()
	t.ForeignID("user_id").Constrained("users").CascadeOnDelete()
	t.String("title")
	t.Enum("status", "todo", "doing", "done").Default("todo")
	t.Timestamps()
	t.Index("user_id", "status")
})

The resource is the wire format, kept apart from the model so a new column never leaks by accident. Its validate tags only shape the OpenAPI schema (required fields, enums):

app/resources/task.go
type TaskJSON struct {
	ID        int64     `json:"id"         validate:"required"`
	Title     string    `json:"title"      validate:"required"`
	Status    string    `json:"status"     validate:"required,oneof=todo doing done"`
	CreatedAt time.Time `json:"created_at" validate:"required"`
	UpdatedAt time.Time `json:"updated_at" validate:"required"`
}

Declare typed inputs

Each endpoint's input is a struct. The query string, the JSON body and the path parameters are decoded into it and validated before the handler runs; a failure is a 422 with field errors in the request's language (Typed handlers):

app/requests/task.go
type ListTasks struct {
	Status  string `json:"status"   validate:"oneof=todo doing done"`
	Q       string `json:"q"        validate:"max=100" doc:"Search in titles"`
	Sort    string `json:"sort"     validate:"oneof=title -title -id"`
	Page    int    `json:"page"     validate:"min=1"`
	PerPage int    `json:"per_page" validate:"min=1,max=100"`
}

type UpdateTask struct {
	Task   int64  `json:"task"   validate:"required,positive"`
	Title  string `json:"title"  validate:"required,min=3,max=120"`
	Status string `json:"status" validate:"required,oneof=todo doing done"`
}

Write the controller

Typed handlers take the input struct and return the output value. Every query starts from the signed-in user's rows, so another user's task is a 404, never a 403 that would confirm it exists:

app/controllers/apiv1/tasks.go
func (t *Tasks) owned(c *web.Ctx) (orm.Query[models.Task], error) {
	u, err := auth.User[models.User](c, "api")
	if err != nil {
		return orm.Query[models.Task]{}, err
	}

	return orm.QueryOf[models.Task](t.db).Where("user_id", u.ID), nil
}

Index filters, then lets api.Query apply the allowlisted sort and the capped page size (Pagination):

app/controllers/apiv1/tasks.go
func (t *Tasks) Index(
	c *web.Ctx, in requests.ListTasks,
) (resources.TaskPageJSON, error) {
	q, err := t.owned(c)
	if err != nil {
		return resources.TaskPageJSON{}, err
	}

	if in.Status != "" {
		q = q.Where("status", in.Status)
	}

	if in.Q != "" {
		q = q.Where("title", "LIKE", "%"+in.Q+"%")
	}

	// api.Query allowlists the sort columns and caps per_page.
	req, err := api.Query(c, q,
		api.Sorts("title", "created_at", "-id"),
		api.PerPage(20, 100),
	)
	if err != nil {
		return resources.TaskPageJSON{}, err
	}

	page, err := req.Paginate(c.Context())
	if err != nil {
		return resources.TaskPageJSON{}, err
	}

	return resources.TaskPage(page), nil
}

Values are always bound parameters, and column names come only from the allowlist, so the URL cannot inject SQL or sort by a column you did not list.

Register the routes

The typed routes sit behind the token guard, with 60 requests per minute per token. The OpenAPI document is served outside production:

routes/tasks.go
limit := ratelimit.Middleware(
	ratelimit.NewMemory(60, time.Minute), ratelimit.ByToken,
)
bearer := []string{"bearer"}
tags := []string{"tasks"}

r.Group("/api/v1", func(g *router.Group) {
	g.Use(middleware.JSONOnly(), limit, auth.Required(acc.API))

	api.Register(g, "GET /tasks", api.Op{
		Name: "tasks.index", Summary: "List your tasks",
		Tags: tags, Security: bearer,
	}, tasks.Index)
	api.Register(g, "POST /tasks", api.Op{
		Name: "tasks.store", Summary: "Create a task",
		Tags: tags, Security: bearer,
	}, tasks.Store)
	// GET, PUT and DELETE /tasks/{task} the same way
}).Name("api.v1.")

// Not registered in production unless OPENAPI_IN_PRODUCTION=true.
vukem.ServeOpenAPI(app, r, "/openapi.json")

Security: bearer only documents the scheme; auth.Required is what refuses a request without a valid token. ratelimit.NewMemory keeps counters in the process: use a shared store when you run several instances (Rate limiting).

Try it with curl

Terminal
H=(-H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'
   -H 'Content-Type: application/json')
URL=localhost:3000/api/v1/tasks

curl -s "${H[@]}" -X POST $URL -d '{"title":"Write the tutorial"}'
# {"id":1,"title":"Write the tutorial","status":"todo",...}

curl -s "${H[@]}" -X POST $URL -d '{"title":"Ship it","status":"doing"}'

curl -s -i "${H[@]}" "$URL?status=doing&sort=-created_at&per_page=10"
# Ratelimit-Limit: 60
# Ratelimit-Remaining: 57
# {"data":[{"id":2,"title":"Ship it","status":"doing",...}],
#  "meta":{"page":1,"per_page":10,"total":1,"last_page":1}}

curl -s "${H[@]}" -X PUT $URL/1 \
  -d '{"title":"Write the tutorial","status":"done"}'

curl -s "${H[@]}" -X POST $URL -d '{"title":"x","status":"later"}'
# {"type":"about:blank","title":"Validation failed","status":422,...,
#  "errors":{"status":["The selected status is invalid."],
#  "title":["The title field must be at least 3 characters."]}}

curl -s -o /dev/null -w '%{http_code}\n' "${H[@]}" -X DELETE $URL/1
# 204

curl -s -H 'Accept: application/json' localhost:3000/api/v1/tasks
# {"type":"about:blank","title":"Unauthorized","status":401,"detail":"Unauthenticated.",...}

After 60 requests in a minute the API answers 429 with Retry-After. Revoke the token with curl -X DELETE localhost:3000/api/v1/tokens/current -H "Authorization: Bearer $TOKEN".

Export the document and generate a client

Typed routes describe themselves; no annotations are needed (OpenAPI):

Terminal
vukem openapi:export > openapi.json
curl -s localhost:3000/openapi.json        # same document, development only

Generate a client from the same document (Client generation):

Terminal
vukem codegen:client -lang go     # internal/apiclient/client.go
vukem codegen:client              # TypeScript: resources/js/api.ts

The Go client has one method per operation, named after the route's own name (tasks.store → TasksStore), with typed path parameters, query and body:

cmd/demo/main.go
c := apiclient.New("http://localhost:3000")
c.Header = func(r *http.Request) {
	r.Header.Set("Authorization", "Bearer "+token)
}

created, err := c.TasksStore(ctx,
	apiclient.TasksStoreBody{Title: "From the Go client"})
page, err := c.TasksIndex(ctx, apiclient.TasksIndexQuery{})

_, err = c.TasksStore(ctx, apiclient.TasksStoreBody{Title: "x"})
apiErr, ok := errors.AsType[*apiclient.Error](err)
if ok && apiErr.Status == http.StatusUnprocessableEntity {
	// apiErr.Problem.Errors["title"]
}

Test it

The tests drive the API with the fluent client and check each response against the OpenAPI document (Testing):

tests/tasks_test.go
ada := app.Client(t).WithToken(apiUser(t, app, "ada@example.com"))

ada.GetJSON("/api/v1/tasks?sort=title&per_page=2").AssertOK().
	AssertMatchesOpenAPI().
	AssertJSONCount("data", 2).
	AssertJSONPath("meta.total", 3)

bob := app.Client(t).WithToken(apiUser(t, app, "bob@example.com"))
bob.GetJSON("/api/v1/tasks/1").AssertProblem(404)

tests/client_test.go fails when internal/apiclient is stale (rerun vukem codegen:client -lang go) and runs the generated client against the app over httptest.

Terminal
go test ./...

Security#

What the starter and the steps above give you:

  • Tokens are stored hashed. A database leak does not reveal usable tokens.
  • Every query is scoped to the user. Another user's task answers 404.
  • Inputs are allowlisted. Unknown sorts, filters and fields are a 400; values are bound parameters.
  • The document is private by default. /openapi.json is not served in production unless OPENAPI_IN_PRODUCTION=true.

Read API tokens for abilities, expiry and revocation.

Common mistakes#

Forgetting Accept: application/json with curl. Errors may come back as an HTML page. The JSONOnly middleware fixes a missing or */* Accept; send the header from other clients too.

Querying orm.QueryOf[models.Task] directly in a handler. It returns every user's tasks. Start from t.owned(c).

Sharing ratelimit.NewMemory across instances. Each instance counts on its own, so the real limit is multiplied by the number of instances. Use a shared store in production.

Committing a stale client. TestGeneratedClientIsCurrent fails with internal/apiclient/client.go is stale. Rerun vukem codegen:client -lang go.

Next steps#