tako docs guide reference api
GitHub

tako.API

JSON endpoints: one declaration is the route, the docs, and the callable endpoint (hello chapter).

Declare

A package-level var, a Go 1.22+ method-and-pattern route (a string constant — analyzer-enforced), a handler func(Ctx, Req) (Res, error) (examples/hello/hello.go):

var Hello = tako.API("GET /hello", hello)
var Greet = tako.API("GET /greet/{name}", greet)
var Echo = tako.API("POST /echo", echo)

tako.Route carries the settings that describe the ROUTE they sit on: SignIn (401 without a session; ctx.User() guaranteed past it), Allow (the declared tako.Policy that decides which signed-in users may use this route — see auth.md; it implies SignIn), Private (IAM-only ingress — other services, not the public internet), and MaxAge (how long shared caches may keep the response — see pages.md for pages and bucket.md for blobs). It has no effect on a JSON route, where the client decides how it caches what it parsed.

Who sent the request

c := ctx.Client()   // c.IP, c.UserAgent

Two fields, deliberately. Country, device class and browser name are guesses dressed as facts.

IP is read from X-Forwarded-For by counting from the right past the one hop tako provisions (Google's frontend) — never by taking the left-most entry, which is whatever the caller sent. A rate limit keyed on the left-most entry is a rate limit with an opt-out. A list shorter than that did not come through those hops, so the socket address wins instead; locally, where there is no proxy, that is always the answer.

It is the zero Client in a worker, a cron, a job or a subscriber: a queue message has no client, and inventing the publisher's would be worse than saying nothing. Capture what you need at the request and put it in the message — shortlink examples/shortlink/app.go records ctx.Client().IP on the Click it publishes, because the worker that counts it cannot ask.

There is no declared rate limit yet (Route{Limit: …}). It needs a counter shared across instances, and tako.Cache is per-instance in memory even in the cloud — a limit of 5/hour enforced per instance is 100/hour at max=20, which is a promise the framework would break silently. With ctx.Client() an app can implement the policy it wants over the store it already has.

Settings that describe the whole service — the domain it answers on, how it scales — live on tako.Service instead, because a service has many routes and none of them is a more honest place to put a service-wide fact:

var (
	api     = tako.Service(tako.ServiceConfig{Domain: "api.example.com", Profile: tako.ProfileLatency})
	Create  = tako.API("POST /api/links", create)
	Resolve = tako.API("GET /api/links/{slug}", resolve)
	List    = tako.API("GET /api/links", list)
)

Use

Req fields bind by tag — path:, query:, form: — and untagged fields decode from the JSON body. An empty Req struct is fine. query: and form: fields must be string, []string, bool, int, int64, or float64; path: takes the same list without []string, because a route segment is one value.

A []string field receives EVERY value of a repeated parameter — the shape of a checkbox group, where each box carries the same name. It is nil when the field is absent, which is what a form with nothing ticked sends.

// GreetReq binds {name} from the route and loud from the query string.
type GreetReq struct {
	Name string `path:"name"`
	Loud bool   `query:"loud"`
}

Errors are values with status: tako.Errorf(422, "message must not be empty") surfaces as that status with body {"error":"..."}; tako.NotFound() is the 404. Anything else is an opaque 500 with the real error only in your logs.

A tako.Blob result streams with its own Content-Type instead of being JSON-encoded, and a tako.Blob field makes the request multipart (examples/gallery/app.go):

// A tako.Blob field makes the request multipart: img streams from the
// form field, caption arrives beside it.
type UploadReq struct {
	Caption string    `form:"caption"`
	Img     tako.Blob `form:"img"`
}

// Returning tako.Blob streams the object: correct Content-Type, no JSON.
func serveImage(ctx tako.Ctx, req ImageReq) (tako.Blob, error) {
	return photos.Get(ctx, req.ID) // miss → IsNotFound → 404
}

Local

tako dev serves everything on one port (default 8080) and reloads on save. Binding code is generated, readable, reflection-free — the main under .tako/gen/<pkg>/ is the whole request path.

curl 'localhost:8080/greet/ada?loud=true'
curl -X POST localhost:8080/echo -d '{"message":"hi"}'

/_tako/ is not yours

One path prefix is reserved: /_tako/, where tako mounts the endpoints it injects on your behalf.

path appears when
POST / DELETE /_tako/api/session the app declares tako.Auth
POST /_tako/api/session/signout ” (the form-post counterpart to DELETE)
GET /_tako/signin ” (the framework's own sign-in page)
POST /_tako/api/push a worker or subscriber receives deliveries
GET/PUT /_tako/api/signed/{token} the app declares a tako.Bucket
POST /_tako/api/cron/{cron} the app declares a tako.Cron
/_tako/z/… reserved for diagnostics; nothing serves it yet

The generated service answers that namespace before your router is consulted, so nothing under it can be shadowed or reordered by a route you declare — static files in a tako.Site directory included. Declare one anyway and analysis refuses it (tako/reserved-prefix), because a handler that silently never runs is the worst way to learn this.

The separation is not decoration. Go's ServeMux refuses to register two patterns where neither is more specific on both path and method, and a framework mount beside one of your wildcard routes is exactly that pair — /_tako/api/signed/ against GET /{user}/{album}/{photo}/{size}, or the old /api/_session against GET /{section}/{slug}. Sharing one mux made those apps panic at startup, before a line of your code ran. Two muxes make it unrepresentable: patterns that never meet cannot fail to be ordered.

Cloud

Package = service: your API routes deploy as one Cloud Run service running as its own service account, with IAM derived from the code. The grouping is derived, never declared — tako.Service names that service and carries its settings, it does not create or split it (service.md). The default profile scales to zero — an idle API costs nothing. Route.Private closes the front door: browsers and curl get 403 from Cloud Run itself; callers get run.invoker derived from the call edge. Mixing private and public routes in one package is an analysis error — private endpoints need their own package.

Test it

Handlers are directly callable — no server needed (examples/hello/hello_test.go; use tako.NewTestApp(t) and app.Ctx() once the app declares resources):

got, err := Greet.Call(ctx, GreetReq{Name: "memo", Loud: true})

_, err = Greet.Call(ctx, GreetReq{Name: "nobody"})
if !tako.IsNotFound(err) {
	t.Fatalf("err = %v, want IsNotFound", err)
}

Errors you'll meet

At analysis time, positioned and specific:

tako.API route must be a string constant — routes are read at analysis time, not run time; got variable "route" (tako/const-route)
route "GET /greet/{name}" has path parameter {name} but GreetReq has no field tagged path:"name" (tako/route-params)
field When: query:-tagged fields must be string, []string, bool, int, int64, or float64; got time.Time (tako/bind-type)
field Tag: path:-tagged fields cannot be []string — a route segment is one value; a field that repeats binds with query: or form: (tako/bind-type)
GET route "GET /search" cannot bind a JSON body, but SearchReq field Extra has no path:/query: tag (tako/body-on-get)
tako.Blob field Img needs a form:"…" tag — blobs bind from multipart form fields (tako/blob-binding)
GET route "GET /b" cannot receive an upload — requests with tako.Blob fields need a non-GET route (tako/blob-binding)
tako.Route must be written as a literal with constant fields — configuration is read at analysis time, not run time; got variable "cfg" (tako/config-literal)
route "GET /_tako/sneaky" is inside /_tako/, which tako reserves for the endpoints it injects (session, sign-in, cron, signed URLs, push) — the framework answers that namespace before this app's routes, so Sneaky would never run; choose a path outside it (tako/reserved-prefix)
SetRole cannot be mounted beside a route this service already declares: POST /moderate/{id}/retract — Go's router refuses a pair where neither pattern is more specific, and the service panics at startup. Make one of them narrower (a literal segment beats a wildcard) or move one to a different path (tako/route-conflict)

The last one is worth a sentence, because the absence of it is the common case people rely on. Go orders GET /admin/new ahead of GET /admin/{slug} — a literal beats a wildcard, which is the trick every app uses for a "new" page. What it refuses is a pair where neither wins: POST /moderate/{id}/retract and POST /moderate/people/{id} both match /moderate/people/retract. That refusal is a panic at startup, so before this rule it was found by running the service. The check registers your routes on a real http.ServeMux and reports what it says, so tako and the router can never disagree about what conflicts.

On the wire: bad input is a 400 — {"error":"body: invalid JSON"}, {"error":"query \"loud\": \"banana\" is not a bool"}; NotFound() is a 404 {"error":"not found"}; anonymous on a Route.SignIn route is a 401 sign in to continue.

Not yet

Responses are JSON or a tako.Blob stream — nothing else. Multipart uploads buffer through the service (32MB in memory, then temp files); large uploads belong on signed URLs — see bucket.md. No per-endpoint rate limiting (rate is a queue setting — Delivery, see queue.md).