Building a REST CRUD app with Golang Revel

In this guide we are are going to check a simple implementation Revel Framework REST API. In this tutorial we create will be a simple posts CRUD app that will connect to the Postgres Database. Feel free to checkout the repo where the full code is hosted here.

Before proceeding, ensure that golang is nstalled.

Installing Revel

Next we need to install the revel framework. Revel is a golang framework, it can be installed as a golang library.

To get the Revel framework, use this command?

go get github.com/revel/revel

This command does a couple of things:

  • Go uses git to clone the repository into $GOPATH/src/github.com/revel/revel/
  • Go transitively finds all of the dependencies and runs go get on them as well.

Next, get and build the revel command line tool

The revel command line tool is used to buildrun, and package Revel applications.

Use go get to install the revel command line tool:

go get github.com/revel/cmd/revel

Ensure the $GOPATH/bin directory is in your PATH so that you can reference the command from anywhere.

export PATH="$PATH:$GOPATH/bin"

Verify that revel works:

$ revel                        
Usage:
  revel [OPTIONS] <command>

Application Options:
  -v, --debug                If set the logger is set to verbose
      --historic-run-mode    If set the runmode is passed a string not json
      --historic-build-mode  If set the code is scanned using the original parsers,
                             not the go.1.11+
  -X, --build-flags=         These flags will be used when building the application.
                             May be specified multiple times, only applicable for
                             Build, Run, Package, Test commands
      --gomod-flags=         These flags will execute go mod commands for each flag,
                             this happens during the build process

Available commands:
  build
  clean
  new
  package
  run
  test
  version

Creating our application Database

We will store our data in postgres database. So before proceeding, we will need a posgres database and some credentials to connect. Ensure that you have a postges databases created with a user and a password that can connect to it.

Now create a database table that we will use. Checkout better way of using db migrations to manage database operations here

create table posts (
  id bigserial primary key,
  username varchar(255) not null,
  title varchar(100) not null,
  content text not null,
  created_at timestamptz not null default clock_timestamp(),
  updated_at timestamptz
);

Creating Revel app

Create a revel application using this command. Here we are creating an app called

go-revel-crud. The app will be created in the current directory. This command will auto generate  _boilerplate _like `app, conf, message, public, test, utils` for the app that we are creating.
➜ revel new -a go-revel-crud
Revel executing: create a skeleton Revel application
Your application has been created in:
   /Users/etowett/Proj/me/go-revel-crud

You can run it with:
   revel run -a  go-revel-crud

Next, change to the application directory and run the go mod tidy to download the initial application dependencies.

cd go-revel-crud
❯ go mod tidy
go: finding module for package github.com/garyburd/redigo/redis
go: finding module for package github.com/patrickmn/go-cache
go: finding module for package github.com/bradfitz/gomemcache/memcache
go: found github.com/bradfitz/gomemcache/memcache in github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b
go: found github.com/garyburd/redigo/redis in github.com/garyburd/redigo v1.6.2
go: found github.com/patrickmn/go-cache in github.com/patrickmn/go-cache v2.1.0+incompatible

After that, open your browser and navigate to localhost:9000. You will see IT Works! in your browser, that means that your revel app already running. But since we want a REST CRUD API, We will proceed to customize the app in our case.

Routes

Let us create the routes that we will need for our app. URL’s and routes are defined in the conf/routes file and have three columns as example below:

[METHOD] [URL Pattern] [Controller.Method]
GET      /              MySite.Welcome

By default it has some routes added, so you can figure out how it works. Add these routes to the file conf/routes

GET         /health                                     App.Health

GET         /posts                                      Post.List
GET         /post/:id                                   Post.Get
POST        /post                                       Post.Add
PUT         /post/:id                                   Post.Update
DELETE      /post/:id                                   Post.Delete

Controllers

The revel.Controller is the context for a single request and controls

  • the incoming Request stuff
  • and the Response back, in Html, Json, Xml, File or your own custom.

