Skip to content
Zever
Home

Requests and responses

Decode JSON bodies, read params and headers, paginate lists, and write consistent JSON responses.

2 min readEdit on GitHub

Handlers stay close to net/http: decode the request, run your logic, encode a JSON envelope. No magic binding or config block.

Container is for services, not I/O:

main.go
c := container.New(config.Default())
db, _ := c.DB()
r, _ := c.Router()
r.Handle("POST", "/v1/users", createUser(db))
handlers.go
func createUser(db db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if strings.TrimSpace(in.Email) == "" {
writeError(w, http.StatusUnprocessableEntity, "email: required")
return
}
writeJSON(w, http.StatusCreated, map[string]string{"email": in.Email})
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
Terminal window
curl -s -X POST localhost:8080/v1/users -H 'Content-Type: application/json' -d '{"email":"a@example.com","name":"Ada"}'
{"email":"a@example.com"}

Success returns the resource (200/201); errors use {"error": "<stable message>"}. Set Content-Type before WriteHeader.

Source How
Path params router.Param(req, name), router.ParamNames(req)
Query r.URL.Query().Get(key)
Headers r.Header.Get(key)
JSON body json.NewDecoder(r.Body).Decode(&v)
Body cap http.MaxBytesReader(w, r.Body, n)
Test params router.WithParams(ctx, params)
Cancellation r.Context(), into DB, cache, queue calls

Cap public bodies (413) and pass r.Context() downstream for middleware.Timeout.

.zen RPCs flagged paginated: true generate cursor scaffolding; expose cursors, not offsets:

Terminal window
GET /v1/orders?cursor=eyJpZCI6NDJ9&limit=50

The synthesized request message carries cursor and limit fields (so gRPC clients paginate too, and a paginated RPC may not declare params of those names). The generated service method takes the opaque cursor string plus limit int32. Both transports apply the same bounds before the service runs: zero becomes the default (20), a limit above the max (100) is clamped, and a negative limit is InvalidArgument. The response is the protobuf <Op>Response message (items plus next_cursor, empty when last).

key := orm.NewCursorKey(UserID, lastSeenID)
users, err := orm.NextPage(
orm.From(Users).OrderBy(UserID.Asc()),
key,
).Limit(50).All(ctx, conn)

orm.OffsetPage(q, page, size) applies a 1-based LIMIT/OFFSET and returns (Query, error). For keyset paging, CursorKey.Encode() and orm.DecodeCursor(token, col) round-trip the cursor, and orm.AfterTuple builds multi-column predicates.

An empty body decodes without error

r.Body was consumed or nil. Use httptest.NewRequest with a strings.Reader.

Params are always empty under test

Plain test requests carry no driver context. Seed with router.WithParams.

The client sees Content-Type: text/plain

WriteHeader ran before the header. Set Content-Type, then status, then body.

Huge uploads exhaust memory

No MaxBytesReader: wrap r.Body, return 413.

Deadline middleware has no effect

A handler or ORM call uses context.Background(). Thread r.Context() through.