Build a local task API with two operations: create a task and list tasks. This combines structs, slices, errors, synchronization, and tests in one program. Tasks live in process memory and disappear when the server restarts. That explicit limitation keeps the project focused on HTTP behavior; durable storage is a separate next step.
An HTTP handler receives a request and writes a response. A router, called ServeMux in the standard library, selects a handler by method and path. We will accept JSON, validate a title, and return JSON with deliberate status codes. No framework or third-party dependency is required.
Create the project
Make a fresh directory named go-task-api, enter it, and run go mod init example.com/go-task-api. Save this complete source as main.go:
package main
import (
"encoding/json"
"errors"
"io"
"log"
"mime"
"net/http"
"strings"
"sync"
"time"
"unicode/utf8"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(value); err != nil {
log.Printf("write response: %v", err)
}
}
func newHandler() http.Handler {
var mu sync.Mutex
tasks := make([]Task, 0)
nextID := 1
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
snapshot := make([]Task, len(tasks))
copy(snapshot, tasks)
mu.Unlock()
writeJSON(w, http.StatusOK, snapshot)
})
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediaType != "application/json" {
http.Error(w, "use application/json", http.StatusUnsupportedMediaType)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
defer r.Body.Close()
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
var input struct {
Title string `json:"title"`
}
if err := decoder.Decode(&input); err != nil {
http.Error(w, "invalid task JSON", http.StatusBadRequest)
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
http.Error(w, "send one JSON object", http.StatusBadRequest)
return
}
input.Title = strings.TrimSpace(input.Title)
if input.Title == "" || utf8.RuneCountInString(input.Title) > 80 {
http.Error(w, "title must contain 1 to 80 runes", http.StatusBadRequest)
return
}
mu.Lock()
task := Task{ID: nextID, Title: input.Title}
nextID++
tasks = append(tasks, task)
mu.Unlock()
writeJSON(w, http.StatusCreated, task)
})
return mux
}
func main() {
server := &http.Server{
Addr: "127.0.0.1:8090",
Handler: newHandler(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Println("listening on http://127.0.0.1:8090")
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}
Run go run .. In another terminal, use curl; on Windows PowerShell you can invoke curl.exe:
curl -i http://127.0.0.1:8090/tasks
curl -i -X POST http://127.0.0.1:8090/tasks -H 'Content-Type: application/json' -d '{"title":"Learn Go"}'
curl -i http://127.0.0.1:8090/tasks
Initially the list is []. Creation returns status 201 and an object with id: 1 and title: "Learn Go". The next list contains that object. Stop the server with Ctrl+C. If the port is occupied, change the address and curl commands together.
Trace the request boundary
The POST handler checks the media type, limits the request body to 4096 bytes, decodes into a typed struct, rejects unknown fields, and requires end-of-input after one value. A decoder can read a stream of JSON values, so one successful decode alone does not reject a second object. Finally, we trim whitespace and apply the title rule. The JSON reference explains decoding behavior; rejecting unknown fields is not a guarantee against every ambiguous JSON representation, such as duplicate field names.
All invalid JSON or title inputs here return 400; an unsupported media type returns 415. Error responses use http.Error and are plain text, while successful responses are JSON. Keeping that distinction explicit helps clients parse responses correctly. A larger API could introduce one consistent JSON error envelope.
Set headers before writing status or body. Once response bytes are sent, a later encoding or connection failure cannot turn the response into a fresh error document. Our helper logs write failures. The net/http documentation defines handlers, routing, response writing, and server timeouts.
Protect state without holding a lock during I/O
Requests may run concurrently. The same mutex protects the task slice and ID allocation. GET copies a snapshot while locked, then encodes after unlocking. POST appends while locked, then writes its response after unlocking. A slow client therefore does not hold the store lock during response writing.
These copies are sufficient because each task contains only an integer and an immutable string. Adding slice or pointer fields would require revisiting the snapshot contract. Each newHandler call owns independent state, which also makes tests isolated.
Test the observable API
Save this as main_test.go alongside main.go:
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestTaskAPI(t *testing.T) {
handler := newHandler()
request := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(`{"title":" Learn Go "}`))
request.Header.Set("Content-Type", "application/json")
created := httptest.NewRecorder()
handler.ServeHTTP(created, request)
if created.Code != http.StatusCreated {
t.Fatalf("create status = %d: %s", created.Code, created.Body.String())
}
var task Task
if err := json.Unmarshal(created.Body.Bytes(), &task); err != nil {
t.Fatal(err)
}
if task.ID != 1 || task.Title != "Learn Go" {
t.Fatalf("unexpected task: %+v", task)
}
listed := httptest.NewRecorder()
handler.ServeHTTP(listed, httptest.NewRequest(http.MethodGet, "/tasks", nil))
if listed.Code != http.StatusOK {
t.Fatalf("list status = %d", listed.Code)
}
var tasks []Task
if err := json.Unmarshal(listed.Body.Bytes(), &tasks); err != nil {
t.Fatal(err)
}
if len(tasks) != 1 || tasks[0] != task {
t.Fatalf("unexpected list: %+v", tasks)
}
}
Run go fmt ./..., go vet ./..., go test -race ./..., and go build ./.... httptest exercises handlers without opening a public port. It checks the status, normalized result, and later read; see the httptest reference. This test is sequential, so the race flag alone does not exercise concurrent requests.
Exercise: test rejected input and plan shutdown
Add table-driven request tests for a blank title, unknown field, second JSON object, oversized body, and wrong media type. Check that rejected requests leave the task list unchanged. As an extension, implement graceful shutdown so an interrupt allows active requests a bounded time to finish.
Solution guidance
Use a fresh handler per test, set each request's content type, and compare status codes: 400 for invalid bodies and 415 for the wrong media type. For shutdown, use signal.NotifyContext, run the server under an explicit lifetime, and call Server.Shutdown with a separate timeout context after the interrupt. Wait for the serving goroutine and handle ErrServerClosed as expected termination. Request work that calls other services should propagate r.Context(). Durable storage, authorization, and deployment remain separate extensions to this local project.
Previous: Testing and Tooling. Review from Getting Started and revisit any example you cannot yet explain without running it.

