Build a Bookmark Manager REST API

Build a full CRUD REST API in Go using only the standard library and SQLite.

Introduction

30 Second Summary

Every time you save a link in your browser or a read-later app, a backend service is handling that request. It receives the data, validates it, and stores it in a database.

In this project, you will build a bookmark manager REST API from scratch in Go using only the standard library. You will create endpoints for full CRUD operations, backed by a SQLite database.

What You'll Build

A REST API that lets you save, retrieve, update, and delete web bookmarks, responding with JSON and using proper HTTP status codes for success and error cases.

By the end of this project, you'll have:

  • A Go module with a clean project structure separating HTTP handlers, database logic, and data models.
  • Full CRUD endpoints (create, list, get, update, delete) with proper HTTP methods and status codes.
  • Input validation and consistent JSON error responses that return clear messages for bad requests.
  • Secret Mission: Add tag-based filtering with a many-to-many database relationship and JOIN queries.

Are there any prerequisites?

This project assumes you are comfortable with basic Go syntax: variables, functions, structs, and error handling. You do not need prior experience building web servers or working with databases in Go.

Before We Start

Before We Start...

Set Up the Project

Every backend service starts the same way: a project folder, a dependency manager, and a minimal server that proves everything compiles.

In this step, you will initialize a Go module, pull in a SQLite driver, lay out a clean directory structure, and confirm the whole thing runs with a single curl command.

In this step, get ready to:
  • Initialize a Go module and install the SQLite driver.
  • Create the project directory structure.
  • Write and run a minimal health check server.
Initialize the Go module and install the SQLite driver

A Go module is the dependency management unit for your project. Initializing one creates a go.mod file that tracks your module name and any third-party packages you depend on.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir bookmarks
cd bookmarks
  • Confirm the folder exists by running:
ls ~/Desktop | grep bookmarks

You should see bookmarks in the output.

  • Initialize a Go module by running this command:
go mod init bookmarks

What does this command do?

This creates a go.mod file that declares your module path as bookmarks. Every Go project needs one. When you import your own packages later (like bookmarks/internal/store), Go resolves them relative to this module name.

  • Add the SQLite dependency by running this command:
go get modernc.org/sqlite@v1.51.0

Why this driver?

The modernc.org/sqlite package is a pure Go implementation of SQLite. It requires no C compiler, so go get is the only step needed to install it.

  • Verify the dependency was added by running:
cat go.mod

You should see modernc.org/sqlite v1.51.0 listed under a require block. You will also see several lines marked // indirect for transitive dependencies.

Seeing a network error?

The go get command downloads packages from the internet. If you see a timeout or connection error, check that you have a working internet connection and try again.

help me debug a network error when running go get

Create the project directory structure

Go projects organize code into packages by directory. You will use three directories that mirror how production Go services are laid out.

  • Create the three directories by running this command:
mkdir -p cmd/server internal/store internal/handler

What is this structure for?

  • cmd/server/ holds the entry point for your server binary.
  • internal/store/ holds the database layer (queries, connections).
  • internal/handler/ holds the HTTP handler logic (parsing requests, writing responses).

The internal/ directory is special in Go. Code inside it cannot be imported by other modules, which keeps your implementation private.

  • Create the placeholder files by running these commands:
echo 'package main' > cmd/server/main.go
echo 'package store' > internal/store/store.go
echo 'package handler' > internal/handler/handler.go
  • Confirm the structure looks right by running:
find . -type f | sort

You should see ./cmd/server/main.go, ./go.mod, ./go.sum, ./internal/handler/handler.go, and ./internal/store/store.go in the output.

Write and run a minimal health check server

Before building anything complex, you want proof that Go can compile your project and serve an HTTP response. A health check endpoint is the simplest version of that proof.

  • Open cmd/server/main.go in Neovim by running:
nvim cmd/server/main.go
  • Replace the entire contents of the file with the following code:
package main

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

func main() {
	fmt.Println("Server starting on :8080")
	// Register a health check endpoint that returns JSON
	http.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"status": "ok"}`))
	})
	// Start the server on port 8080 (blocks until stopped)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

What does this code do?

  • http.HandleFunc("GET /health", ...) registers a handler that only responds to GET requests at the /health path. This method-based routing pattern was introduced in Go 1.22.
  • w.Header().Set("Content-Type", "application/json") tells the client that the response body is JSON.
  • log.Fatal(http.ListenAndServe(":8080", nil)) starts the server on port 8080 and crashes the program if the server fails to start.
  • Save the file and exit Neovim by pressing Esc, then typing :wq and pressing Enter.
  • Start the server by running:
go run ./cmd/server/

You should see Server starting on :8080 printed in your terminal. The process will keep running until you stop it.

  • Open a second terminal window.
  • Test the health endpoint by running:
curl http://localhost:8080/health

You should see {"status": "ok"} printed as the response.

Seeing "address already in use"?

This means another process is already using port 8080. Find and kill it by running lsof -i :8080 to identify the process, then kill <PID> to stop it.

help me fix an address already in use error on port 8080

  • Return to the terminal running the server and press Ctrl+C to stop it.

✔️ Awesome, I've got everything!

Great. Make sure you have saved cmd/server/main.go and that your curl response matched.

ⓧ I'd like to double check the full code

Here is the complete cmd/server/main.go at this point:

package main

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

func main() {
	fmt.Println("Server starting on :8080")
	http.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"status": "ok"}`))
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Your project compiles, serves a response, and has a clean directory structure ready for real code. Next up, you will build the database layer that stores and retrieves bookmarks from SQLite.

Build the Database Layer

Your server is running and responds to health checks. Now it needs somewhere to store data.

Every bookmark the API receives needs to persist between restarts. In this step, you will build a database layer that opens a SQLite file, creates a table for bookmarks, and exposes methods to create, read, update, and delete rows.