Let us create Posts controller functions for out code.It should be located on <app_root_directory>/app/controllers . We will call it posts.go.

We need to add “package controller” in the beginning of our code, and by default all controllers should extend a class Controller located on Revel’s library. The controller name needs to be the same declared on routes file. Now we should make a method to render the actions defined in the routes. Revel pages can receive as many attributes as you want.

For our case, let us define controller functions for all our routes:

package controllers

import (
	"fmt"
	"go-revel-crud/app/db"
	"go-revel-crud/app/entities"
	"go-revel-crud/app/forms"
	"go-revel-crud/app/models"
	"go-revel-crud/app/webutils"
	"net/http"
	"strings"

	"github.com/revel/revel"
)

type Post struct {
	App
}

func (c Post) List() revel.Result {
	var result entities.Response

	ctx := c.Request.Context()

	paginationFilter, err := webutils.FilterFromQuery(c.Params)
	if err != nil {
		c.Log.Errorf("could not filter from params: %v", err)
		result = entities.Response{
			Success: false,
			Status:  http.StatusBadRequest,
			Message: "Failed to parse page filters",
		}
		return c.RenderJSON(result)
	}

	newPost := &models.Post{}
	data, err := newPost.All(ctx, db.DB(), paginationFilter)
	if err != nil {
		c.Log.Errorf("could not get posts: %v", err)
		result = entities.Response{
			Success: false,
			Status:  http.StatusInternalServerError,
			Message: "Could not get posts",
		}
		return c.RenderJSON(result)
	}

	recordsCount, err := newPost.Count(c.Request.Context(), db.DB(), paginationFilter)
	if err != nil {
		c.Log.Errorf("could not get posts count for listing: %v", err)
		result = entities.Response{
			Success: false,
			Status:  http.StatusInternalServerError,
			Message: "Could not get posts count",
		}
		return c.Render(result)
	}

	result = entities.Response{
		Success: true,
		Status:  http.StatusOK,
		Data: map[string]interface{}{
			"posts":      data,
			"pagination": models.NewPagination(recordsCount, paginationFilter.Page, paginationFilter.Per),
		},
	}
	return c.RenderJSON(result)
}

func (c Post) Get(id int64) revel.Result {
	result := &entities.Response{}
	ctx := c.Request.Context()

	newPost := &models.Post{}
	post, err := newPost.ByID(ctx, db.DB(), id)
	if err != nil {
		c.Log.Errorf("could not get post: %v", err)
		result.Success = false
		result.Message = "Could not get the post"
		result.Data = post
		return c.RenderJSON(result)
	}

	result.Success = true
	result.Data = post
	return c.RenderJSON(result)
}

func (c Post) Add() revel.Result {
	var status int
	postForm := forms.Post{}
	err := c.Params.BindJSON(&postForm)
	if err != nil {
		c.Log.Errorf("could not bind create post form to json: %v", err)
		status = http.StatusBadRequest
		c.Response.SetStatus(status)
		return c.RenderJSON(entities.Response{
			Message: "There was a problem with the form you submitted",
			Status:  status,
			Success: false,
		})
	}

	ctx := c.Request.Context()

	v := c.Validation
	postForm.Validate(v)
	if v.HasErrors() {
		retErrors := make([]string, 0)
		for _, theErr := range v.Errors {
			retErrors = append(retErrors, theErr.Message)
		}
		status = http.StatusBadRequest
		c.Response.SetStatus(status)
		return c.RenderJSON(entities.Response{
			Message: strings.Join(retErrors, ","),
			Status:  status,
			Success: false,
		})
	}

	newPost := &models.Post{
		Username: postForm.Username,
		Title:    postForm.Title,
		Content:  postForm.Content,
	}
	err = newPost.Save(ctx, db.DB())
	if err != nil {
		c.Log.Errorf("could not save post: %v", err)
		status = http.StatusInternalServerError
		c.Response.SetStatus(status)
		return c.RenderJSON(entities.Response{
			Message: "Encountered an error saving request.",
			Status:  status,
			Success: false,
		})
	}

	status = http.StatusCreated
	c.Response.SetStatus(status)
	return c.RenderJSON(entities.Response{
		Data:    newPost,
		Status:  status,
		Success: true,
	})
}

