Expert · Building a REST API with a database · topic 35 of 46 · 45 min
REST API design and JSON handlers
Explanation
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.
| Request | Meaning | Success status |
|---|---|---|
| GET /v1/expenses | List expenses (with ?limit=20&offset=0) | 200 OK |
| GET /v1/expenses/{id} | Get one expense | 200 OK, or 404 Not Found |
| POST /v1/expenses | Create an expense from a JSON body | 201 Created (+ Location header) |
| PUT /v1/expenses/{id} | Replace an expense | 200 OK |
| PATCH /v1/expenses/{id} | Change some fields | 200 OK |
| DELETE /v1/expenses/{id} | Remove an expense | 204 No Content |
| Error status | When |
|---|---|
| 400 Bad Request | Malformed JSON, wrong types |
| 401 Unauthorized / 403 Forbidden | Not logged in / logged in but not allowed |
| 404 Not Found | The resource does not exist |
| 409 Conflict | Duplicate, for example an email already registered |
| 422 Unprocessable Entity | Valid JSON, but the values break a rule (amount <= 0). Some teams use 400 for this too; be consistent. |
| 500 Internal Server Error | Your bug or a database failure. Do not leak details to the client. |
Two small helpers
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})
}A store behind an interface
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 with validation
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
}Testing handlers with httptest
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)
}
}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
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.
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":"..."}Practice (0/4 done)
A client creates a new order successfully. Which status should the API return?
The JSON is valid but amount_paise is -50. Which status fits best?
The database is down when a client calls GET /v1/expenses/5. What should the client see?
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
Read more (free)
Related help
Before this, learn
This topic builds on these. If something here feels new, open them first.
Related topics
- Middleware: logging, recovery and request IDsBuilding a REST API with a database · same module
- Databases with database/sqlBuilding a REST API with a database · same module
- Configuration, project structure and graceful shutdownBuilding a REST API with a database · same module
- REST APIs and JSONComputer Networking (CCNA level) · Automation, SDN and cloud networking
- What an API is and how REST APIs are designedBackend Development · APIs and request handling
- Routing, model binding, validation and status codesC# and .NET · Web APIs with ASP.NET Core and EF Core
Questions people ask about this
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.
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.
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
- 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.
- 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.
- Then the next module, CLI tools and Go in cloud and DevOps. 46 topics left in the course before the final exam.