In this step, get ready to:
  • Define the data model and initialize the SQLite database.
  • Write methods to insert and list bookmarks.
  • Write methods to get, update, and delete individual bookmarks.
Define the Bookmark struct and create the bookmarks table

Before writing any logic, you need two things: a Go struct that represents a bookmark in memory, and a function that opens the database file and creates the table on disk.

  • Open internal/store/store.go in Neovim and replace the placeholder content with the following code:
package store

import (
	"database/sql"
	"fmt"
	"time"

	_ "modernc.org/sqlite"
)

// Bookmark represents a single saved URL with metadata
type Bookmark struct {
	ID          int64     `json:"id"`
	URL         string    `json:"url"`
	Title       string    `json:"title"`
	Description string    `json:"description"`
	CreatedAt   time.Time `json:"created_at"`
}

// Store wraps the database connection
type Store struct {
	db *sql.DB
}

// New opens the SQLite database and creates the bookmarks table
func New(dbPath string) (*Store, error) {
	db, err := sql.Open("sqlite", dbPath)
	if err != nil {
		return nil, fmt.Errorf("opening database: %w", err)
	}

	_, err = db.Exec(`
		CREATE TABLE IF NOT EXISTS bookmarks (
			id INTEGER PRIMARY KEY AUTOINCREMENT,
			url TEXT NOT NULL,
			title TEXT NOT NULL,
			description TEXT NOT NULL DEFAULT '',
			created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
		)
	`)
	if err != nil {
		return nil, fmt.Errorf("creating table: %w", err)
	}

	return &Store{db: db}, nil
}

// Close shuts down the database connection
func (s *Store) Close() error {
	return s.db.Close()
}

What does this code do?

The blank import _ "modernc.org/sqlite" registers the SQLite driver with Go's database/sql package. You never call the package directly, but the driver must be loaded for sql.Open to recognize the "sqlite" driver name.

The Bookmark struct defines what a single row looks like in Go. The json:"..." tags tell the JSON encoder which field names to use when converting to JSON later.

The New function opens the database file and immediately runs CREATE TABLE IF NOT EXISTS. This means the table is created on first run and safely skipped on every run after that.

  • Save internal/store/store.go.
  • Confirm the code compiles by running:
go build ./internal/store/

You should see no output. No output from go build means the code compiled successfully.

Seeing a compilation error?

  • Check that your import paths match exactly. The blank import must be _ "modernc.org/sqlite" with the underscore and a space before the path.
  • Make sure go.mod lists modernc.org/sqlite as a dependency. If not, run go get modernc.org/sqlite@v1.51.0 to add it.

Help me fix a compilation error in my store.go file.

Implement Create and GetAll methods

The store needs methods to insert new bookmarks and retrieve all existing ones. These are the two operations that every list-based application needs first.

  • In internal/store/store.go, add the following Create method below the Close function:
func (s *Store) Create(b Bookmark) (Bookmark, error) {
	result, err := s.db.Exec(
		"INSERT INTO bookmarks (url, title, description) VALUES (?, ?, ?)",
		b.URL, b.Title, b.Description,
	)
	if err != nil {
		return Bookmark{}, fmt.Errorf("inserting bookmark: %w", err)
	}

	id, err := result.LastInsertId()
	if err != nil {
		return Bookmark{}, fmt.Errorf("getting last insert id: %w", err)
	}

	return s.GetByID(id)
}

What does this code do?

The ? placeholders prevent SQL injection. Go's database/sql package handles escaping the values safely.

After inserting, LastInsertId() grabs the auto-generated ID. Then Create calls GetByID to return the full bookmark (including the database-generated created_at timestamp). You will write GetByID in the next substep.

  • Add the GetAll method directly below Create:
func (s *Store) GetAll() ([]Bookmark, error) {
	rows, err := s.db.Query("SELECT id, url, title, description, created_at FROM bookmarks ORDER BY created_at DESC")
	if err != nil {
		return nil, fmt.Errorf("querying bookmarks: %w", err)
	}
	defer rows.Close()

	var bookmarks []Bookmark
	for rows.Next() {
		var b Bookmark
		err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.CreatedAt)
		if err != nil {
			return nil, fmt.Errorf("scanning bookmark: %w", err)
		}
		bookmarks = append(bookmarks, b)
	}

	if bookmarks == nil {
		bookmarks = []Bookmark{}
	}

	return bookmarks, rows.Err()
}

What does this code do?

The Query method returns a set of rows. You iterate with rows.Next() and scan each row's columns into a Bookmark struct. The defer rows.Close() ensures the database cursor is released even if an error occurs mid-loop.

The nil check at the bottom ensures the API returns an empty JSON array [] instead of null when no bookmarks exist.

  • Save internal/store/store.go.

The file will not compile yet because Create calls s.GetByID(id) which does not exist yet. You will add it next.

Implement GetByID, Update, and Delete methods

These three methods complete the full CRUD interface. Each one targets a single bookmark by its ID.

  • In internal/store/store.go, add the GetByID method below GetAll:
func (s *Store) GetByID(id int64) (Bookmark, error) {
	var b Bookmark
	err := s.db.QueryRow(
		"SELECT id, url, title, description, created_at FROM bookmarks WHERE id = ?", id,
	).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.CreatedAt)
	if err == sql.ErrNoRows {
		return Bookmark{}, fmt.Errorf("bookmark not found")
	}
	if err != nil {
		return Bookmark{}, fmt.Errorf("querying bookmark: %w", err)
	}
	return b, nil
}

What does this code do?

Unlike Query which returns many rows, QueryRow expects exactly one result. If no row matches the given ID, database/sql returns the sentinel error sql.ErrNoRows. You convert that into a human-readable "bookmark not found" message that the HTTP layer will use later.

  • Add the Update method below GetByID:
