Capstone: JSON API server

Contents
The spec
net/http routes
JSON handlers
Test with httptest

The spec

The server exposes word counting over HTTP. A GET request to the health path reports that the service runs, while a POST request to the words path accepts text and returns the top words as JSON.

The request body carries text plus an optional limit, and the response lists word and count pairs ordered from frequent to rare. Wrong methods and broken JSON answer with clear error codes.

net/http routes

A ServeMux maps paths to handler functions, and HandleFunc registers each pair in one line. The first version below answers both routes with fixed JSON, which proves the wiring before real logic lands.

ListenAndServe blocks main and serves traffic on the given port. Every handler receives a writer for the answer plus a request value with the method, path, and body.

package main import ( "fmt" "net/http" ) func health(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprintln(w, `{"status":"ok"}`) } func words(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprintln(w, `{"top":[]}`) } func main() { mux := http.NewServeMux() mux.HandleFunc("/health", health) mux.HandleFunc("/words", words) fmt.Println("listen on :8080") http.ListenAndServe(":8080", mux) }

curl localhost:8080/health

{"status":"ok"}

JSON handlers

Real handlers decode the request into a struct and encode the answer back. Struct tags bind JSON names to fields, so the wire format stays short while Go names keep their style.

The words handler rejects non POST methods, reports broken JSON with a bad request code, fills a default limit, and encodes the ranked list. The counting helpers mirror the CLI capstone from the previous lesson.

package main import ( "encoding/json" "fmt" "net/http" "sort" "strings" ) type countRequest struct { Text string `json:"text"` N int `json:"n"` } type wordCount struct { Word string `json:"word"` Count int `json:"count"` } func countWords(text string) map[string]int { out := make(map[string]int) for _, w := range strings.Fields(text) { out[strings.ToLower(w)]++ } return out } func topList(freq map[string]int, n int) []wordCount { all := make([]wordCount, 0, len(freq)) for w, c := range freq { all = append(all, wordCount{Word: w, Count: c}) } sort.Slice(all, func(i, j int) bool { if all[i].Count == all[j].Count { return all[i].Word < all[j].Word } return all[i].Count > all[j].Count }) if n > len(all) { n = len(all) } return all[:n] } func health(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprintln(w, `{"status":"ok"}`) } func words(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "post only", http.StatusMethodNotAllowed) return } var req countRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if req.N <= 0 { req.N = 10 } out := topList(countWords(req.Text), req.N) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(out) } func main() { mux := http.NewServeMux() mux.HandleFunc("/health", health) mux.HandleFunc("/words", words) fmt.Println("listen on :8080") http.ListenAndServe(":8080", mux) }

Test with httptest

Handlers are plain functions, so tests call them directly with a fake request and a recording writer. NewRequest builds the request, NewRecorder captures the answer, and the Code field holds the status.

The tests below cover the happy paths: health answers with success, and words accepts a JSON body and answers with success too. Method and decode errors deserve sibling cases in real projects.

package main import ( "net/http" "net/http/httptest" "strings" "testing" ) func TestHealth(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/health", nil) rec := httptest.NewRecorder() health(rec, req) if rec.Code != http.StatusOK { t.Errorf("code = %d", rec.Code) } } func TestWords(t *testing.T) { body := strings.NewReader(`{"text":"go go run","n":2}`) req := httptest.NewRequest(http.MethodPost, "/words", body) rec := httptest.NewRecorder() words(rec, req) if rec.Code != http.StatusOK { t.Errorf("code = %d", rec.Code) } }

go test ./...

ok wordfreq 0.009s

Next: Hero roadmap: the whole course on one page

Article author: Arthur Isaev

Related articles
Golang
Go course: zero to hero in 50 lessons
What is Go and where it runs
Install Go and check the version
Your first Go program
Modules with go mod
Variables
Constants
Types
Numbers
Strings
Conditions with if
Switch in Go
Loops with for
Arrays in Go
Slices in Go
Maps in Go
Functions in Go
Errors as values
Pointers in Go
Structs in Go
Methods in Go
Interfaces in Go
Embedding instead of inheritance
Generics in Go
Packages and imports
The go toolchain
Testing with go test
Advanced errors
defer in Go
Strings, bytes and runes
Time in Go
JSON
Files and IO
HTTP servers
HTTP clients
Context
Goroutines
Channels
select and sync
CLI: args, flags, env
Logging
Regular expressions
Sorting
Concurrency patterns
Databases with database/sql
Benchmarks and profiling
Toolchain and CI
Project layout
Capstone: wordfreq CLI
Capstone: JSON API server
Hero roadmap: the whole course on one page
Install Go on Windows 11
Install Go on Ubuntu
Install Go on Rocky Linux
Install Go on macOS
Install Go on FreeBSD
Go in Docker

Search this site

Channel @aofeed Chat @aofeedchat

Contacts and cooperation:
I recommend our hosting beget.ru
Write to info@urn.su if you:
1. Want to write an article for our site or translate an article into your native language.
2. Want to place thematically relevant ads on the site.
3. Ads on my site pass maximum censorship. If you see an ad block unsuitable for school-age children, shocking or misleading - please contact us by e-mail
4. Found a mistake, inaccuracy, bug, etc. on the site. ... .......
5. Articles can be shared on social media by clicking a network icon: