Skip to content

foyez/simplebank

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

39 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Simple Bank

Project Name: Simple Bank

  • Create and manage account (owner, balance, currency)
  • Record all balance changes (create an account entry for each change)
  • Money transfer transaction (perform money transfer between 2 accounts consistently within a transaction)

Database Design using dbdiagram

View contents

Design database tables using https://dbdiagram.io

Setup dbdigram

# install dbdocs
npm i -g dbdocs

# check dbdocs
dbdocs

# create doc directory
mkdir doc

# copy dbml codes and paste in db.dbml file
# install "vscode-dbml" extension to highlight codes
touch db.dbml

# login to dbdocs
dbdocs login

# generate dbdocs view
dbdocs build doc/db.dbml

# visit: https://dbdocs.io/foyezar/simplebank

# set password
# dbdocs password --set <password> --project <project name>
dbdocs password --set secret --project simplebank

# remove a project
# dbdocs remove <project name>
dbdocs remove simplebank

# install dbml cli
npm i -g @dbml/cli

# convert a dbml file to sql
# dbml2sql <path-to-dbml-file> [--mysql|--postgres] -o <output-filepath>
dbml2sql doc/db.dbml --postgres -o doc/schema.sql

# convert a sql file to dbml
# sql2dbml <path-to-sql-file> [--mysql|--postgres] -o <output-filepath>
sql2dbml doc/schema.sql --postgres -o doc/db.dbml

Install & use Docker + Postgres + Mysql + TablePlus

View contents
  • Download & install docker: link

Postgresql

# Pull postgres image
docker pull postgres:15:2-alpine

# Start postgres container
docker run --name postgres15 -p 5432:5432 -e POSTGRES_USER=root -e POSTGRES_PASSWORD=testpass -d postgres:15.2-alpine

# Run command in container
docker exec -it postgres15 psql -U root

# Test connection
SELECT now();

Postgres commands ref

# Connect to a database (same host)
# -W - forces for the user password
psql -d db_name -U username -W

# Connect to a database (different host)
psql -h db_address -d db_name -U username -W

# Connect to a database (different host in SSL mode)
psql "sslmode=require host=db_address dbname=my_db user=root"

# Know all available psql commands
\?

# List all databases
\l

# Clear screen
# Ctrl + L
\! clear
\! cls

# Create a database
create database mydb;

# Switch to another database
\c db_name

# List database tables
\dt

# Create a table
CREATE TABLE accounts (
  id serial PRIMARY KEY,
  username varchar NOT NULL
);

# Insert data in a able
INSERT INTO accounts (username) VALUES ('foyez');

# Select data from a table
SELECT * FROM accounts;

# describe a table
\d table_name
\d+ table_name # more information

# Delete a database
drop database mydb;

# List all schemas
\dn

# List users and their roles
\du

# Retrieve a specific user
\du username

# Quit psql
\q

Mysql

# Pull mysql image
docker pull mysql:8

# Start mysql container
docker run --name mysql8 -p 3306:3306 -e MYSQL_ROOT_PASSWORD=testpass -d mysql:8

# Run command in container
docker exec -it mysql8 mysql -uroot -ptestpass

Create a Postgres database from command line

# enter postgres shell & create a database
docker exec -it postres15 sh
createdb --username=root --owner=root simple_bank
dropdb simple_bank

# create a database
docker exec -it postres15 createdb --username=root --owner=root simple_bank

# login to db cli
docker exec -it postgres15 psql -U root simple_bank

# exit from db cli
\q

Mysql commands ref

# Connect to database
mysql -h hostname -u username -p
mysql -uroot -ptestpass

# Create a database from command line
mysql -e "create database db_name" -u username -p

# Create a database
create database db_name;

# Show database list
show databases;

# Switch to a database
use db_name;

# Show table list
show tables;

# Create a table
CREATE TABLE accounts (
  id INT(50) NOT NULL AUTO_INCREMENT PRIMARY KEY,
  username VARCHAR(100) NOT NULL
);

# Insert data in a table
INSERT INTO accounts (username) VALUES ('foyez');

# Select data from a table
SELECT * FROM accounts;

# Describe a table
describe table_name;

# Delete a database
drop database db_name;

# Delete a table
drop table table_name;

# Quit mysql
exit;

Create a Mysql database from command line

# create a database
docker exec -it mysql8 mysql -e "create database db_name" -u username -p

# delete a database
docker exec -it mysql8 mysql -e "drop database db_name" -u username -p

# login to db cli
docker exec -it mysql8 mysql db_name -u username -p

# exit from db cli
\q

Show docker logs

# Postgres
docker logs postgres15

# Mysql
docker logs mysql8

