Build a REST API Server in Go
Build a bookmark manager REST API using only Go's standard library.
Introduction
30 Second Summary
Every web app you use talks to a backend server. When you scroll social media, save a bookmark, or search for a product, your device sends a request to a server that processes it and sends back a response.
In this project, you will build a REST API server in Go from scratch using only the standard library. You will create, read, update, and delete bookmarks through HTTP endpoints, just like a real backend service.
What You'll Build
A bookmark manager API called linkbox that handles full CRUD operations, parses JSON requests, and logs every incoming request through a middleware layer.
By the end of this project, you'll have:
- A running REST API with full CRUD endpoints for managing bookmarks using Go's built-in HTTP router.
- Practical experience with JSON encoding/decoding, path parameter extraction, and proper HTTP status codes.
- A logging middleware and thread-safe in-memory data store that mirror how production Go services are structured.
- Secret Mission: Add a search endpoint with query parameters and a tag filtering system.
Are there any prerequisites?
You should have basic programming experience in any language (variables, functions, loops). No prior Go experience is needed. This project starts from installation and builds up one concept at a time.
Before We Start
Before We Start...
Install Go and Set Up the Project
Every Go backend starts with a working Go installation and a project directory.
In this step, you'll install Go 1.26.3 on your Linux machine, create a project directory called linkbox, and write a minimal HTTP server to confirm everything works.
In this step, get ready to:
- Install Go 1.26.3 on Linux.
- Create the linkbox project directory and initialise a Go module.
- Write a minimal HTTP server and run it.
Download and install Go 1.26.3
Go is the programming language you'll use to build your API server. You need version 1.26.3, which includes built-in HTTP routing features that make building APIs much simpler.
- Check if Go is already installed by running this command in your terminal:
go version
✔️ I see go1.26.3
You already have the right version of Go installed. Skip ahead to the next substep where you'll create your project directory.
ⓧ I see an older version
You have an older version of Go that doesn't include the routing features this project uses. You'll need to replace it with version 1.26.3.
- Download the Go 1.26.3 tarball by running:
wget https://go.dev/dl/go1.26.3.linux-amd64.tar.gz
- Remove your existing Go installation and extract the new version by running:
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.26.3.linux-amd64.tar.gz
- Make sure Go is on your PATH. Add this line to your $HOME/.profile file:
echo 'export PATH=$PATH:/usr/local/go/bin' >> $HOME/.profile
- Apply the change to your current session by running:
source $HOME/.profile
ⓧ Command not found
Go isn't installed yet. Follow these steps to install it from the official tarball.
- Download the Go 1.26.3 tarball by running:
wget https://go.dev/dl/go1.26.3.linux-amd64.tar.gz
- Extract the tarball into /usr/local by running:
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.26.3.linux-amd64.tar.gz
What does this command do?
The first part removes any pre-existing Go folder (in case a partial install exists). The second part extracts the downloaded archive into /usr/local/go.
- Add Go to your PATH by appending this line to your $HOME/.profile file:
echo 'export PATH=$PATH:/usr/local/go/bin' >> $HOME/.profile
- Apply the change to your current terminal session by running:
source $HOME/.profile
Why do I need to source my profile?
Changes to your profile file only apply to new terminal sessions. Running source reloads the file so your current session picks up the new PATH immediately.
Verify the installation
- Confirm Go is installed correctly by running:
go version
You should see go version go1.26.3 linux/amd64 printed in your terminal.
Still seeing command not found?
Make sure you ran source $HOME/.profile after editing the file. If the issue persists, check that /usr/local/go/bin actually exists.
help me debug why go version says command not found after installing Go
Create your project directory and initial server
With Go installed, you'll now create a project directory and write a minimal HTTP server to confirm your setup works end to end.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir linkbox
cd linkbox
- Confirm the folder exists by running:
ls ~/Desktop
You should see linkbox in the output.
- Initialise a Go module by running:
go mod init linkbox
What is a Go module?
A Go module is a collection of Go packages stored together. The go mod init command creates a go.mod file that declares your module's name and the Go version it uses.
Every Go project needs a module. It's how Go tracks your dependencies and organises your code.
- Create the main.go file by running:
touch main.go
- Open main.go in your text editor and paste the following code:
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
// Register a handler for the root path
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "linkbox is running")
})
// Start the server on port 8080
log.Fatal(http.ListenAndServe(":8080", nil))
}
What does this code do?
- http.HandleFunc("/", ...) registers a function that runs whenever someone visits the root path of your server.
- fmt.Fprintln(w, "linkbox is running") writes a plain text response back to the client.
- http.ListenAndServe(":8080", nil) starts the server listening on port 8080. If it fails, log.Fatal prints the error and exits.
- Save main.go.
- Start your server by running:
go run main.go
You should not see any output yet. The server is now running and waiting for requests.
- Open a second terminal tab or window.
- Test your server by running:
curl http://localhost:8080/
You should see linkbox is running printed in your second terminal.
- Switch back to your first terminal and press Ctrl+C to stop the server.
Seeing an error when you run go run?
If you see a syntax error, make sure you copied the entire code block including the closing braces. Check that the file starts with package main on the very first line.
If you see "address already in use", another process is using port 8080. Stop it or wait a moment and try again.
help me fix my Go server that won't start
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
Here is the full go.mod file:
module linkbox
go 1.26.3
Here is the full main.go file:
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "linkbox is running")
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Your Go environment is ready and your server compiles and runs. Next up, you'll define the data model that your API will manage.
Define Your Data Model and In-Memory Store
Before your API can handle requests, it needs to know what a "bookmark" actually looks like in code. A clear data model gives every handler a shared vocabulary for creating, reading, and passing bookmarks around.
In this step you will define a Bookmark struct, build a thread-safe in-memory store to hold bookmarks, and add methods that let the rest of your code create, read, update, and delete them.
In this step, get ready to:
- Define a Bookmark struct with JSON struct tags.
- Create a Store struct with a mutex for thread safety.
- Add CRUD methods and verify the code compiles.
Define the Bookmark struct
Every bookmark your API manages needs a consistent shape. A Go struct defines exactly which fields a bookmark carries and how each field serialises to JSON.
- Replace the entire contents of main.go with the following package declaration, imports, and struct:
package main
import (
"log"
"net/http"
"sync"
"time"
)
// Bookmark represents a saved URL with metadata.
type Bookmark struct {
ID int `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
}
What does this code do?
The Bookmark struct defines five fields that describe a saved link: a numeric ID, the URL itself, a human-readable title, a slice of tags for categorisation, and a timestamp recording when it was created.
The backtick annotations (like `json:"id"`) are struct tags. They tell Go's JSON encoder which key name to use when converting the struct to JSON. Without them, the JSON keys would use the Go field names with capital letters.
The file will not compile yet because there is no main function. You will add that at the end of this step.
Create the Store struct and constructor
Go's HTTP server handles each request in its own goroutine. If two requests try to modify the same data at the same time, you get a data race. A sync.Mutex ensures only one goroutine touches the bookmark slice at a time.
- Add the following code below the Bookmark struct in main.go:
// Store provides thread-safe in-memory storage for bookmarks.
type Store struct {
mu sync.Mutex
bookmarks []Bookmark
nextID int
}
// NewStore creates a new empty Store.
func NewStore() *Store {
return &Store{
bookmarks: []Bookmark{},
nextID: 1,
}
}
What does this code do?
The Store struct bundles three things: a mutex (mu) that acts as a lock, a slice holding all bookmarks, and a nextID counter so every new bookmark gets a unique ID.
The NewStore() constructor returns a pointer to a ready-to-use Store. Starting nextID at 1 means the first bookmark you create will have ID 1.
Why in-memory storage instead of a database?
A database would add setup time and shift focus away from Go's HTTP patterns. In-memory storage with a mutex teaches the same concurrency safety concept while keeping the project focused on routing and JSON handling. The Store interface could easily be swapped for a database later.
The file still will not compile. The methods and main function come next.
Add CRUD methods and verify compilation
Your Store needs five methods so the rest of your code can interact with bookmarks without worrying about locking. Each method locks the mutex at the start and defers the unlock so it releases automatically when the function returns.
- Add the following five methods below the NewStore function in main.go:
// All returns all bookmarks.
func (s *Store) All() []Bookmark {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Bookmark{}, s.bookmarks...)
}
// GetByID returns a bookmark by ID, or false if not found.
func (s *Store) GetByID(id int) (Bookmark, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, b := range s.bookmarks {
if b.ID == id {
return b, true
}
}
return Bookmark{}, false
}
// Create adds a new bookmark and returns it with an assigned ID.
func (s *Store) Create(url, title string, tags []string) Bookmark {
s.mu.Lock()
defer s.mu.Unlock()
b := Bookmark{
ID: s.nextID,
URL: url,
Title: title,
Tags: tags,
CreatedAt: time.Now(),
}
s.nextID++
s.bookmarks = append(s.bookmarks, b)
return b
}
// Update modifies an existing bookmark. Returns false if not found.
func (s *Store) Update(id int, url, title string, tags []string) (Bookmark, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i, b := range s.bookmarks {
if b.ID == id {
s.bookmarks[i].URL = url
s.bookmarks[i].Title = title
s.bookmarks[i].Tags = tags
return s.bookmarks[i], true
}
}
return Bookmark{}, false
}
// Delete removes a bookmark by ID. Returns false if not found.
func (s *Store) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, b := range s.bookmarks {
if b.ID == id {
s.bookmarks = append(s.bookmarks[:i], s.bookmarks[i+1:]...)
return true
}
}
return false
}
What do these methods do?
- All() returns a copy of the bookmarks slice. The append([]Bookmark{}, ...) trick creates a fresh slice so callers cannot accidentally mutate the store's internal data.
- GetByID() loops through bookmarks looking for a matching ID. It returns the bookmark and a boolean indicating whether it was found.
- Create() assigns the next available ID, stamps the current time, appends the bookmark, then increments the counter for next time.
- Update() finds a bookmark by ID and overwrites its URL, Title, and Tags fields in place.
- Delete() removes a bookmark by re-slicing around the matched index.
Now you need a main function so Go can compile and run the file.
- Add the following main function at the bottom of main.go:
func main() {
_ = NewStore()
log.Fatal(http.ListenAndServe(":8080", nil))
}
What does this main function do?
It creates a Store (assigned to _ for now because no handlers use it yet) and starts an HTTP server on port 8080. The underscore tells Go you intentionally discard the value so the compiler does not complain about an unused variable.
- Save main.go.
- Verify the code compiles by running:
go run main.go
You should see the process start without any error messages. The server is now listening on port 8080. Press Ctrl+C to stop it.
Seeing a compile error?
- Check that your import block includes "sync" and "time". Missing imports cause "undefined" errors for types like sync.Mutex and time.Time.
- Make sure you removed the old "fmt" import. If no code uses fmt anymore, Go refuses to compile with it present.
- Confirm every opening brace { has a matching closing brace }. A mismatched brace produces unexpected errors on unrelated lines.
Help me fix a compile error in my Go linkbox project
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and that go run main.go starts without errors.
ⓧ I'd like to double check the full code
Here is the complete main.go at this point:
package main
import (
"log"
"net/http"
"sync"
"time"
)
// Bookmark represents a saved URL with metadata.
type Bookmark struct {
ID int `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
}
// Store provides thread-safe in-memory storage for bookmarks.
type Store struct {
mu sync.Mutex
bookmarks []Bookmark
nextID int
}
// NewStore creates a new empty Store.
func NewStore() *Store {
return &Store{
bookmarks: []Bookmark{},
nextID: 1,
}
}
// All returns all bookmarks.
func (s *Store) All() []Bookmark {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Bookmark{}, s.bookmarks...)
}
// GetByID returns a bookmark by ID, or false if not found.
func (s *Store) GetByID(id int) (Bookmark, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, b := range s.bookmarks {
if b.ID == id {
return b, true
}
}
return Bookmark{}, false
}
// Create adds a new bookmark and returns it with an assigned ID.
func (s *Store) Create(url, title string, tags []string) Bookmark {
s.mu.Lock()
defer s.mu.Unlock()
b := Bookmark{
ID: s.nextID,
URL: url,
Title: title,
Tags: tags,
CreatedAt: time.Now(),
}
s.nextID++
s.bookmarks = append(s.bookmarks, b)
return b
}
// Update modifies an existing bookmark. Returns false if not found.
func (s *Store) Update(id int, url, title string, tags []string) (Bookmark, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i, b := range s.bookmarks {
if b.ID == id {
s.bookmarks[i].URL = url
s.bookmarks[i].Title = title
s.bookmarks[i].Tags = tags
return s.bookmarks[i], true
}
}
return Bookmark{}, false
}
// Delete removes a bookmark by ID. Returns false if not found.
func (s *Store) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, b := range s.bookmarks {
if b.ID == id {
s.bookmarks = append(s.bookmarks[:i], s.bookmarks[i+1:]...)
return true
}
}
return false
}
func main() {
_ = NewStore()
log.Fatal(http.ListenAndServe(":8080", nil))
}
Your data model and storage layer are ready. Next up, you will wire these methods to HTTP handlers so your API can respond to real requests.
Build the CRUD Handlers
Your data layer is ready. The Store knows how to create, read, update, and delete bookmarks in memory.
Now you need to connect that data layer to HTTP. Each operation needs a handler function that receives an HTTP request, calls the right Store method, and writes back a JSON response. Go's ServeMux router lets you match requests by both method and path, so GET /bookmarks and POST /bookmarks can point to completely different handler functions.
In this step, get ready to:
- Write GET handlers to list all bookmarks and retrieve one by ID.
- Write a POST handler that decodes JSON and creates a bookmark.
- Write PUT and DELETE handlers and register all routes on a ServeMux.
Write the list and get-by-ID handlers
Every REST API needs read endpoints. You will write two GET handlers: one that returns all bookmarks and one that returns a single bookmark by its ID.
- In main.go, find the import block at the top of the file:
import (
"log"
"net/http"
"sync"
"time"
)
- Replace the entire import block with this updated version that adds the packages you need for JSON encoding, string conversion, and formatted output:
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"sync"
"time"
)
What are these new imports?
- encoding/json provides JSON encoding and decoding for request and response bodies.
- fmt gives you formatted printing for the startup message.
- strconv converts strings to integers, which you need for parsing ID path parameters.
Now you will replace the placeholder main() function with a real router and the first GET handler.
- Find the existing main() function at the bottom of main.go:
func main() {
_ = NewStore()
log.Fatal(http.ListenAndServe(":8080", nil))
}
- Replace the entire main() function with the following code that creates a router and registers the list-all-bookmarks handler:
func main() {
store := NewStore()
mux := http.NewServeMux()
// List all bookmarks
mux.HandleFunc("GET /bookmarks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(store.All())
})
addr := ":8080"
fmt.Printf("linkbox server starting on http://localhost%s\n", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
What does this code do?
- store := NewStore() creates the in-memory data store your handlers will read from and write to.
- mux := http.NewServeMux() creates Go's built-in HTTP router. You register route patterns on it.
- The pattern "GET /bookmarks" tells the router to only match GET requests to /bookmarks. Other methods like POST get routed elsewhere.
- json.NewEncoder(w).Encode(...) serializes the bookmarks slice directly to the response body as JSON.
- http.ListenAndServe(addr, mux) starts the server and passes all incoming requests through your mux router.
- Save main.go.
- Verify the code compiles by running:
go run main.go
You should see linkbox server starting on http://localhost:8080 printed in your terminal. Press Ctrl+C to stop the server.
Seeing a compile error?
- Check that you replaced the entire old main() function, including its closing brace.
- Make sure the import block includes encoding/json, fmt, and strconv. A missing import causes an "undefined" error.
Help me fix a compile error in my Go REST API handler code.
Now add the second GET handler that retrieves a single bookmark by its ID from the URL path.
- In main.go, find the line addr := ":8080" inside your main() function.
- Add the following handler directly above that line:
// Get a single bookmark by ID
mux.HandleFunc("GET /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "invalid bookmark ID", http.StatusBadRequest)
return
}
bookmark, found := store.GetByID(id)
if !found {
http.Error(w, "bookmark not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bookmark)
})
What does this code do?
- The pattern "{id}" in "GET /bookmarks/{id}" is a wildcard. It captures whatever the user puts after /bookmarks/ in the URL.
- r.PathValue("id") extracts that captured value as a string. Since IDs are integers, strconv.Atoi converts it.
- If the ID is not a valid number or the bookmark does not exist, the handler returns an error response with the appropriate HTTP status code.
- Save main.go and verify it compiles by running:
go run main.go
You should see the same linkbox server starting on http://localhost:8080 message. Press Ctrl+C to stop it.
Getting 'r.PathValue undefined'?
This method requires Go 1.22 or later. Confirm your version by running go version in your terminal. It should print go1.26.3 or higher.
Help me troubleshoot PathValue not being recognized in my Go project.
Write the create handler
Read endpoints are only half the story. Your API also needs to accept new bookmarks from clients. The POST handler decodes a JSON request body, validates the required fields, and returns the created bookmark with a 201 status code.
- In main.go, find the line addr := ":8080" inside your main() function.
- Add the following handler directly above that line:
// Create a new bookmark
mux.HandleFunc("POST /bookmarks", func(w http.ResponseWriter, r *http.Request) {
var input struct {
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
if input.URL == "" || input.Title == "" {
http.Error(w, "url and title are required", http.StatusBadRequest)
return
}
if input.Tags == nil {
input.Tags = []string{}
}
bookmark := store.Create(input.URL, input.Title, input.Tags)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(bookmark)
})
What does this code do?
- An anonymous input struct defines the shape of the expected JSON. Only the fields you list here get parsed from the request body.
- json.NewDecoder(r.Body).Decode(&input) reads the request body stream and populates the struct. If the body is not valid JSON, it returns an error.
- The validation check ensures url and title are not empty strings. Missing required fields get a 400 Bad Request response.
- w.WriteHeader(http.StatusCreated) sets the response to 201 instead of the default 200, which is the standard status code for "resource created".
- Save main.go and verify it compiles by running:
go run main.go
You should see the startup message. Press Ctrl+C to stop the server.
Seeing 'input.Tags undefined' or similar?
Make sure the anonymous struct fields use capital letters (URL, Title, Tags). Go only exports and decodes fields that start with an uppercase letter.
Help me fix a struct field error in my POST handler.
Write the update and delete handlers
The final CRUD operations let clients modify and remove bookmarks. The PUT handler follows the same pattern as POST (decode JSON, validate, act). The DELETE handler is simpler since it only needs the ID.
- In main.go, find the line addr := ":8080" inside your main() function.
- Add the update handler directly above that line:
// Update an existing bookmark
mux.HandleFunc("PUT /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "invalid bookmark ID", http.StatusBadRequest)
return
}
var input struct {
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
if input.URL == "" || input.Title == "" {
http.Error(w, "url and title are required", http.StatusBadRequest)
return
}
if input.Tags == nil {
input.Tags = []string{}
}
bookmark, found := store.Update(id, input.URL, input.Title, input.Tags)
if !found {
http.Error(w, "bookmark not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bookmark)
})
What does this code do?
The PUT handler combines the ID extraction from GET /bookmarks/{id} with the JSON decoding from POST. It extracts the ID from the path, decodes the new field values from the body, validates them, and calls store.Update(). If the bookmark does not exist, it returns a 404.
- Save main.go.
You won't run the server yet. The next chunk adds the final two routes.
- Still in main.go, find the line addr := ":8080" again.
- Add the delete handler and root endpoint directly above that line:
// Delete a bookmark
mux.HandleFunc("DELETE /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "invalid bookmark ID", http.StatusBadRequest)
return
}
if !store.Delete(id) {
http.Error(w, "bookmark not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
})
// Root endpoint
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"name": "linkbox", "version": "1.0.0"})
})
What does this code do?
- The DELETE handler extracts the ID, calls store.Delete(), and returns 204 No Content on success. A 204 means "done, no body to send back."
- The root endpoint "GET /{$}" matches only the exact path /. The {$} suffix prevents it from matching every path that starts with /. It returns a simple JSON object identifying the API.
- Save main.go and start the server by running:
go run main.go
You should see linkbox server starting on http://localhost:8080 printed in your terminal. Your API is now live with all five CRUD routes registered.
Server won't start or compile?
- If you see address already in use, a previous server process is still running. Press Ctrl+C in any terminal that shows the startup message, then try again.
- If you see a syntax error, check that every handler's closing }) is on its own line and that the final } of main() is present.
Help me debug why my Go linkbox server won't start.
Why method-matched patterns?
Before Go 1.22, ServeMux could only match paths. You had to check r.Method manually inside every handler. The new pattern syntax like "GET /bookmarks/{id}" handles both method and path matching for you.
If someone sends a POST to /bookmarks/{id}, the router automatically replies with 405 Method Not Allowed. You get correct HTTP behaviour without writing that logic yourself.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and that the server starts without errors.
ⓧ I'd like to double check the full code
Here is the complete main.go as it should look at the end of this step:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"sync"
"time"
)
// Bookmark represents a saved URL with metadata.
type Bookmark struct {
ID int `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
}
// Store provides thread-safe in-memory storage for bookmarks.
type Store struct {
mu sync.Mutex
bookmarks []Bookmark
nextID int
}
// NewStore creates a new empty Store.
func NewStore() *Store {
return &Store{
bookmarks: []Bookmark{},
nextID: 1,
}
}
// All returns all bookmarks.
func (s *Store) All() []Bookmark {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Bookmark{}, s.bookmarks...)
}
// GetByID returns a bookmark by ID, or false if not found.
func (s *Store) GetByID(id int) (Bookmark, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, b := range s.bookmarks {
if b.ID == id {
return b, true
}
}
return Bookmark{}, false
}
// Create adds a new bookmark and returns it with an assigned ID.
func (s *Store) Create(url, title string, tags []string) Bookmark {
s.mu.Lock()
defer s.mu.Unlock()
b := Bookmark{
ID: s.nextID,
URL: url,
Title: title,
Tags: tags,
CreatedAt: time.Now(),
}
s.nextID++
s.bookmarks = append(s.bookmarks, b)
return b
}
// Update modifies an existing bookmark. Returns false if not found.
func (s *Store) Update(id int, url, title string, tags []string) (Bookmark, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i, b := range s.bookmarks {
if b.ID == id {
s.bookmarks[i].URL = url
s.bookmarks[i].Title = title
s.bookmarks[i].Tags = tags
return s.bookmarks[i], true
}
}
return Bookmark{}, false
}
// Delete removes a bookmark by ID. Returns false if not found.
func (s *Store) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, b := range s.bookmarks {
if b.ID == id {
s.bookmarks = append(s.bookmarks[:i], s.bookmarks[i+1:]...)
return true
}
}
return false
}
func main() {
store := NewStore()
mux := http.NewServeMux()
// List all bookmarks
mux.HandleFunc("GET /bookmarks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(store.All())
})
// Get a single bookmark by ID
mux.HandleFunc("GET /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "invalid bookmark ID", http.StatusBadRequest)
return
}
bookmark, found := store.GetByID(id)
if !found {
http.Error(w, "bookmark not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bookmark)
})
// Create a new bookmark
mux.HandleFunc("POST /bookmarks", func(w http.ResponseWriter, r *http.Request) {
var input struct {
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
if input.URL == "" || input.Title == "" {
http.Error(w, "url and title are required", http.StatusBadRequest)
return
}
if input.Tags == nil {
input.Tags = []string{}
}
bookmark := store.Create(input.URL, input.Title, input.Tags)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(bookmark)
})
// Update an existing bookmark
mux.HandleFunc("PUT /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "invalid bookmark ID", http.StatusBadRequest)
return
}
var input struct {
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
if input.URL == "" || input.Title == "" {
http.Error(w, "url and title are required", http.StatusBadRequest)
return
}
if input.Tags == nil {
input.Tags = []string{}
}
bookmark, found := store.Update(id, input.URL, input.Title, input.Tags)
if !found {
http.Error(w, "bookmark not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bookmark)
})
// Delete a bookmark
mux.HandleFunc("DELETE /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "invalid bookmark ID", http.StatusBadRequest)
return
}
if !store.Delete(id) {
http.Error(w, "bookmark not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
})
// Root endpoint
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"name": "linkbox", "version": "1.0.0"})
})
addr := ":8080"
fmt.Printf("linkbox server starting on http://localhost%s\n", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
Your API handlers are wired up and the server starts cleanly. Next up, you will add proper response helpers and test every endpoint with real HTTP requests.
Add Error Handling and Response Helpers
Your handlers work, but they respond inconsistently. Some use http.Error which sends plain text, while others manually set headers and encode JSON. A client consuming your API gets a mix of formats depending on whether the request succeeded or failed.
In this step, you will create two small helper functions that standardise all responses as JSON. Then you will refactor every handler to use them and verify the full API works with curl.
In this step, get ready to:
- Create reusable response helper functions.
- Refactor all handlers to use consistent JSON responses and proper HTTP status codes.
- Test every CRUD endpoint with curl.
Create the writeJSON and writeError helpers
Every handler in your API needs to do the same three things when responding: set the Content-Type header, write a status code, and encode data as JSON. Repeating this logic in every handler creates room for mistakes. A single helper function eliminates that repetition.
- In main.go, find the closing brace of the Delete method (just above func main()).
- Add the following two helper functions between the Delete method and func main():
// writeJSON writes a JSON response with the given status code.
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Printf("Error encoding response: %v", err)
}
}
// writeError writes a JSON error response.
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
What does this code do?
These two functions handle all response formatting for your entire API.
- writeJSON sets the Content-Type header to application/json, writes the HTTP status code, then encodes the data as JSON. If encoding fails, it logs the error instead of crashing.
- writeError wraps writeJSON to send a JSON object like {"error": "message"}. This gives API clients a consistent format to parse errors from.
Why not just use http.Error?
Go's built-in http.Error sends plain text responses with a text/plain content type. API clients expect JSON for both successes and errors. Consistent JSON makes it easy for frontend apps to parse every response the same way.
- Save main.go.
Seeing a compile error?
- Make sure the two new functions are placed between the closing brace of the Delete method and func main(). Placing them inside another function causes a syntax error.
- Check that you have "log" in your import block. The writeJSON function uses log.Printf.
Help me fix a compile error in my Go response helper functions.
Refactor all handlers to use the helpers
Now that your helpers exist, you need to update every handler to use them. The pattern is straightforward: replace every http.Error(...) call with writeError(...), and replace every manual json.NewEncoder(w).Encode(...) with writeJSON(...).
- Replace your entire func main() function with the refactored version below:
func main() {
store := NewStore()
mux := http.NewServeMux()
// List all bookmarks
mux.HandleFunc("GET /bookmarks", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, store.All())
})
// Get a single bookmark by ID
mux.HandleFunc("GET /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid bookmark ID")
return
}
bookmark, found := store.GetByID(id)
if !found {
writeError(w, http.StatusNotFound, "bookmark not found")
return
}
writeJSON(w, http.StatusOK, bookmark)
})
// Create a new bookmark
mux.HandleFunc("POST /bookmarks", func(w http.ResponseWriter, r *http.Request) {
var input struct {
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if input.URL == "" || input.Title == "" {
writeError(w, http.StatusBadRequest, "url and title are required")
return
}
if input.Tags == nil {
input.Tags = []string{}
}
bookmark := store.Create(input.URL, input.Title, input.Tags)
writeJSON(w, http.StatusCreated, bookmark)
})
// Update an existing bookmark
mux.HandleFunc("PUT /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid bookmark ID")
return
}
var input struct {
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if input.URL == "" || input.Title == "" {
writeError(w, http.StatusBadRequest, "url and title are required")
return
}
if input.Tags == nil {
input.Tags = []string{}
}
bookmark, found := store.Update(id, input.URL, input.Title, input.Tags)
if !found {
writeError(w, http.StatusNotFound, "bookmark not found")
return
}
writeJSON(w, http.StatusOK, bookmark)
})
// Delete a bookmark
mux.HandleFunc("DELETE /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid bookmark ID")
return
}
if !store.Delete(id) {
writeError(w, http.StatusNotFound, "bookmark not found")
return
}
w.WriteHeader(http.StatusNoContent)
})
// Root endpoint
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"name": "linkbox",
"version": "1.0.0",
})
})
addr := ":8080"
fmt.Printf("linkbox server starting on http://localhost%s\n", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
What changed in the handlers?
- Every http.Error(w, "message", status) became writeError(w, status, "message"). Notice the argument order changed. The status code comes second, matching the pattern of writeJSON.
- Every manual w.Header().Set(...); json.NewEncoder(w).Encode(...) sequence became a single writeJSON(w, status, data) call.
- The DELETE handler still uses w.WriteHeader(http.StatusNoContent) directly because a 204 response has no body to encode.
What are the HTTP status codes?
- 200 OK means the request succeeded and the response body contains data.
- 201 Created means a new resource was successfully created.
- 204 No Content means the action succeeded but there is nothing to return.
- 400 Bad Request means the client sent something the server cannot process.
- 404 Not Found means the requested resource does not exist.
- Save main.go.
- Verify it compiles by running:
go run main.go
- You should see linkbox server starting on http://localhost:8080 in your terminal. Keep this running.
Server won't start?
- If you see an "address already in use" error, your previous server is still running. Press Ctrl+C in the terminal where it is running, then try again.
- If you see a syntax error, check that the writeJSON and writeError functions are outside of func main(), not inside it.
Help me fix my Go server startup error.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and that the server is running.
ⓧ I'd like to double check the full code
Here is the full main.go at this point. Compare yours against it:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"sync"
"time"
)
// Bookmark represents a saved URL with metadata.
type Bookmark struct {
ID int `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
}
// Store provides thread-safe in-memory storage for bookmarks.
type Store struct {
mu sync.Mutex
bookmarks []Bookmark
nextID int
}
// NewStore creates a new empty Store.
func NewStore() *Store {
return &Store{
bookmarks: []Bookmark{},
nextID: 1,
}
}
// All returns all bookmarks.
func (s *Store) All() []Bookmark {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Bookmark{}, s.bookmarks...)
}
// GetByID returns a bookmark by ID, or false if not found.
func (s *Store) GetByID(id int) (Bookmark, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, b := range s.bookmarks {
if b.ID == id {
return b, true
}
}
return Bookmark{}, false
}
// Create adds a new bookmark and returns it with an assigned ID.
func (s *Store) Create(url, title string, tags []string) Bookmark {
s.mu.Lock()
defer s.mu.Unlock()
b := Bookmark{
ID: s.nextID,
URL: url,
Title: title,
Tags: tags,
CreatedAt: time.Now(),
}
s.nextID++
s.bookmarks = append(s.bookmarks, b)
return b
}
// Update modifies an existing bookmark. Returns false if not found.
func (s *Store) Update(id int, url, title string, tags []string) (Bookmark, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i, b := range s.bookmarks {
if b.ID == id {
s.bookmarks[i].URL = url
s.bookmarks[i].Title = title
s.bookmarks[i].Tags = tags
return s.bookmarks[i], true
}
}
return Bookmark{}, false
}
// Delete removes a bookmark by ID. Returns false if not found.
func (s *Store) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, b := range s.bookmarks {
if b.ID == id {
s.bookmarks = append(s.bookmarks[:i], s.bookmarks[i+1:]...)
return true
}
}
return false
}
// writeJSON writes a JSON response with the given status code.
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Printf("Error encoding response: %v", err)
}
}
// writeError writes a JSON error response.
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func main() {
store := NewStore()
mux := http.NewServeMux()
// List all bookmarks
mux.HandleFunc("GET /bookmarks", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, store.All())
})
// Get a single bookmark by ID
mux.HandleFunc("GET /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid bookmark ID")
return
}
bookmark, found := store.GetByID(id)
if !found {
writeError(w, http.StatusNotFound, "bookmark not found")
return
}
writeJSON(w, http.StatusOK, bookmark)
})
// Create a new bookmark
mux.HandleFunc("POST /bookmarks", func(w http.ResponseWriter, r *http.Request) {
var input struct {
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if input.URL == "" || input.Title == "" {
writeError(w, http.StatusBadRequest, "url and title are required")
return
}
if input.Tags == nil {
input.Tags = []string{}
}
bookmark := store.Create(input.URL, input.Title, input.Tags)
writeJSON(w, http.StatusCreated, bookmark)
})
// Update an existing bookmark
mux.HandleFunc("PUT /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid bookmark ID")
return
}
var input struct {
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if input.URL == "" || input.Title == "" {
writeError(w, http.StatusBadRequest, "url and title are required")
return
}
if input.Tags == nil {
input.Tags = []string{}
}
bookmark, found := store.Update(id, input.URL, input.Title, input.Tags)
if !found {
writeError(w, http.StatusNotFound, "bookmark not found")
return
}
writeJSON(w, http.StatusOK, bookmark)
})
// Delete a bookmark
mux.HandleFunc("DELETE /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid bookmark ID")
return
}
if !store.Delete(id) {
writeError(w, http.StatusNotFound, "bookmark not found")
return
}
w.WriteHeader(http.StatusNoContent)
})
// Root endpoint
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"name": "linkbox",
"version": "1.0.0",
})
})
addr := ":8080"
fmt.Printf("linkbox server starting on http://localhost%s\n", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
Test all endpoints with curl
Your server is running, so now you can hit every endpoint and confirm it returns proper JSON with the correct status code. You will need a second terminal for this since the server occupies the first one.
- Open a new terminal window or tab.
- Create a bookmark by running:
curl -s -X POST http://localhost:8080/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://go.dev","title":"Go Documentation","tags":["golang","docs"]}'
- You should see a JSON response with the bookmark you just created, including an id of 1 and a created_at timestamp.
- List all bookmarks by running:
curl -s http://localhost:8080/bookmarks
- You should see a JSON array containing your bookmark.
- Get a single bookmark by ID by running:
curl -s http://localhost:8080/bookmarks/1
- You should see the same bookmark as a single JSON object.
- Update the bookmark by running:
curl -s -X PUT http://localhost:8080/bookmarks/1 \
-H "Content-Type: application/json" \
-d '{"url":"https://go.dev/doc","title":"Go Docs","tags":["golang","reference"]}'
- You should see the bookmark with the updated title, URL, and tags.
- Delete the bookmark by running:
curl -s -X DELETE http://localhost:8080/bookmarks/1
- You should see no output. A 204 No Content response has an empty body, which is the correct behaviour for a successful delete.
- Confirm the bookmark is gone by running:
curl -s http://localhost:8080/bookmarks/1
- You should see {"error":"bookmark not found"}. This confirms your error responses are now JSON instead of plain text.
What should I see in the server terminal?
The server terminal does not print request logs yet. That comes in the next step when you add middleware. For now, it only shows the startup message.
Getting "connection refused"?
- Make sure your server is still running in the other terminal. If you stopped it, run go run main.go again from your linkbox directory.
- Confirm you are using port 8080 in your curl commands, matching the address in your code.
Help me debug why curl cannot connect to my Go server.
Your API now responds with consistent JSON for every request, whether it succeeds or fails. Next up, you will add logging middleware so you can see every request hitting your server in real time.
Add Logging Middleware
You have a fully working REST API with proper error handling and consistent JSON responses. Every endpoint responds correctly, validates input, and returns meaningful status codes.
One thing is missing: visibility. Right now, when a request hits your server, you have no way of knowing what happened unless something breaks. In this step, you'll add logging middleware that records every request's method, path, and how long it took to process.
In this step, get ready to:
- Write a logging middleware function that wraps your handlers.
- Wire the middleware into your server and observe request logs.
- Compile the final server into a standalone binary.
Write the loggingMiddleware function
In Go, the standard middleware pattern is a function that accepts an http.Handler and returns a new http.Handler. The returned handler runs extra logic (like logging) before or after calling the original handler.
- In main.go, find the writeError function.
- Add the following loggingMiddleware function below it (above func main()):
// loggingMiddleware logs the method, path, and duration of each request.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
What does this code do?
This function takes your existing handler (next) and wraps it in a new handler that does three things:
- Records the current time before passing the request along.
- Calls next.ServeHTTP(w, r) to run the original handler as normal.
- After the handler finishes, logs the HTTP method, the URL path, and how long the whole thing took.
- Save main.go.
Why this pattern?
The func(http.Handler) http.Handler signature is the same middleware pattern used by every major Go framework (Gin, Echo, Chi). Learning it on the standard library means you already understand what those frameworks abstract away.
Wrap the ServeMux with middleware
The middleware function is ready, but it's not connected to your server yet. You need to pass your mux through loggingMiddleware so every incoming request gets logged.
- In main.go, scroll to the bottom of the main() function.
- Find this line:
log.Fatal(http.ListenAndServe(addr, mux))
- Replace mux with loggingMiddleware(mux), so the line now looks like this:
log.Fatal(http.ListenAndServe(addr, loggingMiddleware(mux)))
What changed?
Previously, http.ListenAndServe received mux directly. Now it receives the result of loggingMiddleware(mux), which is a new handler that logs every request before passing it to your router.
- Save main.go.
- Stop your running server with Ctrl+C, then restart it by running:
go run main.go
- In a second terminal, make a request to test the logging by running:
curl http://localhost:8080/bookmarks
Switch back to the terminal running your server. You should see a log line like this:
2026/05/29 12:00:00 GET /bookmarks 50.125µs
The exact timestamp and duration will differ, but the format shows the method, path, and how long the request took.
Not seeing any log output?
- Make sure you saved main.go after both edits (adding the function and changing the ListenAndServe line).
- Confirm you restarted the server with go run main.go after saving. Changes only take effect after a restart.
- Check that you're looking at the server's terminal, not the one where you ran curl.
Still stuck? Help me debug why my logging middleware isn't printing output.
Compile the final server into a standalone binary
So far you've been running your server with go run, which compiles and runs in one shot. For production, you compile a standalone binary that runs without the Go toolchain installed.
- Stop your running server with Ctrl+C.
- Compile your server into a binary called linkbox by running:
go build -o linkbox
What does this command do?
The go build command compiles your Go code into a native executable. The -o linkbox flag names the output file linkbox instead of using the default (the module name).
- Confirm the binary was created by running:
ls linkbox
You should see linkbox listed in the output.
- Run your compiled binary by running:
./linkbox
You should see the familiar startup message:
linkbox server starting on http://localhost:8080
- In your second terminal, test the binary by running:
curl http://localhost:8080/bookmarks
Switch back to the server terminal. You should see a log line printed with the method, path, and duration, just like before.
Permission denied when running ./linkbox?
- Run chmod +x linkbox to make the file executable, then try ./linkbox again.
- If go build itself failed, check for syntax errors in the output. A missing brace or typo in the middleware function is the most common cause.
Need a hand? Help me fix a permission or build error with my Go binary.
✔️ Awesome, I've got everything!
Great job. Double check you've saved main.go and your linkbox binary is working.
ⓧ I'd like to double check the full code
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"sync"
"time"
)
// Bookmark represents a saved URL with metadata.
type Bookmark struct {
ID int `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
}
// Store provides thread-safe in-memory storage for bookmarks.
type Store struct {
mu sync.Mutex
bookmarks []Bookmark
nextID int
}
// NewStore creates a new empty Store.
func NewStore() *Store {
return &Store{
bookmarks: []Bookmark{},
nextID: 1,
}
}
// All returns all bookmarks.
func (s *Store) All() []Bookmark {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Bookmark{}, s.bookmarks...)
}
// GetByID returns a bookmark by ID, or false if not found.
func (s *Store) GetByID(id int) (Bookmark, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, b := range s.bookmarks {
if b.ID == id {
return b, true
}
}
return Bookmark{}, false
}
// Create adds a new bookmark and returns it with an assigned ID.
func (s *Store) Create(url, title string, tags []string) Bookmark {
s.mu.Lock()
defer s.mu.Unlock()
b := Bookmark{
ID: s.nextID,
URL: url,
Title: title,
Tags: tags,
CreatedAt: time.Now(),
}
s.nextID++
s.bookmarks = append(s.bookmarks, b)
return b
}
// Update modifies an existing bookmark. Returns false if not found.
func (s *Store) Update(id int, url, title string, tags []string) (Bookmark, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i, b := range s.bookmarks {
if b.ID == id {
s.bookmarks[i].URL = url
s.bookmarks[i].Title = title
s.bookmarks[i].Tags = tags
return s.bookmarks[i], true
}
}
return Bookmark{}, false
}
// Delete removes a bookmark by ID. Returns false if not found.
func (s *Store) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, b := range s.bookmarks {
if b.ID == id {
s.bookmarks = append(s.bookmarks[:i], s.bookmarks[i+1:]...)
return true
}
}
return false
}
// writeJSON writes a JSON response with the given status code.
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Printf("Error encoding response: %v", err)
}
}
// writeError writes a JSON error response.
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
// loggingMiddleware logs the method, path, and duration of each request.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
func main() {
store := NewStore()
mux := http.NewServeMux()
// List all bookmarks
mux.HandleFunc("GET /bookmarks", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, store.All())
})
// Get a single bookmark by ID
mux.HandleFunc("GET /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid bookmark ID")
return
}
bookmark, found := store.GetByID(id)
if !found {
writeError(w, http.StatusNotFound, "bookmark not found")
return
}
writeJSON(w, http.StatusOK, bookmark)
})
// Create a new bookmark
mux.HandleFunc("POST /bookmarks", func(w http.ResponseWriter, r *http.Request) {
var input struct {
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if input.URL == "" || input.Title == "" {
writeError(w, http.StatusBadRequest, "url and title are required")
return
}
if input.Tags == nil {
input.Tags = []string{}
}
bookmark := store.Create(input.URL, input.Title, input.Tags)
writeJSON(w, http.StatusCreated, bookmark)
})
// Update an existing bookmark
mux.HandleFunc("PUT /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid bookmark ID")
return
}
var input struct {
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if input.URL == "" || input.Title == "" {
writeError(w, http.StatusBadRequest, "url and title are required")
return
}
if input.Tags == nil {
input.Tags = []string{}
}
bookmark, found := store.Update(id, input.URL, input.Title, input.Tags)
if !found {
writeError(w, http.StatusNotFound, "bookmark not found")
return
}
writeJSON(w, http.StatusOK, bookmark)
})
// Delete a bookmark
mux.HandleFunc("DELETE /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid bookmark ID")
return
}
if !store.Delete(id) {
writeError(w, http.StatusNotFound, "bookmark not found")
return
}
w.WriteHeader(http.StatusNoContent)
})
// Root endpoint
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"name": "linkbox",
"version": "1.0.0",
})
})
addr := ":8080"
fmt.Printf("linkbox server starting on http://localhost%s\n", addr)
log.Fatal(http.ListenAndServe(addr, loggingMiddleware(mux)))
}
Your API is complete. You've built a production-style REST server with routing, JSON handling, data management, and now observability through middleware. Next up is the Secret Mission, where you'll extend your API with search and tag filtering.
Secret mission
Add Search and Tag Filtering
Your API can store bookmarks, but finding them again is the hard part. Add search and filtering so users can locate exactly what they need without scrolling through everything.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine, so there are no ongoing cloud costs. Decide whether to keep your files and Go installation in place, or remove them entirely.
Resources you used:
- The linkbox project folder on your Desktop (contains main.go, go.mod, and the compiled linkbox binary).
- Go 1.26.3 installed at /usr/local/go.
- Go build and module caches stored locally.
Keep everything running
No action needed. Choose this if you plan to keep building on linkbox or start another Go project soon.
- Your linkbox folder stays on your Desktop, ready for you to extend.
- Go remains installed and available via go version in any terminal session.
- The compiled linkbox binary can be re-run at any time with ./linkbox from inside the project folder.
Pause - I'll come back to this later
Stop the running server to free up port 8080, but keep all files in place so you can resume later.
- If the linkbox server is running, press Ctrl+C in the terminal to stop it.
- Your project files and Go installation remain untouched.
Delete - I don't want to use this again
Remove all project files and optionally uninstall Go from your system.
- If the linkbox server is running, press Ctrl+C to stop it.
- Delete the project folder by running:
rm -rf ~/Desktop/linkbox
- Clear the Go build and module caches by running:
go clean -cache
go clean -modcache
- To uninstall Go entirely, remove the Go directory and clean up your PATH by running:
sudo rm -rf /usr/local/go
- Open $HOME/.profile in a text editor and remove the line that says export PATH=$PATH:/usr/local/go/bin.
- Confirm Go is removed by running:
go version
You should see a "command not found" message, confirming Go has been fully removed.
Nice Work!
Nice Work!
Nice work! You just built a production-style REST API in Go using nothing but the standard library.
You've learned how to:
- Built a full CRUD REST API with GET, POST, PUT, and DELETE endpoints using only Go's net/http standard library.
- Used Go 1.22+ routing enhancements with method matching and path wildcards to handle requests without any external framework.
- Implemented the middleware pattern used in real Go services, wrapping your handler with logging that tracks every request.
- Added search and tag filtering with query parameters to make your API more useful.
Ready to quiz yourself?