Files

10 KiB

Todo — reference implementation (Go + Chi + SQLite)

This is a complete example. Generate equivalent structure for the given project. Use ONLY the fields from the JSON spec — do not add extras.

go.mod

Chi v5 router, modernc.org/sqlite (pure Go, no CGO).

module todo-go

go 1.23.0

toolchain go1.23.12

require (
	github.com/go-chi/chi/v5 v5.2.1
	modernc.org/sqlite v1.37.1
)

require (
	github.com/dustin/go-humanize v1.0.1 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/ncruces/go-strftime v0.1.9 // indirect
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
	golang.org/x/sys v0.33.0 // indirect
	modernc.org/libc v1.65.7 // indirect
	modernc.org/mathutil v1.7.1 // indirect
	modernc.org/memory v1.11.0 // indirect
)

models.go

Data structs: Todo (full row), CreateTodo (POST), UpdateTodo (PUT, all fields optional pointers).

package main

// Todo represents a task with priority and status tracking.
type Todo struct {
	ID          int64   `json:"id"`
	Title       string  `json:"title"`
	Description *string `json:"description,omitempty"`
	DueDate     *string `json:"due_date,omitempty"`
	Priority    int64   `json:"priority"`
	Status      string  `json:"status"`
}

// CreateTodo is the request body for creating a new todo.
type CreateTodo struct {
	Title       string  `json:"title"`
	Description *string `json:"description,omitempty"`
	DueDate     *string `json:"due_date,omitempty"`
	Priority    *int64  `json:"priority,omitempty"`
	Status      *string `json:"status,omitempty"`
}

// UpdateTodo is the request body for updating an existing todo.
type UpdateTodo struct {
	Title       *string `json:"title,omitempty"`
	Description *string `json:"description,omitempty"`
	DueDate     *string `json:"due_date,omitempty"`
	Priority    *int64  `json:"priority,omitempty"`
	Status      *string `json:"status,omitempty"`
}

handlers.go

CRUD handlers as closures taking *sql.DB. Key patterns: INSERT RETURNING, sql.ErrNoRows for 404, RowsAffected for delete.

package main

import (
	"database/sql"
	"encoding/json"
	"net/http"
	"strconv"
	"github.com/go-chi/chi/v5"
)

// POST — decode JSON, defaults with nil-check, INSERT RETURNING, StatusCreated.
func createTodo(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var input CreateTodo
		if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest); return
		}
		priority := int64(1)
		if input.Priority != nil { priority = *input.Priority }
		status := "pending"
		if input.Status != nil { status = *input.Status }
		var todo Todo
		err := db.QueryRow(
			`INSERT INTO todos (title, description, due_date, priority, status)
			 VALUES (?, ?, ?, ?, ?) RETURNING id, title, description, due_date, priority, status`,
			input.Title, input.Description, input.DueDate, priority, status,
		).Scan(&todo.ID, &todo.Title, &todo.Description, &todo.DueDate, &todo.Priority, &todo.Status)
		if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError); return }
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusCreated)
		json.NewEncoder(w).Encode(todo)
	}
}

// GET list — db.Query + rows.Scan loop, empty slice not nil.
func listTodos(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		rows, err := db.Query("SELECT id, title, description, due_date, priority, status FROM todos")
		if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError); return }
		defer rows.Close()
		todos := []Todo{}
		for rows.Next() {
			var t Todo
			rows.Scan(&t.ID, &t.Title, &t.Description, &t.DueDate, &t.Priority, &t.Status)
			todos = append(todos, t)
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(todos)
	}
}

// GET by id — QueryRow + sql.ErrNoRows → 404.
func getTodo(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
		var todo Todo
		err := db.QueryRow(
			"SELECT id, title, description, due_date, priority, status FROM todos WHERE id = ?", id,
		).Scan(&todo.ID, &todo.Title, &todo.Description, &todo.DueDate, &todo.Priority, &todo.Status)
		if err == sql.ErrNoRows { http.Error(w, "not found", http.StatusNotFound); return }
		if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError); return }
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(todo)
	}
}

// PUT — fetch existing, merge with input nil-checks, UPDATE RETURNING.
func updateTodo(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
		var existing Todo
		err := db.QueryRow("SELECT id, title, description, due_date, priority, status FROM todos WHERE id = ?", id,
		).Scan(&existing.ID, &existing.Title, &existing.Description, &existing.DueDate, &existing.Priority, &existing.Status)
		if err == sql.ErrNoRows { http.Error(w, "not found", http.StatusNotFound); return }
		if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError); return }
		var input UpdateTodo
		if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest); return
		}
		if input.Title != nil { existing.Title = *input.Title }
		if input.Description != nil { existing.Description = input.Description }
		if input.DueDate != nil { existing.DueDate = input.DueDate }
		if input.Priority != nil { existing.Priority = *input.Priority }
		if input.Status != nil { existing.Status = *input.Status }
		var updated Todo
		err = db.QueryRow(
			`UPDATE todos SET title=?, description=?, due_date=?, priority=?, status=? WHERE id=?
			 RETURNING id, title, description, due_date, priority, status`,
			existing.Title, existing.Description, existing.DueDate, existing.Priority, existing.Status, id,
		).Scan(&updated.ID, &updated.Title, &updated.Description, &updated.DueDate, &updated.Priority, &updated.Status)
		if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError); return }
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(updated)
	}
}

