Part 3: The last upgrade
Pagination, update endpoint, request scoped logging, and lightweight OpenAPI
By now we have a small Go API that is not embarrassing.
We have routes, a service layer, middleware, timeouts, and tests. That is already better than most code that ships.
For the last part, we are going to add the stuff that turns “cute demo” into “this could actually run”:
- Pagination for
GET /links -
PUT /links/{id}to update the note - Structured enough logging so you can trace a request without reading tea leaves
- A small OpenAPI spec so other people do not have to guess your API
Still simple. Still standard library.
Step 1: Add pagination to the store and service
We will do offset pagination because it is easy to understand. Cursor pagination is better at scale, but you can add it later when you actually have scale. Most people add it early as a personality trait.
Update internal/links/store.go
Replace the Store interface and add a paginated list method. Also update the memory store.
package links
import (
"sort"
"sync"
"time"
)
type Store interface {
Create(amount int, currency, note string) PayLink
Get(id int) (PayLink, bool)
UpdateNote(id int, note string) (PayLink, bool)
List(offset, limit int) []PayLink
Count() int
}
type MemoryStore struct {
mu sync.RWMutex
next int
links map[int]PayLink
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
next: 1,
links: make(map[int]PayLink),
}
}
func (s *MemoryStore) Create(amount int, currency, note string) PayLink {
s.mu.Lock()
defer s.mu.Unlock()
l := PayLink{
ID: s.next,
Amount: amount,
Currency: currency,
Note: note,
CreatedAt: time.Now().UTC(),
}
s.links[l.ID] = l
s.next++
return l
}
func (s *MemoryStore) Get(id int) (PayLink, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
l, ok := s.links[id]
return l, ok
}
func (s *MemoryStore) UpdateNote(id int, note string) (PayLink, bool) {
s.mu.Lock()
defer s.mu.Unlock()
l, ok := s.links[id]
if !ok {
return PayLink{}, false
}
l.Note = note
s.links[id] = l
return l, true
}
func (s *MemoryStore) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.links)
}
func (s *MemoryStore) List(offset, limit int) []PayLink {
s.mu.RLock()
defer s.mu.RUnlock()
if offset < 0 {
offset = 0
}
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
ids := make([]int, 0, len(s.links))
for id := range s.links {
ids = append(ids, id)
}
sort.Ints(ids)
if offset >= len(ids) {
return []PayLink{}
}
end := offset + limit
if end > len(ids) {
end = len(ids)
}
out := make([]PayLink, 0, end-offset)
for _, id := range ids[offset:end] {
out = append(out, s.links[id])
}
return out
}
Now update the service so handlers do not have to know pagination rules.
Update internal/links/service.go
Add these methods and errors.
package links
import (
"context"
"errors"
"strings"
)
var (
ErrInvalidAmount = errors.New("amount must be greater than zero")
ErrInvalidCurrency = errors.New("currency must be a 3 letter code like USD")
ErrInvalidLimit = errors.New("limit must be between 1 and 100")
ErrInvalidOffset = errors.New("offset must be 0 or greater")
ErrNotFound = errors.New("link not found")
)
type Service struct {
store Store
}
func NewService(store Store) *Service {
return &Service{store: store}
}
func (s *Service) Create(ctx context.Context, amount int, currency, note string) (PayLink, error) {
select {
case <-ctx.Done():
return PayLink{}, ctx.Err()
default:
}
currency = strings.ToUpper(strings.TrimSpace(currency))
note = strings.TrimSpace(note)
if amount <= 0 {
return PayLink{}, ErrInvalidAmount
}
if len(currency) != 3 {
return PayLink{}, ErrInvalidCurrency
}
return s.store.Create(amount, currency, note), nil
}
type Page struct {
Items []PayLink `json:"items"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
func (s *Service) ListPage(ctx context.Context, offset, limit int) (Page, error) {
select {
case <-ctx.Done():
return Page{}, ctx.Err()
default:
}
if offset < 0 {
return Page{}, ErrInvalidOffset
}
if limit <= 0 || limit > 100 {
return Page{}, ErrInvalidLimit
}
return Page{
Items: s.store.List(offset, limit),
Total: s.store.Count(),
Offset: offset,
Limit: limit,
}, nil
}
func (s *Service) Get(ctx context.Context, id int) (PayLink, error) {
select {
case <-ctx.Done():
return PayLink{}, ctx.Err()
default:
}
l, ok := s.store.Get(id)
if !ok {
return PayLink{}, ErrNotFound
}
return l, nil
}
func (s *Service) UpdateNote(ctx context.Context, id int, note string) (PayLink, error) {
select {
case <-ctx.Done():
return PayLink{}, ctx.Err()
default:
}
note = strings.TrimSpace(note)
if note == "" {
// Keep it simple. You can allow empty later if needed.
return PayLink{}, errors.New("note is required")
}
l, ok := s.store.UpdateNote(id, note)
if !ok {
return PayLink{}, ErrNotFound
}
return l, nil
}
Step 2: Add request scoped logging that actually helps
Right now we log method, path, and duration. Good start.
Let us add request ID into the logs and also return it in error responses. Because when a user reports a bug, you want them to give you one string, not a full emotional experience.
Update internal/httpapi/helpers.go
package httpapi
import (
"encoding/json"
"net/http"
)
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func WriteError(w http.ResponseWriter, status int, msg string) {
reqID := w.Header().Get("X-Request-Id")
if reqID == "" {
WriteJSON(w, status, map[string]string{"error": msg})
return
}
WriteJSON(w, status, map[string]string{"error": msg, "request_id": reqID})
}
Update internal/httpapi/middleware.go
Change the logging middleware to include request id.
func Logging(logger *log.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
reqID := w.Header().Get("X-Request-Id")
if reqID == "" {
reqID = "-"
}
logger.Printf("rid=%s %s %s from=%s dur=%s",
reqID,
r.Method,
r.URL.Path,
clientIP(r),
time.Since(start).Round(time.Millisecond),
)
})
}
}
Now your logs are traceable.
Step 3: Update handlers for pagination and PUT
Update internal/httpapi/handlers.go
Replace the collection handler and add a new update handler.
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"example.com/paylinks/internal/links"
)
type API struct {
Links *links.Service
}
func (a *API) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("/links", a.linksCollection)
mux.HandleFunc("GET /links/{id}", a.linksGet)
mux.HandleFunc("PUT /links/{id}", a.linksUpdateNote)
return mux
}
func (a *API) linksCollection(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
switch r.Method {
case http.MethodGet:
offset := parseIntQuery(r, "offset", 0)
limit := parseIntQuery(r, "limit", 20)
page, err := a.Links.ListPage(ctx, offset, limit)
if err != nil {
switch {
case errors.Is(err, links.ErrInvalidOffset), errors.Is(err, links.ErrInvalidLimit):
WriteError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, context.DeadlineExceeded):
WriteError(w, http.StatusRequestTimeout, "request timed out")
default:
WriteError(w, http.StatusInternalServerError, "server error")
}
return
}
WriteJSON(w, http.StatusOK, page)
return
case http.MethodPost:
var body struct {
Amount int `json:"amount"`
Currency string `json:"currency"`
Note string `json:"note"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
WriteError(w, http.StatusBadRequest, "invalid JSON body")
return
}
l, err := a.Links.Create(ctx, body.Amount, body.Currency, body.Note)
if err != nil {
switch {
case errors.Is(err, links.ErrInvalidAmount), errors.Is(err, links.ErrInvalidCurrency):
WriteError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, context.DeadlineExceeded):
WriteError(w, http.StatusRequestTimeout, "request timed out")
default:
WriteError(w, http.StatusInternalServerError, "server error")
}
return
}
WriteJSON(w, http.StatusCreated, l)
return
default:
WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
}
func (a *API) linksGet(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
WriteError(w, http.StatusBadRequest, "invalid link id")
return
}
l, err := a.Links.Get(ctx, id)
if err != nil {
switch {
case errors.Is(err, links.ErrNotFound):
WriteError(w, http.StatusNotFound, "link not found")
case errors.Is(err, context.DeadlineExceeded):
WriteError(w, http.StatusRequestTimeout, "request timed out")
default:
WriteError(w, http.StatusInternalServerError, "server error")
}
return
}
WriteJSON(w, http.StatusOK, l)
}
func (a *API) linksUpdateNote(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
WriteError(w, http.StatusBadRequest, "invalid link id")
return
}
var body struct {
Note string `json:"note"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
WriteError(w, http.StatusBadRequest, "invalid JSON body")
return
}
l, err := a.Links.UpdateNote(ctx, id, body.Note)
if err != nil {
switch {
case errors.Is(err, links.ErrNotFound):
WriteError(w, http.StatusNotFound, "link not found")
default:
WriteError(w, http.StatusBadRequest, err.Error())
}
return
}
WriteJSON(w, http.StatusOK, l)
}
func parseIntQuery(r *http.Request, key string, fallback int) int {
raw := r.URL.Query().Get(key)
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return v
}
Now you have:
GET /links?offset=0&limit=20-
PUT /links/1with body{"note":"new note"}
Step 4: Update tests for the new behavior
Update internal/httpapi/handlers_test.go by adding these tests.
func TestPagination(t *testing.T) {
h := newTestServer()
// Create 3 items
for i := 0; i < 3; i++ {
body := []byte(`{"amount":100,"currency":"usd","note":"x"}`)
req := httptest.NewRequest(http.MethodPost, "/links", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d body=%s", rec.Code, rec.Body.String())
}
}
// Fetch page with limit=2
req := httptest.NewRequest(http.MethodGet, "/links?offset=0&limit=2", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestUpdateNote(t *testing.T) {
h := newTestServer()
// Create
body := []byte(`{"amount":5000,"currency":"kes","note":"old"}`)
req := httptest.NewRequest(http.MethodPost, "/links", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d body=%s", rec.Code, rec.Body.String())
}
// Update
up := []byte(`{"note":"new note"}`)
req2 := httptest.NewRequest(http.MethodPut, "/links/1", bytes.NewReader(up))
req2.Header.Set("Content-Type", "application/json")
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec2.Code, rec2.Body.String())
}
}
Run:
go test ./...
Step 5: Add an OpenAPI spec without turning into a bureaucracy
This is where teams often go wrong.
They either have no docs and everyone guesses, or they treat OpenAPI like a sacred ritual and stop shipping.
We will do a lightweight OpenAPI file by hand. Just enough so someone can integrate without calling you.
Create openapi.yaml at the project root.
openapi: 3.0.3
info:
title: PayLinks API
version: 1.0.0
servers:
- url: http://localhost:8080
paths:
/health:
get:
summary: Health check
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
status:
type: string
/links:
get:
summary: List payment links
parameters:
- in: query
name: offset
schema:
type: integer
minimum: 0
required: false
- in: query
name: limit
schema:
type: integer
minimum: 1
maximum: 100
required: false
responses:
"200":
description: Paginated list
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/PayLink"
total:
type: integer
offset:
type: integer
limit:
type: integer
post:
summary: Create a payment link
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [amount, currency]
properties:
amount:
type: integer
minimum: 1
currency:
type: string
example: USD
note:
type: string
responses:
"201":
description: Created
content:
application/json:
schema:
$ref: "#/components/schemas/PayLink"
/links/{id}:
get:
summary: Get one payment link
parameters:
- in: path
name: id
required: true
schema:
type: integer
minimum: 1
responses:
"200":
description: Found
content:
application/json:
schema:
$ref: "#/components/schemas/PayLink"
"404":
description: Not found
put:
summary: Update the note for a payment link
parameters:
- in: path
name: id
required: true
schema:
type: integer
minimum: 1
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [note]
properties:
note:
type: string
responses:
"200":
description: Updated
content:
application/json:
schema:
$ref: "#/components/schemas/PayLink"
components:
schemas:
PayLink:
type: object
properties:
id:
type: integer
amount:
type: integer
currency:
type: string
note:
type: string
created_at:
type: string
format: date-time
Now your API has a contract.
Not perfect. Not automatic. But good enough to stop people from guessing.
Quick manual smoke test for Part 3
Run the server:
API_KEY=supersecret go run ./cmd/api
Create a few links:
curl -s -X POST http://localhost:8080/links \
-H "X-API-Key: supersecret" \
-H "Content-Type: application/json" \
-d '{"amount":1200,"currency":"usd","note":"coffee"}'
curl -s -X POST http://localhost:8080/links \
-H "X-API-Key: supersecret" \
-H "Content-Type: application/json" \
-d '{"amount":9000,"currency":"kes","note":"invoice 22"}'
List with pagination:
curl -s "http://localhost:8080/links?offset=0&limit=1" -H "X-API-Key: supersecret"
Update note:
curl -s -X PUT http://localhost:8080/links/1 \
-H "X-API-Key: supersecret" \
-H "Content-Type: application/json" \
-d '{"note":"coffee, but make it urgent"}'
The real takeaway
If you remember one thing from all three parts, make it this:
Routing is not the hard part. The hard part is not letting routing become your entire application.
Keep handlers thin. Put rules in a service. Put shared behavior in middleware. Add tests early. Add docs before you forget what your API does.
This is how you build a Go server that does not require a rewrite the moment it gets real traffic.
And yes, you will still rewrite parts of it later. But it will be because you chose to, not because the code forced you into a corner with a chair.
Top comments (0)