~andreicek


Learning Go: Simple bookmark manager

Sunday, October 18, 2020; Reading time 5 minutes.

I’ve started learning Golang so I can stop shipping hundreds of MBs worth of Docker images when deploying my personal projects. So far it’s been going great, and I’m finding Go very intuitive and easy to work.

This blog post is an experiment in documenting my Golang learning. Here is a very simple bookmark manager that I’ve built in a timeboxed period of 2 hours.

Idea

  1. Have a very simple HTML UI where you can enter a link Title and URL
  2. On that same page have a list of saved bookmarks
  3. Save the bookmarks in a database (sqlite)
  4. Have it very small
  5. Have the code not be smart at all

Execution: the server

Let’s start with creating a model for the Bookmark. We are going to use gorm1. Everything starts with a struct 😃.

type Bookmark struct {
	gorm.Model
	Url   string
	Title string
}

Next up in our main function we have to init the DB and run the migrations:

func main() {
	db, err := gorm.Open("sqlite3", "data.db")
	if err != nil {
		log.Panic(err)
	}
	
	db.AutoMigrate(&Bookmark{})
}

Now, if we wanted to try out things we can start by saving some models to the DB:

func main() {
	db, err := gorm.Open("sqlite3", "data.db")
	if err != nil {
		log.Panic(err)
	}
	
	db.AutoMigrate(&Bookmark{})
	
	bookmark := &Bookmark{Title: "0x7f", Url: "https://0x7f.dev"}
	db.Save(bookmark)
}

It’s not a real webapp if we don’t have some HTML! First start by adding some templates. I’ve created a templates/ folder with a single file index.tmpl:

<html>
<body>
	<ul>
		{{range .Bookmarks}}
		<li>
			<a href="{{.Url}}">{{.Title}}</a>
		</li>
		{{end}}
	</ul>
</body>
</html>

The library we are going to use with Go to do HTTP is called gin2. It has a very simple way of serving HTML templates:

type IndexPageData struct {
Bookma  rks []Bookmark
}

func main() {
	db, err := gorm.Open("sqlite3", "data.db")
	if err != nil {
		log.Panic(err)
	}
	
	db.AutoMigrate(&Bookmark{})
	
	r := gin.Default()
	r.LoadHTMLGlob("templates/*")
	
	r.GET("/", func(c *gin.Context) {
		var bookmarks []Bookmark
		db.Find(&bookmarks)
		
		data := IndexPageData{
			Bookmarks: bookmarks,
		}
		c.HTML(http.StatusOK, "index.tmpl", data)
	})
	
	r.Run()
}

Now if we try this out you’ll see a list with one item in the Database! Success 🎉! OK, final stretch let’s add a way to actually save new bookmarks from the UI. We’ll need an extra route:

r.POST("/create", func(c *gin.Context) {
	var input struct {
		Title string `json:"title"`
		Url   string `json:"url" binding:"required"`
	}
	
	if err := c.ShouldBindJSON(&input); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": err.Error(),
		})
	}
	
	newBookmark := &Bookmark{Title: input.Title, Url: input.Url}
	db.Save(newBookmark)
	
	c.JSON(http.StatusCreated, gin.H{
		"status": "created",
	})
})

Now switching focus to the HTML where I’ve added a form and some logic to submit the data and refresh the page once it’s added:

<html>
<head>
	<title>Bkmrk</title>
	<style>
		body {
			margin: 40px auto;
			max-width: 650px;
			line-height: 1.6;
			font-size: 18px;
			color: #cfcfcf;
			padding: 0 10px;
			background: #1e1e1e
		}

		a {
			color: #3dff7e
		}

		a:visited {
			color: #2aad56
		}

		h1, h2, h3 {
			line-height: 1.2
		}
	</style>
</head>
<body>
<h1>Bkmrk</h1>
<hr />
<form class="js-submit">
	<div>
		<label for="title">Title</label>
		<input id="title" class="js-title" name="title" type="text"/>
	</div>
	<div>
		<label for="url">URL</label>
		<input id="url" class="js-url" name="url" type="text"/>
	</div>
	<input type="submit"/>
</form>
<hr />
<ul>
	{{range .Bookmarks}}
	<li>
		<a href="{{.Url}}">{{.Title}}</a>
		<span>{{.CreatedAt}}</span>
	</li>
	{{end}}
</ul>
<script>
	const form = document.querySelector('.js-submit');
	const titleInput = document.querySelector('.js-title');
	const urlInput = document.querySelector('.js-url');

	form.addEventListener('submit', (e) => {
		e.preventDefault();

		const data = {
			title: titleInput.value,
			url: urlInput.value,
		};

		fetch('/create', {
			method: 'POST',
			body: JSON.stringify(data)
		}).then(() => window.location = '/');
	});
</script>
</body>
</html>

And Golang code in full:

package main

import (
	"github.com/gin-gonic/gin"
	"github.com/jinzhu/gorm"
	_ "github.com/jinzhu/gorm/dialects/sqlite"
	"log"
	"net/http"
)

type Bookmark struct {
	gorm.Model
	Url   string
	Title string
}

type IndexPageData struct {
	Bookmarks []Bookmark
}

func main() {
	db, err := gorm.Open("sqlite3", "data.db")
	if err != nil {
		log.Panic(err)
	}

	db.AutoMigrate(&Bookmark{})

	r := gin.Default()
	r.LoadHTMLGlob("templates/*")

	r.GET("/", func(c *gin.Context) {
		var bookmarks []Bookmark
		db.Find(&bookmarks)

		data := IndexPageData{
			Bookmarks: bookmarks,
		}
		c.HTML(http.StatusOK, "index.tmpl", data)
	})

	r.POST("/create", func(c *gin.Context) {
		var input struct {
			Title string `json:"title"`
			Url   string `json:"url" binding:"required"`
		}

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

		newBookmark := &Bookmark{Title: input.Title, Url: input.Url}
		db.Save(newBookmark)

		c.JSON(http.StatusCreated, gin.H{
			"status": "created",
		})
	})

	r.Run()
}

Final words

If we build this Golang code in a single bin on MacOS we get:

~/go-workspace/src/bkmrk% go build main.go
~/go-workspace/src/bkmrk% ls | grep main                                          
.rwxr-xr-x  20M andreicek 18 Oct 13:13 -N main

Have fun!


  1. https://gorm.io ↩︎

  2. https://github.com/gin-gonic/gin ↩︎


Go back to homepage