func (c *Post) Update(id int64) revel.Result {
	var status int
	data := models.Post{}
	err := c.Params.BindJSON(&data)
	if err != nil {
		c.Log.Errorf("could not bind update post form to json: %v", err)
		status = http.StatusBadRequest
		c.Response.SetStatus(status)
		return c.RenderJSON(entities.Response{
			Message: "There was a problem with the form you submitted",
			Status:  status,
			Success: false,
		})
	}

	data.ID = id

	err = data.Save(c.Request.Context(), db.DB())
	if err != nil {
		c.Log.Errorf("could not save post: %v", err)
		status = http.StatusInternalServerError
		c.Response.SetStatus(status)
		return c.RenderJSON(entities.Response{
			Message: "Encountered an error saving request.",
			Status:  status,
			Success: false,
		})

	}

	status = http.StatusOK
	c.Response.SetStatus(status)
	return c.RenderJSON(entities.Response{
		Data:    data,
		Status:  status,
		Success: true,
	})
}

func (c *Post) Delete(id int64) revel.Result {
	var status int
	ctx := c.Request.Context()

	data := models.Post{}
	data.ID = id
	_, err := data.Delete(ctx, db.DB())
	if err != nil {
		c.Log.Errorf("could not delete post: %v", err)
		status = http.StatusInternalServerError
		c.Response.SetStatus(status)
		return c.RenderJSON(entities.Response{
			Message: "Encountered an error deleting the post",
			Status:  status,
			Success: false,
		})
	}

	status = http.StatusOK
	c.Response.SetStatus(status)
	return c.RenderJSON(entities.Response{
		Status:  status,
		Success: true,
		Message: fmt.Sprintf("post id =[%v] deleted", id),
	})
}

Models

For our models, we will create functions that will interact with the database to execute the db queries for the respective functions that we want to achieve. We will have functions to do create records, update, fetch and delete.

Create the models for our posts crud app:

package models

import (
	"context"
	"fmt"
	"go-revel-crud/app/db"
	"go-revel-crud/app/helpers"
	"strings"
)

const (
	createPostSQL = `insert into posts (username, title, content, created_at) values ($1, $2, $3, $4) returning id`
	getPostSQL    = `select id, username, title, content, created_at, updated_at from posts`
	getPostByID   = getPostSQL + ` where id=$1`
	updatePostSQL = `update posts set (username, title, content, updated_at) = ($1, $2, $3, $4) where id = $5`
	countPostSQL  = `select count(id) from posts`
	deletePostSQL = `delete from posts where id=$1`
)

type (
	Post struct {
		SequentialIdentifier
		Username string `json:"username"`
		Title    string `json:"title"`
		Content  string `json:"content"`
		Timestamps
	}
)

func (p *Post) All(
	ctx context.Context,
	db db.SQLOperations,
	filter *Filter,
) ([]*Post, error) {
	posts := make([]*Post, 0)

	query, args := p.buildQuery(
		getPostSQL,
		filter,
	)

	rows, err := db.QueryContext(
		ctx,
		query,
		args...,
	)
	defer rows.Close()
	if err != nil {
		return posts, err
	}

	for rows.Next() {
		var post Post
		err = rows.Scan(
			&post.ID,
			&post.Username,
			&post.Title,
			&post.Content,
			&post.CreatedAt,
			&post.UpdatedAt,
		)
		if err != nil {
			return posts, err
		}
		posts = append(posts, &post)
	}

	return posts, err
}

