Compare commits

...

2 Commits

Author SHA1 Message Date
35b35f5e40
Implements /transactions PUT method
Adds PUT method to OpenAPI spec.

Given that the transaction IDs are generated on server-side, for the PUT
method to remain idempotent, it can only update existing transactions.

It also adds a TransactionExists method on the DAL.

Issue: #20
2024-06-22 21:51:03 +01:00
4c82ca0e2b
Implements /transactions POST method
It adds the method to the OpenAPI spec and generates a new server
config. The requirement for the ID on the Transaction component is
removed, so that it can be reused for insertions.
It also adds two new middlewares, a logging and a spec validator. If a
request does not follow the spec, a 400 is returned immediately.

Issue: #18
2024-06-22 20:11:52 +01:00
13 changed files with 617 additions and 23 deletions

View File

@ -12,6 +12,8 @@ import (
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
echomiddleware "github.com/oapi-codegen/echo-middleware"
) )
func main() { func main() {
@ -41,6 +43,14 @@ func main() {
// 3. HTTP server // 3. HTTP server
e := echo.New() e := echo.New()
e.Use(middleware.Logger())
swagger, err := api.GetSwagger()
if err != nil {
log.Fatalf("unable to get API swagger: %v\n", err)
}
e.Use(echomiddleware.OapiRequestValidator(swagger))
handlers := api.ServerImpl{Dal: &dal} handlers := api.ServerImpl{Dal: &dal}
api.RegisterHandlers(e, &handlers) api.RegisterHandlers(e, &handlers)

View File

@ -56,6 +56,38 @@ paths:
$ref: "#/components/schemas/Transactions" $ref: "#/components/schemas/Transactions"
"204": "204":
description: No transactions description: No transactions
post:
summary: Create a new transaction
operationId: createTransaction
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/Transaction"
responses:
"201":
description: Transaction created
content:
application/json:
schema:
$ref: "#/components/schemas/Transaction"
"400":
description: Transaction not created
put:
summary: Update an existing transaction
operationId: updateTransaction
requestBody:
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Transaction"
- required:
- id
responses:
"204":
description: Transaction updated successfully
/transaction/{transactionId}: /transaction/{transactionId}:
get: get:
@ -138,7 +170,6 @@ components:
category: category:
type: string type: string
required: required:
- id
- date - date
- description - description
- value - value

8
go.mod
View File

@ -7,6 +7,9 @@ require (
github.com/deepmap/oapi-codegen/v2 v2.1.0 github.com/deepmap/oapi-codegen/v2 v2.1.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.5.5 github.com/jackc/pgx/v5 v5.5.5
github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v4 v4.12.0
github.com/oapi-codegen/echo-middleware v1.0.1
github.com/oapi-codegen/runtime v1.1.1 github.com/oapi-codegen/runtime v1.1.1
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
@ -19,13 +22,13 @@ require (
github.com/getkin/kin-openapi v0.124.0 github.com/getkin/kin-openapi v0.124.0
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/invopop/yaml v0.3.1 // indirect github.com/invopop/yaml v0.3.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/joho/godotenv v1.5.1
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/labstack/echo/v4 v4.12.0
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
@ -41,6 +44,7 @@ require (
golang.org/x/sync v0.7.0 // indirect golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.20.0 // indirect golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect golang.org/x/text v0.15.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.21.0 // indirect golang.org/x/tools v0.21.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

8
go.sum
View File

@ -17,8 +17,12 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@ -52,6 +56,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/oapi-codegen/echo-middleware v1.0.1 h1:edYGScq1phCcuDoz9AqA9eHX+tEI1LNL5PL1lkkQh1k=
github.com/oapi-codegen/echo-middleware v1.0.1/go.mod h1:DBQKRn+D/vfXOFbaX5GRwFttoJY64JH6yu+pdt7wU3o=
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
@ -92,6 +98,8 @@ golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw=
golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -3,17 +3,41 @@ package api
import ( import (
"git.rosemyrtle.work/personal-finance/server/internal/entity" "git.rosemyrtle.work/personal-finance/server/internal/entity"
openapi_types "github.com/oapi-codegen/runtime/types" openapi_types "github.com/oapi-codegen/runtime/types"
"github.com/shopspring/decimal"
) )
func convertTransaction(t entity.Transaction) Transaction { func ptr[T any](v T) *T {
return &v
}
func entity2transaction(t entity.Transaction) Transaction {
return Transaction{ return Transaction{
nil, openapi_types.Date{Time: t.Date}, t.Description, int64(t.Id), float32(t.Value.InexactFloat64())} nil,
openapi_types.Date{Time: t.Date},
t.Description,
ptr(int64(t.Id)),
float32(t.Value.InexactFloat64()),
}
}
func transaction2entity(t Transaction) entity.Transaction {
var id uint64 = entity.InvalidId
if t.Id != nil {
id = uint64(*t.Id)
}
return entity.Transaction{
Id: id,
Date: t.Date.Time,
Description: t.Description,
Value: decimal.NewFromFloat32(t.Value),
}
} }
func convertTransactions(ts entity.Transactions) Transactions { func convertTransactions(ts entity.Transactions) Transactions {
var ans Transactions var ans Transactions
for _, t := range ts { for _, t := range ts {
ans = append(ans, convertTransaction(t)) ans = append(ans, entity2transaction(t))
} }
return ans return ans
} }

View File

@ -73,5 +73,49 @@ func (server *ServerImpl) GetTransactionById(ctx echo.Context, transactionId int
return ctx.NoContent(http.StatusNotFound) return ctx.NoContent(http.StatusNotFound)
} }
return ctx.JSON(http.StatusOK, convertTransaction(*transaction)) return ctx.JSON(http.StatusOK, entity2transaction(*transaction))
}
func (server *ServerImpl) CreateTransaction(ctx echo.Context) error {
t := new(Transaction)
if err := ctx.Bind(t); err != nil {
log.Printf("%v", err)
return ctx.NoContent(http.StatusBadRequest)
}
transaction, err := server.Dal.InsertTransaction(transaction2entity(*t))
if err != nil {
log.Printf("%v", err)
return ctx.NoContent(http.StatusInternalServerError)
}
return ctx.JSON(http.StatusCreated, entity2transaction(transaction))
}
func (server *ServerImpl) UpdateTransaction(ctx echo.Context) error {
t := new(Transaction)
if err := ctx.Bind(t); err != nil {
log.Printf("%v", err)
return ctx.NoContent(http.StatusBadRequest)
}
transaction := transaction2entity(*t)
exists, err := server.Dal.TransactionExists(transaction.Id)
if err != nil {
log.Printf("%v", err)
return ctx.NoContent(http.StatusInternalServerError)
}
if exists {
transaction, err := server.Dal.UpdateTransaction(transaction)
if err != nil {
log.Printf("%v", err)
return ctx.NoContent(http.StatusInternalServerError)
}
return ctx.JSON(http.StatusOK, entity2transaction(transaction))
} else {
return ctx.NoContent(http.StatusNotFound)
}
} }

View File

@ -3,7 +3,9 @@ package api
import ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"reflect"
"strconv" "strconv"
"strings"
"testing" "testing"
"time" "time"
@ -224,3 +226,140 @@ func TestServerImpl_GetTransactionById(t *testing.T) {
}) })
} }
} }
func TestServerImpl_CreateTransaction(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mock_dal := mock.NewMockDAL(ctrl)
date, _ := time.Parse(time.DateOnly, "1974-04-25")
type fields struct {
Dal dal.DAL
}
type args struct {
request string
}
type want struct {
entity entity.Transaction
response string
}
tests := []struct {
name string
fields fields
args args
want want
wantErr bool
}{
{
"201",
fields{mock_dal},
args{`{"date": "1974-04-25", "description": "freedom", "value": 9000}`},
want{
entity.Transaction{Id: 1, Date: date, Description: "freedom", Value: decimal.New(9000, 0)},
`{"date":"1974-04-25","description":"freedom","id":1,"value":9000}`,
},
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &ServerImpl{Dal: tt.fields.Dal}
req := httptest.NewRequest(http.MethodPost, "/transactions", strings.NewReader(tt.args.request))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
e := echo.New()
ctx := e.NewContext(req, rec)
inserted := tt.want.entity
inserted.Id = entity.InvalidId
mock_dal.EXPECT().InsertTransaction(gomock.AssignableToTypeOf(reflect.TypeOf(tt.want.entity))).Return(tt.want.entity, nil).Times(1)
if err := s.CreateTransaction(ctx); (err != nil) != tt.wantErr {
t.Errorf("ServerImpl.GetTransactionById() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(strings.TrimSpace(rec.Body.String()), tt.want.response) {
t.Errorf("DalImpl.InsertTransaction() = %v, want %v", rec.Body.String(), tt.want.response)
}
})
}
}
func TestServerImpl_UpdateTransaction(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
date, _ := time.Parse(time.DateOnly, "1974-04-25")
m := mock.NewMockDAL(ctrl)
e := m.EXPECT()
type fields struct {
// Dal dal.DAL
fn func(any) *gomock.Call
args any
returns any
err error
}
type args struct {
request string
}
type want struct {
status int
body string
}
tests := []struct {
name string
fields []fields
args args
want want
wantErr bool
}{
{
"UpdateExisting",
[]fields{
{e.TransactionExists, gomock.Any(), true, nil},
{e.UpdateTransaction, gomock.Any(), entity.Transaction{Id: 1, Date: date, Description: "freedom", Value: decimal.New(9000, 0)}, nil},
},
args{`{"date":"1974-04-25","description":"freedom","id":1,"value":9000}`},
want{200, `{"date":"1974-04-25","description":"freedom","id":1,"value":9000}`},
false,
},
{
"NotExisting",
[]fields{
{e.TransactionExists, gomock.Any(), false, nil},
},
args{`{"date":"1974-04-25","description":"freedom","id":1,"value":9000}`},
want{404, ""},
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := &ServerImpl{
Dal: m,
}
for _, expect := range tt.fields {
expect.fn(expect.args).Return(expect.returns, expect.err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tt.args.request))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
ctx := echo.New().NewContext(req, rec)
if err := server.UpdateTransaction(ctx); (err != nil) != tt.wantErr {
t.Errorf("ServerImpl.UpdateTransaction() error = %v, wantErr %v", err, tt.wantErr)
}
if got := rec.Code; !reflect.DeepEqual(got, tt.want.status) {
t.Errorf("ServerImpl.UpdateTransaction() = %v, want %v", got, tt.name)
}
if got := strings.TrimSpace(rec.Body.String()); !reflect.DeepEqual(got, tt.want.body) {
t.Errorf("ServerImpl.UpdateTransaction() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -34,7 +34,7 @@ type Transaction struct {
Category *string `json:"category,omitempty"` Category *string `json:"category,omitempty"`
Date openapi_types.Date `json:"date"` Date openapi_types.Date `json:"date"`
Description string `json:"description"` Description string `json:"description"`
Id int64 `json:"id"` Id *int64 `json:"id,omitempty"`
Value float32 `json:"value"` Value float32 `json:"value"`
} }
@ -59,6 +59,21 @@ type GetTransactionsParams struct {
Sort *string `form:"sort,omitempty" json:"sort,omitempty"` Sort *string `form:"sort,omitempty" json:"sort,omitempty"`
} }
// UpdateTransactionJSONBody defines parameters for UpdateTransaction.
type UpdateTransactionJSONBody struct {
Category *string `json:"category,omitempty"`
Date openapi_types.Date `json:"date"`
Description string `json:"description"`
Id int64 `json:"id"`
Value float32 `json:"value"`
}
// CreateTransactionJSONRequestBody defines body for CreateTransaction for application/json ContentType.
type CreateTransactionJSONRequestBody = Transaction
// UpdateTransactionJSONRequestBody defines body for UpdateTransaction for application/json ContentType.
type UpdateTransactionJSONRequestBody UpdateTransactionJSONBody
// ServerInterface represents all server handlers. // ServerInterface represents all server handlers.
type ServerInterface interface { type ServerInterface interface {
// Find bank by ID // Find bank by ID
@ -73,6 +88,12 @@ type ServerInterface interface {
// Retrieve existing transactions // Retrieve existing transactions
// (GET /transactions) // (GET /transactions)
GetTransactions(ctx echo.Context, params GetTransactionsParams) error GetTransactions(ctx echo.Context, params GetTransactionsParams) error
// Create a new transaction
// (POST /transactions)
CreateTransaction(ctx echo.Context) error
// Update an existing transaction
// (PUT /transactions)
UpdateTransaction(ctx echo.Context) error
} }
// ServerInterfaceWrapper converts echo contexts to parameters. // ServerInterfaceWrapper converts echo contexts to parameters.
@ -167,6 +188,24 @@ func (w *ServerInterfaceWrapper) GetTransactions(ctx echo.Context) error {
return err return err
} }
// CreateTransaction converts echo context to params.
func (w *ServerInterfaceWrapper) CreateTransaction(ctx echo.Context) error {
var err error
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.CreateTransaction(ctx)
return err
}
// UpdateTransaction converts echo context to params.
func (w *ServerInterfaceWrapper) UpdateTransaction(ctx echo.Context) error {
var err error
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.UpdateTransaction(ctx)
return err
}
// This is a simple interface which specifies echo.Route addition functions which // This is a simple interface which specifies echo.Route addition functions which
// are present on both echo.Echo and echo.Group, since we want to allow using // are present on both echo.Echo and echo.Group, since we want to allow using
// either of them for path registration // either of them for path registration
@ -199,27 +238,31 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
router.GET(baseURL+"/banks", wrapper.GetBanks) router.GET(baseURL+"/banks", wrapper.GetBanks)
router.GET(baseURL+"/transaction/:transactionId", wrapper.GetTransactionById) router.GET(baseURL+"/transaction/:transactionId", wrapper.GetTransactionById)
router.GET(baseURL+"/transactions", wrapper.GetTransactions) router.GET(baseURL+"/transactions", wrapper.GetTransactions)
router.POST(baseURL+"/transactions", wrapper.CreateTransaction)
router.PUT(baseURL+"/transactions", wrapper.UpdateTransaction)
} }
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
"AAD//5ap8eBoDgAA",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file

