Echo - A Go web framework - Todo App

Read more articles on Go
github logo
Github Repository
Code for this articles is available on GitHub
Echo - A Go web framework - Todo App

Subscribe

Get updated 1 - 2 times a month when new articles are published, no spam ever.

Purpose of this article #

In this article I want to give you a taste of what building Go web applications with Echo framework looks like. It is hard and time consuming to go through the entire documentation of a framework to just get the feel of what it is like and if the framework is for you.

We will use the example of a Todo app for demonstration covering topics like routing, middleware, authentication, path/query parameters, request body and more. The complete example application can be found in this github repository.

Why a todo app? #

I have seen a lot of hate ๐Ÿ˜  towards todo apps being used as an example in tutorials and courses. Todo apps are a great way to showcase the technology/framework to the reader without requiring the reader to gain additional context of the project. When you read "todo app" you already know the what the requirements are. Additionaly todo apps cover the most common operations of framework/libraries which is CRUD (create, read, update, delete) data.

Project Setup #

Create a new project using go mod init <your_module_name> and install the echo framework.

go get github.com/labstack/echo/v4
go get github.com/labstack/echo/v4/middleware

Setting up a Data Store #

You need a way to store/manage the todos, you will not be using a database as that is not the focus of this article, you will be storing the data in-memory.

Create file in the main package itself called todomanager.go and paste the following code in it. We have something called a TodoManager, we will be adding all the CRUD operations as functions on this TodoManager so give this code a good read.

package main

import (
	"sync"
)

// Todo represents our todo
type Todo struct {
	ID         string `json:"id"`
	Title      string `json:"title"`
	IsComplete bool   `json:"isDone"`
}

type TodoManager struct {
	todos []Todo
	m     sync.Mutex // we want all our operations to be atomic
}

func NewTodoManager() TodoManager {
	return TodoManager{
		todos: make([]Todo, 0),
		m:     sync.Mutex{},
	}
}
todomanager.go

Setting up Echo #

Now you are going to set up a simple http server using echo.

package main

import (
	"github.com/labstack/echo/v4"
)

func main() {
	e := echo.New()

	e.Start(":8888")
}
main.go

Run the code using go run main.go. This will start an http server on port 8888 and you should see the following output in your terminal.

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.9.0
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
โ‡จ http server started on [::]:8888

Building the app #

Let's start adding functionality to the app. The steps will be quite similar for all of the operations: 1. Add a function in TodoManager, 2. Add an api endpoint in echo.

The code snippets below will only showcase new additions and leave out the already written code, if you feel lost or want to see the enitre working thing head over to the this GitHub repo that contains the complete working example.

Get all the todos #

Inside TodoManager add a function to return all todos.

...

func (tm *TodoManager) GetAll() []Todo {
	return tm.todos
}
todomanager.go

Add a GET route on echo.

package main

import (
	"net/http"

	"github.com/labstack/echo/v4"
)

func main() {
	tm := NewTodoManager() // ๐Ÿ‘ˆ instance of todo manager

	e := echo.New()

	// ๐Ÿ‘‡ new GET route
	e.GET("/", func(c echo.Context) error {
		todos := tm.GetAll()

		return c.JSON(http.StatusOK, todos) // ๐Ÿ‘ˆ sending json data back
	})
    
	e.Start(":8888")
}
main.go

It is as simple as using the GET function on echo and providing it with a route path and a function.

Note: The function signature has a single parameter of type echo.Context, it is not your standard func(http.ResponseWriter, *http.Request) signature.

Let's send a curl request and see the output.

โฏ curl -s localhost:8888/ | jq

[]

As expected you will see an empty array because there is no todo yet ๐Ÿคทโ€โ™‚๏ธ.

Middleware to log all requests #

Echo has the concept of middlewares which are functions that run before the actual request function.

Let's set up a middleware to log all the requests to terminal.

You will be using an inbuilt middleware provided by echo (you will see an example of a custom middleware later) from its github.com/labstack/echo/v4/middleware package.

package main

import (
	"net/http"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	tm := NewTodoManager()

	e := echo.New()

	e.Use(middleware.Logger()) // ๐Ÿ‘ˆ log all requests

	e.GET("/", func(c echo.Context) error {
		todos := tm.GetAll()

		return c.JSON(http.StatusOK, todos)
	})

	e.Start(":8888")
}
main.go

If you send a request again you should see a json object being printed in the standard output.

{"time":"2022-09-08T04:07:37.20349-04:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8888","method":"GET","uri":"/","user_agent":"curl/7.79.1","status":200,"error":"","latency":72063,"latency_human":"72.063ยตs","bytes_in":0,"bytes_out":3}

Echo has a number of built in middlewares, check out the documentation on middleware here.

Authenticated routes #

Before we proceed adding additional routes in our application, let's talk about authentication. I am not going to discuss how to do authentication (not the focus of this article). I am going to show you how to make routes accessible only by logged in user.

โญ๏ธ A common pattern in backend applications is to pass an auth token as a header in every request, Authorization: <auth_token>.

You will use Echo Groups to group together routes that require authentication and add a custom middleware that will check for the presence of an auth token, for simplicity it will be a simple string equality check.

func main() {
	...
    
	authenticatedGroup := e.Group("/todos", func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
        	// ๐Ÿ‘‡ check for auth token
			authorization := c.Request().Header.Get("authorization")
			if authorization != "auth-token" {
				c.Error(echo.ErrUnauthorized)
				return nil
			}
            
            // ๐Ÿ‘‡ token exists, keep moving forward
			next(c)
			return nil
		}
	})

	...
}
main.go