func (q *Post) Count(
	ctx context.Context,
	db db.SQLOperations,
	filter *Filter,
) (int, error) {
	query, args := q.buildQuery(
		countPostSQL,
		&Filter{
			Term: filter.Term,
		},
	)
	var recordsCount int
	err := db.QueryRowContext(ctx, query, args...).Scan(&recordsCount)
	return recordsCount, err
}

func (p *Post) Delete(
	ctx context.Context,
	db db.SQLOperations,
) (int64, error) {
	res, err := db.ExecContext(ctx, deletePostSQL, p.ID)
	if err != nil {
		return 0, err
	}

	rowsDeleted, err := res.RowsAffected()
	if err != nil {
		return 0, err
	}

	return rowsDeleted, nil
}

func (p *Post) ByID(
	ctx context.Context,
	db db.SQLOperations,
	id int64,
) (*Post, error) {
	row := db.QueryRowContext(ctx, getPostByID, id)
	return p.scan(row)
}

func (p *Post) Save(
	ctx context.Context,
	db db.SQLOperations,
) error {
	p.Timestamps.Touch()

	var err error
	if p.IsNew() {
		err := db.QueryRowContext(
			ctx,
			createPostSQL,
			p.Username,
			p.Title,
			p.Content,
			p.Timestamps.CreatedAt,
		).Scan(&p.ID)
		return err
	}
	_, err = db.ExecContext(
		ctx,
		updatePostSQL,
		p.Username,
		p.Title,
		p.Content,
		p.Timestamps.UpdatedAt,
		p.ID,
	)
	return err
}

func (*Post) scan(
	row db.RowScanner,
) (*Post, error) {
	var p Post
	err := row.Scan(
		&p.ID,
		&p.Username,
		&p.Title,
		&p.Content,
		&p.CreatedAt,
		&p.UpdatedAt,
	)
	return &p, err
}

func (p *Post) buildQuery(
	query string,
	filter *Filter,
) (string, []interface{}) {
	conditions := make([]string, 0)
	args := make([]interface{}, 0)
	placeholder := helpers.NewPlaceholder()

	if filter.Term != "" {
		likeStmt := make([]string, 0)
		columns := []string{"username", "title", "content"}
		for _, col := range columns {
			search := fmt.Sprintf(" (lower(%s) like '%%' || $%d || '%%')", col, placeholder.Touch())
			likeStmt = append(likeStmt, search)
			args = append(args, filter.Term)
		}
		conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(likeStmt, " or")))
	}

	if len(conditions) > 0 {
		query += " where" + strings.Join(conditions, " and")
	}

	if filter.Per > 0 && filter.Page > 0 {
		query += fmt.Sprintf(" order by id desc limit $%d offset $%d", placeholder.Touch(), placeholder.Touch())
		args = append(args, filter.Per, (filter.Page-1)*filter.Per)
	}

	return query, args
}

Other logic

We could also have code for the following things in our code:

  • Views – defines the html content to be rendered to the browser parsing the data. By default they are located on: /<controller_name>/. You will need to create the Apps directory on: <app_root_directory>/app/views.
  • Tests – Revel has a place to tests, it is located on <app_root_directory>/tests

Runing the app

Once the app is ready, we can run it using the

revel run . command in the same directory. But before doing that, we need to set the application Database URL and the PORT.

❯ export DB_URL=postgres://go-revel-crud:go-revel-crud@127.0.0.1:5432/go-revel-crud?sslmode=disable
<meta charset="utf-8">❯ export PORT=8090
<meta charset="utf-8">❯ revel run .
Revel executing: run a Revel application
Changed detected, recompiling
Parsing packages, (may require download if not cached)... Completed
INFO  10:56:59    app     run.go:34: Running revel server
INFO  10:56:59    app   plugin.go:9: Go to /@tests to run the tests.
INFO  10:56:59    app revel_logger.go:29: DB Connected Successfully
Revel engine is listening on.. 0.0.0.0:50065
Revel proxy is listening, point your browser to : 8090