Searching ran commands starting with docker run

history | grep "docker run"
  • Download & install database management tool TablePlus

Database Migration using golang-migrate

View contents

Install migrate cli: link

$ curl -L https://github.com/golang-migrate/migrate/releases/download/$version/migrate.$os-$arch.tar.gz | tar xvz
# OR
brew install golang-migrate

# migrate help command
migrate -help

# create migration files
migrate create -ext sql -dir db/migration -seq init_schema

Database CRUD

View contents
  • Create: insert new records to the database
  • Read: select or search for records in the database
  • Update: change some fields of the record in the database
  • Delete: remove records from the database

CRUD Tools

  • Database/SQL: t
  • ORM: GORM
  • SQLX
  • SQLC

Setup SQLC

# install sqlc
brew install sqlc

# to know sqlc commands
sqlc help

# Create an empty sqlc.yaml settings file
# schema_path: db/migration
# query path: db/query
# output path: db/sqlc
sqlc init

# Generate Go code from SQL
sqlc generate

Unit tests for Database

View contents
  • Install a pure postgres driver for Go's database/sql package
go get github.com/lib/pq

main_test.go

package db

import (
 "database/sql"
 "log"
 "os"
 "testing"

 _ "github.com/lib/pq"
)

const (
 dbDriver = "postgres"
 dbSource = "postgresql://root:testpass@localhost:5432/simplebank?sslmode=disable"
)

var testQueries *Queries

func TestMain(m *testing.M) {
 db, err := sql.Open(dbDriver, dbSource)
 if err != nil {
  log.Fatal("cannot connect to db: ", err)
 }

 testQueries = New(db)

 os.Exit(m.Run())
}
  • Run go mod tidy to add dependency in go.mod file
  • Install testify - A toolkit for assertions and mocks
go get github.com/stretchr/testify

account_test.go

package db

import (
 "context"
 "testing"

 "github.com/stretchr/testify/require"
)

func TestCreateAccount(t *testing.T) {
 arg := CreateAccountParams{
  Owner:    "Mithu",
  Balance:  20,
  Currency: "USD",
 }
 account, err := testQueries.CreateAccount(context.Background(), arg)
 require.NoError(t, err)
 require.NotEmpty(t, account)

 require.Equal(t, account.Owner, arg.Owner)
 require.Equal(t, account.Balance, arg.Balance)
 require.Equal(t, account.Currency, arg.Currency)

 require.NotZero(t, account.ID)
 require.NotZero(t, account.CreatedAt)
}
  • Run go mod tidy to add testify dependency

Database Transaction

View contents

DB Transaction

  • A single unit of work
  • Often made up of multiple db operations

Example: Transfer 10 USD from bank account 1 to bank account 2.

1. Create a transfer record with amount = 10
2. Create an account entry for account 1 with amount = -10
3. Create an account entry for account 2 with amount = +10
4. Subtract 10 from the balance of account 1
5. Add 10 to the balance of account 2

Why do we need db transaction?

  1. To provide a reliable and consistent unit of work, even in case of system failure
  2. To provide isolation between programs that access the database concurrently

A transaction in a database system must maintain ACID (Atomicity, Consistency, Isolation and Durability) in order to ensure accuracy, completeness and data integrity.

  1. Atomicity Either all operations complete successfully or if the transaction fails, everything will be rolled back and the db will be unchanged.

  2. Consistency The db state must be valid after the transaction. All constraints must be satisfied. More precisely, all data written to the database must be valid according to predefined rules, including constraints, cascade, and triggers.

  3. Isolation Concurrent transaction must not affect each other.

  4. Durability Data written by a successful transaction must be recorded in persistent storage, even in case of system failure.

How to run SQL TX?

BEGIN;
COMMIT;

-- if the transaction is failed
BEGIN;
ROLLBACK;

Deadlock

  • a situation in which two or more transactions are waiting for one another to give up locks

Deadlocks can happen in multi-user environments when two or more transactions are running concurrently and try to access the same data in a different order. When this happens, one transaction may hold a lock on a resource that another transaction needs, while the second transaction may hold a lock on a resource that the first transaction needs. Both transactions are then blocked, waiting for the other to release the resource they need.

DBMSs often use various techniques to detect and resolve deadlocks automatically. These techniques include timeout mechanisms, where a transaction is forced to release its locks after a certain period of time, and deadlock detection algorithms, which periodically scan the transaction log for deadlock cycles and then choose a transaction to abort to resolve the deadlock.

It is also possible to prevent deadlocks by careful design of transactions, such as always acquiring locks in the same order or releasing locks as soon as possible. Proper design of the database schema and application can also help to minimize the likelihood of deadlocks