View File

@ -5,6 +5,9 @@ import "git.rosemyrtle.work/personal-finance/server/internal/entity"
type DAL interface { type DAL interface {
Transaction(transactionId int64) (*entity.Transaction, error) Transaction(transactionId int64) (*entity.Transaction, error)
Transactions() (entity.Transactions, error) Transactions() (entity.Transactions, error)
InsertTransaction(entity.Transaction) (entity.Transaction, error)
UpdateTransaction(entity.Transaction) (entity.Transaction, error)
TransactionExists(uint64) (bool, error)
Bank(bankId string) (*entity.Bank, error) Bank(bankId string) (*entity.Bank, error)
Banks() (entity.Banks, error) Banks() (entity.Banks, error)
} }

View File

@ -2,6 +2,7 @@ package dal
import ( import (
"database/sql" "database/sql"
"errors"
"log" "log"
"git.rosemyrtle.work/personal-finance/server/internal/entity" "git.rosemyrtle.work/personal-finance/server/internal/entity"
@ -46,6 +47,82 @@ func (dal *DalImpl) Transactions() (entity.Transactions, error) {
return convert[entity.Transaction](rows), nil return convert[entity.Transaction](rows), nil
} }
func (dal *DalImpl) InsertTransaction(t entity.Transaction) (entity.Transaction, error) {
log.Print("DAL::InsertTransaction")
if dal.Db == nil {
log.Panic("database not available")
}
stmt := `
INSERT INTO pfbudget.transactions (date, description, amount)
VALUES ($1, $2, $3)
RETURNING id
`
id := new(uint64)
if err := dal.Db.QueryRow(stmt, t.Date, t.Description, t.Value).Scan(id); err != nil {
return entity.Transaction{}, err
}
t.Id = *id
return t, nil
}
func (dal *DalImpl) UpdateTransaction(t entity.Transaction) (entity.Transaction, error) {
log.Print("DAL::UpdateTransaction")
if dal.Db == nil {
log.Panic("database not available")
}
stmt := `
UPDATE pfbudget.transactions
SET date = $2, description = $3, amount = $4
WHERE id = $1
`
result, err := dal.Db.Exec(stmt, t.Id, t.Date, t.Description, t.Value)
if err != nil {
return entity.Transaction{}, err
}
nAffected, err := result.RowsAffected()
if err != nil {
return entity.Transaction{}, err
}
if nAffected != 1 {
return t, errors.New("more than 1 row affected")
}
return t, nil
}
func (dal *DalImpl) TransactionExists(id uint64) (bool, error) {
log.Print("DAL::TransactionExists")
if dal.Db == nil {
log.Panic("database not available")
}
stmt := `
SELECT EXISTS(
SELECT 1
FROM pfbudget.transactions
WHERE id = $1
)
`
exists := new(bool)
err := dal.Db.QueryRow(stmt, id).Scan(&exists)
if err != nil {
return false, err
}
return *exists, nil
}
func (dal *DalImpl) Bank(bankId string) (*entity.Bank, error) { func (dal *DalImpl) Bank(bankId string) (*entity.Bank, error) {
log.Printf("DAL::Bank(%v)", bankId) log.Printf("DAL::Bank(%v)", bankId)

View File

@ -260,3 +260,167 @@ func TestDalImpl_Banks(t *testing.T) {
}) })
} }
} }
func TestDalImpl_InsertTransaction(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
date := time.Now()
type fields struct {
Db *sql.DB
}
type args struct {
t entity.Transaction
}
tests := []struct {
name string
fields fields
args args
want entity.Transaction
wantErr bool
}{
{
"201",
fields{db},
args{entity.Transaction{Id: entity.InvalidId, Date: date, Description: "freedom", Value: decimal.NewFromInt(9000)}},
entity.Transaction{Id: 1, Date: date, Description: "freedom", Value: decimal.NewFromInt(9000)},
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dal := &DalImpl{
Db: tt.fields.Db,
}
mock.
ExpectQuery(`
INSERT INTO .* \(date, description, amount\)
VALUES \(\$1, \$2, \$3\)
RETURNING id`).
WithArgs(tt.args.t.Date, tt.args.t.Description, tt.args.t.Value).
WillReturnRows(
mock.NewRows([]string{"id"}).
AddRows([]driver.Value{tt.want.Id}),
)
got, err := dal.InsertTransaction(tt.args.t)
if (err != nil) != tt.wantErr {
t.Errorf("DalImpl.InsertTransaction() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("DalImpl.InsertTransaction() = %v, want %v", got, tt.want)
}
})
}
}
func TestDalImpl_UpdateTransaction(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
date := time.Now()
type fields struct {
Db *sql.DB
}
type args struct {
t entity.Transaction
}
tests := []struct {
name string
fields fields
args args
want entity.Transaction
wantErr bool
}{
{
"SuccessfulUpdate",
fields{db},
args{entity.Transaction{Id: entity.InvalidId, Date: date, Description: "freedom", Value: decimal.NewFromInt(9000)}},
entity.Transaction{Id: entity.InvalidId, Date: date, Description: "freedom", Value: decimal.NewFromInt(9000)},
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dal := &DalImpl{
Db: tt.fields.Db,
}
mock.
ExpectExec(`
UPDATE pfbudget.transactions
SET date = \$2, description = \$3, value = \$4
WHERE id = \$1`).
WithArgs(tt.args.t.Id, tt.args.t.Date, tt.args.t.Description, tt.args.t.Value).
WillReturnResult(sqlmock.NewResult(0, 1))
got, err := dal.UpdateTransaction(tt.args.t)
if (err != nil) != tt.wantErr {
t.Errorf("DalImpl.UpdateTransaction() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("DalImpl.UpdateTransaction() = %v, want %v", got, tt.want)
}
})
}
}
func TestDalImpl_TransactionExists(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
type fields struct {
Db *sql.DB
}
type args struct {
id uint64
}
tests := []struct {
name string
fields fields
args args
want bool
wantErr bool
}{
{"TransactionExists", fields{db}, args{1}, true, false},
{"TransactionNotExists", fields{db}, args{1}, false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dal := &DalImpl{
Db: tt.fields.Db,
}
mock.
ExpectQuery(`
SELECT EXISTS\(
SELECT 1
FROM pfbudget.transactions
WHERE id = \$1
\)`).
WithArgs(tt.args.id).
WillReturnRows(
mock.NewRows([]string{"exists"}).
AddRows([]driver.Value{tt.want}),
)
got, err := dal.TransactionExists(tt.args.id)
if (err != nil) != tt.wantErr {
t.Errorf("DalImpl.TransactionExists() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("DalImpl.TransactionExists() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -7,6 +7,8 @@ import (
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
const InvalidId uint64 = 0
type Transaction struct { type Transaction struct {
Id uint64 Id uint64
Date time.Time Date time.Time

View File

@ -69,6 +69,21 @@ func (mr *MockDALMockRecorder) Banks() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Banks", reflect.TypeOf((*MockDAL)(nil).Banks)) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Banks", reflect.TypeOf((*MockDAL)(nil).Banks))
} }
// InsertTransaction mocks base method.
func (m *MockDAL) InsertTransaction(arg0 entity.Transaction) (entity.Transaction, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertTransaction", arg0)
ret0, _ := ret[0].(entity.Transaction)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InsertTransaction indicates an expected call of InsertTransaction.
func (mr *MockDALMockRecorder) InsertTransaction(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertTransaction", reflect.TypeOf((*MockDAL)(nil).InsertTransaction), arg0)
}
// Transaction mocks base method. // Transaction mocks base method.
func (m *MockDAL) Transaction(arg0 int64) (*entity.Transaction, error) { func (m *MockDAL) Transaction(arg0 int64) (*entity.Transaction, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@ -84,6 +99,21 @@ func (mr *MockDALMockRecorder) Transaction(arg0 any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Transaction", reflect.TypeOf((*MockDAL)(nil).Transaction), arg0) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Transaction", reflect.TypeOf((*MockDAL)(nil).Transaction), arg0)
} }
// TransactionExists mocks base method.
func (m *MockDAL) TransactionExists(arg0 uint64) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "TransactionExists", arg0)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// TransactionExists indicates an expected call of TransactionExists.
func (mr *MockDALMockRecorder) TransactionExists(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TransactionExists", reflect.TypeOf((*MockDAL)(nil).TransactionExists), arg0)
}
// Transactions mocks base method. // Transactions mocks base method.
func (m *MockDAL) Transactions() ([]entity.Transaction, error) { func (m *MockDAL) Transactions() ([]entity.Transaction, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@ -98,3 +128,18 @@ func (mr *MockDALMockRecorder) Transactions() *gomock.Call {
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Transactions", reflect.TypeOf((*MockDAL)(nil).Transactions)) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Transactions", reflect.TypeOf((*MockDAL)(nil).Transactions))
} }
// UpdateTransaction mocks base method.
func (m *MockDAL) UpdateTransaction(arg0 entity.Transaction) (entity.Transaction, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateTransaction", arg0)
ret0, _ := ret[0].(entity.Transaction)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateTransaction indicates an expected call of UpdateTransaction.
func (mr *MockDALMockRecorder) UpdateTransaction(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTransaction", reflect.TypeOf((*MockDAL)(nil).UpdateTransaction), arg0)
}