Any routes added onto authenticatedGroup will have the middleware executed before the actual route is executed.

In this middleware, there is a check for authentication header, if the header is empty or not valid an error is returned using echo's c.Error.

Sending a request without a header will give you back this response.

โฏ curl -s localhost:8888/todos/ | jq

{
  "message": "Unauthorized"
}

Create new todo #

Using the previous authenticated group add a route to create a new todo.

Let's take care of the TodoManager part by adding a Create function.

// ๐Ÿ‘‡ required data to create new todo
type CreateTodoRequest struct {
	Title string `json:"title"`
}

func (tm *TodoManager) Create(createTodoRequest CreateTodoRequest) Todo {
	tm.m.Lock()
	defer tm.m.Unlock()

	newTodo := Todo{
		ID:         strconv.FormatInt(time.Now().UnixMilli(), 10),
		Title:      createTodoRequest.Title,
		IsComplete: false,
	}

	tm.todos = append(tm.todos, newTodo)

	return newTodo
}
todomanager.go

Now set up a POST route which will parse the request body and call the TodoManager.Create function.

func main() {
	...

	authenticatedGroup.POST("/create", func(c echo.Context) error {
		requestBody := CreateTodoRequest{}

		// ๐Ÿ‘‡ parses the body and "binds" it to the passed type
		err := c.Bind(&requestBody)
		if err != nil {
			return err
		}

		todo := tm.Create(requestBody)
        
		return c.JSON(http.StatusCreated, todo)
	})

    ...
}
main.go

Since this create route is on the group that requires authentication you need to pass the auth token. Making the request using curl, you get back a new todo.

โฏ curl -s -X POST localhost:8888/todos/create \
  -H "Content-Type: application/json" \
  -H "Authorization: auth-token" \
  -d '{ "title": "Todo Title" }' \
  | jq

{
  "id": "1662719050096",
  "title": "Todo Title",
  "isDone": false
}

Fetch all the todos using the GET / endpoint.

โฏ curl -s localhost:8888/ | jq

[
  {
    "id": "1662719050096",
    "title": "Todo Title",
    "isDone": false
  }
]

Complete a todo #

Now you will add a new PATCH /todos/:id/complete endpoint that marks a todo as complete. Here you will see how to parse path parameters in echo.

First add a new function Complete(ID string) in the TodoManager.

func (tm *TodoManager) Complete(ID string) error {
	tm.m.Lock()
	defer tm.m.Unlock()

	// Find the todo with id
	var todo *Todo
	var index int = -1

	for i, t := range tm.todos {
		if t.ID == ID {
			todo = &t
			index = i
		}
	}

	if todo == nil {
		return echo.ErrNotFound
	}

	// Check todo is not already completed
	if todo.IsComplete {
		err := echo.ErrBadRequest
		err.Message = "todo is already complete"
		return err
	}

	// Update todo
	tm.todos[index].IsComplete = true

	return nil
}
todomanager.go

Notice that the errors being returned are from echo itself, echo.ErrNotFound and echo.ErrBadRequest. If these errors are passed to echo's e.Error function, echo will automatically set the appropriate status code and message in the response.

func main() {
	...

	authenticatedGroup.PATCH("/:id/complete", func(c echo.Context) error {
		id := c.Param("id") // ๐Ÿ‘ˆ getting the path parameter

		err := tm.Complete(id)
		if err != nil {
			c.Error(err)
			return err
		}

		return nil
	})

	...
}
main.go

The value path parameter (id) is being grabbed by using c.Param function. There is a similar function in echo called c.QueryParam to get values of query parameters.

โฏ curl -i -X PATCH localhost:8888/todos/1662757576599/complete \
-H "Content-Type: application/json" \
-H "Authorization: auth-token" 

HTTP/1.1 200 OK
Date: Fri, 09 Sep 2022 21:06:25 GMT
Content-Length: 0

Deleting a todo #

Add a function in TodoManager to remove a todo.

func (tm *TodoManager) Remove(ID string) error {
	tm.m.Lock()
	defer tm.m.Unlock()

	index := -1

	for i, t := range tm.todos {
		if t.ID == ID {
			index = i
			break
		}
	}

	if index == -1 {
		return echo.ErrNotFound
	}

	tm.todos = append(tm.todos[:index], tm.todos[index+1:]...)

	return nil
}
todomanager.go

Add a route on authenticated group to delete a the todo.

func main() {
	...
    
	authenticatedGroup.DELETE("/:id", func(c echo.Context) error {
		id := c.Param("id")

		err := tm.Remove(id)
		if err != nil {
			c.Error(err)
			return err
		}

		return nil
	})

	...
}
main.go

Just like the complete todo endpoint you are parsing the path parameter using c.Param.

โฏ curl -i -X POST localhost:8888/todos/1662763466488 \
-H "Content-Type: application/json" \
-H "Authorization: auth-token"

HTTP/1.1 200 OK
Date: Fri, 09 Sep 2022 22:44:33 GMT
Content-Length: 0

I hope this article gave you a good overview of echo framework, there is so much more you can do with echo, if you are interested visit the echo's official page here.

Subscribe

Get updated 1 - 2 times a month when new articles are published, no spam ever.

    TheDeveloperCafe ยฉ 2022-2024