DEV Community

Ramu Mangalarapu
Ramu Mangalarapu

Posted on

Building REST APIs in Golang go-gin with persistence database Postgres

Let's build REST APIs in Go using Gin web framework.

I will adding authentication, docker image and sample deployment in Google cloud in next coming articles.

We are going to build APIs around Albums that I took from go.dev but I will extend by using the Postgres database. So, this will have persistent data storage.

Usually when we are working with web frameworks, they usually contains all the required components to build full application without importing any other extra library. See here

We will be having routing, JSON/XML parsers (we could say famous data interchange format parsers), validations, sanitizations of input data, HTTP clients to make call to external APIs, database drivers, things that makes response writing easy to server, ORMs (object relational mapper), Security measures such as CSRF, XSS, SQL Injections, logging etc.. which are required to build application easily.

Just imagine, we do not have fully tested router functionality and in case if want to build router from scratch, it is really hard for normal developer to build and maintain all the times. All the libraries, frameworks and SDKs provide abstraction for us to build application easily. We only need to worry about what is our business logic. In fact there are even Go libraries that generated REST APIs skelton for us. Awesome right ?

Yes, it is, it is happening and it will continue to happen that we do not bother to write code for common web application items such as authentication, authorization, input validations, response generation, unit testing, integration testing, logging, middleware, take any functionality you wanted to have, there will be multiple libraries and APIs (this could be SaaS companies which provide enterprise level things) which makes things easy for us.

I think we are going beyond our current topic, all I am sharing here is basic example of building REST APIs which has persistent memory with Postgres Server.

Let's initialize go module to create to rest apis.

go mod init example.com/web-services

First of all we need to connect to database, I am keeping all the files in same main package as this is simple one but usually we should divide into packages, there are no official project layout for Go but usually developers follow things like handlers, config, services, apis, domain, etc like that, but there are like golang project layout

postgressql.sql


CREATE TABLE IF NOT EXISTS artist (
    id SERIAL PRIMARY KEY,
    name VARCHAR ( 50 ) NOT NULL --this is name of the Author,
    title VARCHAR ( 50 ) UNIQUE NOT NULL,
    price VARCHAR ( 255 ) NOT NULL
);

SELECT id,name,title,price FROM artist LIMIT 5 OFFSET 1;

UPDATE artist 
SET name='nae56',title='tle56', price=66.7667
WHERE id=8;

SELECT * FROM artist;

SELECT * FROM artist WHERE id=8;

DELETE FROM artist WHERE id=1;

INSERT INTO artist(name,title,price) VALUES('name3','title3','price3') ON CONFLICT DO NOTHING;
Enter fullscreen mode Exit fullscreen mode

database.go


package main

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

    _ "github.com/lib/pq"
)

// Env holds database connection to Postgres
type Env struct {
    DB *sql.DB
}

// database variables
// usually we should get them from env like os.Getenv("variableName")
const (
    host     = "localhost"
    port     = 5439
    user     = "postgres"
    password = "root"
    dbname   = "artists"
)

// ConnectDB tries to connect DB and on succcesful it returns
// DB connection string and nil error, otherwise return empty DB and the corresponding error.
func ConnectDB() (*sql.DB, error) {
    connString := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname = %s sslmode=disable", host, port, user, password, dbname)
    db, err := sql.Open("postgres", connString)
    if err != nil {
        log.Printf("failed to connect to database: %v", err)
        return &sql.DB{}, err
    }
    return db, nil
}
Enter fullscreen mode Exit fullscreen mode

main.go

package main

import (
    "log"

    "github.com/gin-gonic/gin"
)

func main() {
    // connect to DB first
    env := new(Env)
    var err error
    env.DB, err = ConnectDB()
    if err != nil {
        log.Fatalf("failed to start the server: %v", err)
    }

    router := gin.Default()
    router.GET("/albums/:id", env.GetAlbumByID)
    router.GET("/albums", env.GetAlbums)
    router.POST("/albums", env.PostAlbum)
    router.PUT("/albums", env.UpdateAlbum)
    router.DELETE("/albums/:id", env.DeleteAlbumByID)

    router.Run("localhost:8080")
}

Enter fullscreen mode Exit fullscreen mode

handler.go

package main

// This is to ensure we do not overload the server when we have millions of rows
var recordFetchLimit = 100 // testing purpose
Enter fullscreen mode Exit fullscreen mode

album_get.go

package main

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

    "github.com/gin-gonic/gin"

    _ "github.com/lib/pq"
)

