Compare commits

...

2 Commits

Author SHA1 Message Date
18c93b6c0f
Fix resources naming scheme
The accepted practice is to retain the collection name even for a single
resource operation, like getting a specific transaction.

Also moves the PUT /transactions to /transactions/{id} which makes it
clearer that it can only be applied to an already created transaction.
2024-06-22 22:34:14 +01:00
55bd87c864
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 22:30:00 +01:00
8 changed files with 400 additions and 54 deletions

View File

@ -8,7 +8,7 @@ info:
license:
name: GNU General Public License v3.0 or later
url: https://www.gnu.org/licenses/gpl-3.0-standalone.html
version: 0.0.1
version: 0.1.0
paths:
/transactions:
@ -74,7 +74,7 @@ paths:
"400":
description: Transaction not created
/transaction/{transactionId}:
/transactions/{transactionId}:
get:
summary: Find transaction by ID
operationId: getTransactionById
@ -97,6 +97,31 @@ paths:
description: Invalid ID supplied
"404":
description: Transaction not found
put:
summary: Update an existing transaction
operationId: updateTransaction
parameters:
- name: transactionId
in: path
description: ID of transaction to update
required: true
schema:
type: integer
format: int64
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/Transaction"
responses:
"204":
description: Transaction updated successfully
content:
application/json:
schema:
$ref: "#/components/schemas/Transaction"
"404":
description: Transaction not found
/banks:
get:
@ -112,7 +137,7 @@ paths:
"204":
description: No banks
/bank/{bankId}:
/banks/{bankId}:
get:
summary: Find bank by ID
operationId: getBankById

View File

@ -83,11 +83,37 @@ func (server *ServerImpl) CreateTransaction(ctx echo.Context) error {
return ctx.NoContent(http.StatusBadRequest)
}
ans, err := server.Dal.InsertTransaction(transaction2entity(*t))
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(ans))
return ctx.JSON(http.StatusCreated, entity2transaction(transaction))
}
func (server *ServerImpl) UpdateTransaction(ctx echo.Context, transactionId int64) error {
if exists, err := server.Dal.TransactionExists(uint64(transactionId)); err != nil {
log.Printf("%v", err)
return ctx.NoContent(http.StatusInternalServerError)
} else if !exists {
return ctx.NoContent(http.StatusNotFound)
}
t := new(Transaction)
if err := ctx.Bind(t); err != nil {
log.Printf("%v", err)
return ctx.NoContent(http.StatusBadRequest)
}
transaction := transaction2entity(*t)
transaction.Id = uint64(transactionId)
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))
}

View File

