Working /transactions endpoint with DB connection

Adds the database abstraction layer (DAL) with a Transactions method
and a Connection object to encapsulate the DB open connection.
Includes a DAL unit test using the go-sqlmock library.

Connection to a PostgreSQL database using environment variables to
store the secrets.

Overall, with the /transactions endpoint requesting directly to the DB,
this patch finish the tracer bullet project.

Issues #1, #5, #6 and #7
This commit is contained in:
Luís Murta 2024-02-13 19:27:26 +00:00
parent a7359aacea
commit 19974a5f44
Signed by: satprog
GPG Key ID: 169EF1BBD7049F94
8 changed files with 177 additions and 39 deletions

View File

@ -2,14 +2,17 @@ package main
import (
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"git.rosemyrtle.work/personal-finance/server/internal/db"
"git.rosemyrtle.work/personal-finance/server/internal/model"
"git.rosemyrtle.work/personal-finance/server/internal/dal"
"git.rosemyrtle.work/personal-finance/server/internal/entities"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"github.com/shopspring/decimal"
)
@ -29,22 +32,18 @@ type Bank struct {
NordigenID string `json:"nordigenId,omitempty"`
}
// ResponseError struct represents an error response
type ResponseError struct {
Message string `json:"message"`
type Env struct {
db *dal.Connection
}
// Handlers
func retrieveTransactions(c *gin.Context) {
func (e *Env) retrieveTransactions(c *gin.Context) {
// Handle the logic for retrieving transactions here
// limit := c.Query("limit")
// offset := c.Query("offset")
// bank := c.Query("bank")
// sort := c.Query("sort")
db := db.Handle{}
transactions := db.Transactions()
transactions := e.db.Transactions()
var ret []Transaction
for _, t := range transactions {
@ -57,12 +56,12 @@ func retrieveTransactions(c *gin.Context) {
c.JSON(http.StatusOK, ret)
}
func retrieveTransactionByID(c *gin.Context) {
func (e *Env) retrieveTransactionByID(c *gin.Context) {
// Handle the logic for retrieving a transaction by ID here
// transactionID := c.Param("transactionId")
// Placeholder response
transaction := model.Transaction{
transaction := entities.Transaction{
Id: 1,
Date: time.Date(2024, 01, 24, 0, 0, 0, 0, time.UTC),
Description: "Groceries",
@ -73,7 +72,7 @@ func retrieveTransactionByID(c *gin.Context) {
c.JSON(http.StatusOK, transaction)
}
func retrieveBanks(c *gin.Context) {
func (e *Env) retrieveBanks(c *gin.Context) {
// Handle the logic for retrieving banks here
// Placeholder response
@ -85,7 +84,7 @@ func retrieveBanks(c *gin.Context) {
c.JSON(http.StatusOK, banks)
}
func retrieveBankByID(c *gin.Context) {
func (e *Env) retrieveBankByID(c *gin.Context) {
// Handle the logic for retrieving a bank by ID here
bankID := c.Param("bankId")
@ -96,13 +95,33 @@ func retrieveBankByID(c *gin.Context) {
}
func main() {
if err := godotenv.Load(); err != nil {
log.Fatal("Error loading .env file")
return
}
user := os.Getenv("PG_USER")
pass := os.Getenv("PG_PASSWORD")
location := os.Getenv("PG_URL")
database := os.Getenv("PG_DB")
credentials := entities.Credentials{Database: "postgres://" + user + ":" + pass + "@" + location + "/" + database}
db, err := dal.Open(&credentials)
if err != nil {
log.Fatal(err)
return
}
defer db.Close()
router := gin.Default()
env := Env{db: db}
// Routes
router.GET("/transactions", retrieveTransactions)
router.GET("/transactions/:transactionId", retrieveTransactionByID)
router.GET("/banks", retrieveBanks)
router.GET("/banks/:bankId", retrieveBankByID)
router.GET("/transactions", env.retrieveTransactions)
router.GET("/transactions/:transactionId", env.retrieveTransactionByID)
router.GET("/banks", env.retrieveBanks)
router.GET("/banks/:bankId", env.retrieveBankByID)
// Start server
port := 8080

13
go.mod
View File

@ -5,6 +5,7 @@ go 1.21.1
require github.com/gin-gonic/gin v1.9.1
require (
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
@ -13,6 +14,11 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.3 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
@ -24,10 +30,11 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

21
go.sum
View File

@ -1,3 +1,5 @@
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
@ -23,8 +25,19 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.3 h1:Ces6/M3wbDXYpM8JyyPD57ivTtJACFZJd885pdIaV2s=
github.com/jackc/pgx/v5 v5.5.3/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
@ -61,14 +74,22 @@ golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=

60
internal/dal/dal.go Normal file
View File

@ -0,0 +1,60 @@
package dal
import (
"database/sql"
"errors"
"fmt"
"log"
"os"
"git.rosemyrtle.work/personal-finance/server/internal/entities"
_ "github.com/jackc/pgx/v5/stdlib"
)
type Connection struct {
db *sql.DB
}
func Open(credentials *entities.Credentials) (*Connection, error) {
if credentials == nil {
return nil, errors.New("missing credentials")
}
db, err := sql.Open("pgx", credentials.Database)
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
return nil, err
}
if err := db.Ping(); err != nil {
log.Fatal(err)
}
return &Connection{db}, nil
}
func (c *Connection) Close() {
c.db.Close()
}
func (conn *Connection) Transactions() []entities.Transaction {
q := "SELECT t.date, t.description, t.amount FROM transactions t;"
rows, err := conn.db.Query(q)
if err != nil {
log.Fatal(err)
}
var result []entities.Transaction
for rows.Next() {
var t entities.Transaction
if err := rows.Scan(&t.Date, &t.Description, &t.Value); err != nil {
log.Fatal(err)
}
result = append(result, t)
}
return result
}

43
internal/dal/dal_test.go Normal file
View File

@ -0,0 +1,43 @@
package dal
import (
"database/sql/driver"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
type AnyTime struct{}
func (t AnyTime) Match(v driver.Value) bool {
_, ok := v.(time.Time)
return ok
}
// type AnyDecimal struct{}
func TestTransactions(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create a mock DB: %s", err)
}
defer db.Close()
conn := Connection{db}
rows := sqlmock.NewRows([]string{"date", "description", "amount"}).
AddRow(time.Now(), "income", 1000).
AddRow(time.Now(), "expense", -500)
mock.ExpectQuery("SELECT t.date, t.description, t.amount").WithoutArgs().WillReturnRows(rows)
transactions := conn.Transactions()
if len(transactions) <= 0 {
t.Error("No transactions returned")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %s", err)
}
}

View File

@ -1,17 +0,0 @@
package db
import (
"time"
"git.rosemyrtle.work/personal-finance/server/internal/model"
"github.com/shopspring/decimal"
)
type Handle struct{}
func (*Handle) Transactions() []model.Transaction {
return []model.Transaction{
{Id: 1, Date: time.Date(2024, 01, 24, 0, 0, 0, 0, time.UTC), Description: "Groceries", Value: decimal.NewFromFloat(-50.0), Category: "Food"},
{Id: 2, Date: time.Date(2024, 01, 23, 0, 0, 0, 0, time.UTC), Description: "Salary", Value: decimal.NewFromFloat(3000.0), Category: "Income"},
}
}

View File

@ -0,0 +1,5 @@
package entities
type Credentials struct {
Database string
}

View File

@ -1,4 +1,4 @@
package model
package entities
import (
"time"