// GetAlbumByID locates the album whose ID value matches the id
// parameter sent by the client, then returns that album as a response.
func (env Env) GetAlbumByID(c *gin.Context) {
    id, err := strconv.Atoi(c.Param("id"))
    if err != nil {
        e := fmt.Sprintf("received invalid id path param which is not string: %v", c.Param("id"))
        log.Println(e)
        makeGinResponse(c, http.StatusNotFound, e)
        return
    }

    var name, title string
    var price float64
    q := `SELECT * FROM artist WHERE id=$1;`
    row := env.DB.QueryRow(q, id)
    err = row.Scan(&id, &name, &title, &price)
    switch err {
    case sql.ErrNoRows:
        log.Printf("no rows are present for alubum with id: %d", id)
        makeGinResponse(c, http.StatusBadRequest, err.Error())
    case nil:
        log.Printf("we are able to fetch album with given id: %d", id)
        c.JSON(http.StatusOK, NewAlbum(id, title, name, price))
    default:
        e := fmt.Sprintf("error: %v occurred while reading the databse for Album record with id: %d", err, id)
        log.Println(e)
        makeGinResponse(c, http.StatusInternalServerError, err.Error())
    }
}

// GetAlbums responds with the list of all albums as JSON.
func (env Env) GetAlbums(c *gin.Context) {
    // Note:
    //
    // pagnination can be impleted in may ways, but I am following one of the way,
    // if you feel this is confusing, please read medium article that I have added below
    // For this page and perPage isseus, front end engineers can take care of it
    // by escaping and sanitization of query params.
    // Please see: https://www.enterprisedb.com/postgres-tutorials/how-use-limit-and-offset-postgresql
    // Please see: https://levelup.gitconnected.com/creating-a-data-pagination-function-in-postgresql-2a032084af54
    page := c.Query("page") // AKA limit in SQL terms
    if page == "" {
        e := "missing query param: page"
        log.Println(e)
        makeGinResponse(c, http.StatusNotFound, e)
        return
    }

    perPage := c.Query("perPage") // AKA limit in SQL terms
    if perPage == "" {
        e := "missing query param: perPage"
        log.Println(e)
        makeGinResponse(c, http.StatusNotFound, e)
        return
    }

    limit, err := strconv.Atoi(page)
    if err != nil {
        e := fmt.Sprintf("received invalid page query param which is not integer : %v", page)
        log.Println(e)
        makeGinResponse(c, http.StatusBadRequest, e)
        return
    }

    if limit > recordFetchLimit {
        // Seems some bad user or front end developer playing with query params!
        e := fmt.Sprintf("we agreed to fetch less than %d records but we received request for %d", recordFetchLimit, limit)
        log.Println(e)
        makeGinResponse(c, http.StatusBadRequest, e)
        return
    }

    offset, err := strconv.Atoi(perPage)
    if err != nil {
        e := fmt.Sprintf("received invalid offset query param which is not integer : %v", page)
        log.Println(e)
        makeGinResponse(c, http.StatusBadRequest, e)
        return
    }

    // anyway, let's check if offset is a negative value
    if offset < 0 {
        e := "offset query param cannot be negative"
        log.Println(e)
        makeGinResponse(c, http.StatusBadRequest, e)
        return
    }

    q := `SELECT id,name,title,price FROM artist LIMIT $1 OFFSET $2;`
    rows, err := env.DB.Query(q, limit, offset)
    switch err {
    case sql.ErrNoRows:
        defer rows.Close()
        e := "no rows records found in artist table to read"
        log.Println(e)
        makeGinResponse(c, http.StatusBadRequest, e)
    case nil:
        defer rows.Close()
        a := make([]Album, 0)
        var rowsReadErr bool
        for rows.Next() {
            var id int
            var name, title string
            var price float64
            err = rows.Scan(&id, &name, &title, &price)
            if err != nil {
                log.Printf("error occurred while reading the database rows: %v", err)
                rowsReadErr = true
                break
            }
            a = append(a, NewAlbum(id, title, name, price))
        }

        if rowsReadErr {
            log.Println("we are not able to fetch few records")
        }

        // let's return the read rows at least
        log.Printf("we are able to fetch albums for requested limit: %d and offest: %d", limit, offset)
        c.JSON(http.StatusOK, a)
    default:
        defer rows.Close()
        // this should not happen
        e := "some internal database server error"
        log.Println(e)
        makeGinResponse(c, http.StatusInternalServerError, e)
    }
}

Enter fullscreen mode Exit fullscreen mode

album_post.go

package main