ref: Deadlock in DBMS

Update accounts concurrently

BEGIN;

SELECT * FROM accounts WHERE id = 15 FOR UPDATE;
UPDATE accounts SET balance = 500 WHERE id = 15;

COMMIT;

Check deadlocks

SELECT
   a.application_name,
   l.relation::regclass,
   l.transactionid,
   l.mode,
   l.locktype,
   l.GRANTED,
   a.username,
   a.query,
   a.pid
FROM pq_stat_activity a
JOIN pg_locks l ON l.pid = a.pid
WHERE a.application_name = 'psql'
ORDER BY a.pid;

Github Actions

View contents
  • We can trigger a workflow by 3 ways: event, schedule, or manually
  • A workflow consists of one or multiple jobs
  • A job is composed of multiple steps
  • Each step has one or more actions
  • All jobs inside a workflow normally run in parallel, unless they depend on each other
  • If some jobs depend on each other, they run serially
  • Each job will be run separately by a specific runner
  • The runners will report progress, logs, and results of the jobs back to github
image

Setup a workflow for Golang and Postgres

Implement HTTP API using Gin

View contents

Popular web frameworks

  • Gin
  • Beego
  • Echo
  • Revel
  • Martini
  • Fiber
  • Buffalo

Popular HTTP routers

  • FastHttp
  • Gorilla Mux
  • HttpRouter
  • Chi

Install gin package:

https://github.com/gin-gonic/gin
View contents

db/query/account.sql

-- name: CreateAccount :one
INSERT INTO accounts (
  owner,
  balance,
  currency
) VALUES (
  $1, $2, $3
) RETURNING *;

api/server.go

package api

import (
 db "github.com/foyez/simplebank/db/sqlc"
 "github.com/gin-gonic/gin"
)

// Server serves HTTP requests.
type Server struct {
 store  *db.Store
 router *gin.Engine
}

// NewServer creates a new HTTP server and setup routing.
func NewServer(store *db.Store) *Server {
 server := &Server{store: store}
 router := gin.Default()

 router.POST("/accounts", server.createAccount)

 server.router = router
 return server
}

// Start runs the HTTP server on a specific address.
func (server *Server) Start(address string) error {
 return server.router.Run(address)
}

func errorResponse(err error) gin.H {
 return gin.H{"error": err.Error()}
}

api/account.go

package api

import (
 "net/http"

 db "github.com/foyez/simplebank/db/sqlc"
 "github.com/gin-gonic/gin"
)

type createAccountRequest struct {
 // json tag to de-serialize json body
 Owner    string `json:"owner" binding:"required"`
 Currency string `json:"currency" binding:"required,oneof=USD EUR"`
}

func (server *Server) createAccount(ctx *gin.Context) {
 var req createAccountRequest
 if err := ctx.ShouldBindJSON(&req); err != nil {
  ctx.JSON(http.StatusBadRequest, errorResponse(err))
  return
 }

 arg := db.CreateAccountParams{
  Owner:    req.Owner,
  Currency: req.Currency,
  Balance:  0,
 }

 account, err := server.store.CreateAccount(ctx, arg)
 if err != nil {
  ctx.JSON(http.StatusInternalServerError, errorResponse(err))
  return
 }

 ctx.JSON(http.StatusCreated, account)
}

main.go

package main

import (
 "database/sql"
 "log"

 "github.com/foyez/simplebank/api"
 db "github.com/foyez/simplebank/db/sqlc"
 _ "github.com/lib/pq"
)

const (
 dbDriver = "postgres"
 dbSource = "postgresql://root:testpass@localhost:5432/simplebank?sslmode=disable"
 address  = "0.0.0.0:8080"
)

func main() {
 conn, err := sql.Open(dbDriver, dbSource)
 if err != nil {
  log.Fatal("cannot connect to db: ", err)
 }

 store := db.NewStore(conn)
 server := api.NewServer(store)

 err = server.Start(address)
 if err != nil {
  log.Fatal("cannot start server: ", err)
 }
}
  • A GET api:
View contents

db/query/account.sql

-- name: GetAccount :one
SELECT * FROM accounts
WHERE id = $1 LIMIT 1;

api/account.go

type getAccountRequest struct {
 ID int64 `uri:"id" binding:"required,min=1"`
}

func (server *Server) getAccount(ctx *gin.Context) {
 var req getAccountRequest
 if err := ctx.ShouldBindUri(&req); err != nil {
  ctx.JSON(http.StatusBadRequest, errorResponse(err))
  return
 }

 account, err := server.store.GetAccount(ctx, req.ID)
 if err != nil {
  if errors.Is(err, db.ErrRecordNotFound) {
   ctx.JSON(http.StatusNotFound, errorResponse(err))
   return
  }
  ctx.JSON(http.StatusInternalServerError, errorResponse(err))
  return
 }

 ctx.JSON(http.StatusOK, account)
}

