Skip to content

Commit

Permalink
Merge pull request #1 from viralparmarme/feature/http-server
Browse files Browse the repository at this point in the history
Add REST APIs, mocking, config via env
  • Loading branch information
viralparmarme authored Sep 23, 2023
2 parents cc0c073 + a703313 commit 3d70ae5
Show file tree
Hide file tree
Showing 22 changed files with 1,305 additions and 19 deletions.
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,10 @@ sqlc:
test:
go test -v -cover ./...

.PHONY: postgres createdb dropdb migrateup migratedown sqlc test
server:
go run main.go

mock:
mockgen -package mockdb -destination db/mock/store.go github.com/viralparmarme/simple-bank/db/sqlc Store

.PHONY: postgres createdb dropdb migrateup migratedown sqlc test server mock
93 changes: 93 additions & 0 deletions api/account.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package api

import (
"database/sql"
"net/http"

"github.com/gin-gonic/gin"
db "github.com/viralparmarme/simple-bank/db/sqlc"
)

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

type createAccountRequest struct {
Owner string `json:"owner" binding:"required"`
Currency string `json:"currency" binding:"required,currency"`
}

func (server *Server) CreateAccount(ctx *gin.Context) {
var req createAccountRequest
err := ctx.ShouldBindJSON(&req)
if 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.StatusOK, account)
}

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

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

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

ctx.JSON(http.StatusOK, account)
}

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

func (server *Server) ListAccount(ctx *gin.Context) {
var req listAccountRequest
err := ctx.ShouldBindQuery(&req)
if 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)
}
123 changes: 123 additions & 0 deletions api/account_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package api

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

"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
mockdb "github.com/viralparmarme/simple-bank/db/mock"
db "github.com/viralparmarme/simple-bank/db/sqlc"
"github.com/viralparmarme/simple-bank/util"
)

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)
},
},
{
name: "NotFound",
accountID: account.ID,
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
GetAccount(gomock.Any(), gomock.Eq(account.ID)).
Times(1).
Return(db.Account{}, sql.ErrNoRows)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusNotFound, recorder.Code)
},
},
{
name: "InternalError",
accountID: account.ID,
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
GetAccount(gomock.Any(), gomock.Eq(account.ID)).
Times(1).
Return(db.Account{}, sql.ErrConnDone)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusInternalServerError, recorder.Code)
},
},
{
name: "InvalidID",
accountID: 0,
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
GetAccount(gomock.Any(), gomock.Any()).
Times(0)
},
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
}

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)

server := NewServer(store)
recorder := httptest.NewRecorder()

url := fmt.Sprintf("/accounts/%d", tc.accountID)
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)
}
14 changes: 14 additions & 0 deletions api/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package api

import (
"os"
"testing"

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

func TestMain(m *testing.M) {
gin.SetMode(gin.TestMode)

os.Exit(m.Run())
}
35 changes: 35 additions & 0 deletions api/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package api

import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
db "github.com/viralparmarme/simple-bank/db/sqlc"
)

type Server struct {
store db.Store
router *gin.Engine
}

func NewServer(store db.Store) *Server {
server := &Server{store: store}
router := gin.Default()

if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("currency", validCurrency)
}

router.POST("/accounts", server.CreateAccount)
router.GET("/accounts/:id", server.GetAccount)
router.GET("/accounts", server.ListAccount)

router.POST("/transfers", server.CreateTransfer)

server.router = router
return server
}

func (server *Server) Start(address string) error {
return server.router.Run(address)
}
67 changes: 67 additions & 0 deletions api/transfer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package api

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

"github.com/gin-gonic/gin"
db "github.com/viralparmarme/simple-bank/db/sqlc"
)

type transferRequest struct {
FromAccountId int64 `json:"from_account_id" binding:"required,min=1"`
ToAccountId int64 `json:"to_account_id" binding:"required,min=1"`
Amount int64 `json:"amount" binding:"required,gt=0"`
Currency string `json:"currency" binding:"required,currency"`
}

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

if !server.validAccount(ctx, req.FromAccountId, req.Currency) {
return
}
if !server.validAccount(ctx, req.ToAccountId, req.Currency) {
return
}

arg := db.TransferTxParams{
FromAccountID: req.FromAccountId,
ToAccountID: req.ToAccountId,
Amount: req.Amount,
}

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

ctx.JSON(http.StatusOK, result)
}

func (server *Server) validAccount(ctx *gin.Context, accountID int64, currency string) bool {
account, err := server.store.GetAccount(ctx, accountID)
if err != nil {
if err == sql.ErrNoRows {
ctx.JSON(http.StatusNotFound, errorResponse(err))
return false
}
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return false
}

if account.Currency != currency {
err := fmt.Errorf("account %d mismatch: %s vs %s", accountID, account.Currency, currency)
ctx.JSON(http.StatusBadRequest, errorResponse(err))
return false
}

return true
}
13 changes: 13 additions & 0 deletions api/validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package api

import (
"github.com/go-playground/validator/v10"
"github.com/viralparmarme/simple-bank/util"
)

var validCurrency validator.Func = func(fieldlevel validator.FieldLevel) bool {
if currency, ok := fieldlevel.Field().Interface().(string); ok {
return util.IsSupportedCurrency(currency)
}
return false
}
3 changes: 3 additions & 0 deletions app.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
DB_DRIVER="postgres"
DB_SOURCE="postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable"
SERVER_ADDRESS="0.0.0.0:8080"
Loading

0 comments on commit 3d70ae5

Please sign in to comment.