import (
    "log"
    "net/http"

    "github.com/gin-gonic/gin"

    _ "github.com/lib/pq"
)

// PostAlbums adds an album from JSON received in the request body.
func (env Env) PostAlbum(c *gin.Context) {
    // Call BindJSON to bind the received JSON to
    // newAlbum.
    var newAlbum Album
    if err := c.BindJSON(&newAlbum); err != nil {
        log.Printf("invalid JSON body: %v", err)
        makeGinResponse(c, http.StatusNotFound, err.Error())
        return
    }

    q := `INSERT INTO artist(name,title,price) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`
    result, err := env.DB.Exec(q, newAlbum.Artist, newAlbum.Title, newAlbum.Price)
    if err != nil {
        log.Printf("error occurred while inserting new record into artist table: %v", err)
        makeGinResponse(c, http.StatusInternalServerError, err.Error())
        return
    }

    // checking the number of rows affected
    n, err := result.RowsAffected()
    if err != nil {
        log.Printf("error occurred while checking the returned result from database after insertion: %v", err)
        makeGinResponse(c, http.StatusInternalServerError, err.Error())
        return
    }

    // if no record was inserted, let us say client has failed
    if n == 0 {
        e := "could not insert the record, please try again after sometime"
        log.Println(e)
        makeGinResponse(c, http.StatusInternalServerError, e)
        return
    }

    // NOTE:
    //
    // Here I wanted to return the location for newly created Album but this
    // 'pq' library does not support, LastInsertionID functionality.
    m := "successfully created the record"
    log.Println(m)
    makeGinResponse(c, http.StatusOK, m)
}

Enter fullscreen mode Exit fullscreen mode

album_put.go

package main

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

    "github.com/gin-gonic/gin"

    _ "github.com/lib/pq"
)

// UpdateAlbum updates the Album with the given details if record found.
func (env Env) UpdateAlbum(c *gin.Context) {
    // Call BindJSON to bind the received JSON to
    // toBeUpdatedAlbum.
    var toBeUpdatedAlbum Album
    if err := c.BindJSON(&toBeUpdatedAlbum); err != nil {
        e := fmt.Sprintf("invalid JSON body: %v", err)
        log.Println(e)
        makeGinResponse(c, http.StatusBadRequest, e)
        return
    }

    q := `UPDATE artist 
    SET name=$1,title=$2, price=$3
    WHERE id=$4;`
    result, err := env.DB.Exec(q, toBeUpdatedAlbum.Artist, toBeUpdatedAlbum.Title, toBeUpdatedAlbum.Price, toBeUpdatedAlbum.ID)
    if err != nil {
        e := fmt.Sprintf("error: %v occurred while updating artist record with id: %d", err, toBeUpdatedAlbum.ID)
        log.Println(e)
        makeGinResponse(c, http.StatusInternalServerError, e)
        return
    }

    // checking the number of rows affected
    n, err := result.RowsAffected()
    if err != nil {
        e := fmt.Sprintf("error occurred while checking the returned result from database after updation: %v", err)
        log.Println(e)
        makeGinResponse(c, http.StatusInternalServerError, e)
    }

    // if no record was updated, let us say client has failed
    if n == 0 {
        e := "could not update the record, please try again after sometime"
        log.Println(e)
        makeGinResponse(c, http.StatusInternalServerError, e)
        return
    }

    m := "successfully updated the record"
    log.Println(m)
    makeGinResponse(c, http.StatusOK, m)
}

Enter fullscreen mode Exit fullscreen mode

album_delete.go

package main

import (
    "fmt"
    "log"
    "net/http"
    "strconv"

    "github.com/gin-gonic/gin"

    _ "github.com/lib/pq"
)

// DeleteAlbumByID locates the album whose ID value matches the id
// parameter sent by the client, then returns that corresponding message.
func (env Env) DeleteAlbumByID(c *gin.Context) {
    id, err := strconv.Atoi(c.Param("id"))
    if err != nil {
        e := fmt.Sprintf("received invalid id path param which is not string: %v", c.Param("id"))
        log.Println(e)
        makeGinResponse(c, http.StatusNotFound, e)
        return
    }

    q := `DELETE FROM artist WHERE id = $1;`
    result, err := env.DB.Exec(q, id)
    if err != nil {
        e := fmt.Sprintf("error occurred while deleting artist record with id: %d and error is: %v", id, err)
        log.Println(e)
        makeGinResponse(c, http.StatusInternalServerError, e)
        return
    }

    // checking the number of rows affected
    n, err := result.RowsAffected()
    if err != nil {
        e := fmt.Sprintf("error occurred while checking the returned result from database after deletion: %v", err)
        log.Println(e)
        makeGinResponse(c, http.StatusInternalServerError, e)
        return
    }

    // if no record was deleted, let us inform that there might be no
    // records to delete for this given album ID.
    if n == 0 {
        e := "could not delete the record, there might be no records for the given ID"
        log.Println(e)
        makeGinResponse(c, http.StatusBadRequest, e)
        return
    }

    m := "successfully deleted the record"
    log.Println(m)
    makeGinResponse(c, http.StatusOK, m)
}