func (s *Store) Update(id int64, b Bookmark) (Bookmark, error) {
	result, err := s.db.Exec(
		"UPDATE bookmarks SET url = ?, title = ?, description = ? WHERE id = ?",
		b.URL, b.Title, b.Description, id,
	)
	if err != nil {
		return Bookmark{}, fmt.Errorf("updating bookmark: %w", err)
	}

	rows, err := result.RowsAffected()
	if err != nil {
		return Bookmark{}, fmt.Errorf("getting rows affected: %w", err)
	}
	if rows == 0 {
		return Bookmark{}, fmt.Errorf("bookmark not found")
	}

	return s.GetByID(id)
}

Why check RowsAffected?

An UPDATE that matches zero rows is not an error from SQLite's perspective. Checking RowsAffected() lets you detect when the caller passed a nonexistent ID and return a clear "not found" error instead of silently succeeding.

  • Add the Delete method below Update:
func (s *Store) Delete(id int64) error {
	result, err := s.db.Exec("DELETE FROM bookmarks WHERE id = ?", id)
	if err != nil {
		return fmt.Errorf("deleting bookmark: %w", err)
	}

	rows, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("getting rows affected: %w", err)
	}
	if rows == 0 {
		return fmt.Errorf("bookmark not found")
	}

	return nil
}

What does this code do?

Delete follows the same RowsAffected pattern as Update. If the ID does not exist, the caller gets a clear error. The method returns only an error because there is no meaningful data to return after a deletion.

  • Save internal/store/store.go.
  • Confirm the full file compiles by running:
go build ./internal/store/

No output means every method compiled cleanly.

Seeing an error about undefined methods?

  • If the error mentions s.GetByID as undefined, make sure the GetByID function is present in the file and its receiver is (s *Store).
  • Check that every method uses func (s *Store) as its receiver. A typo like func (s Store) (missing the pointer *) will not match.

Help me debug a compilation error in my store package.

Now verify the store actually works at runtime. You will temporarily replace cmd/server/main.go with a small test script, run it, then restore the original server code.

  • Open cmd/server/main.go and replace its entire contents with this temporary test:
package main

import (
	"fmt"
	"log"

	"bookmarks/internal/store"
)

func main() {
	// Open a test database file
	s, err := store.New("test.db")
	if err != nil {
		log.Fatal(err)
	}
	defer s.Close()

	// Insert a bookmark
	created, err := s.Create(store.Bookmark{
		URL:   "https://go.dev",
		Title: "Go Documentation",
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Created: %+v\n", created)

	// Retrieve all bookmarks
	all, err := s.GetAll()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("All bookmarks: %+v\n", all)
}
  • Run the test by executing:
go run ./cmd/server/

You should see output showing the created bookmark with its ID, URL, title, and a timestamp. The All bookmarks line should show the same bookmark in a slice.

  • Clean up the test database file by running:
rm test.db
  • Restore cmd/server/main.go back to the original health check server by replacing the test code with:
package main

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

func main() {
	fmt.Println("Server starting on :8080")
	http.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"status": "ok"}`))
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}
  • Save cmd/server/main.go.

Test printed an error instead of a bookmark?

  • If you see opening database in the error, the SQLite driver may not be registered. Confirm the blank import _ "modernc.org/sqlite" is present in internal/store/store.go.
  • If you see inserting bookmark in the error, check that your CREATE TABLE SQL matches exactly. A missing comma or mismatched column name will cause the table creation to fail silently (it succeeds but creates wrong columns).

Help me debug why my store test is failing.

✔️ Awesome, I've got everything!

Great. Make sure you have saved internal/store/store.go with all five methods and that cmd/server/main.go is back to the health check server.

ⓧ I'd like to double check the full code

Here is the complete internal/store/store.go file:

package store

import (
	"database/sql"
	"fmt"
	"time"

	_ "modernc.org/sqlite"
)

type Bookmark struct {
	ID          int64     `json:"id"`
	URL         string    `json:"url"`
	Title       string    `json:"title"`
	Description string    `json:"description"`
	CreatedAt   time.Time `json:"created_at"`
}

type Store struct {
	db *sql.DB
}

func New(dbPath string) (*Store, error) {
	db, err := sql.Open("sqlite", dbPath)
	if err != nil {
		return nil, fmt.Errorf("opening database: %w", err)
	}

	_, err = db.Exec(`
		CREATE TABLE IF NOT EXISTS bookmarks (
			id INTEGER PRIMARY KEY AUTOINCREMENT,
			url TEXT NOT NULL,
			title TEXT NOT NULL,
			description TEXT NOT NULL DEFAULT '',
			created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
		)
	`)
	if err != nil {
		return nil, fmt.Errorf("creating table: %w", err)
	}

	return &Store{db: db}, nil
}

func (s *Store) Close() error {
	return s.db.Close()
}

func (s *Store) Create(b Bookmark) (Bookmark, error) {
	result, err := s.db.Exec(
		"INSERT INTO bookmarks (url, title, description) VALUES (?, ?, ?)",
		b.URL, b.Title, b.Description,
	)
	if err != nil {
		return Bookmark{}, fmt.Errorf("inserting bookmark: %w", err)
	}

	id, err := result.LastInsertId()
	if err != nil {
		return Bookmark{}, fmt.Errorf("getting last insert id: %w", err)
	}

	return s.GetByID(id)
}

func (s *Store) GetAll() ([]Bookmark, error) {
	rows, err := s.db.Query("SELECT id, url, title, description, created_at FROM bookmarks ORDER BY created_at DESC")
	if err != nil {
		return nil, fmt.Errorf("querying bookmarks: %w", err)
	}
	defer rows.Close()

	var bookmarks []Bookmark
	for rows.Next() {
		var b Bookmark
		err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.CreatedAt)
		if err != nil {
			return nil, fmt.Errorf("scanning bookmark: %w", err)
		}
		bookmarks = append(bookmarks, b)
	}

	if bookmarks == nil {
		bookmarks = []Bookmark{}
	}

	return bookmarks, rows.Err()
}

func (s *Store) GetByID(id int64) (Bookmark, error) {
	var b Bookmark
	err := s.db.QueryRow(
		"SELECT id, url, title, description, created_at FROM bookmarks WHERE id = ?", id,
	).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.CreatedAt)
	if err == sql.ErrNoRows {
		return Bookmark{}, fmt.Errorf("bookmark not found")
	}
	if err != nil {
		return Bookmark{}, fmt.Errorf("querying bookmark: %w", err)
	}
	return b, nil
}

func (s *Store) Update(id int64, b Bookmark) (Bookmark, error) {
	result, err := s.db.Exec(
		"UPDATE bookmarks SET url = ?, title = ?, description = ? WHERE id = ?",
		b.URL, b.Title, b.Description, id,
	)
	if err != nil {
		return Bookmark{}, fmt.Errorf("updating bookmark: %w", err)
	}

	rows, err := result.RowsAffected()
	if err != nil {
		return Bookmark{}, fmt.Errorf("getting rows affected: %w", err)
	}
	if rows == 0 {
		return Bookmark{}, fmt.Errorf("bookmark not found")
	}

	return s.GetByID(id)
}

func (s *Store) Delete(id int64) error {
	result, err := s.db.Exec("DELETE FROM bookmarks WHERE id = ?", id)
	if err != nil {
		return fmt.Errorf("deleting bookmark: %w", err)
	}

	rows, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("getting rows affected: %w", err)
	}
	if rows == 0 {
		return fmt.Errorf("bookmark not found")
	}

	return nil
}

And here is cmd/server/main.go (restored to the health check server):

package main

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

func main() {
	fmt.Println("Server starting on :8080")
	http.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"status": "ok"}`))
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Your database layer is complete. Every CRUD operation is covered, and you have confirmed it works by inserting and retrieving a real bookmark. Next up, you will wire these store methods to HTTP handlers so the API can accept requests from the outside world.

Wire Up the HTTP Handlers

You have a database layer that can create, read, update, and delete bookmarks. Now you need to expose those operations over HTTP so any client can interact with your API using standard requests.

In this step, you will write the HTTP handlers that sit between incoming requests and your store. Each handler parses input, calls the appropriate store method, and returns a JSON response with the correct status code.

In this step, get ready to:
  • Create a handler struct and register all five CRUD routes.
  • Implement the create and list handlers with JSON parsing and validation.
  • Implement the get, update, and delete handlers with proper error responses.
Create a handler struct and register routes

The handler layer translates HTTP concerns (request bodies, status codes, path parameters) into calls to your store. By keeping this logic in its own package, you separate "what the network sees" from "what the database does."

  • Open internal/handler/handler.go in Neovim.
  • Replace the placeholder content with the following handler skeleton:
package handler

import (
	"encoding/json"
	"net/http"
	"net/url"
	"strconv"
	"strings"

	"bookmarks/internal/store"
)

// Handler holds a reference to the store for database access
type Handler struct {
	store *store.Store
}

// New creates a handler with the given store
func New(s *store.Store) *Handler {
	return &Handler{store: s}
}

// SetupRoutes registers all API endpoints on the given mux
func (h *Handler) SetupRoutes(mux *http.ServeMux) {
	mux.HandleFunc("POST /bookmarks", h.createBookmark)
	mux.HandleFunc("GET /bookmarks", h.listBookmarks)
	mux.HandleFunc("GET /bookmarks/{id}", h.getBookmark)
	mux.HandleFunc("PUT /bookmarks/{id}", h.updateBookmark)
	mux.HandleFunc("DELETE /bookmarks/{id}", h.deleteBookmark)
	mux.HandleFunc("GET /health", h.healthCheck)
}

// healthCheck responds with a simple OK status
func (h *Handler) healthCheck(w http.ResponseWriter, r *http.Request) {
	respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// respondJSON encodes data as JSON and writes it with the given status code
func respondJSON(w http.ResponseWriter, status int, data any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

// respondError sends a JSON error response with the given status and message
func respondError(w http.ResponseWriter, status int, message string) {
	respondJSON(w, status, map[string]string{"error": message})
}

What does this code do?

The Handler struct holds a pointer to the store so every handler method can access the database without global variables.

SetupRoutes uses Go 1.22+ method-based routing patterns like "POST /bookmarks". The mux only routes requests that match both the HTTP method and path. Wildcards like {id} capture path segments for extraction later.

The two helper functions (respondJSON and respondError) standardize every response. Every endpoint returns JSON with the correct Content-Type header.

Why use the standard library instead of a framework?

Frameworks like Gin or Echo add convenience, but they hide the mechanics of request handling. Go's standard library now includes method-based routing, which was the main reason developers reached for third-party routers.

Building directly on net/http teaches you the actual interface that every Go HTTP framework wraps. This makes debugging and switching frameworks much easier later.

  • Save internal/handler/handler.go.

This file will not compile yet because the handler methods (createBookmark, listBookmarks, etc.) are referenced in SetupRoutes but not defined yet. You will add them in the next two substeps.

Next, update cmd/server/main.go to use the store and handler packages instead of the inline health check.

  • Open cmd/server/main.go and replace its entire contents with the following:
package main

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

	"bookmarks/internal/handler"
	"bookmarks/internal/store"
)

func main() {
	// Open the SQLite database and create the bookmarks table
	s, err := store.New("bookmarks.db")
	if err != nil {
		log.Fatal(err)
	}
	defer s.Close()

	// Create the handler with access to the store
	h := handler.New(s)

	// Register all routes on a new mux
	mux := http.NewServeMux()
	h.SetupRoutes(mux)

	fmt.Println("Server starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", mux))
}

What changed in main.go?

  • store.New("bookmarks.db") opens the database and creates the table if it does not exist yet.
  • handler.New(s) creates a handler that can call store methods from any route.
  • Instead of the default mux (nil), you now pass your own http.NewServeMux() to ListenAndServe. This keeps route registration explicit and testable.
  • Save cmd/server/main.go.
Implement the create and list handlers

The create handler is the most complex because it must parse a JSON body, validate the input, and return the newly created resource. The list handler is simpler but establishes the pattern for returning collections.

  • In internal/handler/handler.go, add the createBookmark method after the healthCheck function:
func (h *Handler) createBookmark(w http.ResponseWriter, r *http.Request) {
	var input struct {
		URL         string `json:"url"`
		Title       string `json:"title"`
		Description string `json:"description"`
	}

	// Decode the JSON request body into the input struct
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		respondError(w, http.StatusBadRequest, "invalid JSON body")
		return
	}

	// Trim whitespace so blank-only fields don't pass validation
	input.URL = strings.TrimSpace(input.URL)
	input.Title = strings.TrimSpace(input.Title)

	// Validate required fields
	if input.URL == "" {
		respondError(w, http.StatusBadRequest, "url is required")
		return
	}
	if input.Title == "" {
		respondError(w, http.StatusBadRequest, "title is required")
		return
	}

	// Validate URL format
	if _, err := url.ParseRequestURI(input.URL); err != nil {
		respondError(w, http.StatusBadRequest, "invalid URL format")
		return
	}

	// Build a Bookmark and pass it to the store
	b := store.Bookmark{
		URL:         input.URL,
		Title:       input.Title,
		Description: input.Description,
	}

	created, err := h.store.Create(b)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to create bookmark")
		return
	}

	// Respond with 201 Created and the new bookmark
	respondJSON(w, http.StatusCreated, created)
}

What does this handler do?

  • json.NewDecoder(r.Body).Decode(&input) reads the request body and parses it as JSON into the input struct. If the body is not valid JSON, it immediately returns a 400 error.
  • strings.TrimSpace strips leading and trailing whitespace so a request body like {"url": " "} is caught by the empty-string check.
  • url.ParseRequestURI validates that the URL is structurally valid (has a scheme and host). It prevents garbage strings from ending up in the database.
  • The handler returns 201 Created on success, which is the standard HTTP status for resource creation.
  • Below createBookmark, add the listBookmarks method:
func (h *Handler) listBookmarks(w http.ResponseWriter, r *http.Request) {
	// Fetch all bookmarks from the store
	bookmarks, err := h.store.GetAll()
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to fetch bookmarks")
		return
	}

	// Respond with 200 OK and the full list as a JSON array
	respondJSON(w, http.StatusOK, bookmarks)
}

What does this handler do?

This handler calls store.GetAll() to retrieve every bookmark. The store already returns an empty slice (not nil) when no bookmarks exist, so the JSON response will always be a valid array ([] instead of null).

  • Save internal/handler/handler.go.
Implement get, update, and delete handlers

These three handlers all need to extract the bookmark ID from the URL path. Go's r.PathValue("id") reads the value captured by the {id} wildcard in your route patterns.

  • Below listBookmarks, add the getBookmark method:
func (h *Handler) getBookmark(w http.ResponseWriter, r *http.Request) {
	// Extract the {id} path parameter and convert to int64
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		respondError(w, http.StatusBadRequest, "invalid bookmark ID")
		return
	}

	// Look up the bookmark in the store
	bookmark, err := h.store.GetByID(id)
	if err != nil {
		respondError(w, http.StatusNotFound, "bookmark not found")
		return
	}

	respondJSON(w, http.StatusOK, bookmark)
}

What does this handler do?

  • r.PathValue("id") returns the raw string captured by {id} in the route pattern.
  • strconv.ParseInt converts it to an int64. If someone sends /bookmarks/abc, the parse fails and the handler returns a 400.
  • If the store returns an error (the ID does not exist), the handler responds with a 404 Not Found.
  • Below getBookmark, add the updateBookmark method:
func (h *Handler) updateBookmark(w http.ResponseWriter, r *http.Request) {
	// Extract and validate the bookmark ID
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		respondError(w, http.StatusBadRequest, "invalid bookmark ID")
		return
	}

	var input struct {
		URL         string `json:"url"`
		Title       string `json:"title"`
		Description string `json:"description"`
	}

	// Decode the request body
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		respondError(w, http.StatusBadRequest, "invalid JSON body")
		return
	}

	input.URL = strings.TrimSpace(input.URL)
	input.Title = strings.TrimSpace(input.Title)

	// Validate required fields
	if input.URL == "" {
		respondError(w, http.StatusBadRequest, "url is required")
		return
	}
	if input.Title == "" {
		respondError(w, http.StatusBadRequest, "title is required")
		return
	}

	// Validate URL format
	if _, err := url.ParseRequestURI(input.URL); err != nil {
		respondError(w, http.StatusBadRequest, "invalid URL format")
		return
	}

	b := store.Bookmark{
		URL:         input.URL,
		Title:       input.Title,
		Description: input.Description,
	}

	// Update in the store; returns error if ID not found
	updated, err := h.store.Update(id, b)
	if err != nil {
		respondError(w, http.StatusNotFound, "bookmark not found")
		return
	}

	respondJSON(w, http.StatusOK, updated)
}

What does this handler do?

The update handler combines the patterns from create (JSON parsing, validation) and get (ID extraction). It performs full replacement of the bookmark fields. If the ID does not exist, the store's Update method returns an error, which triggers a 404 response.

  • Below updateBookmark, add the deleteBookmark method:
func (h *Handler) deleteBookmark(w http.ResponseWriter, r *http.Request) {
	// Extract and validate the bookmark ID
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		respondError(w, http.StatusBadRequest, "invalid bookmark ID")
		return
	}

	// Delete from the store; returns error if ID not found
	err = h.store.Delete(id)
	if err != nil {
		respondError(w, http.StatusNotFound, "bookmark not found")
		return
	}

	// 204 No Content means success with no response body
	w.WriteHeader(http.StatusNoContent)
}

Why 204 No Content for delete?

When a resource is successfully deleted, there is nothing meaningful to return in the body. Status 204 signals success without sending any response data. This is a REST convention that most API consumers expect.

  • Save internal/handler/handler.go.

All handler methods are now defined. The project should compile successfully.

  • Start the server by running:
go run ./cmd/server/

You should see Server starting on :8080 printed in your terminal.

  • Open a second terminal and test the create endpoint by running:
curl -s -X POST http://localhost:8080/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"url": "https://go.dev", "title": "Go Homepage", "description": "Official Go site"}'

You should see a JSON response with the created bookmark, including an id and created_at field populated automatically.

  • Test the list endpoint by running:
curl -s http://localhost:8080/bookmarks

You should see a JSON array containing the bookmark you just created.

  • Test the get-by-ID endpoint by running:
curl -s http://localhost:8080/bookmarks/1

You should see the single bookmark with ID 1 returned as a JSON object.

  • Test the delete endpoint by running:
curl -s -o /dev/null -w "%{http_code}" -X DELETE http://localhost:8080/bookmarks/1

You should see 204 printed, confirming the delete succeeded with no content returned.

Server not starting?

  • If you see a compile error about undefined methods, make sure all five handler methods (createBookmark, listBookmarks, getBookmark, updateBookmark, deleteBookmark) are defined in handler.go with the receiver (h *Handler).
  • If port 8080 is already in use, stop the previous server process first (Ctrl+C in the terminal where it was running).
  • If imports show as unused, check that each handler method uses the packages listed in the import block. Unused imports cause compile errors in Go.

Help me debug why my Go bookmark server won't compile or start.

✔️ Awesome, I've got everything!

Great. Double check you have saved both internal/handler/handler.go and cmd/server/main.go.

ⓧ I'd like to double check the full code

Here is the complete internal/handler/handler.go:

package handler

import (
	"encoding/json"
	"net/http"
	"net/url"
	"strconv"
	"strings"

	"bookmarks/internal/store"
)

type Handler struct {
	store *store.Store
}

func New(s *store.Store) *Handler {
	return &Handler{store: s}
}

func (h *Handler) SetupRoutes(mux *http.ServeMux) {
	mux.HandleFunc("POST /bookmarks", h.createBookmark)
	mux.HandleFunc("GET /bookmarks", h.listBookmarks)
	mux.HandleFunc("GET /bookmarks/{id}", h.getBookmark)
	mux.HandleFunc("PUT /bookmarks/{id}", h.updateBookmark)
	mux.HandleFunc("DELETE /bookmarks/{id}", h.deleteBookmark)
	mux.HandleFunc("GET /health", h.healthCheck)
}

func (h *Handler) healthCheck(w http.ResponseWriter, r *http.Request) {
	respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func (h *Handler) createBookmark(w http.ResponseWriter, r *http.Request) {
	var input struct {
		URL         string `json:"url"`
		Title       string `json:"title"`
		Description string `json:"description"`
	}

	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		respondError(w, http.StatusBadRequest, "invalid JSON body")
		return
	}

	input.URL = strings.TrimSpace(input.URL)
	input.Title = strings.TrimSpace(input.Title)

	if input.URL == "" {
		respondError(w, http.StatusBadRequest, "url is required")
		return
	}
	if input.Title == "" {
		respondError(w, http.StatusBadRequest, "title is required")
		return
	}

	if _, err := url.ParseRequestURI(input.URL); err != nil {
		respondError(w, http.StatusBadRequest, "invalid URL format")
		return
	}

	b := store.Bookmark{
		URL:         input.URL,
		Title:       input.Title,
		Description: input.Description,
	}

	created, err := h.store.Create(b)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to create bookmark")
		return
	}

	respondJSON(w, http.StatusCreated, created)
}

func (h *Handler) listBookmarks(w http.ResponseWriter, r *http.Request) {
	bookmarks, err := h.store.GetAll()
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to fetch bookmarks")
		return
	}

	respondJSON(w, http.StatusOK, bookmarks)
}

func (h *Handler) getBookmark(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		respondError(w, http.StatusBadRequest, "invalid bookmark ID")
		return
	}

	bookmark, err := h.store.GetByID(id)
	if err != nil {
		respondError(w, http.StatusNotFound, "bookmark not found")
		return
	}

	respondJSON(w, http.StatusOK, bookmark)
}

func (h *Handler) updateBookmark(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		respondError(w, http.StatusBadRequest, "invalid bookmark ID")
		return
	}

	var input struct {
		URL         string `json:"url"`
		Title       string `json:"title"`
		Description string `json:"description"`
	}

	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		respondError(w, http.StatusBadRequest, "invalid JSON body")
		return
	}

	input.URL = strings.TrimSpace(input.URL)
	input.Title = strings.TrimSpace(input.Title)

	if input.URL == "" {
		respondError(w, http.StatusBadRequest, "url is required")
		return
	}
	if input.Title == "" {
		respondError(w, http.StatusBadRequest, "title is required")
		return
	}

	if _, err := url.ParseRequestURI(input.URL); err != nil {
		respondError(w, http.StatusBadRequest, "invalid URL format")
		return
	}

	b := store.Bookmark{
		URL:         input.URL,
		Title:       input.Title,
		Description: input.Description,
	}

	updated, err := h.store.Update(id, b)
	if err != nil {
		respondError(w, http.StatusNotFound, "bookmark not found")
		return
	}

	respondJSON(w, http.StatusOK, updated)
}

