Updated Jun 23, 2026

Building a REST API

This is the phase where it all comes together. For five phases you've collected the pieces — the engine and its routes, route groups, binding JSON onto structs, validation tags, c.JSON and status codes, middleware. None of those were ends in themselves. They were parts for this: a real REST API you can hit with curl and watch behave like the services you'll build at work.

We're going to grow the tasks API from "a few scattered handlers" into one complete, coherent resource. By the end you'll have create, read, update, and delete all wired up — and, more importantly, a mental model that makes the next resource you build feel like filling in a template.

The mental model: a resource is five handlers over one collection

💡 A REST resource is a collection of things plus the five standard operations you can do to it. For our tasks, that's: list them all, get one by id, create a new one, update an existing one, and delete one. That's the whole shape. Five handlers, one collection. Every resource you ever build — users, orders, invoices, comments — is the same five verbs over a different noun.

Those five operations map onto HTTP methods and paths so predictably that the mapping is practically a law:

Operation Method & path Success status
List all GET /tasks 200 OK
Get one GET /tasks/:id 200 OK (or 404)
Create POST /tasks 201 Created
Update PUT /tasks/:id 200 OK (or 404)
Delete DELETE /tasks/:id 204 No Content (or 404)

Notice the symmetry: the collection (/tasks) is where you list and create; a single item (/tasks/:id) is where you get, update, and delete. Once you see that, you're not memorizing five unrelated functions — you're filling in a known grid. We'll state the grid, then build it cell by cell.

The store: shared state, guarded

Before handlers, we need somewhere to keep tasks. We'll use an in-memory store — a plain Go map behind a counter. But there's a trap here that catches people who came from single-threaded backgrounds, so let's name it before we write the code.

⚠️ Gin handles requests concurrently. Every incoming request runs its handler in its own goroutine, and two of them can hit your map at the exact same moment. Go maps are not safe for concurrent read+write — a concurrent map write will crash your program with a fatal error: concurrent map writes (and it won't be a recoverable panic; it kills the process). Any state shared across requests must be guarded. We'll use a sync.RWMutex: many readers at once, one writer alone.

package main

import (
	"net/http"
	"sync"

	"github.com/gin-gonic/gin"
)

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

type store struct {
	mu     sync.RWMutex
	tasks  map[int]Task
	nextID int
}

func newStore() *store {
	return &store{
		tasks:  make(map[int]Task),
		nextID: 1,
	}
}

What just happened: Task is our stored model — ID, Title, Done, each with a json tag so it serializes with lowercase keys. The store bundles three things that belong together: the map[int]Task of tasks keyed by id, a nextID counter for assigning fresh ids, and an RWMutex that guards both. newStore hands back a ready-to-use store with an initialized map (a nil map panics on write) and the counter starting at 1. Every handler will reach for the mutex before touching tasks or nextID — that discipline is what keeps concurrent requests from corrupting each other.

📝 An RWMutex distinguishes read locks (RLock/RUnlock) from write locks (Lock/Unlock). Reads can overlap each other freely; a write blocks everyone until it's done. For a read-heavy API that's a nice fit. If this feels like overkill for now, a plain sync.Mutex (one lock for everything) would also be correct — just less concurrent on reads.

The five handlers

We'll hang the handlers as methods on *store, so each one has direct access to the map and the lock. Method-on-store keeps the wiring tidy and means we don't reach for package-level globals. Here they are, one per operation.

List — GET /tasks

func (s *store) list(c *gin.Context) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	out := make([]Task, 0, len(s.tasks))
	for _, t := range s.tasks {
		out = append(out, t)
	}
	c.JSON(http.StatusOK, out)
}

What just happened: We took a read lock (RLock) because we're only looking, defer-ing the unlock so it releases no matter how the function exits. We copy the map's values into a slice and return it with 200. The make([]Task, 0, ...) detail matters: an empty slice serializes to [], but a nil slice serializes to null — and clients much prefer an empty array to a surprise null. Pre-sizing with len(s.tasks) is a small efficiency, not a requirement.

Get one — GET /tasks/:id

func (s *store) getOne(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "id must be an integer"})
		return
	}

	s.mu.RLock()
	t, ok := s.tasks[id]
	s.mu.RUnlock()

	if !ok {
		c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
		return
	}
	c.JSON(http.StatusOK, t)
}

What just happened: We pulled :id from the path with c.Param("id") and converted it with strconv.Atoi — a non-numeric id like /tasks/abc fails the conversion and earns a 400 before we ever touch the store. Then a quick read-locked map lookup. The t, ok := s.tasks[id] comma-ok idiom is the whole game: ok is false when the key is absent, which is exactly our 404 case. Found means 200 with the task. (You could bind :id with ShouldBindUri as in Phase 3; strconv.Atoi is the lighter-weight choice for a single param.)