Enter fullscreen mode Exit fullscreen mode

We can add business object in domain.go

package main

// Album holds of few important details about it.
type Album struct {
    ID     int     `json:"id,omitempty"`
    Title  string  `json:"title"`
    Artist string  `json:"artist"`
    Price  float64 `json:"price"`
}

// NewAlbum is Album constructor.
func NewAlbum(id int, title, artist string, price float64) Album {
    return Album{id, title, artist, price}
}

Enter fullscreen mode Exit fullscreen mode

There are certain things in application we repeat again, may be we can make one function (DRY principal) add it in the utils.go

package main

import (
    "github.com/gin-gonic/gin"
)

func makeGinResponse(c *gin.Context, statusCode int, value string) {
    c.JSON(statusCode, gin.H{
        "message":    value,
        "statusCode": statusCode,
    })
}

Enter fullscreen mode Exit fullscreen mode

In case if you want try to just take all the codes and copy it run go mod download or go mod tidy to get go.sum and go.mod files.

I have tested them fully, please find Postman collection

postmainCollection.json


{
    "info": {
        "_postman_id": "b7f050de-a273-408c-b11d-3ec27fb8c5d4",
        "name": "Learning01",
        "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
    },
    "item": [
        {
            "name": "Get Albums",
            "request": {
                "method": "GET",
                "header": [],
                "url": {
                    "raw": "localhost:8080/albums?page=100&perPage=78",
                    "host": [
                        "localhost"
                    ],
                    "port": "8080",
                    "path": [
                        "albums"
                    ],
                    "query": [
                        {
                            "key": "page",
                            "value": "100"
                        },
                        {
                            "key": "perPage",
                            "value": "78"
                        }
                    ]
                }
            },
            "response": []
        },
        {
            "name": "Get Albums By ID",
            "request": {
                "method": "GET",
                "header": [],
                "url": {
                    "raw": "localhost:8080/albums/10",
                    "host": [
                        "localhost"
                    ],
                    "port": "8080",
                    "path": [
                        "albums",
                        "10"
                    ]
                }
            },
            "response": []
        },
        {
            "name": "Post Album",
            "request": {
                "method": "POST",
                "header": [],
                "body": {
                    "mode": "raw",
                    "raw": "{\r\n    \"id\":6667778,\r\n    \"title\":\"newTitle\",\r\n    \"artist\":\"dfjkfdkj\",\r\n    \"price\":123434.49\r\n}",
                    "options": {
                        "raw": {
                            "language": "json"
                        }
                    }
                },
                "url": {
                    "raw": "localhost:8080/albums",
                    "host": [
                        "localhost"
                    ],
                    "port": "8080",
                    "path": [
                        "albums"
                    ]
                }
            },
            "response": []
        },
        {
            "name": "Update Album",
            "request": {
                "method": "PUT",
                "header": [],
                "body": {
                    "mode": "raw",
                    "raw": "{\r\n    \"id\":10,\r\n    \"title\":\"dddf\",\r\n    \"artist\":\"88888888\",\r\n    \"price\":888.88888\r\n}",
                    "options": {
                        "raw": {
                            "language": "json"
                        }
                    }
                },
                "url": {
                    "raw": "localhost:8080/albums",
                    "host": [
                        "localhost"
                    ],
                    "port": "8080",
                    "path": [
                        "albums"
                    ]
                }
            },
            "response": []
        },
        {
            "name": "Delete Album by ID",
            "request": {
                "method": "DELETE",
                "header": [],
                "url": {
                    "raw": "localhost:8080/albums/10",
                    "host": [
                        "localhost"
                    ],
                    "port": "8080",
                    "path": [
                        "albums",
                        "10"
                    ]
                }
            },
            "response": []
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

More sharing of learnings to come ...
Happy unlearn.learn,relearn :)
Thank you.

Discussion (2)

Collapse
peteole profile image
Ole Petersen

Interesting article!

Collapse
aaravrrrrrr profile image
Aarav Reddy

Thanks for writing