func (h *Handler) deleteBookmark(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		respondError(w, http.StatusBadRequest, "invalid bookmark ID")
		return
	}

	err = h.store.Delete(id)
	if err != nil {
		respondError(w, http.StatusNotFound, "bookmark not found")
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

func respondJSON(w http.ResponseWriter, status int, data any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

func respondError(w http.ResponseWriter, status int, message string) {
	respondJSON(w, status, map[string]string{"error": message})
}

And here is the complete cmd/server/main.go:

package main

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

	"bookmarks/internal/handler"
	"bookmarks/internal/store"
)

func main() {
	s, err := store.New("bookmarks.db")
	if err != nil {
		log.Fatal(err)
	}
	defer s.Close()

	h := handler.New(s)

	mux := http.NewServeMux()
	h.SetupRoutes(mux)

	fmt.Println("Server starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", mux))
}

Your API now handles all five CRUD operations with proper status codes. Next up, you will tighten the input validation and make every error response consistent across the entire API.

Add Input Validation and Error Consistency

Your API handles the happy path well, but real users send broken requests all the time. Empty bodies, gibberish URLs, typos in the path. A production API needs to reject bad input clearly and consistently so clients can fix their requests without guessing.

In this step, you'll review the validation patterns already in your handlers and stress-test them with curl. Every error response should be a JSON object with an error key, making your API predictable for any client.

In this step, get ready to:
  • Review the respondJSON and respondError helper functions.
  • Understand how URL format validation catches bad URLs.
  • Test edge cases with curl to confirm consistent error responses.
Review the JSON error response helpers

Every error your API returns needs to be a JSON object. If some endpoints return plain text errors and others return JSON, clients have to guess how to parse the response.

  • In internal/handler/handler.go, scroll to the bottom of the file.
  • Find the two helper functions, respondJSON and respondError:
