- 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
# 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
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
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
- 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
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 ingo.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
View contents
- 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
- To provide a reliable and consistent unit of work, even in case of system failure
- 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.
-
Atomicity Either all operations complete successfully or if the transaction fails, everything will be rolled back and the db will be unchanged.
-
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.
-
Isolation Concurrent transaction must not affect each other.
-
Durability Data written by a successful transaction must be recorded in persistent storage, even in case of system failure.
BEGIN;
COMMIT;
-- if the transaction is failed
BEGIN;
ROLLBACK;
- 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
BEGIN;
SELECT * FROM accounts WHERE id = 15 FOR UPDATE;
UPDATE accounts SET balance = 500 WHERE id = 15;
COMMIT;
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;
View contents
- We can trigger a workflow by 3 ways:
event
,schedule
, ormanually
- 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
- Goto
Actions
tab - Then, in
Go
action clickconfigure
- Create github workflows directory:
mkdir -p .github/workflows
- Create workflow file:
touch .github/workflows/test.yml
- Then, copy and paste the template from github for go
- Creating PostgreSQL service containers
- How to setup Github Actions for Go + Postgres to run automated tests
View contents
- Gin
- Beego
- Echo
- Revel
- Martini
- Fiber
- Buffalo
- FastHttp
- Gorilla Mux
- HttpRouter
- Chi
Install gin
package:
https://github.com/gin-gonic/gin
-
A POST api:
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)
// ...
}
View contents
- 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())
}
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
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"
}
}
}
]
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"`
}
View contents
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))
}
View contents
View contents
- JSON Web Token - JWT
JWT sigining algorithms
Problems of JWT
- Platform-Agnostic SEcurity TOkens - PASETO
PASETO structure
View contents
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
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}}