HTTP Basics

A JSON API with net/http

ReadingPreview

আপনি একটি free preview lesson দেখছেন।

Goal

Connect the web server to the Tasker Store. Build three endpoints that send and receive JSON: list all tasks, get one task, and create a task.

What you will learn

  • How to give handlers access to shared data with a server struct and method values
  • How to send JSON responses with json.Encoder
  • How to read JSON request bodies safely with json.Decoder
  • How to limit the body size with http.MaxBytesReader
  • How to map Go errors to HTTP status codes
  • Why an empty list must be [] and not null
  • How to test JSON endpoints with httptest

Why this matters

Most backends you will build are JSON APIs. A website or an app sends JSON, and your server answers with JSON. The hard part is not the happy path. The hard part is bad input: empty bodies, broken JSON, wrong types, huge requests. In this lesson you handle all of them, and you learn to send the correct status code for each one.

Step 1: The API we build

MethodPathWhat it doesSuccess status
GET/healthCheck that the server runs200
GET/tasksList all tasks200
GET/tasks/{id}Get one task200
POST/tasksCreate a task201

Every error is JSON with the same shape:

{"error":"task not found"}

One shape for all errors makes life easy for clients. They always know where to find the message.

The /hello route from Lesson 7.1 was only for practice. Remove handleHello and its route.

Step 2: Give handlers access to the store

A handler must have the signature func(w http.ResponseWriter, r *http.Request). You cannot add a store parameter. So how does a handler get the store?

Use a struct, and make the handlers methods on it. Create cmd/tasker-api/tasks.go and start with:

package main

import (
	"errors"
	"log"
	"net/http"
	"strconv"

	"github.com/yourname/tasker/internal/task"
)

// server holds what the handlers need.
type server struct {
	store *task.Store
}

When you write s.handleListTasks without parentheses, Go creates a method value: a function with the right signature that remembers s. That is exactly what mux.HandleFunc needs.

Now update cmd/tasker-api/main.go:

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/yourname/tasker/internal/storage"
	"github.com/yourname/tasker/internal/task"
)

func main() {
	store, err := task.Open(storage.NewMemory())
	if err != nil {
		log.Fatal(err)
	}

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           newMux(store),
		ReadHeaderTimeout: 5 * time.Second,
	}

	log.Printf("listening on %s", srv.Addr)
	if err := srv.ListenAndServe(); err != nil {
		log.Fatal(err)
	}
}

// newMux connects each route to its handler.
func newMux(store *task.Store) *http.ServeMux {
	s := &server{store: store}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", handleHealth)
	mux.HandleFunc("GET /tasks", s.handleListTasks)
	mux.HandleFunc("GET /tasks/{id}", s.handleGetTask)
	mux.HandleFunc("POST /tasks", s.handleCreateTask)
	return mux
}

// handleHealth tells the caller that the server is running.
func handleHealth(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	fmt.Fprintln(w, "ok")
}

Look at how much you reuse from Part 1. task.Open, storage.NewMemory, the Store with its mutex: none of it changes. The store does not know that it is now behind a web server. This is the payoff of the clean package design from Module 4.

For now the API uses memory storage, so all tasks are lost when the server stops. In Module 9 you replace it with Postgres.

Step 3: JSON helpers

Every handler needs to write JSON and errors, and some need to read JSON. Put these helpers in their own file, cmd/tasker-api/json.go:

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
)

// maxBodyBytes is the largest request body the API accepts.
const maxBodyBytes = 1 << 20 // 1 MB

// errBodyTooLarge is returned by readJSON when the body is larger than maxBodyBytes.
var errBodyTooLarge = errors.New("body is too large")

// writeJSON sends v as JSON with the given status code.
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(v); err != nil {
		log.Printf("write json: %v", err)
	}
}

// errorResponse is the JSON body of every error.
type errorResponse struct {
	Error string `json:"error"`
}

// writeError sends an error message as JSON with the given status code.
func writeError(w http.ResponseWriter, status int, message string) {
	writeJSON(w, status, errorResponse{Error: message})
}

// readJSON decodes the request body into dst.
// It rejects bodies that are too large, unknown fields, and extra data.
func readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)

	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(dst); err != nil {
		var maxErr *http.MaxBytesError
		if errors.As(err, &maxErr) {
			return errBodyTooLarge
		}
		if errors.Is(err, io.EOF) {
			return errors.New("body must not be empty")
		}
		return fmt.Errorf("invalid JSON: %w", err)
	}
	if dec.More() {
		return errors.New("body must contain only one JSON object")
	}
	return nil
}

Let us look at writeJSON first:

  • The order is headers, then status, then body, as you learned in Lesson 7.1.
  • json.NewEncoder(w).Encode(v) writes JSON directly into the response. In Lesson 3.2 you used json.Marshal, which builds a []byte first. The encoder skips that step.
  • If Encode fails, the status is already sent. We cannot send a new error to the client, so we only log it.

Now readJSON. Each line protects you from a real problem:

  • http.MaxBytesReader stops reading after 1 MB. Without it, a client could send a 10 GB body and fill your memory. If the limit is hit, the decoder returns an *http.MaxBytesError. We find it with errors.As (Lesson 4.4) and return our own error, so the handler can choose the status code.
  • DisallowUnknownFields rejects fields that the struct does not have. Without it, a typo like "titel" is ignored silently, and the client does not know why the title is empty.
  • io.EOF means the body was empty.
  • dec.More() checks for extra data after the first object, like {"title":"A"}{"title":"B"}.

The parameter dst any means readJSON works with any struct. You pass a pointer, like &req, so the decoder can fill it.

Step 4: List all tasks

Add to tasks.go:

// handleListTasks returns all tasks.
func (s *server) handleListTasks(w http.ResponseWriter, r *http.Request) {
	tasks := s.store.List()
	if tasks == nil {
		tasks = []task.Task{} // send [] instead of null
	}
	writeJSON(w, http.StatusOK, tasks)
}

Why the nil check? In Lesson 2.2 you learned that a slice can be nil. Store.List returns a nil slice when there are no tasks. The JSON encoder writes a nil slice as null, but an empty slice as []. A JavaScript client that runs tasks.length on null crashes. Always send [] for an empty list.

Step 5: Get one task

// handleGetTask returns one task by its ID.
func (s *server) handleGetTask(w http.ResponseWriter, r *http.Request) {
	id, err := task.ParseID(r.PathValue("id"))
	if err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}

	t, err := s.store.Get(id)
	if errors.Is(err, task.ErrNotFound) {
		writeError(w, http.StatusNotFound, err.Error())
		return
	}
	if err != nil {
		log.Printf("get task %d: %v", id, err)
		writeError(w, http.StatusInternalServerError, "internal error")
		return
	}
	writeJSON(w, http.StatusOK, t)
}

task.ParseID is the same function the CLI uses for tasker done 3. Now it also checks IDs from URLs.

This handler shows the most important pattern of this module: map errors to status codes.

  • A bad ID is the client's mistake: 400 Bad Request.
  • A missing task is a normal answer: 404 Not Found.
  • Any other error is our problem: 500 Internal Server Error.

For a 500 error, we log the real error and send only "internal error" to the client. The real error can contain file paths, SQL, or other details that strangers must not see. The client cannot fix a 500 error anyway.

Step 6: Create a task

// createTaskRequest is the JSON body of POST /tasks.
type createTaskRequest struct {
	Title string   `json:"title"`
	Tags  []string `json:"tags"`
}

// handleCreateTask adds a new task from the JSON body.
func (s *server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
	var req createTaskRequest
	if err := readJSON(w, r, &req); err != nil {
		status := http.StatusBadRequest
		if errors.Is(err, errBodyTooLarge) {
			status = http.StatusRequestEntityTooLarge
		}
		writeError(w, status, err.Error())
		return
	}

	t, err := s.store.Add(req.Title, req.Tags)
	if errors.Is(err, task.ErrEmptyTitle) || errors.Is(err, task.ErrTitleTooLong) {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}
	if err != nil {
		log.Printf("add task: %v", err)
		writeError(w, http.StatusInternalServerError, "internal error")
		return
	}

	w.Header().Set("Location", "/tasks/"+strconv.Itoa(t.ID))
	writeJSON(w, http.StatusCreated, t)
}

Why a separate createTaskRequest struct, and not task.Task? Because the client must not choose the id, done or created_at. The request struct contains only the fields a client is allowed to send. With DisallowUnknownFields, a body with "id": 5 is rejected. This keeps the API and the internal model separate.

The answer uses status 201 Created and a Location header with the URL of the new task. This is the HTTP standard for "I created something, and here it is".

Step 7: Try it with curl

Start the server:

go run ./cmd/tasker-api

In a second terminal. The -d flag sends a body, and it makes curl use POST:

curl localhost:8080/tasks
[]
curl -i -X POST localhost:8080/tasks -H "Content-Type: application/json" -d '{"title":"Buy milk","tags":["home"]}'

Output (Date header removed):

HTTP/1.1 201 Created
Content-Type: application/json
Location: /tasks/1
Content-Length: 103

{"id":1,"title":"Buy milk","done":false,"tags":["home"],"created_at":"2026-10-16T23:34:56.938379361Z"}

Add a second task, then list them. The tool jq formats JSON nicely. Install it from jqlang.org, or leave out | jq:

curl -X POST localhost:8080/tasks -H "Content-Type: application/json" -d '{"title":"Write report"}'
curl -s localhost:8080/tasks | jq
[
  {
    "id": 1,
    "title": "Buy milk",
    "done": false,
    "tags": [
      "home"
    ],
    "created_at": "2026-10-16T23:35:16.085366382Z"
  },
  {
    "id": 2,
    "title": "Write report",
    "done": false,
    "created_at": "2026-10-16T23:35:16.100550591Z"
  }
]

The second task has no tags field. That is the omitempty tag from Lesson 3.2.

Now the errors. Each line shows the command and the answer:

$ curl localhost:8080/tasks/99
{"error":"task not found"}

$ curl localhost:8080/tasks/abc
{"error":"\"abc\" is not a number"}

$ curl -X POST localhost:8080/tasks -d '{"title":""}'
{"error":"title is empty"}

$ curl -X POST localhost:8080/tasks -d '{"titel":"Typo"}'
{"error":"invalid JSON: json: unknown field \"titel\""}

$ curl -X POST localhost:8080/tasks -d '{"title":42}'
{"error":"invalid JSON: json: cannot unmarshal number into Go struct field createTaskRequest.title of type string"}

$ curl -X POST localhost:8080/tasks -d '{"title":'
{"error":"invalid JSON: unexpected EOF"}

$ curl -X POST localhost:8080/tasks
{"error":"body must not be empty"}

Some of these messages show Go details, like createTaskRequest. That is fine for now. In Module 10 you build clear validation messages for each field.

A body larger than 1 MB gets status 413:

HTTP/1.1 413 Request Entity Too Large
Connection: close
Content-Type: application/json

{"error":"body is too large"}

The server also adds Connection: close. It does not want to read the rest of the huge body, so it closes the connection.

Step 8: Test the endpoints

Replace cmd/tasker-api/main_test.go. Two small helpers keep each test short:

package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/yourname/tasker/internal/storage"
	"github.com/yourname/tasker/internal/task"
)

// newTestMux returns a router with a store that already has one task.
func newTestMux(t *testing.T) *http.ServeMux {
	t.Helper()
	store, err := task.Open(storage.NewMemory())
	require.NoError(t, err)
	_, err = store.Add("Buy milk", []string{"home"})
	require.NoError(t, err)
	return newMux(store)
}

// do sends one request to mux and returns the recorded response.
func do(mux http.Handler, method, target, body string) *httptest.ResponseRecorder {
	req := httptest.NewRequest(method, target, strings.NewReader(body))
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)
	return rec
}

func TestHealth(t *testing.T) {
	rec := do(newTestMux(t), http.MethodGet, "/health", "")
	assert.Equal(t, http.StatusOK, rec.Code)
	assert.Equal(t, "ok\n", rec.Body.String())
}

func TestListTasks(t *testing.T) {
	rec := do(newTestMux(t), http.MethodGet, "/tasks", "")

	assert.Equal(t, http.StatusOK, rec.Code)
	assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))

	var tasks []task.Task
	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &tasks))
	require.Len(t, tasks, 1)
	assert.Equal(t, "Buy milk", tasks[0].Title)
}

func TestListTasksEmpty(t *testing.T) {
	store, err := task.Open(storage.NewMemory())
	require.NoError(t, err)

	rec := do(newMux(store), http.MethodGet, "/tasks", "")

	assert.Equal(t, http.StatusOK, rec.Code)
	assert.Equal(t, "[]\n", rec.Body.String())
}

func TestGetTask(t *testing.T) {
	tests := []struct {
		name       string
		target     string
		wantStatus int
		wantBody   string
	}{
		{"found", "/tasks/1", http.StatusOK, `"title":"Buy milk"`},
		{"not found", "/tasks/99", http.StatusNotFound, `{"error":"task not found"}`},
		{"not a number", "/tasks/abc", http.StatusBadRequest, `{"error":"\"abc\" is not a number"}`},
	}

	mux := newTestMux(t)
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			rec := do(mux, http.MethodGet, tt.target, "")
			assert.Equal(t, tt.wantStatus, rec.Code)
			assert.Contains(t, rec.Body.String(), tt.wantBody)
		})
	}
}

func TestCreateTask(t *testing.T) {
	mux := newTestMux(t)

	rec := do(mux, http.MethodPost, "/tasks", `{"title":"Write report","tags":["work"]}`)

	require.Equal(t, http.StatusCreated, rec.Code)
	assert.Equal(t, "/tasks/2", rec.Header().Get("Location"))

	var created task.Task
	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
	assert.Equal(t, 2, created.ID)
	assert.Equal(t, "Write report", created.Title)
	assert.Equal(t, []string{"work"}, created.Tags)
	assert.False(t, created.Done)

	// The new task can be read back.
	rec = do(mux, http.MethodGet, "/tasks/2", "")
	assert.Equal(t, http.StatusOK, rec.Code)
}

func TestCreateTaskBadInput(t *testing.T) {
	tests := []struct {
		name     string
		body     string
		wantBody string
	}{
		{"empty body", ``, `{"error":"body must not be empty"}`},
		{"broken JSON", `{"title":`, `invalid JSON`},
		{"unknown field", `{"title":"A","owner":"Ana"}`, `unknown field \"owner\"`},
		{"two objects", `{"title":"A"}{"title":"B"}`, `only one JSON object`},
		{"wrong type", `{"title":42}`, `invalid JSON`},
		{"empty title", `{"title":""}`, `{"error":"title is empty"}`},
		{"long title", `{"title":"` + strings.Repeat("a", 51) + `"}`, `{"error":"title is too long"}`},
	}

	mux := newTestMux(t)
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			rec := do(mux, http.MethodPost, "/tasks", tt.body)
			assert.Equal(t, http.StatusBadRequest, rec.Code)
			assert.Contains(t, rec.Body.String(), tt.wantBody)
		})
	}
}

func TestCreateTaskBodyTooLarge(t *testing.T) {
	body := `{"title":"` + strings.Repeat("a", maxBodyBytes) + `"}`

	rec := do(newTestMux(t), http.MethodPost, "/tasks", body)

	assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
	assert.Equal(t, `{"error":"body is too large"}`+"\n", rec.Body.String())
}

Notes:

  • TestListTasks decodes the JSON into []task.Task. This is better than comparing strings, because created_at changes every time.
  • TestCreateTask checks the answer and then reads the task back. A create test that only checks the answer can miss a bug where nothing was saved.
  • Each test gets a fresh store from newTestMux, so tests do not affect each other.

Run all tests with the race detector. The server runs handlers in many goroutines, so this check matters:

go test -race ./...
?   	github.com/yourname/tasker/cmd/tasker	[no test files]
ok  	github.com/yourname/tasker/cmd/tasker-api	1.119s
ok  	github.com/yourname/tasker/internal/cli	1.048s
ok  	github.com/yourname/tasker/internal/importer	1.136s
ok  	github.com/yourname/tasker/internal/storage	1.033s
ok  	github.com/yourname/tasker/internal/task	1.027s

Why it works

  • Method values like s.handleGetTask bind the receiver s to the function. Every handler shares the same server, and so the same Store.
  • The Store is safe for many goroutines because of the mutex from Lesson 6.1. The HTTP server runs each request in its own goroutine, so without that mutex, two POST /tasks at the same time could create two tasks with the same ID.
  • The handlers only translate. They turn HTTP into calls to Store and turn the results and errors back into HTTP. All rules about tasks still live in internal/task.

Exercises

  1. Add POST /tasks/{id}/done. It calls store.Complete. Return 200 with the task, 404 if it does not exist, and 409 Conflict for task.ErrAlreadyDone. Write a table test with all three cases.
  2. Add a query parameter to GET /tasks: ?tag=home returns only tasks with that tag, and ?open=true returns only open tasks. Use task.Filter from Part 1.
  3. Write a test that sends POST /tasks with {"title":"A","id":7}. What status do you get, and why is this a good thing?
  4. Change writeJSON to use enc.SetIndent("", " ") only when the request has the query parameter ?pretty=true. You will need to pass r to the helper.
  5. Write a test that starts 20 goroutines, and each one sends POST /tasks to the same mux. After all of them finish, GET /tasks must return 21 tasks with 21 different IDs. Run it with -race.

Checkpoint

You are ready for the next lesson when you can:

  • Explain how a handler gets access to the store
  • Explain why the API sends [] and not null for an empty list
  • Name four kinds of bad request bodies and how readJSON handles each one
  • Choose between 400, 404, 413 and 500 for an error
  • Explain why a 500 answer does not include the real error message