api/server.go

router.GET("/accounts/:id", server.getAccount)

API:

http://localhost:8080/accounts/1
  • A GET api with offset pagination:
View contents

db/query/account.sql

-- name: ListAccounts :many
SELECT * FROM accounts
ORDER BY id
LIMIT $1
OFFSET $2;

api/account.go

type listAccountsRequest struct {
 PageID   int32 `form:"page_id" binding:"required,min=1"`
 PageSize int32 `form:"page_size" binding:"required,min=5,max=10"`
}

func (server *Server) listAccounts(ctx *gin.Context) {
 var req listAccountsRequest
 if err := ctx.ShouldBindQuery(&req); err != nil {
  ctx.JSON(http.StatusBadRequest, errorResponse(err))
  return
 }

 arg := db.ListAccountsParams{
  Limit:  req.PageSize,
  Offset: (req.PageID - 1) * req.PageSize,
 }

 accounts, err := server.store.ListAccounts(ctx, arg)
 if err != nil {
  ctx.JSON(http.StatusInternalServerError, errorResponse(err))
  return
 }

 ctx.JSON(http.StatusOK, accounts)
}

api/server.go

router.GET("/accounts", server.listAccounts)

API:

http://localhost:8080/accounts?page_id=1&page_size=10
  • A GET api with cursor pagination:
View contents

db/query/account.sql

-- name: ListAccountWithCursor :many
SELECT * FROM accounts
WHERE created_at < sqlc.narg('cursor') OR sqlc.narg('cursor') IS NULL
ORDER BY created_at DESC
LIMIT sqlc.arg('limit');

api/account.go

type listAccountsWithCursorRequest struct {
 Cursor time.Time `form:"cursor"`
 Limit  int32     `form:"limit" binding:"required,min=5,max=10"`
}

func (server *Server) listAccountsWithCursor(ctx *gin.Context) {
 var req listAccountsWithCursorRequest
 if err := ctx.ShouldBindQuery(&req); err != nil {
  ctx.JSON(http.StatusBadRequest, errorResponse(err))
  return
 }

 limitPlusOne := req.Limit + 1

 arg := db.ListAccountWithCursorParams{
  Limit: limitPlusOne,
  Cursor: sql.NullTime{
   Time:  req.Cursor,
   Valid: !req.Cursor.IsZero(),
  },
 }

 accounts, err := server.store.ListAccountWithCursor(ctx, arg)
 if err != nil {
  ctx.JSON(http.StatusInternalServerError, errorResponse(err))
  return
 }

 newAccounts := accounts
 if int32(len(accounts)) > req.Limit {
  newAccounts = accounts[0:req.Limit]
 }

 rsp := gin.H{
  "accounts": newAccounts,
  "has_more": int32(len(accounts)) == limitPlusOne,
 }

 ctx.JSON(http.StatusOK, rsp)
}

api/server.go

router.GET("/accountsWithCursor", server.listAccountsWithCursor)

API:

http://localhost:8080/accountsWithCursor?limit=5&cursor=2023-06-05T02%3A36%3A19.167614Z

Load config from file & environment variables with Viper

View contents

Install viper:

go get github.com/spf13/viper

app.env

DB_DRIVER=postgres
DB_SOURCE=postgresql://root:testpass@localhost:5432/simplebank?sslmode=disable
SERVER_ADDRESS=0.0.0.0:8080

util/config.go

package util

import "github.com/spf13/viper"

// Config stores all configuration of the application.
type Config struct {
 DBDriver      string `mapstructure:"DB_DRIVER"`
 DBSource      string `mapstructure:"DB_SOURCE"`
 ServerAddress string `mapstructure:"SERVER_ADDRESS"`
}

// LoadConfig reads configuration from file or environment variables.
func LoadConfig(path string) (config Config, err error) {
 viper.AddConfigPath(path)
 viper.SetConfigName("app")
 viper.SetConfigType("env")

 viper.AutomaticEnv()

 err = viper.ReadInConfig()
 if err != nil {
  return
 }

 err = viper.Unmarshal(&config)
 return
}

main.go

package main

import (
 "database/sql"
 "log"

 "github.com/foyez/simplebank/util"
 _ "github.com/lib/pq"
)

func main() {
 config, err := util.LoadConfig(".")
 if err != nil {
  log.Fatal("cannot load config: ", err)
 }

 conn, err := sql.Open(config.DBDriver, config.DBSource)

//  ...
}

