Restful API with Golang practical approach
#go#tutorial#microservices#devops
In this tutorial, we would be creating a Restful API with a practical approach of clean architecture and native Golang without any frameworks.
There are lots of tutorials out there, for Restful API but the issue they are facing is they don't have a clear structure and use various frameworks.
Those tutorials depend on the existence of some frameworks of Golang. With every external framework being used in the project, it comes with great responsibility. I don't have anything against these frameworks, but rather I prefer to minimize the use of external frameworks and stick with standard packages of Golang.
When a beginner starts learning Golang, most of them jump straight away to the famous external frameworks, such as Gin, Beego, Fiber and many more. Frameworks are good to implement something fast, but the main issue is here that beginners are using frameworks without understanding the native approach that Golang provides out of the box. This approach leads to the developer being dependent on the specific framework. If you are sticking with frameworks, what would you do if these frameworks are being deprecated, have bugs or are not being supported anymore?
All of these frameworks use the native Golang codes behind them with an extra layer which makes them frameworks.
So my two cents here is to try to learn standard Golang packages this way you will not get lost in the world of frameworks, as Golang provides most of the required things out of the box.
The initial requirement before we start our tutorial is to make sure that you have the following installed setup on your computer:
1) Golang (https://go.dev/doc/install)
22)) IDE of your choice VSCode or GoLand JetBrains.
3) Postgresql (https://www.postgresql.org/download/)
44)) Insomnia (https://insomnia.rest/)
55)) Golang-Migrate (https://github.com/golang-migrate/migrate/blob/master/cmd/migrate/README.md)
For the purpose of this tutorial, we will not be using Docker as explaining Docker and it is function is out of the context of this tutorial.
Initial setup
Create your folder and initialize the app:
mkdir rest-api
cd rest-api
git init
Golang has a package manager which is handled by go.mod
file. To generate this file, you must run the following command:go mod init {Name Of Remote Repository}
.
The convention to create a go.mod
is to use the name of the remote repository of your app, in my case, it will be go mod init
github.com/fir1/rest-api
.
It is important to name it as a repository name because that is how Go manages dependencies when you execute the following command go get {PackageName}
.
First, we need to create a project structure, before starting the actual implementation of Restful API.
I have already written a full blog regarding structuring a Golang application, you can read it in full by clicking here. (https://dev.to/firdavs_kasymov/a-practical-approach-to-structuring-golang-applications-1cc2)
So in this project, we would be following the same structure as it is explained in the previous blog.
Architecture
The architecture of our program will be looking like the following diagram:
As is clear from the diagram we have isolation, between Repository, Model, Service and Delivery layers.
This approach has been taken by the rules of the clean architecture of Uncle Bob more info at https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html
With this pattern we can have a clean and easy-to-follow logic, independent of any layer, so your business rules simply don't know anything at all about the outside world. The below flow explains how the layers access each other.
Delivery (REST, gRPC) -> Business Service -> Repository -> Model.
As you can see that we are not exposing the repository directly to the Delivery layer, instead only our business service has access to it. And once we have received an API call we just expose the business service to the delivery layer, which I think is quite logical to do and easier to follow.
Creating Restful endpoints
For the purpose of this tutorial we will be creating a simple REST API for the TO-DO list application which has a business logic of Creating, Reading, Updating and Deleting the todo item, known as (CRUD).
We will be using some essential libraries to make the REST API so please execute the following commands:
go get github.com/gorilla/mux
go get github.com/sirupsen/logrus
go get github.com/jmoiron/sqlx
go get github.com/lib/pq
go get github.com/asaskevich/govalidator
go get github.com/joho/godotenv
go get github.com/kelseyhightower/envconfig
Briefly, we will be explaining what each library does:
mux:
Package gorilla/mux implements a request router and dispatcher for matching incoming requests to their respective handler. Read in full from here (https://github.com/gorilla/mux)
logrus:
Logrus is a structured logger for Go (golang), completely API compatible with the standard library logger. (https://github.com/sirupsen/logrus)
sqlx:
sqlx is a library which provides a set of extensions on go's standard database/sql library. (https://github.com/jmoiron/sqlx)
pq:
A pure Go postgres driver for Go's database/sql package (https://github.com/lib/pq)
govalidator:
A package of validators and sanitisers for strings, structs and collections (https://github.com/asaskevich/govalidator)
godotenv:
A library for loading .env
config files
envconfig:
Library for managing configuration data from environment variables (https://github.com/joho/godotenv)
Config
In this project, we are going to use dependencies such as Postgres, as Postgres requires the client connection to provide credentials to be able to connect to the database.
So we have decided to create .env
file in the root of project with the credentials for the database we are going to use:
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=postgres
DATABASE_PASSWORD=password
DATABASE_NAME=rest
SERVER_PORT=80
The above credentials for Database might be different in your local computer, so make sure to adjust it accordingly.
Create a file in directory configs/config.go
with the content:
package configs
import (
"github.com/joho/godotenv"
"github.com/kelseyhightower/envconfig"
)
type Config struct {
Database Database
ServerPort int `envconfig:"SERVER_PORT" default:"80"`
}
type Database struct {
Host string `envconfig:"DATABASE_HOST" required:"true"`
Port int `envconfig:"DATABASE_PORT" required:"true"`
User string `envconfig:"DATABASE_USER" required:"true"`
Password string `envconfig:"DATABASE_PASSWORD" required:"true"`
Name string `envconfig:"DATABASE_NAME" required:"true"`
}
func NewParsedConfig() (Config, error) {
_ = godotenv.Load(".env")
cnf := Config{}
err := envconfig.Process("", &cnf)
return cnf, err
}
This code will be responsible for parsing .env
files content to the struct Config
.
Helper packages
In this section, we would be creating a helper package, which can be used within our project or can be imported to external projects.
Package: db
This package will have methods linked to the Database, such as connecting to the database, and error handling.
Create a folder in pkg/db
which will include the files of db
package.
Create the following associated files.
pkg/db/db.go
package db
import (
"fmt"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
type ConfingDB struct {
Host string
Port int
User string
Password string
Name string
}
func Connect(cnf ConfingDB) (*sqlx.DB, error) {
dsn := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
cnf.Host,
cnf.Port,
cnf.User,
cnf.Password,
cnf.Name,
)
db, err := sqlx.Connect("postgres", dsn)
return db, err
}
And the following file for error handling
pkg/db/error.go
package db
import (
"database/sql"
"errors"
"fmt"
)
func HandleError(err error) error {
if errors.Is(err, sql.ErrNoRows) {
return ErrObjectNotFound{}
}
return err
}
// ErrObjectNotFound is used to indicate that selecting an individual object
// yielded no result. Declared as type, not value, for consistency reasons.
type ErrObjectNotFound struct{}
func (ErrObjectNotFound) Error() string {
return "object not found"
}
func (ErrObjectNotFound) Unwrap() error {
return fmt.Errorf("object not found")
}
Database migration
If you have followed the initial setup, you should have already "golang-migrate" installed locally.
So we use migration files all together to keep the track of database scheme changes.
For the purpose of this tutorial, we must create a table named todo
for our database so for that we would be using golang-migrate
tool.
First, create a folder migrations
in the root directory.
Next, execute the following command from the root working directory of the project
migrate create -ext sql -dir migrations -seq create_todo_table
The command will automatically create two files inside migrations
folder.
So we have now two files:
migrations/000001_create_todo_table.up.sql
We need to put all the changes which we would like to do in a database on this file, such as the creation of a new table or any changes related to the database.
Please put the following content into this file:
CREATE TABLE todo
(
id SERIAL,
name TEXT NOT NULL,
description TEXT NOT NULL,
status SMALLINT NOT NULL,
created_on TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_on TIMESTAMP(0) WITHOUT TIME ZONE,
deleted_on TIMESTAMP(0) WITHOUT TIME ZONE,
PRIMARY KEY (id)
);
When using Migrate CLI we need to pass to database URL. Let's export it to a variable for convenience:
export POSTGRESQL_URL='postgres://postgres:password@localhost:5432/rest?sslmode=disable'
Then run migration files by executing the command:
migrate -database ${POSTGRESQL_URL} -path migrations up
000001_create_todo_table.down.sql
In case of if you would like to roll back migrations, we will use .down.sql
files, so in this tutorial rollback of migration would be to delete a created table.
So please put the following content into this file:
DROP TABLE IF EXISTS todo;
To rollback the previously executed migration run the following command:
migrate -database ${POSTGRESQL_URL} -path migrations down
Creating Layers
Model
We would be creating a separate folder for the model layer in the directory internal/todo/model/model.go
inside the file please have the following content:
package model
import "time"
type Status int
const (
StatusPending Status = iota + 1
StatusInProgress
StatusDone
)
func (s Status) IsValid() bool {
switch s {
case StatusPending:
return true
case StatusInProgress:
return true
case StatusDone:
return true
}
return false
}
type ToDo struct {
ID int `db:"id"`
Name string `db:"name"`
Description string `db:"description"`
Status Status `db:"status"`
CreatedOn time.Time `db:"created_on"`
UpdatedOn *time.Time `db:"updated_on"`
DeletedOn *time.Time `db:"deleted_on"`
}
We would be using model
purely to interact with the Database, to get the object from it and parse it to our model.
Repository
Let's create our repository which will be responsible for handling all the database interactions for the ToDo model.
Create a separate directory for the repository internal/todo/repository
and have file repository.go
within:
package repository
import (
"context"
"fmt"
"github.com/fir1/rest-api/internal/todo/model"
"github.com/fir1/rest-api/pkg/db"
"github.com/jmoiron/sqlx"
)
type Repository struct {
Db *sqlx.DB
}
func NewRepository(db *sqlx.DB) Repository {
return Repository{Db: db}
}
func (r Repository) Find(ctx context.Context, id int) (model.ToDo, error) {
entity := model.ToDo{}
query := fmt.Sprintf(
"SELECT * FROM todo WHERE id = $1 AND deleted_on IS NULL",
)
err := r.Db.GetContext(ctx, &entity, query, id)
return entity, db.HandleError(err)
}
func (r Repository) Create(ctx context.Context, entity *model.ToDo) error {
query := `INSERT INTO todo (name, description, status, created_on, updated_on)
VALUES (:name, :description, :status, :created_on, :updated_on) RETURNING id;`
rows, err := r.Db.NamedQueryContext(ctx, query, entity)
if err != nil {
return db.HandleError(err)
}
for rows.Next() {
err = rows.StructScan(entity)
if err != nil {
return db.HandleError(err)
}
}
return db.HandleError(err)
}
func (r Repository) Update(ctx context.Context, entity model.ToDo) error {
query := `UPDATE todo
SET name = :name,
description = :description,
status = :status,
created_on = :created_on,
updated_on = :updated_on,
deleted_on = :deleted_on
WHERE id = :id;`
_, err := r.Db.NamedExecContext(ctx, query, entity)
return db.HandleError(err)
}
func (r Repository) FindAll(ctx context.Context) ([]model.ToDo, error) {
var entities []model.ToDo
query := fmt.Sprintf(
"SELECT * FROM todo WHERE deleted_on IS NULL",
)
err := r.Db.SelectContext(ctx, &entities, query)
return entities, db.HandleError(err)
}
The repository layer has only one purpose, it will make a SQL query to the Database. So in this case it provides the base CRUD operations for the ToDo model.
Create a separate directory for the service layer, so you can write the business logic here, so we would be creating a directory internal/todo/service
and the files internal/todo/service/service.go
and internal/todo/service/get.go
within it:
Please have the following content on the file internal/todo/service/service.go
Service
We would be implementing the business logic for the CRUD operations. Please create the following folder in directory internal/todo/service
and all the following files within the created directory
internal/todo/service/service.go
package service
import (
"github.com/fir1/rest-api/internal/todo/repository"
)
type Service struct {
repo repository.Repository
}
func NewService(r repository.Repository) Service {
return Service{
repo: r,
}
}
It returns an instance of service, and this instance would be having all methods of our business logic.
So in this tutorial, the Delivery Layer, can only call the Service layer to get access to the business logic.
Create
The business logic will validate the parameters of the function and save the ToDo in the database.
internal/todo/service/create.go
package service
import (
"context"
"github.com/asaskevich/govalidator"
"github.com/fir1/rest-api/internal/todo/model"
"github.com/fir1/rest-api/pkg/erru"
"time"
)
type CreateParams struct {
Name string `valid:"required"`
Description string `valid:"required"`
Status model.Status `valid:"required"`
}
func (s Service) Create(ctx context.Context, params CreateParams) (int, error) {
if _, err := govalidator.ValidateStruct(params); err != nil {
return 0, erru.ErrArgument{Wrapped: err}
}
tx, err := s.repo.Db.BeginTxx(ctx, nil)
if err != nil {
return 0, err
}
// Defer a rollback in case anything fails.
defer tx.Rollback()
entity := model.ToDo{
Name: params.Name,
Description: params.Description,
Status: params.Status,
CreatedOn: time.Now().UTC(),
}
err = s.repo.Create(ctx, &entity)
if err != nil {
return 0, err
}
err = tx.Commit()
return entity.ID, err
}
It returns an instance of service, and this instance would be having all methods of our business logic. Please note that here we have a separate type CreateParams
for taking arguments to function rather than using model.go
. Because we would like to have clear isolation between each layer, so this way we would not clutter the tags of types and mandatory arguments to the function.
If we have used type ToDo
from model.go
as an argument to the function.
type ToDo struct {
ID int `db:"id"`
Name string `db:"name"`
Description string `db:"description"`
Status Status `db:"status"`
CreatedOn time.Time `db:"created_on"`
UpdatedOn *time.Time `db:"updated_on"`
DeletedOn *time.Time `db:"deleted_on"`
}
Which fields from ToDo
model would be mandatory for the business logic?
As it is clear that it will mess up our architecture if we use the model.go
types as arguments to the business layer. That is one of the reasons why I like to isolate the layers, so we have a clear understanding of mandatory fields for the functions.
Get
The business logic here is quite straightforward, we will have a function which will take id
as a mandatory parameter then we would be looking up the database.
internal/todo/service/get.go
package service
import (
"context"
"errors"
"github.com/fir1/rest-api/internal/todo/model"
"github.com/fir1/rest-api/pkg/db"
"github.com/fir1/rest-api/pkg/erru"
)
func (s Service) Get(ctx context.Context, id int) (model.ToDo, error) {
todo, err := s.repo.Find(ctx, id)
switch {
case err == nil:
case errors.As(err, &db.ErrObjectNotFound{}):
return model.ToDo{}, erru.ErrArgument{errors.New("todo object not found")}
default:
return model.ToDo{}, err
}
return todo, nil
}
Update
The business logic here is that we will be taking id
as a mandatory field, so we can look up the ToDo
entity from the Database, then update the fields name, description, status
optionally if it is provided.
internal/todo/service/update.go
package service
import (
"context"
"errors"
"github.com/asaskevich/govalidator"
"github.com/fir1/rest-api/internal/todo/model"
"github.com/fir1/rest-api/pkg/erru"
)
type UpdateParams struct {
ID int `valid:"required"`
Name *string
Description *string
Status *model.Status
}
func (s Service) Update(ctx context.Context, params UpdateParams) error {
if _, err := govalidator.ValidateStruct(params); err != nil {
return erru.ErrArgument{Wrapped: err}
}
// find todo object
todo, err := s.Get(ctx, params.ID)
if err != nil {
return err
}
if params.Name != nil {
todo.Name = *params.Name
}
if params.Description != nil {
todo.Description = *params.Description
}
if params.Status != nil {
if !params.Status.IsValid() {
return erru.ErrArgument{Wrapped: errors.New("given status not valid")}
}
todo.Status = *params.Status
}
tx, err := s.repo.Db.BeginTxx(ctx, nil)
if err != nil {
return err
}
// Defer a rollback in case anything fails.
defer tx.Rollback()
err = s.repo.Update(ctx, todo)
if err != nil {
return err
}
err = tx.Commit()
return err
}
Delete
Here we would be finding the ToDo
entity from the database, if it is found then we soft deleted it by putting the current time for the field DeletedOn
.
I choose to soft delete the records compared to hard delete from the database table, because this way we would be having records which we can use for various purposes, such as auditing and etc.
internal/todo/service/delete.go
package service
import (
"context"
"time"
)
func (s Service) Delete(ctx context.Context, id int) error {
todo, err := s.Get(ctx, id)
if err != nil {
return err
}
tx, err := s.repo.Db.BeginTxx(ctx, nil)
if err != nil {
return err
}
// Defer a rollback in case anything fails.
defer tx.Rollback()
now := time.Now().UTC()
todo.DeletedOn = &now
err = s.repo.Update(ctx, todo)
if err != nil {
return err
}
err = tx.Commit()
return err
}
Delivery
Here we would be finding the ToDo
entity from the database, if it is found then we soft deleted it by putting the current time for the field DeletedOn
.
I choose to soft delete the records compared to hard delete from the database table, because this way we would be having records which we can use for various purposes, such as auditing and etc. In this section, we would be implementing the delivery layer, with the Restful server.
So please create a folder in the directory http/rest
.
As we have described CRUD, so for each functionality of CRUD we would be having a separate handler which will receive external API calls, and then each handler will call it is relevant business service. We would be creating a separate handler and explaining the logic.
Initiate handler service
First we must initiate a handler service, so please create a file in http/rest/handlers/handler.go
and put the following content:
package handlers
import (
toDoRepo "github.com/fir1/rest-api/internal/todo/repository"
toDoService "github.com/fir1/rest-api/internal/todo/service"
"github.com/gorilla/mux"
"github.com/jmoiron/sqlx"
"github.com/sirupsen/logrus"
)
type service struct {
logger *logrus.Logger
router *mux.Router
toDoService toDoService.Service
}
func newHandler(lg *logrus.Logger, db *sqlx.DB) service {
return service{
logger: lg,
toDoService: toDoService.NewService(toDoRepo.NewRepository(db)),
}
}
The handler service will take business service as a dependency argument in our case the dependency is toDoService
which is our internal business service layer.
Helpers
There is common logic which is being used by every handler, so I have decided to put that logic in a separate helper function, so we can reuse it rather than duplicate it.
Create a file in http/rest/handlers/helper.go
and put the following content.
package handlers
import (
"bytes"
"encoding/json"
"errors"
"github.com/fir1/rest-api/pkg/erru"
"io"
"net/http"
)
/*
Don’t have to repeat yourself every time you respond to user, instead you can use some helper functions.
*/
func (s service) respond(w http.ResponseWriter, data interface{}, status int) {
var respData interface{}
switch v := data.(type) {
case nil:
case erru.ErrArgument:
status = http.StatusBadRequest
respData = ErrorResponse{ErrorMessage: v.Unwrap().Error()}
case error:
if http.StatusText(status) == "" {
status = http.StatusInternalServerError
} else {
respData = ErrorResponse{ErrorMessage: v.Error()}
}
default:
respData = data
}
w.WriteHeader(status)
w.Header().Set("Content-Type", "application/json")
if data != nil {
err := json.NewEncoder(w).Encode(respData)
if err != nil {
http.Error(w, "Could not encode in json", http.StatusBadRequest)
return
}
}
}
// it does not read to the memory, instead it will read it to the given 'v' interface.
func (s service) decode(r *http.Request, v interface{}) error {
return json.NewDecoder(r.Body).Decode(v)
}
// it reads to the memory.
func (s service) readRequestBody(r *http.Request) ([]byte, error) {
// Read the content
var bodyBytes []byte
var err error
if r.Body != nil {
bodyBytes, err = io.ReadAll(r.Body)
if err != nil {
err := errors.New("could not read request body")
return nil, err
}
}
return bodyBytes, nil
}
// will place the body bytes back to the request body which could be read in subsequent calls on Handlers
// for example, you have more than 1 middleware and each of them need to read the body. If the first middleware read the body
// the second one won't be able to read it, unless you put the request body back.
func (s service) restoreRequestBody(r *http.Request, bodyBytes []byte) {
// Restore the io.ReadCloser to its original state
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
Create
We will be creating handlers and the practical approach would be to have a struct for the request, response
within the relevant handler, this way we have clear isolation between the data which is being required from the API call, and the data which is being inserted into the business layer, rather than using model
everywhere within our application.
Some of you might not agree with me but I prefer clean code over a complicated one.
The main important thing for developers is to write the code clean, so the fellow developers can understand easily.
http/rest/handlers/create.go
package handlers
import (
"github.com/fir1/rest-api/internal/todo/model"
toDoService "github.com/fir1/rest-api/internal/todo/service"
"net/http"
)
func (s service) Create() http.HandlerFunc {
type request struct {
Name string `json:"name"`
Description string `json:"description"`
Status model.Status `json:"status"`
}
type response struct {
ID int `json:"id"`
}
return func(w http.ResponseWriter, r *http.Request) {
req := request{}
// Try to decode the request body into the struct. If there is an error,
// respond to the client with the error message and a 400 status code.
err := s.decode(r, &req)
if err != nil {
s.respond(w, err, 0)
return
}
id, err := s.toDoService.Create(r.Context(), toDoService.CreateParams{
Name: req.Name,
Description: req.Description,
Status: req.Status,
})
if err != nil {
s.respond(w, err, 0)
return
}
s.respond(w, response{ID: id}, http.StatusOK)
}
}
As you can see the job of our handler is to receive an API call, and then call the relevant business service layer, to handle the rest of the logic (s.toDoService.Create - this is our business service layer).
It is a clean approach, as our handler knows only about the business service layer, and the handler does not know anything about the underlying logic of the business.
This approach is practical because imagine if you would like to expose your API with gRPC protocol, with the current approach we just add an additional delivery layer in the directory http/grpc
then call our business service layer, without the need to write any business logic.
Get
Please create a file in http/rest/handlers/get.go
and have the following content:
package handlers
import (
"errors"
"github.com/fir1/rest-api/internal/todo/model"
"github.com/fir1/rest-api/pkg/erru"
"github.com/gorilla/mux"
"net/http"
"strconv"
"time"
)
func (s service) Get() http.HandlerFunc {
type response struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Status model.Status `json:"status"`
CreatedOn time.Time `json:"created_on"`
UpdatedOn *time.Time `json:"updated_on,omitempty"`
}
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
s.respond(w, erru.ErrArgument{
Wrapped: errors.New("valid id must provide in path"),
}, 0)
return
}
getResponse, err := s.toDoService.Get(r.Context(), id)
if err != nil {
s.respond(w, err, 0)
return
}
s.respond(w, response{
ID: getResponse.ID,
Name: getResponse.Name,
Description: getResponse.Description,
Status: getResponse.Status,
CreatedOn: getResponse.CreatedOn,
UpdatedOn: getResponse.UpdatedOn,
}, http.StatusOK)
}
}
Update
Please create a file in http/rest/handlers/update.go
and have the following content:
package handlers
import (
"errors"
"github.com/fir1/rest-api/internal/todo/model"
toDoService "github.com/fir1/rest-api/internal/todo/service"
"github.com/fir1/rest-api/pkg/erru"
"github.com/gorilla/mux"
"net/http"
"strconv"
)
func (s service) Update() http.HandlerFunc {
type request struct {
Name *string `json:"name"`
Description *string `json:"description"`
Status *model.Status `json:"status"`
}
type response struct {
ID int `json:"id"`
}
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
s.respond(w, erru.ErrArgument{
Wrapped: errors.New("valid id must provide in path"),
}, 0)
return
}
req := request{}
// Try to decode the request body into the struct. If there is an error,
// respond to the client with the error message and a 400 status code.
err = s.decode(r, &req)
if err != nil {
s.respond(w, err, 0)
return
}
err = s.toDoService.Update(r.Context(), toDoService.UpdateParams{
ID: id,
Name: req.Name,
Description: req.Description,
Status: req.Status,
})
if err != nil {
s.respond(w, err, 0)
return
}
s.respond(w, response{ID: id}, http.StatusOK)
}
}
Delete
Please create a file in http/rest/handlers/delete.go
and have the following content:
package handlers
import (
"github.com/gorilla/mux"
"net/http"
"strconv"
)
func (s service) Delete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
s.respond(w, err, 0)
return
}
err = s.toDoService.Delete(r.Context(), id)
if err != nil {
s.respond(w, err, 0)
return
}
s.respond(w, nil, http.StatusOK)
}
}
Middleware
As you might have noticed by this time that one of the most important parts of every application is being able to log, request and response to API calls.
Disclaimer, when you log requests and responses you must be careful not to log sensitive information such as credentials, usernames, passwords and any other data which violates privacy.
Please create a file in http/rest/middleware_logger.go
and put the following content inside it:
package handlers
import (
"fmt"
"net/http"
"time"
)
// responseWriter is a minimal wrapper for http.ResponseWriter that allows the
// written HTTP status code to be captured for logging. This type will implement http.ResponseWriter.
type responseWriter struct {
http.ResponseWriter
status int
body []byte
wroteHeader bool
wroteBody bool
}
func wrapResponseWriter(w http.ResponseWriter) *responseWriter {
return &responseWriter{ResponseWriter: w}
}
func (rw *responseWriter) Status() int {
return rw.status
}
func (rw *responseWriter) WriteHeader(code int) {
if rw.wroteBody {
return
}
rw.status = code
rw.ResponseWriter.WriteHeader(code)
rw.wroteHeader = true
}
func (rw *responseWriter) Write(body []byte) (int, error) {
if rw.wroteBody {
return 0, nil
}
i, err := rw.ResponseWriter.Write(body)
if err != nil {
return 0, err
}
rw.body = body
return i, err
}
func (rw *responseWriter) Body() []byte {
return rw.body
}
// middlewarMiddlewareLoggereLogger logs the incoming HTTP request and response. Enable it only for debug purpose disable it on production.
func (s service) MiddlewareLogger() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
// Call the next handler don't log if it is internal request from health check of Kubernetes
next.ServeHTTP(w, r)
return
}
defer func() {
if err := recover(); err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
}()
requestBody, err := s.readRequestBody(r)
if err != nil {
s.respond(w, err, 0)
return
}
s.restoreRequestBody(r, requestBody)
logMessage := fmt.Sprintf("path:%s, method: %s, requestBody: %v", r.URL.EscapedPath(), r.Method, string(requestBody))
start := time.Now()
wrapped := wrapResponseWriter(w)
next.ServeHTTP(wrapped, r)
logMessage = fmt.Sprintf("%s, responseStatus: %d, responseBody: %s", logMessage, wrapped.Status(), string(wrapped.Body()))
s.logger.Infof("%s, duration: %v", logMessage, time.Since(start))
}
return http.HandlerFunc(fn)
}
}
Register Handlers
As of now, we have all the handlers with the relevant handlers, but we need to register those handlers to the router with the customized Path.
We would be creating a file in http/rest/handlers/routes.go
and have the following content:
package handlers
import (
"github.com/gorilla/mux"
"github.com/jmoiron/sqlx"
"github.com/sirupsen/logrus"
"net/http"
)
func Register(r *mux.Router, lg *logrus.Logger, db *sqlx.DB) {
handler := newHandler(lg, db)
// adding logger middleware
r.Use(handler.MiddlewareLogger())
r.HandleFunc("/healthz", handler.Health())
r.HandleFunc("/todo", handler.Create()).Methods(http.MethodPost)
r.HandleFunc("/todo/{id}", handler.Get()).Methods(http.MethodGet)
r.HandleFunc("/todo/{id}", handler.Update()).Methods(http.MethodPut)
r.HandleFunc("/todo/{id}", handler.Delete()).Methods(http.MethodDelete)
}
Creating Server
In this stage, we need to create an instance of an actual server which can receive API calls and handle those calls.
Please create a file in http/rest/logger.go
and have the following content inside it:
package rest
import (
"github.com/sirupsen/logrus"
"os"
)
func NewLogger() *logrus.Logger {
log := logrus.New()
log.SetOutput(os.Stdout)
log.SetLevel(logrus.InfoLevel)
log.SetFormatter(&logrus.TextFormatter{
ForceColors: true,
TimestampFormat: "2006-01-02 15:04:05.999999999",
FullTimestamp: true,
})
return log
}
Create a file in http/rest/server.go
and have the following content inside it:
package rest
import (
"context"
"fmt"
"github.com/fir1/rest-api/configs"
"github.com/fir1/rest-api/http/rest/handlers"
"github.com/fir1/rest-api/pkg/db"
"github.com/gorilla/mux"
"github.com/rs/cors"
"github.com/sirupsen/logrus"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
)
type Server struct {
logger *logrus.Logger
router *mux.Router
config configs.Config
}
func NewServer() (*Server, error) {
cnf, err := configs.NewParsedConfig()
if err != nil {
return nil, err
}
database, err := db.Connect(db.ConfingDB{
Host: cnf.Database.Host,
Port: cnf.Database.Port,
User: cnf.Database.User,
Password: cnf.Database.Password,
Name: cnf.Database.Name,
})
if err != nil {
return nil, err
}
log := NewLogger()
router := mux.NewRouter()
handlers.Register(router, log, database)
s := Server{
logger: log,
config: cnf,
router: router,
}
return &s, nil
}
func (s *Server) Run(ctx context.Context) error {
server := http.Server{
Addr: fmt.Sprintf(":%d", s.config.ServerPort),
Handler: cors.Default().Handler(s.router),
}
stopServer := make(chan os.Signal, 1)
signal.Notify(stopServer, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(stopServer)
// channel to listen for errors coming from the listener.
serverErrors := make(chan error, 1)
var wg sync.WaitGroup
wg.Add(1)
go func(wg *sync.WaitGroup) {
defer wg.Done()
s.logger.Printf("REST API listening on port %d", s.config.ServerPort)
serverErrors <- server.ListenAndServe()
}(&wg)
// blocking run and waiting for shutdown.
select {
case err := <-serverErrors:
return fmt.Errorf("error: starting REST API server: %w", err)
case <-stopServer:
s.logger.Warn("server received STOP signal")
// asking listener to shutdown
err := server.Shutdown(ctx)
if err != nil {
return fmt.Errorf("graceful shutdown did not complete: %w", err)
}
wg.Wait()
s.logger.Info("server was shut down gracefully")
}
return nil
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}
Run Server
At this point now we can run a Restful server, and test all our integrations.
Please create a file in cmd/app/main.go
and have the following content inside it:
package main
import (
"context"
"github.com/fir1/rest-api/http/rest"
"log"
)
func main() {
if err := run(context.Background()); err != nil {
log.Fatalf("%+v", err)
}
}
func run(ctx context.Context) error {
server, err := rest.NewServer()
if err != nil {
return err
}
err = server.Run(ctx)
return err
}
Our main
package has a few lines of code, which is an entry point to our application. I think the main
package should be clean and must be used to start servers
without any extra logic.
Tests
In this section, we are going to test our API server manually by making a Restful API call with the help of the Insomnia Tool.
Make sure that you are in the root directory and run the following command from the terminal go run cmd/app/*.go
Create
Make an API call to the
POST localhost:80/todo
Request Body
{
"name": "Tutorial Restful pending",
"description": "Still we are testing Restful api so not finished",
"status": 1
}
Get
Make an API call to the
GET localhost:80/todo/4 (id of todo - 4 might be different in your case)
Update
Make an API call to the
PUT localhost:80/todo/4 (id of todo - 4 might be different in your case)
Request Body
{
"name": "Tutorial Restful finished",
"description": "Restful api testing finished",
"status": 2
}
Get Updated ToDo
Make an API call to the
GET localhost:80/todo/4 (id of todo - 4 might be different in your case)
From the above screenshot, it is clear that the entity ToDo
was updated.
Delete
Make an API call to the
DELETE localhost:80/todo/4 (id of todo - 4 might be different in your case)
The entity was deleted, so when we do GET
request we will be getting an error, as the object does not exist anymore.
Enhancements
You can even enhance this project by adding more functionalities, such as:
Add todo list endpoint, that will return all existing ToDo's entity.
Extra functionalities, such as filtering the ToDo by status and keywords.
Add authentication
Once you have done these enhancements you can create a PR towards my repository github.com/fir1/rest-api
The end
Hopefully, this tutorial was useful and I would be more than happy to receive questions or suggestions about it.
The source code for this tutorial can be found here
That’s it for this blog. Please feel free to comment with your views on this blog. Thanks for your time reading this blog and hope it was useful.
Happy learning and sharing.