// DELETE — Exec + RowsAffected == 0 → 404, else 204.
func deleteTodo(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
		result, err := db.Exec("DELETE FROM todos WHERE id = ?", id)
		if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError); return }
		rows, _ := result.RowsAffected()
		if rows == 0 { http.Error(w, "not found", http.StatusNotFound); return }
		w.WriteHeader(http.StatusNoContent)
	}
}

main.go

Entry point: SQLite connection, table init, Chi router on port 3000.

package main

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

	"github.com/go-chi/chi/v5"
	_ "modernc.org/sqlite"
)

// InitDB creates tables if they don't exist.
func InitDB(db *sql.DB) {
	_, err := db.Exec(`CREATE TABLE IF NOT EXISTS todos (
		id          INTEGER PRIMARY KEY AUTOINCREMENT,
		title       TEXT NOT NULL,
		description TEXT,
		due_date    TEXT,
		priority    INTEGER NOT NULL DEFAULT 1,
		status      TEXT NOT NULL DEFAULT 'pending'
	)`)
	if err != nil {
		log.Fatal(err)
	}
}

// NewRouter creates a chi router with all routes.
func NewRouter(db *sql.DB) http.Handler {
	r := chi.NewRouter()
	r.Post("/todos", createTodo(db))
	r.Get("/todos", listTodos(db))
	r.Get("/todos/{id}", getTodo(db))
	r.Put("/todos/{id}", updateTodo(db))
	r.Delete("/todos/{id}", deleteTodo(db))
	return r
}

func main() {
	db, err := sql.Open("sqlite", "file:app.db?mode=rwc")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
	InitDB(db)

	fmt.Println("Server running: http://127.0.0.1:3000")
	log.Fatal(http.ListenAndServe("127.0.0.1:3000", NewRouter(db)))
}

handlers_test.go

Integration tests: setupTestServer with httptest.NewServer + :memory: SQLite, unique data per test.

package main

import (
	"database/sql"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	_ "modernc.org/sqlite"
)

func setupTestServer(t *testing.T) (*httptest.Server, *sql.DB) {
	t.Helper()
	db, err := sql.Open("sqlite", ":memory:")
	if err != nil { t.Fatal(err) }
	InitDB(db)
	return httptest.NewServer(NewRouter(db)), db
}

func TestCreateTodo(t *testing.T) {
	ts, db := setupTestServer(t)
	defer ts.Close()
	defer db.Close()
	resp, err := http.Post(ts.URL+"/todos", "application/json",
		strings.NewReader(`{"title":"Buy groceries","priority":2}`))
	if err != nil { t.Fatal(err) }
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) }
	var body map[string]interface{}
	json.NewDecoder(resp.Body).Decode(&body)
	if body["title"] != "Buy groceries" { t.Fatalf("expected 'Buy groceries', got %v", body["title"]) }
	if body["id"] == nil { t.Fatal("expected id") }
}

func TestGetTodoByID(t *testing.T) {
	ts, db := setupTestServer(t)
	defer ts.Close()
	defer db.Close()
	resp, _ := http.Post(ts.URL+"/todos", "application/json",
		strings.NewReader(`{"title":"Fetchable task"}`))
	var created map[string]interface{}
	json.NewDecoder(resp.Body).Decode(&created)
	resp.Body.Close()
	id := created["id"].(float64)
	resp, _ = http.Get(ts.URL + "/todos/" + fmt.Sprintf("%.0f", id))
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) }
}

func TestGetTodoNotFound(t *testing.T) {
	ts, db := setupTestServer(t)
	defer ts.Close()
	defer db.Close()
	resp, _ := http.Get(ts.URL + "/todos/99999")
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404, got %d", resp.StatusCode) }
}

func TestDeleteTodo(t *testing.T) {
	ts, db := setupTestServer(t)
	defer ts.Close()
	defer db.Close()
	resp, _ := http.Post(ts.URL+"/todos", "application/json",
		strings.NewReader(`{"title":"Deletable task"}`))
	var created map[string]interface{}
	json.NewDecoder(resp.Body).Decode(&created)
	resp.Body.Close()
	id := created["id"].(float64)
	req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/todos/"+fmt.Sprintf("%.0f", id), nil)
	resp, _ = http.DefaultClient.Do(req)
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNoContent { t.Fatalf("expected 204, got %d", resp.StatusCode) }
	resp, _ = http.Get(ts.URL + "/todos/" + fmt.Sprintf("%.0f", id))
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404 after delete, got %d", resp.StatusCode) }
}