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
--apistarter 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:
go install github.com/AlexGalhardo/vukem/cmd/vukem@latestIf Go is new to you, the Tour of Go covers structs, methods and errors, which every step below uses.
Build it#
Create the app
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:
vukem make:command CreateUserIt 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:
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)
}
// ...
},
}echo "correct horse battery" | vukem user:create -name Ada ada@example.com
# created user 1 <ada@example.com>Get a token
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"}'{"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:
TOKEN='<id>.<secret>' # the token from the responseCreate the Task model
vukem make:model Task -m -f
vukem make:resource Task
vukem migrateEvery task belongs to a user:
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):
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):
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:
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):
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:
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
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):
vukem openapi:export > openapi.json
curl -s localhost:3000/openapi.json # same document, development onlyGenerate a client from the same document (Client generation):
vukem codegen:client -lang go # internal/apiclient/client.go
vukem codegen:client # TypeScript: resources/js/api.tsThe Go client has one method per operation, named after the route's own name (tasks.store → TasksStore), with typed path parameters, query and body:
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):
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.
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.jsonis not served in production unlessOPENAPI_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.