Create — POST /tasks

type CreateTask struct {
	Title string `json:"title" binding:"required,min=1,max=120"`
	Done  bool   `json:"done"`
}

func (s *store) create(c *gin.Context) {
	var in CreateTask
	if err := c.ShouldBindJSON(&in); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	s.mu.Lock()
	id := s.nextID
	s.nextID++
	t := Task{ID: id, Title: in.Title, Done: in.Done}
	s.tasks[id] = t
	s.mu.Unlock()

	c.JSON(http.StatusCreated, t)
}

What just happened: This is Phase 3's binding plus Phase 4's responses, fused. We bind onto a separate CreateTask input struct — the client doesn't get to set the id, so the input contract differs from the stored Task. Binding fails on bad input and returns 400 with the validator's message. On success we take a write lock (Lock, not RLock — we're mutating), grab and bump nextID, build the Task, and store it. Crucially, both the id-bump and the map-write happen inside one lock, so two simultaneous creates can never grab the same id. We return 201 Created with the new task, id and all, so the client learns what id it got.

Update — PUT /tasks/:id

func (s *store) update(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "id must be an integer"})
		return
	}

	var in CreateTask
	if err := c.ShouldBindJSON(&in); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	if _, ok := s.tasks[id]; !ok {
		c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
		return
	}
	t := Task{ID: id, Title: in.Title, Done: in.Done}
	s.tasks[id] = t
	c.JSON(http.StatusOK, t)
}

What just happened: PUT is "replace the whole thing at this id," so we do both jobs the create and get-one did: parse :id, then bind the new body. We reuse CreateTask as the input shape since a full replace wants the same fields. Under a write lock, we check the task exists — 404 if it doesn't, because PUT to a missing id is a not-found, not a silent create here — then overwrite it with a fresh Task carrying the original id. We return the updated task with 200. Note the defer s.mu.Unlock() this time: with two early-return paths inside the lock, defer is the safe way to guarantee the unlock fires on every branch.

Delete — DELETE /tasks/:id

func (s *store) remove(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "id must be an integer"})
		return
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	if _, ok := s.tasks[id]; !ok {
		c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
		return
	}
	delete(s.tasks, id)
	c.Status(http.StatusNoContent)
}

What just happened: We parse and validate the id, take a write lock, and confirm the task exists — 404 if not, so a delete tells you honestly whether there was anything to delete. If it's there, Go's built-in delete removes the key, and we reply 204 No Content. We use c.Status rather than c.JSON because 204 means "success, and there's deliberately no body" — sending JSON with a 204 is contradictory. (We named the method remove, not delete, because delete is a Go built-in and shadowing it would be asking for confusion.)

Wiring it to a route group

Handlers do nothing until they're registered. Here's main, mapping each handler to its method and path inside a versioned group — the /api/v1 prefix from Phase 2, so a future /api/v2 can live alongside it without breaking clients.

func main() {
	r := gin.Default()
	s := newStore()

	v1 := r.Group("/api/v1")
	{
		v1.GET("/tasks", s.list)
		v1.POST("/tasks", s.create)
		v1.GET("/tasks/:id", s.getOne)
		v1.PUT("/tasks/:id", s.update)
		v1.DELETE("/tasks/:id", s.remove)
	}

	r.Run(":8080")
}

What just happened: gin.Default() gives us an engine with the Logger and Recovery middleware from Phase 5 already attached. We create one store and share it across all handlers — that single shared state is exactly why the mutex earned its keep. r.Group("/api/v1") returns a group whose routes all carry the /api/v1 prefix; the { } braces are just a Go block for visual grouping (they have no special meaning to Gin, but they read nicely). Each v1.METHOD(path, handler) call binds one cell of our five-cell grid. r.Run(":8080") starts serving. That's the entire API — five lines of routing over a store and five handlers.

📝 The full paths are /api/v1/tasks and /api/v1/tasks/:id. Gin routes GET /api/v1/tasks and GET /api/v1/tasks/:id to different handlers even though they share a prefix, because the trailing /:id segment distinguishes them — that's the router doing exactly what Phase 2 promised.

Driving it with curl

Theory's done. Let's hit the running server and watch the grid behave. Start it with go run ., then in another terminal:

Create a task:

curl -s -X POST localhost:8080/api/v1/tasks \
  -H 'Content-Type: application/json' \
  -d '{"title": "write the phase 6 guide"}'
{"id":1,"title":"write the phase 6 guide","done":false}