@ -286,3 +286,80 @@ func TestServerImpl_CreateTransaction(t *testing.T) {
})
}
}
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 {
fn func(any) *gomock.Call
args any
returns any
err error
}
type args struct {
request string
transactionId int64
}
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","value":9000}`, 1},
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}`, 1},
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, tt.args.transactionId); (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

@ -62,23 +62,29 @@ type GetTransactionsParams struct {
// CreateTransactionJSONRequestBody defines body for CreateTransaction for application/json ContentType.
type CreateTransactionJSONRequestBody = Transaction
// UpdateTransactionJSONRequestBody defines body for UpdateTransaction for application/json ContentType.
type UpdateTransactionJSONRequestBody = Transaction
// ServerInterface represents all server handlers.
type ServerInterface interface {
// Find bank by ID
// (GET /bank/{bankId})
GetBankById(ctx echo.Context, bankId string) error
// Retrieve existing banks
// (GET /banks)
GetBanks(ctx echo.Context) error
// Find transaction by ID
// (GET /transaction/{transactionId})
GetTransactionById(ctx echo.Context, transactionId int64) error
// Find bank by ID
// (GET /banks/{bankId})
GetBankById(ctx echo.Context, bankId string) error
// Retrieve existing transactions
// (GET /transactions)
GetTransactions(ctx echo.Context, params GetTransactionsParams) error
// Create a new transaction
// (POST /transactions)
CreateTransaction(ctx echo.Context) error
// Find transaction by ID
// (GET /transactions/{transactionId})
GetTransactionById(ctx echo.Context, transactionId int64) error
// Update an existing transaction
// (PUT /transactions/{transactionId})
UpdateTransaction(ctx echo.Context, transactionId int64) error
}
// ServerInterfaceWrapper converts echo contexts to parameters.
@ -86,6 +92,15 @@ type ServerInterfaceWrapper struct {
Handler ServerInterface
}
// GetBanks converts echo context to params.
func (w *ServerInterfaceWrapper) GetBanks(ctx echo.Context) error {
var err error
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.GetBanks(ctx)
return err
}
// GetBankById converts echo context to params.
func (w *ServerInterfaceWrapper) GetBankById(ctx echo.Context) error {
var err error
@ -102,31 +117,6 @@ func (w *ServerInterfaceWrapper) GetBankById(ctx echo.Context) error {
return err
}
// GetBanks converts echo context to params.
func (w *ServerInterfaceWrapper) GetBanks(ctx echo.Context) error {
var err error
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.GetBanks(ctx)
return err
}
// GetTransactionById converts echo context to params.
func (w *ServerInterfaceWrapper) GetTransactionById(ctx echo.Context) error {
var err error
// ------------- Path parameter "transactionId" -------------
var transactionId int64
err = runtime.BindStyledParameterWithOptions("simple", "transactionId", ctx.Param("transactionId"), &transactionId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter transactionId: %s", err))
}
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.GetTransactionById(ctx, transactionId)
return err
}
// GetTransactions converts echo context to params.
func (w *ServerInterfaceWrapper) GetTransactions(ctx echo.Context) error {
var err error
@ -182,6 +172,38 @@ func (w *ServerInterfaceWrapper) CreateTransaction(ctx echo.Context) error {
return err
}
// GetTransactionById converts echo context to params.
func (w *ServerInterfaceWrapper) GetTransactionById(ctx echo.Context) error {
var err error
// ------------- Path parameter "transactionId" -------------
var transactionId int64
err = runtime.BindStyledParameterWithOptions("simple", "transactionId", ctx.Param("transactionId"), &transactionId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter transactionId: %s", err))
}
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.GetTransactionById(ctx, transactionId)
return err
}
// UpdateTransaction converts echo context to params.
func (w *ServerInterfaceWrapper) UpdateTransaction(ctx echo.Context) error {
var err error
// ------------- Path parameter "transactionId" -------------
var transactionId int64
err = runtime.BindStyledParameterWithOptions("simple", "transactionId", ctx.Param("transactionId"), &transactionId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter transactionId: %s", err))
}
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.UpdateTransaction(ctx, transactionId)
return err
}
// 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
// either of them for path registration
@ -210,33 +232,35 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
Handler: si,
}
router.GET(baseURL+"/bank/:bankId", wrapper.GetBankById)
router.GET(baseURL+"/banks", wrapper.GetBanks)
router.GET(baseURL+"/transaction/:transactionId", wrapper.GetTransactionById)
router.GET(baseURL+"/banks/:bankId", wrapper.GetBankById)
router.GET(baseURL+"/transactions", wrapper.GetTransactions)
router.POST(baseURL+"/transactions", wrapper.CreateTransaction)
router.GET(baseURL+"/transactions/:transactionId", wrapper.GetTransactionById)
router.PUT(baseURL+"/transactions/:transactionId", wrapper.UpdateTransaction)
}
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"H4sIAAAAAAAC/8RWTW/jNhD9KwTboyIpcdCDTm0abGCg2Abd9rTdAy2NbO5SpJYc2jUM//diKMWmPhKn",
"QLN7cSRxOPPmzeNjDrw0TWs0aHS8OHBXbqAR4fFO6C/0t7WmBYsSwldZ0S/uW+AFd2ilXvNjwrVoYH7B",
"2EquQS/DvtrYRiAvuPey4sk4/JhwC1+9tFDx4iMPISHzp1OoWX2GEikz4esgITTh4UcLNS/4D9m5p6xv",
"KAvdHE9phLViT+9/WqGdKFEaPW22FAhrY/eznVUCYdBT+JDMBIIrrWyfSkzW5ZAaqfGnW57wRmrZ+IYX",
"+Smn1AhrsLRpK5Qflq+VEXiur32zotARpz3IGNNTsjmSI3pez3XM6YRyAiR1bQK/RqMokR6hEVJRF1IL",
"XcLPjbco0gq2Ewb5I1hntFDsXRfLPoDdgv2bOlGyBO0CMZ0k+cP7v9gDaLBCsUe/UrJkv3VBbLtIc2Ys",
"UwLB8oR7Swg2iK0rsmy326Vr7VNj11mf1mXrVl0t0vzKodCVUEZDusFGhTYlKpiDd8V+b0H/8rhkizQn",
"tsG6rpM8zdNr2mta0KKVvOCLNE8XPOGtwE0gOlsJ/SU70O+yOtKXNQTGSKiCKKGjxR8ASeJ3+2UVdlvR",
"AIJ1vPh4GPG3vGemZpSQoWEW0FuiTtIalX06dAXvivJYQWg9JL1NzKj5+ImCXWuILVq/yfOnSYMOuEXb",
"KlkG5Nln152Jc77LZ/g4EcQHX5bgXO0VO5FCrN52tUfd661QsmLLe+Y8QYGqi72dxlJBpg2y2nhdhbPk",
"fNMIcgT+TuqqY3G1Z8v7sBqG5S4NyfE3Jsn9F5Zu5jp/b9iqTxT3/AeglbAFBv9Ih1Kvo6gMz8c+O0Qv",
"F2QbucXr1RulvyTiAZIXtTyx4LHtvqm6B675DUUe1X1Z6zHnkeRxdEW8Ys7u0pBrqRAsVYmLnq7jfs5f",
"PYSXftDR8plx7ZUSKzLmbtqT/zjGpbt7c6QxNxXZqLiSjcRB5Qpq4RXy4jrPk4G2FjcXrvcpKlPXDpDV",
"1jRstwELPaDuNOKQ2jl8XYJ5gFfXM/guY+pP4gaCCTxTt196/sJIprMHVTHaTvPfbWS5oWadsfhMjX7p",
"+1xKA1n/D7aLw3wvu+8wOOGtcTOH71cLAiH2l84FweGdqfZv5WDHCevX38osY08rQ/fVs1459r9T/ID7",
"jkMmmIZdTHto9N8AAAD//+YRCdlGDQAA",
"H4sIAAAAAAAC/8RWTW/jNhD9KwTboyIpcdCDTm0abGCg2Abd3dN2D7Q4krmlSC05tGsE/u8FSdnRl9cJ",
"ukkutmQO5+O9mTd+oKVuWq1AoaXFA7XlGhoWHm+Y+sd/t0a3YFBA+FVw/4m7FmhBLRqharpPqGINzB9o",
"w0UNahnuVdo0DGlBnROcJmPzfUINfHPCAKfFZxpMgucvR1O9+goles8+v5gSQhMefjZQ0YL+lD3WlHUF",
"ZaGa/dENM4bt/PtHw5RlJQqtpsWWDKHWZjdbGWcIg5rCD8mMIdjSiPYQYnIuhtAIhb9c04Q2QonGNbTI",
"jz6FQqjB+EsbJt0wfCU1w8f4yjUrbzrCtEuyn9PB2RzIPXiejnUf0wnkPiGhKh3w1QpZif4RGiakr0Io",
"pkr4tXEGWcphM0GQ3oOxWjFJ3kVb8gHMBszfvhIpSlA2ABNbkt69/0TuQIFhkty7lRQl+SMakc0izYk2",
"RDIEQxPqjM9gjdjaIsu2221aK5dqU2edW5vVrbxYpPmFRaY4k1pBusZGhjIFSphL74L82YL67X5JFmnu",
"0QZjYyV5epnm/q5uQbFW0IIu0jxd0IS2DNcB6Gx16PMaAlK+QZmHwo8UvQOMg+Bptq32WXqrqzw/IAwq",
"3GNtK0UZbmZfbezFyNhTZsdG5oZMfHBlCdZWTpJjVr6cq/za+xwav9dk1TlKqHVNw/xg0b8AjYANEPhX",
"WBSq7lnF2rMH/7Xk+3Mg3OyWPEBnWAMIxtLi8ziL5S3RVQhBUBMD6IzvG+HPPOYHxSloDEr744PGQdID",
"bSxfX16YhOdwcB1jj6pXGyYFJ8tbYp1PBXi0neHLByRKI6m0U3zE2juheERxtSPL28gWjsTiFFcDUTnD",
"VyUkgvFRet7JUZg75r45CC8ddb3jR3CVk5Kt/IhGGie7Zxw6Kqhvl35h07YZBZeiETiIzKFiTiItLvM8",
"GQj94uqM0E+z0lVlAUlldEO2azDQJRSHCIfQzuUXHcwneHE5k9/5nOJQ4RpCS5yI2x2dnp5kyj1ITvx1",
"z/92Lcq1L9ZqgydidEdvM6GDtv4BaolDf98XzaFxQlttZ4bvdwMMob+fo7yBxRvNdy8BhUdiP0H98qVC",
"jUH/2FeNUD0/qY59Wy98R/sB9hFDwoiCbR/2qQZmD723M/urF/vpa6wviWe22SCT7y61yR/Ryfy/0hC9",
"6rYbc39y6fUx73ZfQls3w+qnlk+G7fmkurb7y/4apL6BEly/hRJEUDmxx3aSu//VGpFrwtSsIofK/wsA",
"AP//Ba56DmsPAAA=",
}
// GetSwagger returns the content of the embedded swagger specification file

View File

@ -6,6 +6,8 @@ type DAL interface {
Transaction(transactionId int64) (*entity.Transaction, 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)
Banks() (entity.Banks, error)
}

View File

@ -2,6 +2,7 @@ package dal
import (
"database/sql"
"errors"
"log"
"git.rosemyrtle.work/personal-finance/server/internal/entity"
@ -68,6 +69,60 @@ func (dal *DalImpl) InsertTransaction(t entity.Transaction) (entity.Transaction,
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) {
log.Printf("DAL::Bank(%v)", bankId)

View File

@ -317,3 +317,110 @@ func TestDalImpl_InsertTransaction(t *testing.T) {
})
}
}
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, amount = \$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

@ -99,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)
}
// 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.
func (m *MockDAL) Transactions() ([]entity.Transaction, error) {
m.ctrl.T.Helper()
@ -113,3 +128,18 @@ func (mr *MockDALMockRecorder) Transactions() *gomock.Call {
mr.mock.ctrl.T.Helper()
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)
}