Time to recompile 4.931984424s

Testing the application

Verify that the healthcheck endpoint is working with this curl command?

➜ curl http://127.0.0.1:8090/health
{
  "build_time": "2021-10-29T07:56:56Z",
  "db": {
    "hello": "1",
    "type": "postgres",
    "up": true,
    "version": "PostgreSQL 14.0 on x86_64-pc-linux-musl, compiled by gcc (Alpine 10.3.1_git20210424) 10.3.1 20210424, 64-bit"
  },
  "server": {
    "compiler": "gc",
    "cpu": 12,
    "goarch": "amd64",
    "goos": "darwin",
    "goroutines": 11,
    "hostname": "eutychus-pc.local",
    "memory": {
      "alloc": "1 MB",
      "num_gc": 1,
      "sys": "18 MB",
      "total_alloc": "2 MB"
    }
  },
  "status": 200,
  "success": true,
  "time": {
    "now": "2021-10-29T10:58:15.161494+03:00",
    "offset": 10800,
    "timezone": "EAT"
  },
  "version": "git-8e73d25-dirty"
}

Add post endpoint {.wp-block-heading}

Next verify the Add post endpoint:

curl -X POST http://127.0.0.1:8090/post \
    -H "Accept: application/json" \
    -H "Content-Type: application/json" \
    -d '{
        "username": "eutychus",
        "title": "Post One",
        "content": "This is the first post"
    }'

Response:

{
  "success": true,
  "status": 201,
  "message": "",
  "data": {
    "id": 1,
    "username": "eutychus",
    "title": "Post One",
    "content": "This is the first post",
    "created_at": "2021-10-29T11:11:23.50606+03:00",
    "updated_at": null
}

List Posts endpoint

curl http://127.0.0.1:8090/posts

Response:

{
  "success": true,
  "status": 200,
  "message": "",
  "data": {
    "pagination": {
      "count": 2,
      "has_next_page": false,
      "has_prev_page": false,
      "next_page": null,
      "num_pages": 1,
      "page": 1,
      "per": 20,
      "prev_page": null
    },
    "posts": [
      {
        "id": 2,
        "username": "eutychus",
        "title": "Post Two",
        "content": "This is the second post",
        "created_at": "2021-10-29T08:15:31.026777Z",
        "updated_at": null
      },
      {
        "id": 1,
        "username": "eutychus",
        "title": "Post One",
        "content": "This is the first post",
        "created_at": "2021-10-29T08:11:23.50606Z",
        "updated_at": null
      }
    ]
  }
}

Get single post

curl http://127.0.0.1:8090/post/1

Response:

{
  "success": true,
  "status": 0,
  "message": "",
  "data": {
    "id": 1,
    "username": "eutychus",
    "title": "Post One",
    "content": "This is the first post",
    "created_at": "2021-10-29T08:11:23.50606Z",
    "updated_at": null
  }
}

Update post

curl -X PUT http://127.0.0.1:8090/post/1 \
    -H "Accept: application/json" \
    -H "Content-Type: application/json" \
    -d '{
        "username": "eutychus",
        "title": "Post One - Updated",
        "content": "This is the first post - Updated"
    }'

Response:

{
  "success": true,
  "status": 200,
  "message": "",
  "data": {
    "id": 1,
    "username": "eutychus",
    "title": "Post One - Updated",
    "content": "This is the first post - Updated",
    "created_at": "2021-10-29T11:22:00.507955+03:00",
    "updated_at": null
  }
}

Delete Post

curl -X DELETE http://127.0.0.1:8090/post/1

Response:

{
  "success": true,
  "status": 200,
  "message": "post id =[1] deleted",
  "data": null
}

Conclusion:

In this post we managed to explore how to do CRUD with golang revel. Checkout full code in this repo here.

comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy