Skip to content

Expert · Building a REST API with a database · topic 35 of 46 · 45 min

REST API design and JSON handlers

Explanation

A REST API exposes resources (expenses, orders) at URLs and uses HTTP methods to act on them: GET reads, POST creates, PUT or PATCH updates, DELETE removes. Good handlers decode and validate input, call the business logic, and reply with the right status code and a JSON body, including clear JSON errors.

Resources, methods and status codes

REST (Representational State Transfer) is a common style for web APIs. You think in resources, which are nouns, each with a URL. HTTP methods are the verbs. Clients, such as a React app or an Android app, then know what to expect without reading your code.

RequestMeaningSuccess status
GET /v1/expensesList expenses (with ?limit=20&offset=0)200 OK
GET /v1/expenses/{id}Get one expense200 OK, or 404 Not Found
POST /v1/expensesCreate an expense from a JSON body201 Created (+ Location header)
PUT /v1/expenses/{id}Replace an expense200 OK
PATCH /v1/expenses/{id}Change some fields200 OK
DELETE /v1/expenses/{id}Remove an expense204 No Content
Error statusWhen
400 Bad RequestMalformed JSON, wrong types
401 Unauthorized / 403 ForbiddenNot logged in / logged in but not allowed
404 Not FoundThe resource does not exist
409 ConflictDuplicate, for example an email already registered
422 Unprocessable EntityValid JSON, but the values break a rule (amount <= 0). Some teams use 400 for this too; be consistent.
500 Internal Server ErrorYour bug or a database failure. Do not leak details to the client.

Two small helpers

go
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 {
		slog.Error("write json", "err", err)
	}
}

func writeError(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, map[string]string{"error": msg})
}
Every response, success or error, is JSON with the right Content-Type. Clients can always parse it.

A store behind an interface

go
type Expense struct {
	ID       int64     `json:"id"`
	Title    string    `json:"title"`
	Paise    int64     `json:"amount_paise"`
	Category string    `json:"category"`
	Created  time.Time `json:"created_at"`
}

var ErrNotFound = errors.New("not found")

type Store interface {
	Create(ctx context.Context, e Expense) (Expense, error)
	Get(ctx context.Context, id int64) (Expense, error)
	List(ctx context.Context, limit, offset int) ([]Expense, error)
}
Handlers depend on this interface. Today an in-memory map, tomorrow PostgreSQL; tests use a fake. The handlers never change.

Handlers with validation

go
type API struct{ store Store }

type createReq struct {
	Title    string `json:"title"`
	Paise    int64  `json:"amount_paise"`
	Category string `json:"category"`
}

func (r createReq) validate() error {
	if strings.TrimSpace(r.Title) == "" {
		return errors.New("title is required")
	}
	if r.Paise <= 0 {
		return errors.New("amount_paise must be positive")
	}
	return nil
}

func (a *API) createExpense(w http.ResponseWriter, r *http.Request) {
	var in createReq
	dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&in); err != nil {
		writeError(w, http.StatusBadRequest, "invalid JSON body")
		return
	}
	if err := in.validate(); err != nil {
		writeError(w, http.StatusUnprocessableEntity, err.Error())
		return
	}
	e, err := a.store.Create(r.Context(), Expense{Title: in.Title, Paise: in.Paise, Category: in.Category})
	if err != nil {
		slog.Error("create expense", "err", err)
		writeError(w, http.StatusInternalServerError, "could not save expense")
		return
	}
	w.Header().Set("Location", fmt.Sprintf("/v1/expenses/%d", e.ID))
	writeJSON(w, http.StatusCreated, e)
}

func (a *API) getExpense(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		writeError(w, http.StatusBadRequest, "id must be a number")
		return
	}
	e, err := a.store.Get(r.Context(), id)
	if errors.Is(err, ErrNotFound) {
		writeError(w, http.StatusNotFound, "expense not found")
		return
	}
	if err != nil {
		slog.Error("get expense", "id", id, "err", err)
		writeError(w, http.StatusInternalServerError, "internal error")
		return
	}
	writeJSON(w, http.StatusOK, e)
}

func (a *API) routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /v1/expenses", a.createExpense)
	mux.HandleFunc("GET /v1/expenses/{id}", a.getExpense)
	return mux
}
Decode → validate → call the store → map errors to status codes → write JSON. Every handler follows the same shape.
Careful: Never send raw internal errors to clients ("pq: relation users does not exist", stack traces). Log the details with slog on the server; send a short, safe message to the client.

Testing handlers with httptest

go
func TestCreateExpense(t *testing.T) {
	api := &API{store: newMemStore()}
	body := strings.NewReader(`{"title":"Lunch","amount_paise":-5}`)
	req := httptest.NewRequest(http.MethodPost, "/v1/expenses", body)
	rec := httptest.NewRecorder()

	api.routes().ServeHTTP(rec, req)

	if rec.Code != http.StatusUnprocessableEntity {
		t.Fatalf("status = %d; want 422", rec.Code)
	}
}
No real server or network: the recorder captures the status, headers and body.
Tip: Version your API from day one (/v1/...). When you must make a breaking change, add /v2 and keep /v1 working for old app versions still installed on users' phones.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

REST API design and JSON handlers · 1/7

REST APIs in Go

A REST API exposes resources at URLs and uses HTTP methods as verbs.

Prefer a video by a teacher? Find free videos on “golang rest api net/http json handlers status codes tutorial” on YouTube ↗

In real life: An expense API for a student budgeting app

A college team builds a budgeting app. The Android app talks to a Go API: POST /v1/expenses saves a spend, GET /v1/expenses/{id} reads it. Validation rejects zero amounts with 422 and a clear message, unknown ids give 404, and every reply is JSON. The in-memory store below is enough for a demo; the next topics swap it for PostgreSQL.

go
type memStore struct {
	mu     sync.Mutex
	nextID int64
	data   map[int64]Expense
}

func newMemStore() *memStore { return &memStore{data: map[int64]Expense{}} }

func (m *memStore) Create(_ context.Context, e Expense) (Expense, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.nextID++
	e.ID = m.nextID
	e.Created = time.Now().UTC()
	m.data[e.ID] = e
	return e, nil
}

func (m *memStore) Get(_ context.Context, id int64) (Expense, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	e, ok := m.data[id]
	if !ok {
		return Expense{}, ErrNotFound
	}
	return e, nil
}

// curl -i -X POST localhost:8080/v1/expenses -d '{"title":"Metro card","amount_paise":20000,"category":"travel"}'
// HTTP/1.1 201 Created
// Location: /v1/expenses/1
// {"id":1,"title":"Metro card","amount_paise":20000,"category":"travel","created_at":"..."}
A mutex protects the map because each request runs in its own goroutine. (List is left for you to add.)

Practice (0/4 done)

Task 1 · Question

A client creates a new order successfully. Which status should the API return?

Task 2 · Question

The JSON is valid but amount_paise is -50. Which status fits best?

Task 3 · Question

The database is down when a client calls GET /v1/expenses/5. What should the client see?

Task 4 · Practice

Finish the expense API: add List to memStore (sorted by id, with limit and offset from the query string, default limit 20, max 100) and a DELETE /v1/expenses/{id} handler that returns 204 or 404. Write httptest tests for one success and one failure of each.

Quick quiz

Question 1

1.Which HTTP method should delete a resource?

Question 2

2.Why make handlers depend on a Store interface?

Question 3

3.What does httptest.NewRecorder give you?

Question 4

4.Why put /v1 in the path?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

Which HTTP method should delete a resource?

Answer: DELETE

DELETE removes the resource at the URL.

Why make handlers depend on a Store interface?

Answer: The storage can change and tests can use a fake without touching handlers

This is the "accept interfaces" idea applied to APIs.

What does httptest.NewRecorder give you?

Answer: A ResponseWriter that records status, headers and body for tests

Call your handler with it and inspect rec.Code and rec.Body.

Why put /v1 in the path?

Answer: So you can release a breaking /v2 later without breaking old clients

API versioning protects existing clients.

What does "stateless" mean for a REST API?Related

Answer: Each request carries everything needed, such as the token; the server does not remember earlier requests

Stateless means no memory of earlier requests, so every request includes its own authentication and data.

From REST APIs and JSON

Which URL follows REST naming best?Related

Answer: /products

REST URLs are plural nouns. The method (GET) already says what to do.

From What an API is and how REST APIs are designed

Which status is right for a successful DELETE with nothing to return?Related
In Express 5, what happens when an async route handler throws an error?Related

Answer: Express passes the error to the error-handling middleware automatically

Express 5 catches rejected promises from handlers and calls next(err) for you. Express 4 needed try/catch or a wrapper.

From Route handlers that follow the contract

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 3 more topics in “Building a REST API with a database” before its exam: Middleware: logging, recovery and request IDs, Databases with database/sql, Configuration, project structure and graceful shutdown.
  2. Module 8 exam: Building a REST API with a database · 14 questions · 25 min, without help. 4 topics of this module still to finish first.
  3. Then the next module, CLI tools and Go in cloud and DevOps. 46 topics left in the course before the final exam.

← Previous: Files, time and structured logs