Mock DB for testing HTTP API

View contents

Source

Why mock database?

  • Independent tests: avoid conflicts
  • Faster tests
  • 100% coverage: easily setup edge cases

Install gomock:

go install github.com/golang/mock/mockgen@v1.6.0
go get github.com/golang/mock/mockgen@v1.6.0

db/sqlc/store.go

package db

import (
 "context"
 "database/sql"
)

// Store provides all the function to exec
type Store interface {
 Querier
 TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error)
}

// SQLStore provides all functionalities to execute SQL queries and transaction
type SQLStore struct {
 *Queries
 db *sql.DB
}

// NewStore creates a new store
func NewStore(db *sql.DB) Store {
 return &SQLStore{
  db:      db,
  Queries: New(db),
 }
}

Generate mock interfaces:

mockgen -package mockdb -destination db/mock/store.go github.com/foyez/simplebank/db/sqlc Store

If we update the database base we have to run this command to update the mock store.

api/account_test.go

package api

import (
 "bytes"
 "encoding/json"
 "fmt"
 "io/ioutil"
 "net/http"
 "net/http/httptest"
 "testing"

 mockdb "github.com/foyez/simplebank/db/mock"
 db "github.com/foyez/simplebank/db/sqlc"
 "github.com/foyez/simplebank/util"
 "github.com/golang/mock/gomock"
 "github.com/stretchr/testify/require"
)

func TestGetAccountAPI(t *testing.T) {
 account := randomAccount()

 testCases := []struct {
  name          string
  accountID     int64
  buildStubs    func(store *mockdb.MockStore)
  checkResponse func(t *testing.T, recorder *httptest.ResponseRecorder)
 }{
  {
   name:      "OK",
   accountID: account.ID,
   buildStubs: func(store *mockdb.MockStore) {
    store.EXPECT().
     GetAccount(gomock.Any(), gomock.Eq(account.ID)).
     Times(1).
     Return(account, nil)
   },
   checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
    require.Equal(t, http.StatusOK, recorder.Code)
    requireBodyMatchAccount(t, recorder.Body, account)
   },
  },
 }

 for i := range testCases {
  tc := testCases[i]

  t.Run(tc.name, func(t *testing.T) {
   ctrl := gomock.NewController(t)
   defer ctrl.Finish()

   store := mockdb.NewMockStore(ctrl)
   tc.buildStubs(store)

   // start test server and send request
   server := NewServer(store)
   recorder := httptest.NewRecorder()

   url := fmt.Sprintf("/accounts/%d", account.ID)
   request, err := http.NewRequest(http.MethodGet, url, nil)
   require.NoError(t, err)

   server.router.ServeHTTP(recorder, request)
   tc.checkResponse(t, recorder)
  })

 }
}

func randomAccount() db.Account {
 return db.Account{
  ID:       util.RandomInt(1, 1000),
  Owner:    util.RandomOwner(),
  Balance:  util.RandomMoney(),
  Currency: util.RandomCurrency(),
 }
}

func requireBodyMatchAccount(t *testing.T, body *bytes.Buffer, account db.Account) {
 data, err := ioutil.ReadAll(body)
 require.NoError(t, err)

 var gotAccount db.Account
 err = json.Unmarshal(data, &gotAccount)
 require.NoError(t, err)
 require.Equal(t, account, gotAccount)
}

api/main_test.go

package api

