Ardian About Directory
Claude Code
Email me

api

2026 — A Go library that carries who is calling from one service to the next

01 Who is calling

Most services never meet the person using them. A gateway at the edge already knows who the caller is and passes the request along with that answer in its headers, and from there every handler wants it and every call to the next service has to carry it on.

Threading a user ID through every function signature to reach the one handler that needs it is noise. api puts it in the request's context.Context once, at the door, and writes it back out on the way to the next service.

02 Three headers

The user package reads user-id, the session package reads session-id, and the timezone package reads time-zone. Each one wraps your mux the same way, so you import only the ones you need, and the whole module is standard library only, so go get pulls in nothing else.

Behind the middleware, the ok from IDFromContext is always true. It is there for code reached some other way, like a cron job or a test, where ContextWithID fills the context with the same empty check and no header at all.

handler := user.HTTPWithID(session.HTTPWithID(timezone.HTTPWithZone(mux)))

uid, ok := user.IDFromContext(r.Context())
sid, ok := session.IDFromContext(r.Context())
loc, ok := timezone.ZoneFromContext(r.Context())

ctx, err := user.ContextWithID(ctx, "u_123")

03 Turned away at the door

A missing or empty user-id or session-id stops the request with a 401 and a JSON body that says which one was empty, so your handler never runs without them.

The time zone has to be an IANA name like Asia/Jakarta. It is loaded with time.LoadLocation before it goes into the context, so Mars/Olympus never reaches the handler, and neither does Local, which would quietly be the server's own zone. A missing zone, an unknown one and Local are all a 400, and what the handler gets back is the *time.Location itself, ready for t.In(loc).

Drop or break a header in the request here and it stops at the first middleware that refuses it, with the response that middleware really writes.

client := &http.Client{
Transport: &api.Transport{
Propagators: []api.Propagator{
user.PropagateID,
session.PropagateID,
timezone.PropagateZone,
},
},
}

mux := http.NewServeMux()
mux.HandleFunc("GET /agenda", func(w http.ResponseWriter, r *http.Request) {
uid, _ := user.IDFromContext(r.Context())
loc, _ := timezone.ZoneFromContext(r.Context())

req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "http://calendar.internal/events", nil)
resp, err := client.Do(req)
if err != nil {
api.WriteProblem(w, http.StatusBadGateway, map[string]any{"detail": err.Error()})
return
}
defer resp.Body.Close()

api.WriteJSON(w, http.StatusOK, map[string]any{
"user_id": uid,
"today": time.Now().In(loc).Format(time.DateOnly),
})
})

handler := user.HTTPWithID(session.HTTPWithID(timezone.HTTPWithZone(mux)))
  1. 01 Request in handler.ServeHTTP

    GET /agenda HTTP/1.1
    Host: agenda.internal
    user-id: u_123
    session-id: s_789
    time-zone: Asia/Jakarta
  2. 02 User ID stored user.HTTPWithID

    user.IDFromContext(ctx)
    "u_123", true
  3. 03 Session ID stored session.HTTPWithID

    session.IDFromContext(ctx)
    "s_789", true
  4. 04 Time zone stored timezone.HTTPWithZone

    timezone.ZoneFromContext(ctx)
    Asia/Jakarta, true
    time.Now().In(loc)
    2026-11-06 03:00:00 +0700 WIB
  5. 05 Request out api.Transport

    GET /events HTTP/1.1
    Host: calendar.internal
    User-Agent: Go-http-client/1.1
    Session-Id: s_789
    Time-Zone: Asia/Jakarta
    User-Id: u_123
    Accept-Encoding: gzip
  6. 06 Response api.WriteJSON

    HTTP/1.1 200 OK
    Content-Type: application/json
    Date: Thu, 05 Nov 2026 20:00:00 GMT
    Content-Length: 40
    
    {"today":"2026-11-06","user_id":"u_123"}

04 Back out

When the handler calls the next service, the headers go back on. Transport runs each Propagator on the outgoing request, which writes the value from its context under the same name the middleware read it from, and a value that is not there is skipped, so the same client works for anonymous calls too.

It clones the request before setting any header and sends the clone through Base, which takes your own RoundTripper and falls back to http.DefaultTransport when it is nil.

A Propagator is just a func(ctx, header), so a trace ID or anything else you want on the wire is a few lines of your own.

client := &http.Client{
Transport: &api.Transport{
Propagators: []api.Propagator{
user.PropagateID,
session.PropagateID,
timezone.PropagateZone,
},
},
}

req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "http://calendar.internal/events", nil)
resp, err := client.Do(req)

05 Two writers

The middleware answers with WriteProblem, and your handlers answer with WriteJSON. Today the two are identical, and that is on purpose. WriteProblem is the one that will grow into RFC 9457 problem details, so every error call site already points at the right function when it does.

Both marshal the body before they send the status, so a payload that cannot be encoded becomes a 500 instead of a 200 with nothing in it.

api.WriteJSON(w, http.StatusOK, payload)
api.WriteProblem(w, http.StatusUnprocessableEntity, map[string]any{"detail": "..."})

06 Plain names

The headers are user-id, session-id and time-zone, not X-User-Id. RFC 6648 retired the X- prefix back in 2012, and HTTP/2 lowercases every header name on the wire anyway.

Go still writes them as User-Id, Session-Id and Time-Zone on HTTP/1.1, which is how the request out above looks. Header names match without regard to case, so the next service reads them either way, and a caller that sends User-Id works just as well.

07 What it leaves to you

It does not authenticate anyone. It carries an identity and never checks one, so anyone who can reach the service can send whatever user-id they like, and these headers have to come from something you trust at the edge that strips them off outside traffic.

It does not route, log, trace or measure. Every middleware is a plain func(http.Handler) http.Handler, so you bring your own mux, and a Propagator can carry a trace ID even though none ships.

It carries three values and no more. A request ID, a locale or a tenant is a copy of the session package under a new name, which is two files.

api

Turns away a request without its user, session or time zone before any handler runs, in 255 lines of Go and nothing outside the standard library