Building a Todo RESTful API in Go

Zaid Afzal ⚡️
5 min readJan 4, 2023

--

Go is a powerful programming language that is well-suited for building web applications as well as RESTful APIs. In this tutorial, I’ll walk through the steps of building a simple todo app in Go.

Prerequisites

  • Go 1.x installed.
  • VSCode or IntelliJ or any text editor.

Setting up the Project

Lets start by creating a new directory ‘TodoApp’ for our project. Inside it create main package. Then in main directory, create new file ‘main.go’

TodoApp -> main -> main.go

Connecting to the Database

For this tutorial, we’ll be using an SQLite database to store our todo items. Installing the SQLite driver for Go by running the following command:

go get github.com/mattn/go-sqlite3

Next, create a new SQLite database and a todos table within it.

CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT false,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Workflow

Now open main.go and add the following code snippets. We’ll go through each function step by step.

  1. Importing necessary packages and adding an empty main function.
package main

import (
"database/sql"
"encoding/json"
"log"
"net/http"
"strconv"
"time"

_ "github.com/mattn/go-sqlite3"
)

func main(){
}

In Go, the underscore character (_) is used as a placeholder when you want to import a package but do not want to use any of its functions or variables.

This would import the ‘go-sqlite3’ package and run the initialisation code in go/pkg/go-sqlite3, but not bring the package's functions or variables into the current scope.

2. ‘Todo’ struct represents a todo item.

type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
CreatedAt time.Time `json:"created_at"`
}

3. ‘TodoStore’ is the interface that wraps the CRUD methods for a todo store.

type TodoStore interface {
GetAll() ([]*Todo, error)
GetByID(int) (*Todo, error)
Create(string) (*Todo, error)
Update(*Todo) error
Delete(int) error
}

4. ‘DB’ is a struct that represents a connection to the sqlite database.

type DB struct {
*sql.DB
}

5. ‘NewDB’ method creates a new connection to the sqlite database.

func NewDB(dataSourceName string) (*DB, error) {
db, err := sql.Open("sqlite3", dataSourceName)
if err != nil {
return nil, err
}
if err = db.Ping(); err != nil {
return nil, err
}
return &DB{db}, nil
}

6. ‘EnsureMigration’ method runs the necessary migrations to create the necessary tables in the database.

func (db *DB) EnsureMigration() error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT false,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`)
return err
}

Note that the Exec function returns the number of rows affected and an error, but in this case we are not interested in either of those values, so they are discarded using the _ identifier.

7. ‘TodoSQLStore’ is a concrete implementation of the TodoStore interface that. For now, it uses a SQLite database as the backend.

type TodoSQLStore struct {
DB *DB
}

8. ‘GetAll’ method returns all the todos in the data store.

func (store *TodoSQLStore) GetAll() ([]*Todo, error) {
rows, err := store.DB.Query("SELECT id, title, completed, created_at FROM todos")
if err != nil {
return nil, err
}
defer rows.Close()

var todos []*Todo
for rows.Next() {
var todo Todo
if err := rows.Scan(&todo.ID, &todo.Title, &todo.Completed, &todo.CreatedAt); err != nil {
return nil, err
}
todos = append(todos, &todo)
}
return todos, nil
}

9. ‘GetByID’ method returns the todo with the given ID.

func (store *TodoSQLStore) GetByID(id int) (*Todo, error) {
row := store.DB.QueryRow("SELECT id, title, completed, created_at FROM todos WHERE id = ?", id)

var todo Todo
if err := row.Scan(&todo.ID, &todo.Title, &todo.Completed, &todo.CreatedAt); err != nil {
return nil, err
}
return &todo, nil
}

10. ‘Create’ method inserts a new todo into the store and returns the created todo.

func (store *TodoSQLStore) Create(title string) (*Todo, error) {
res, err := store.DB.Exec("INSERT INTO todos (title) VALUES (?)", title)
if err != nil {
return nil, err
}

id, err := res.LastInsertId()
if err != nil {
return nil, err
}

return store.GetByID(int(id))
}

11. ‘Update’ method updates the given todo in the store.

func (store *TodoSQLStore) Update(todo *Todo) error {
_, err := store.DB.Exec("UPDATE todos SET title = ?, completed = ? WHERE id = ?", todo.Title, todo.Completed, todo.ID)
return err
}

12. ‘Delete’ method removes the todo with the given ID from the store.

func (store *TodoSQLStore) Delete(id int) error {
_, err := store.DB.Exec("DELETE FROM todos WHERE id = ?", id)
return err
}

13. In ‘main’ function, initialise the data store connection.

db, err := NewDB("todos.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()

if err := db.EnsureMigration(); err != nil {
log.Fatal(err)
}

store := &TodoSQLStore{db}

14. Create our API’s route for getting all todos as list (and creating new todo on same endpoint) with a handler we created earlier.

 http.HandleFunc("/todos", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {

case http.MethodGet:
todos, err := store.GetAll()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(todos); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

case http.MethodPost:
var todo *Todo
if err := json.NewDecoder(r.Body).Decode(&todo); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
todo, err := store.Create(todo.Title)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(todo); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})

15. Using different HTTP methods, we can use single route for following operations via todo id:

  • Retrieving a single todo
  • Editing a todo
  • Deleting a todo
http.HandleFunc("/todos/", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.URL.Path[len("/todos/"):])
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

switch r.Method {

case http.MethodGet:
todo, err := store.GetByID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(todo); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

case http.MethodPut:
var todo Todo
if err := json.NewDecoder(r.Body).Decode(&todo); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
todo.ID = id
if err := store.Update(&todo); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(todo); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

case http.MethodDelete:
if err := store.Delete(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})

16. Finally start our web server listening on port 8080.

log.Println("Listening on :8080...")
log.Fatal(http.ListenAndServe(":8080", nil))

That’s all. Run the code with following command:

go run main.go

If you see a similar message like below that web server is listening on port 8080, then you are good to go.

2023/01/04 21:54:51 Listening on :8080...

Now lets’s open Postman to interact with our API via different endpoints.

You can browse complete code file here in my Github repository. Postman collection with test examples is also included.

Feel free to ask any question or suggest any update.

--

--