import (
 "os"
 "testing"

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

func TestMain(m *testing.M) {
 gin.SetMode(gin.TestMode)
 os.Exit(m.Run())
}

Dockerize the app

View contents

Dockerfile

# Build stage
FROM golang:1.20.2-alpine3.17 AS builder
WORKDIR /app
COPY . .
RUN go build -o main main.go

# Run stage
FROM alpine:3.17
WORKDIR /app
COPY --from=builder /app/main .
COPY app.env .

EXPOSE 8080
CMD [ "/app/main" ]

Build and Run docker container:

# build image
docker build -t foyezar/simplebank:latest .

# run container
docker run --name simplebank -p 8080:8080 -e GIN_MODE=release foyezar/simplebank:latest

Connect postgres with simplebank using network

View contents
# Get container details information
docker container inspect postgres15
[
  {
    "Id": "efebd1beb2f417887655d482767644a4c816a28154ba1bb8f5cd1cb5cf2ad150",
    "Created": "2023-05-22T06:54:07.034351837Z",
    "Path": "docker-entrypoint.sh",
    "Args": ["postgres"],
    "Name": "/postgres15",
    "NetworkSettings": {
      "Ports": {
        "5432/tcp": [
          {
            "HostIp": "0.0.0.0",
            "HostPort": "5432"
          }
        ]
      },
      "Gateway": "172.17.0.1",
      "IPAddress": "172.17.0.2",
      "MacAddress": "02:42:ac:11:00:02",
      "Networks": {
        "bridge": {
          "Gateway": "172.17.0.1",
          "IPAddress": "172.17.0.2"
        }
      }
    }
  }
]
docker container inspect simplebank
[
  {
    "Id": "928356064d037aef04f027b4bef1580b4381866cd8eb0cb02fd9b9675772eb26",
    "Created": "2023-06-12T17:30:41.745803471Z",
    "Path": "/app/main",
    "Name": "/simplebank",
    "NetworkSettings": {
      "Ports": {
        "8080/tcp": [
          {
            "HostIp": "0.0.0.0",
            "HostPort": "8080"
          }
        ]
      },
      "Gateway": "172.17.0.1",
      "IPAddress": "172.17.0.3",
      "MacAddress": "02:42:ac:11:00:03",
      "Networks": {
        "bridge": {
          "Gateway": "172.17.0.1",
          "IPAddress": "172.17.0.3"
        }
      }
    }
  }
]

Here, the IP address of postgres15 container (172.17.0.2) is different than the IP address of simplebank container (172.17.0.3).

# Get network list
docker network ls

# Get network details information
docker network inspect bridge
[
  {
    "Name": "bridge",
    "Driver": "bridge",
    "Containers": {
      "928356064d037aef04f027b4bef1580b4381866cd8eb0cb02fd9b9675772eb26": {
        "Name": "simplebank",
        "MacAddress": "02:42:ac:11:00:03",
        "IPv4Address": "172.17.0.3/16",
        "IPv6Address": ""
      },
      "efebd1beb2f417887655d482767644a4c816a28154ba1bb8f5cd1cb5cf2ad150": {
        "Name": "postgres15",
        "MacAddress": "02:42:ac:11:00:02",
        "IPv4Address": "172.17.0.2/16",
        "IPv6Address": ""
      }
    }
  }
]
# Get docker network COMMAND
docker network --help

# Create a network
docker network create bank-network

# Get docker network connect COMMAND
docker network connect --help

# Connect a container with a network
docker network connect bank-network postgres15

# Get network details information
docker network inspect bank-network
[
  {
    "Name": "bank-network",
    "Scope": "local",
    "Driver": "bridge",
    "Containers": {
      "efebd1beb2f417887655d482767644a4c816a28154ba1bb8f5cd1cb5cf2ad150": {
        "Name": "postgres15",
        "MacAddress": "02:42:ac:12:00:02",
        "IPv4Address": "172.18.0.2/16",
        "IPv6Address": ""
      }
    }
  }
]
# Get container details information
docker container inspect postgres15
[
  {
    "Name": "/postgres15",
    "NetworkSettings": {
      "Gateway": "172.17.0.1",
      "IPAddress": "172.17.0.2",
      "MacAddress": "02:42:ac:11:00:02",
      "Networks": {
        "bank-network": {
          "Gateway": "172.18.0.1",
          "IPAddress": "172.18.0.2",
          "MacAddress": "02:42:ac:12:00:02"
        },
        "bridge": {
          "Gateway": "172.17.0.1",
          "IPAddress": "172.17.0.2",
          "MacAddress": "02:42:ac:11:00:02"
        }
      }
    }
  }
]
# Run a container in a specific network
docker run --name simplebank --network bank-network -p 8080:8080 -e GIN_MODE=release -e DB_SOURCE="postgresql://root:testpass@postgres15:5432/simplebank?sslmode=disable" foyezar/simplebank:latest
# Get network details information
docker network inspect bank-network
[
  {
    "Name": "bank-network",
    "Scope": "local",
    "Driver": "bridge",
    "Containers": {
      "bc239683e762e39e6d3d368f16c377ddacc3e6a02e6f0efd5c50bf8aed138ded": {
        "Name": "simplebank",
        "MacAddress": "02:42:ac:12:00:03",
        "IPv4Address": "172.18.0.3/16"
      },
      "efebd1beb2f417887655d482767644a4c816a28154ba1bb8f5cd1cb5cf2ad150": {
        "Name": "postgres15",
        "MacAddress": "02:42:ac:12:00:02",
        "IPv4Address": "172.18.0.2/16"
      }
    }
  }
]

Add docker-compose file to control network

View contents

Dockerfile

# Build stage
FROM golang:1.20.2-alpine3.17 AS builder
WORKDIR /app
COPY . .
RUN go build -o main main.go
# RUN apk add curl
# RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.16.0/migrate.linux-amd64.tar.gz | tar xvz

# Run stage
FROM alpine:3.17
WORKDIR /app
COPY --from=builder /app/main .
COPY app.env .
COPY start.sh .
COPY wait-for.sh .
COPY db/migration ./db/migration

EXPOSE 8080

CMD [ "/app/main" ]
ENTRYPOINT [ "/app/start.sh" ]

docker-compose.yml

version: "3.9"
services:
  postgres:
    image: postgres:15.2-alpine
    environment:
      - POSTGRES_USER=root
      - POSTGRES_PASSWORD=testpass
      - POSTGRES_DB=simplebank
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - DB_SOURCE=postgresql://root:testpass@postgres:5432/simplebank?sslmode=disable
    depends_on:
      - postgres
    entrypoint: ["/app/wait-for.sh", "postgres:5432", "--", "/app/start.sh"]
    command: ["/app/main"]

Note:

There are several things to be aware of when using depends_on:

depends_on does not wait for postgres to be “ready” before starting web - only until they have been started. If you need to wait for a service to be ready, see Controlling startup order for more on this problem and strategies for solving it.
The depends_on option is ignored when deploying a stack in swarm mode with a version 3 Compose file.
# Download wait-for.sh
curl https://raw.github
usercontent.com/eficode/wait-for/v2.2.4/wait-for -o wait-for.
sh

# Make wait-for.sh and start.sh executable
chmod +x wait-for.sh
chmod +x start.sh

# Build docker image and run the container
docker compose up

# Get network info
docker network inspect simplebank_default

# Remove existing containers
docker compose down

# Remove docker image
docker image rm simplebank_api

Use golang-migrate in go project ref

View contents

main.go

import (
 "github.com/golang-migrate/migrate/v4"
 _ "github.com/golang-migrate/migrate/v4/database/postgres"
 _ "github.com/golang-migrate/migrate/v4/source/file"
)

func main() {
 config, err := util.LoadConfig(".")
 if err != nil {
  log.Fatal("cannot load config: ", err)
 }

 conn, err := sql.Open(config.DBDriver, config.DBSource)
 if err != nil {
  log.Fatal("cannot connect to db: ", err)
 }

 runDBMigration(config.MigrationURL, config.DBSource)

//  ...
}

func runDBMigration(migrationURL string, dbSource string) {
 migration, err := migrate.New(migrationURL, dbSource)
 if err != nil {
  log.Fatal("cannot create new migration instance: ", err)
 }

 if err = migration.Up(); err != nil && err != migrate.ErrNoChange {
  log.Fatal("failed to run migrate up: ", err)
 }

 log.Println("db migrated successfully")
}

app.env

MIGRATION_URL=file://db/migration

util/config.go

type Config struct {
 MigrationURL  string `mapstructure:"MIGRATION_URL"`
}

Add User

View contents

Store password securely

Encrypt: plain password --> (bcrypt hash + cost + salt) --> hashed password

Hashed Password:
$(2A)$(10)$(N9QO8ULOICKGX2ZMRZOMYE)(IJZAGCFL7P92LDGXAD68LJZDL17LHWY)

Group 1: Algorithm (bcrypt)
Group 2: Cost (2^10 = 1024 key expansion rounds)
Group 3: Salt (16 bytes = 128 bits, 22 characters (base64))
Group 4: Hash (24 bytes = 192 bits, 31 characters (base64))

Decrypt: hashed password --> (bcrypt + cost + salt) --> plain password
  • Create user API:
View contents

db/query/user.sql

-- name: CreateUser :one
INSERT INTO users (
  username,
  hashed_password,
  full_name,
  email
) VALUES (
  $1, $2, $3, $4
) RETURNING *;

api/server.go

package api

import (
 db "github.com/foyez/simplebank/db/sqlc"
 "github.com/gin-gonic/gin"
)

// Server serves HTTP requests.
type Server struct {
 store  *db.Store
 router *gin.Engine
}

// NewServer creates a new HTTP server and setup routing.
func NewServer(store *db.Store) *Server {
 server := &Server{store: store}
 router := gin.Default()

 router.POST("/userrs", server.createUser)

 server.router = router
 return server
}

api/user.go

package api

import (
 "net/http"
 "time"

 db "github.com/foyez/simplebank/db/sqlc"
 "github.com/foyez/simplebank/util"
 "github.com/gin-gonic/gin"
 "github.com/lib/pq"
)

type createUserRequest struct {
 Username string `json:"username" binding:"required,alphanum"`
 Password string `json:"password" binding:"required,min=6"`
 FullName string `json:"full_name" binding:"required"`
 Email    string `json:"email" binding:"required,email"`
}

type createUserResponse struct {
 Username          string    `json:"username"`
 FullName          string    `json:"full_name"`
 Email             string    `json:"email"`
 PasswordChangedAt time.Time `json:"password_changed_at"`
 CreatedAt         time.Time `json:"created_at"`
}

func (server *Server) createUser(ctx *gin.Context) {
 var req createUserRequest
 if err := ctx.ShouldBindJSON(&req); err != nil {
  ctx.JSON(http.StatusBadRequest, errorResponse(err))
  return
 }

 hashedPassword, err := util.HashPassword(req.Password)
 if err != nil {
  ctx.JSON(http.StatusInternalServerError, errorResponse(err))
  return
 }

 arg := db.CreateUserParams{
  Username:       req.Username,
  HashedPassword: hashedPassword,
  FullName:       req.FullName,
  Email:          req.Email,
 }
 user, err := server.store.CreateUser(ctx, arg)
 if err != nil {
  if pqErr, ok := err.(*pq.Error); ok {
   switch pqErr.Code.Name() {
   case "unique_violation":
    ctx.JSON(http.StatusForbidden, errorResponse(err))
    return
   }
  }

  ctx.JSON(http.StatusInternalServerError, errorResponse(err))
  return
 }

 rsp := createUserResponse{
  Username:          user.Username,
  FullName:          user.FullName,
  Email:             user.Email,
  PasswordChangedAt: user.PasswordChangedAt,
  CreatedAt:         user.CreatedAt,
 }

 ctx.JSON(http.StatusCreated, rsp)
}

util/password.go

package util

import (
 "fmt"

 "golang.org/x/crypto/bcrypt"
)

// HashPassword returns the bcrypt hash of the password
func HashPassword(password string) (string, error) {
 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
 if err != nil {
  return "", fmt.Errorf("failed to hash password: %w", err)
 }
 return string(hashedPassword), nil
}

// CheckPassword checks if the provided password is correct or not
func CheckPassword(password string, hashedPassword string) error {
 return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
}

Authentication

View contents

Token-based Authentication

View contents image
  1. JSON Web Token - JWT
image

JWT sigining algorithms

image

Problems of JWT

image
  1. Platform-Agnostic SEcurity TOkens - PASETO
image

PASETO structure

image

Refresh Token

View contents image

app.env

REFRESH_TOKEN=24h

util/config.go

type Config struct {
 RefreshTokenDuration time.Duration `mapstructure:"REFRESH_TOKEN_DURATION"`
}

Add migration for sessions table

make create_migration name=add_sessions

db/migration/000003_add_sessions.up.sql

CREATE TABLE "sessions" (
  "id" uuid PRIMARY KEY,
  "username" varchar NOT NULL,
  "refresh_token" varchar NOT NULL,
  "user_token" varchar NOT NULL,
  "client_ip" varchar NOT NULL,
  "is_blocked" boolean NOT NULL DEFAULT false,
  "expires_at" timestamptz NOT NULL,
  "created_at" timestamptz NOT NULL DEFAULT (now())
);

ALTER TABLE "sessions" ADD FOREIGN KEY ("username") REFERENCES "users" ("username");

db/migration/000003_add_sessions.down.sql

DROP TABLE IF EXISTS "sessions";

Run migration up

make migrateup

db/query/session.sql

-- name: CreateSession :one
INSERT INTO sessions (
  id,
  username,
  refresh_token,
  user_token,
  client_ip,
  is_blocked,
  expires_at
) VALUES (
  $1, $2, $3, $4, $5, $6, $7
) RETURNING *;

-- name: GetSession :one
SELECT * FROM sessions
WHERE id = $1 LIMIT 1;

Run sqlc to generate codes for queries

make sqlc

Run mock to regenerate mock store

make mock

Run unit tests

make test

Postman Tests

View contents

Login endpoint: POST /users/login Body:

{
  "username": "foyez",
  "password": "testpass"
}

Response:

{
  "access_token": "v2.local.nBMJul9vDH3bs9yrxyi54E",
  "user": {
    "username": "foyez",
    "full_name": "Foyez Ahmed",
    "email": "foyez@email.com",
    "password_changed_at": "0001-01-01T00:00:00Z",
    "created_at": "2023-10-08T10:49:24.939704Z"
  }
}

Navigate to Tests

pm.test("Status code is 200", function () {
  pm.response.to.have.status(200);
});

var jsonData = JSON.parse(responseBody);
pm.collectionVariables.set("access_token", jsonData.access_token);

After call the login api: Test results

PASS Status code is 200

Now we can use access_token collection variable to other apis of this collection, e.g. {{access_token}}

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published