What just happened: We POSTed a JSON body with just a title. Binding filled Title, left Done at its zero value (false), the store assigned id: 1, and we got 201 with the created task — including the id we now know to use for the next calls.

Create another, then list them all:

curl -s -X POST localhost:8080/api/v1/tasks \
  -H 'Content-Type: application/json' \
  -d '{"title": "ship it", "done": true}'

curl -s localhost:8080/api/v1/tasks
[{"id":1,"title":"write the phase 6 guide","done":false},{"id":2,"title":"ship it","done":true}]

What just happened: The second create got id: 2 from the counter. The GET /api/v1/tasks returned both as a JSON array with 200. (Map iteration order in Go is randomized, so the order across calls isn't guaranteed — if you need a stable order, sort the slice before returning it.)

Get one by id, then delete it:

curl -s localhost:8080/api/v1/tasks/1

curl -s -i -X DELETE localhost:8080/api/v1/tasks/1
{"id":1,"title":"write the phase 6 guide","done":false}
HTTP/1.1 204 No Content

What just happened: The GET /tasks/1 returned task 1 with 200. The DELETE /tasks/1 returned 204 with an empty body — we used -i to show the status line, since there's no body to print. Ask for it again with curl localhost:8080/api/v1/tasks/1 now and you'll get 404 {"error":"task not found"}, because it's gone.

The store is a stand-in

💡 Look back at the five handlers and notice what they don't depend on: nothing in them cares that the data lives in a map. They take input, validate it, call s.something(id), and shape a response. That map is a placeholder for a real database. When you later swap it for GORM talking to Postgres, the handler bodies barely change — s.tasks[id] becomes db.First(&task, id), s.tasks[id] = t becomes db.Save(&t), and the RWMutex disappears entirely because the database handles concurrency for you. The routing, binding, validation, status codes, and response shapes — everything this phase built — stay exactly as they are. That's the payoff of keeping the store behind a small interface in your head: the web layer and the data layer are separable.

Recap

  • A resource is five handlers over one collection: list (GET /tasks), get-one (GET /tasks/:id), create (POST), update (PUT), delete (DELETE) — a grid you fill in, not five unrelated functions.
  • Shared state must be guarded: Gin runs handlers concurrently, so any state touched by multiple requests needs a sync.Mutex/RWMutex — read-lock for lookups, write-lock for mutations — or your map will crash the process.
  • Match status codes to operations: 200 for reads and updates, 201 for create (return the new item with its id), 204 with no body for delete, 404 when an id isn't found, 400 when input is bad.
  • Keep input and stored models separate: bind onto a CreateTask struct so the client can't set the id; the stored Task carries the id the server assigns.
  • The store is a database stand-in: handlers depend on operations, not on the map — swap in GORM later and the routing, binding, and responses stay put.

Quick check

[
  {
    "q": "Why does the in-memory store need a sync.Mutex (or RWMutex)?",
    "choices": ["To make handlers run faster", "Because Gin handles requests concurrently and concurrent map read+write crashes the program", "Because Go maps require a lock to be created", "To enable JSON serialization of the map"],
    "answer": 1,
    "explain": "Gin runs each request in its own goroutine. Two goroutines writing the same map concurrently triggers a fatal 'concurrent map writes' error, so shared state must be guarded."
  },
  {
    "q": "Which status code does the create handler (POST /tasks) return on success, and why?",
    "choices": ["200 OK, because the request succeeded", "204 No Content, because nothing was returned", "201 Created, because a new resource was made and the new task (with its id) is returned", "302 Found, to redirect to the new task"],
    "answer": 2,
    "explain": "201 Created is the standard for a successful POST that makes a new resource, and returning the created task lets the client learn the assigned id."
  },
  {
    "q": "Why does the delete handler use c.Status(http.StatusNoContent) instead of c.JSON?",
    "choices": ["Because c.JSON does not support 204", "Because 204 means success with deliberately no body, so sending JSON would contradict it", "Because delete is faster without serialization", "Because the deleted task must not be revealed"],
    "answer": 1,
    "explain": "204 No Content signals success with no response body. Attaching a JSON body to a 204 is contradictory, so c.Status is the right call."
  }
]

← Phase 5: Middleware · Guide overview · Phase 7: Error Handling & Project Structure →

Check your understanding

1. Why does the in-memory store need a sync.Mutex (or RWMutex)?

2. Which status code does the create handler (POST /tasks) return on success, and why?

3. Why does the delete handler use c.Status(http.StatusNoContent) instead of c.JSON?

Was this page helpful?