func respondJSON(w http.ResponseWriter, status int, data any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

func respondError(w http.ResponseWriter, status int, message string) {
	respondJSON(w, status, map[string]string{"error": message})
}

What do these helpers do?

respondJSON sets Content-Type: application/json on every response, writes the HTTP status code, and encodes the data as JSON. Every handler uses this single function for all responses.

respondError calls respondJSON with a map[string]string{"error": message}. This guarantees every error is a JSON object with one key: error. Clients can always parse errors the same way.

Understand URL format validation

A bookmark manager that accepts banana as a URL is useless. Your handlers already validate URL format before inserting into the database.

  • In the same file, find this block inside createBookmark:
if _, err := url.ParseRequestURI(input.URL); err != nil {
	respondError(w, http.StatusBadRequest, "invalid URL format")
	return
}

Why ParseRequestURI?

net/url.ParseRequestURI is stricter than url.Parse. It rejects strings that are missing a scheme (like http:// or https://). This means example.com is rejected, but https://example.com passes.

The same validation exists inside updateBookmark too, so you cannot sneak a bad URL past either endpoint.

What about 405 Method Not Allowed?

Go 1.22+ method-based routing patterns (like "POST /bookmarks") automatically return a 405 Method Not Allowed response when a client uses an unsupported method. You get this behavior for free from the standard library.

Test edge cases with curl

Time to break your API on purpose. You will send intentionally bad requests and confirm the error responses are clear and consistent.

  • Make sure your server is still running in a terminal. If it is not, restart it by running:
go run ./cmd/server/
  • In a separate terminal, send a POST request with an empty body to test malformed JSON handling:
curl -s -X POST http://localhost:8080/bookmarks

You should see:

{"error":"invalid JSON body"}
  • Test missing required fields by sending an empty JSON object:
curl -s -X POST http://localhost:8080/bookmarks -H "Content-Type: application/json" -d '{}'

You should see:

{"error":"url is required"}
  • Test invalid URL format by sending a URL without a scheme:
curl -s -X POST http://localhost:8080/bookmarks -H "Content-Type: application/json" -d '{"url":"not-a-url","title":"Test"}'

You should see:

{"error":"invalid URL format"}
  • Test a non-numeric ID in the path:
curl -s http://localhost:8080/bookmarks/abc

You should see:

{"error":"invalid bookmark ID"}
  • Test an unsupported HTTP method to confirm the automatic 405 response:
curl -s -X PATCH http://localhost:8080/bookmarks

You should see a 405 Method Not Allowed response. Go's ServeMux handles this automatically because you registered method-specific patterns.

Why do consistent errors matter?

Every error your API returns follows the same shape: {"error": "message"}. This means any client (frontend app, mobile app, CLI tool) can parse errors with one code path. Inconsistent error shapes are one of the most common complaints about poorly designed APIs.

Seeing 'connection refused'?

Make sure your server is running in a separate terminal window. Run go run ./cmd/server/ and keep that terminal open while you test with curl in another terminal.

help me debug why curl cannot connect to my server

Your API now rejects bad input with clear, consistent JSON errors. Next up, you will run a full CRUD workflow to test every endpoint working together.

Test the Complete API

Your API code is complete. The handlers validate input, the store manages data, and the response helpers format consistent JSON. Now it's time to put the whole system through a structured test run.

In this step, you'll run a complete CRUD workflow from create to delete, then deliberately send bad requests to confirm your error handling catches every edge case.

In this step, get ready to:
  • Run a full CRUD workflow (create, read, update, delete) with curl.
  • Verify that bad inputs return proper 4xx error responses with JSON messages.
Run a full CRUD workflow with curl

To get clean, predictable results, you'll start fresh with an empty database. This way the bookmark IDs start at 1 and the output matches what you expect.

  • Switch to the terminal where your server is running.
  • Press Ctrl+C to stop the server.
  • Delete the existing database and restart the server by running these commands:
rm -f bookmarks.db
go run ./cmd/server/

You should see Server starting on :8080 printed in the terminal.

  • Open a second terminal window for running curl commands.
  • Navigate to your bookmarks project directory in the second terminal.
  • Create your first bookmark by running this command:
curl -X POST http://localhost:8080/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"url": "https://go.dev/doc", "title": "Go Documentation", "description": "Official Go docs"}'

You should see a JSON response with the created bookmark, including an id of 1 and a created_at timestamp.

  • Create a second bookmark by running:
curl -X POST http://localhost:8080/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"url": "https://pkg.go.dev", "title": "Go Packages", "description": "Package discovery site"}'

This bookmark should have an id of 2.

  • List all bookmarks by running:
curl http://localhost:8080/bookmarks

You should see a JSON array containing both bookmarks, ordered with the most recent first.

  • Get a single bookmark by ID by running:
curl http://localhost:8080/bookmarks/1

You should see just the first bookmark (Go Documentation) returned as a single JSON object.

  • Update the first bookmark by running:
curl -X PUT http://localhost:8080/bookmarks/1 \
  -H "Content-Type: application/json" \
  -d '{"url": "https://go.dev/doc/effective_go", "title": "Effective Go", "description": "Best practices guide"}'

The response shows the updated bookmark with the new title, URL, and description. The id and created_at remain unchanged.

  • Delete the first bookmark by running:
curl -i -X DELETE http://localhost:8080/bookmarks/1

You should see HTTP/1.1 204 No Content in the response headers. There is no body for a 204 response.

  • Confirm the deletion stuck by requesting the same bookmark again:
curl http://localhost:8080/bookmarks/1

You should see {"error":"bookmark not found"} confirming the bookmark no longer exists.

What just happened?

You just ran through the complete lifecycle of a REST resource. Create (POST, 201), Read (GET, 200), Update (PUT, 200), Delete (DELETE, 204). These four operations map directly to the SQL operations INSERT, SELECT, UPDATE, and DELETE that your store executes under the hood.

The 404 response after deletion proves your API correctly distinguishes between "success with no content" (204) and "resource does not exist" (404).

Seeing "connection refused"?

Make sure your server is still running in the other terminal. If you accidentally closed it, restart with go run ./cmd/server/ from inside the bookmarks directory.

If you see a port-in-use error, another process is using port 8080. Find and kill it, or wait a few seconds for the old process to release the port.

Help me debug my curl connection issue with the bookmarks API.

Verify error handling

A reliable API rejects bad input clearly. You'll now send intentionally broken requests to confirm every error path returns a structured JSON error with the right status code.

  • Request a bookmark that does not exist by running:
curl http://localhost:8080/bookmarks/999

You should see {"error":"bookmark not found"} with a 404 status.

  • Send a POST with a missing required field by running:
curl -X POST http://localhost:8080/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}'

You should see {"error":"title is required"} with a 400 status. The title field is missing from the request body.

  • Send a POST with an invalid URL format by running:
curl -X POST http://localhost:8080/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"url": "not-a-url", "title": "Bad URL"}'

You should see {"error":"invalid URL format"} with a 400 status. Your net/url.ParseRequestURI check caught the invalid URL.

  • Send a PUT to a bookmark that does not exist by running:
curl -X PUT http://localhost:8080/bookmarks/999 \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "title": "Ghost Bookmark"}'

You should see {"error":"bookmark not found"} with a 404 status.

Why does consistent error handling matter?

Every error response from your API uses the same {"error": "message"} structure. This predictability is what front-end developers rely on. They can write one error-handling path in their code that works for any failure your API returns.

The correct HTTP status codes (400 for bad input, 404 for missing resources, 500 for server errors) let clients react differently to each category without parsing the message string.

Getting unexpected status codes?

If you see a 200 where you expected a 400 or 404, double check that your handler calls respondError with the correct status constant (e.g. http.StatusBadRequest for 400, http.StatusNotFound for 404).

If the JSON looks garbled or incomplete, make sure your curl -d payload uses single quotes around the JSON string and double quotes inside it.

Help me debug unexpected status codes from my Go API.

Secret mission

Add Tag-Based Filtering

Your bookmark API handles CRUD, but real bookmark managers let you organize with tags. Can you add a many-to-many relationship, database transactions, and JOIN queries to support tag-based filtering?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files or delete them entirely.

Resources you used:

  • The bookmarks project directory on your Desktop (contains all Go source files and the bookmarks.db SQLite database file).

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment further with the API.

  • Your project files remain in ~/Desktop/bookmarks.
  • The bookmarks.db database file is a local file and costs nothing to keep.
  • If the server is still running in your terminal, press Ctrl+C to stop it when you are done testing.

Pause - I'll come back to this later

Stop the running server to free up port 8080. Your files stay on disk for when you return.

  • Press Ctrl+C in the terminal where your server is running.
  • When you come back, navigate to ~/Desktop/bookmarks and run go run ./cmd/server/ to start it again.

Delete - I don't want to use this again

Remove all project files and the database. This cannot be undone.

  • If the server is still running, press Ctrl+C to stop it.
  • Remove the entire project directory by running:
rm -rf ~/Desktop/bookmarks
  • Confirm the folder is gone by running:
ls ~/Desktop | grep bookmarks

You should see no output, confirming the directory has been removed.

Nice Work!

Nice Work!

You just built a production-style REST API from scratch in Go. You designed endpoints, wrote SQL, validated input, and handled errors without leaning on a framework or AI to fill in the gaps.

You've learned how to:

  • Build a complete REST API in Go using only the standard library for HTTP routing, with Go 1.22+ method-based patterns eliminating the need for third-party routers.
  • Write a database layer using Go's database/sql interface with SQLite, implementing full CRUD operations with proper HTTP methods and status codes.
  • Add input validation and consistent JSON error responses that return appropriate 4xx and 5xx codes for every failure case.
  • Secret Mission: Extended the API with tag-based filtering using many-to-many relationships, transactions, and JOIN queries.

Ready to quiz yourself?