cashless2ecash

cashless2ecash: pay with cards for digital cash (experimental)
Log | Files | Refs | README

commit 59d26fbea663abc3becdf1f8f45010e079a57e00
parent 82a397b5ad89183b87394f16148bf143428ea853
Author: Joel-Haeberli <haebu@rubigen.ch>
Date:   Fri,  8 Mar 2024 22:12:49 +0100

nonce2ecash: implement http utility

Diffstat:
MREADME | 12++++++++++++
Mnonce2ecash/go.mod | 4++++
Anonce2ecash/go.sum | 4++++
Anonce2ecash/pkg/common/codec.go | 43+++++++++++++++++++++++++++++++++++++++++++
Anonce2ecash/pkg/common/codec_test.go | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anonce2ecash/pkg/common/http-util.go | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnonce2ecash/pkg/common/model.go | 14+++++++-------
Mnonce2ecash/pkg/taler-bank-integration/client.go | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
8 files changed, 401 insertions(+), 13 deletions(-)

diff --git a/README b/README @@ -8,3 +8,15 @@ the exchange got the guarantee that the payment will eventually reach the exchan The flow establishes trust not with the final transaction, but the authenticated guarantee, that the terminal operator will eventually pay the amount to the bankaccount of the exchange. + + +The following tree describes the structure of the document and a rough description of each directory +``` +. +├── data : contains sql +├── docs : contains the thesis (LaTeX) +├── infra : contains configs for running the components +├── nonce2ecash : contains code of the nonce2ecash component +├── schemaspy : contains erd using schemaspy +└── specs : contains specifications +``` diff --git a/nonce2ecash/go.mod b/nonce2ecash/go.mod @@ -1,3 +1,7 @@ module nonce2ecash go 1.22.0 + +require gotest.tools/v3 v3.5.1 + +require github.com/google/go-cmp v0.5.9 // indirect diff --git a/nonce2ecash/go.sum b/nonce2ecash/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/nonce2ecash/pkg/common/codec.go b/nonce2ecash/pkg/common/codec.go @@ -0,0 +1,43 @@ +package common + +import ( + "bytes" + "encoding/json" + "io" +) + +type Codec[T any] interface { + httpApplicationContentHeader() string + encode(*T) (io.Reader, error) + decode(io.Reader) (*T, error) +} + +type JsonCodec[T any] struct { + Codec[T] +} + +func NewJsonCodec[T any]() Codec[T] { + + return new(JsonCodec[T]) +} + +func (*JsonCodec[T]) HttpApplicationContentHeader() string { + return "application/json" +} + +func (*JsonCodec[T]) Encode(body *T) (io.Reader, error) { + + encodedBytes, err := json.Marshal(body) + if err != nil { + return nil, err + } + + return bytes.NewReader(encodedBytes), err +} + +func (*JsonCodec[T]) Decode(reader io.Reader) (*T, error) { + + body := new(T) + err := json.NewDecoder(reader).Decode(body) + return body, err +} diff --git a/nonce2ecash/pkg/common/codec_test.go b/nonce2ecash/pkg/common/codec_test.go @@ -0,0 +1,62 @@ +package common_test + +import ( + "bytes" + "fmt" + "nonce2ecash/pkg/common" + "testing" + + "gotest.tools/v3/assert" +) + +func TestJsonCodecRoundTrip(t *testing.T) { + + type TestStruct struct { + A string + B int + C []string + D byte + E []byte + F *TestStruct + } + + testObj := TestStruct{ + "TestA", + 1, + []string{"first", "second"}, + 'A', + []byte{0xdf, 0x01, 0x34}, + &TestStruct{ + "TestAA", + 2, + []string{"third", "fourth", "fifth"}, + 'B', + []byte{0xdf, 0x01, 0x34}, + nil, + }, + } + + jsonCodec := new(common.JsonCodec[TestStruct]) + + encodedTestObj, err := jsonCodec.Encode(&testObj) + if err != nil { + fmt.Println("error happened while encoding test obj", err.Error()) + t.FailNow() + } + + encodedTestObjBytes := make([]byte, 200) + _, err = encodedTestObj.Read(encodedTestObjBytes) + if err != nil { + fmt.Println("error happened while encoding test obj to byte array", err.Error()) + t.FailNow() + } + + encodedTestObjReader := bytes.NewReader(encodedTestObjBytes) + decodedTestObj, err := jsonCodec.Decode(encodedTestObjReader) + if err != nil { + fmt.Println("error happened while encoding test obj to byte array", err.Error()) + t.FailNow() + } + + assert.DeepEqual(t, &testObj, decodedTestObj) +} diff --git a/nonce2ecash/pkg/common/http-util.go b/nonce2ecash/pkg/common/http-util.go @@ -0,0 +1,144 @@ +package common + +import ( + "errors" + "net/http" + "strings" +) + +const HTTP_OK = 200 +const HTTP_NO_CONTENT = 204 +const HTTP_NOT_FOUND = 404 +const HTTP_CONFLICT = 409 +const HTTP_INTERNAL_SERVER_ERROR = 500 + +// execute a GET request and parse body or retrieve error +func HttpGetBodyOrError[T any]( + req string, + pathParams map[string]string, + queryParams map[string]string, + codec Codec[T], +) (*T, int, error) { + + res, err := http.Get(formatUrl(req, pathParams, queryParams)) + if err != nil { + return nil, -1, err + } + + if codec == nil { + return nil, res.StatusCode, err + } else { + resBody, err := codec.decode(res.Body) + return resBody, res.StatusCode, err + } +} + +// execute a POST request and parse response or retrieve error +func HttpPostOrError[T any, R any]( + req string, + pathParams map[string]string, + queryParams map[string]string, + body *T, + requestCodec Codec[T], + responseCodec Codec[R], +) (*R, int, error) { + + encodedBody, err := requestCodec.encode(body) + if err != nil { + return nil, -1, err + } + + var res *http.Response + if body == nil { + if requestCodec == nil { + res, err = http.Post( + formatUrl(req, pathParams, queryParams), + "", + encodedBody, + ) + } else { + return nil, -1, errors.New("invalid arguments - body was not present but codec was defined") + } + } else { + if requestCodec == nil { + return nil, -1, errors.New("invalid arguments - body was present but no codec was defined") + } else { + res, err = http.Post( + formatUrl(req, pathParams, queryParams), + requestCodec.httpApplicationContentHeader(), + encodedBody, + ) + } + } + + if err != nil { + return nil, -1, err + } + + if responseCodec == nil { + return nil, res.StatusCode, err + } + + resBody, err := responseCodec.decode(res.Body) + if err != nil { + return nil, -1, err + } + + return resBody, res.StatusCode, err +} + +// builds request URL containing the path and query +// parameters of the respective parameter map. +func formatUrl( + req string, + pathParams map[string]string, + queryParams map[string]string, +) string { + + return setUrlQuery(setUrlPath(req, pathParams), queryParams) +} + +// Sets the parameters which are part of the url. +// The function expects each parameter in the path to be prefixed +// using a ':'. The function handles url as follows: +// +// /some/:param/tobereplaced -> ':param' will be replace with value. +// +// For replacements, the pathParams map must be supplied. The map contains +// the name of the parameter with the value mapped to it. +// The names MUST not contain the prefix ':'! +func setUrlPath( + req string, + pathParams map[string]string, +) string { + + if pathParams == nil && len(pathParams) < 1 { + return req + } + + var url = req + for k, v := range pathParams { + + url = strings.Replace(url, ":"+k, v, 1) + } + return url +} + +func setUrlQuery( + req string, + queryParams map[string]string, +) string { + + if queryParams == nil && len(queryParams) < 1 { + return req + } + + var url = req + "?" + for k, v := range queryParams { + + url = strings.Join([]string{url, k, "=", v, "&"}, "") + } + + url, _ = strings.CutSuffix(url, "&") + return url +} diff --git a/nonce2ecash/pkg/common/model.go b/nonce2ecash/pkg/common/model.go @@ -1,16 +1,16 @@ package common // https://docs.taler.net/core/api-common.html#hash-codes -type WithdrawalIdentifier [32]byte +type WithdrawalIdentifier string // https://docs.taler.net/core/api-common.html#cryptographic-primitives -type EddsaPublicKey [32]byte +type EddsaPublicKey string -type WithdrawalOperationStatus int +type WithdrawalOperationStatus string const ( - PENDING WithdrawalOperationStatus = iota - SELECTED - ABORTED - CONFIRMED + PENDING WithdrawalOperationStatus = "pending" + SELECTED = "selected" + ABORTED = "aborted" + CONFIRMED = "confirmed" ) diff --git a/nonce2ecash/pkg/taler-bank-integration/client.go b/nonce2ecash/pkg/taler-bank-integration/client.go @@ -2,20 +2,139 @@ package talerbankintegration import ( "errors" + "fmt" "nonce2ecash/pkg/common" ) +const WITHDRAWAL_ID_PATH_PARAM_NAME = "withdrawal_id" + +const WITHDRAWAL_OPERATION_API = "/withdrawal-operation" +const WITHDRAWAL_OPERATION_BY_ID_API = WITHDRAWAL_OPERATION_API + "/:" + WITHDRAWAL_ID_PATH_PARAM_NAME +const WITHDRAWAL_OPERATION_ABORT_BY_ID_API = WITHDRAWAL_OPERATION_BY_ID_API + "/abort" + +type TalerBankIntegration interface { + init(string) + + withdrawalOperationStatus(common.WithdrawalIdentifier) (*BankWithdrawalOperationStatus, error) + withdrawalOperationCreate(common.EddsaPublicKey, string) (*BankWithdrawalOperationPostResponse, error) + withdrawalOperationAbort(common.WithdrawalIdentifier) error +} + +type TalerBankIntegrationImpl struct { + TalerBankIntegration + + exchangeBaseUrl string +} + +// Initialize the taler bank integration implementation. +// The exchangeBaseUrl will be used as target by the impl. +func (tbi *TalerBankIntegrationImpl) init(exchangeBaseUrl string) { + + tbi.exchangeBaseUrl = exchangeBaseUrl +} + // check status of withdrawal -func withdrawalOperationStatus(id common.WithdrawalIdentifier) (*BankWithdrawalOperationStatus, error) { - return nil, errors.New("not implemented yet") +func (tbi *TalerBankIntegrationImpl) withdrawalOperationStatus( + id common.WithdrawalIdentifier, +) (*BankWithdrawalOperationStatus, error) { + + withdrawalOperationStatus, status, err := common.HttpGetBodyOrError( + tbi.exchangeBaseUrl+WITHDRAWAL_OPERATION_BY_ID_API, + map[string]string{WITHDRAWAL_ID_PATH_PARAM_NAME: string(id)}, + nil, + common.NewJsonCodec[BankWithdrawalOperationStatus](), + ) + + if err != nil { + return nil, err + } + + if status == common.HTTP_OK { + + return withdrawalOperationStatus, nil + } + + if status == common.HTTP_NOT_FOUND { + + return nil, errors.New("HTTP 404 - The operation was not found") + } + + return nil, fmt.Errorf("HTTP %d - unexpected", status) } // send parameters for reserve to exchange core. -func withdrawalOperationCreate(reservePubKey common.EddsaPublicKey, exchangeBaseUrl string) (*BankWithdrawalOperationPostResponse, error) { - return nil, errors.New("not implemented yet") +func (tbi *TalerBankIntegrationImpl) withdrawalOperationCreate( + id common.WithdrawalIdentifier, + reservePubKey common.EddsaPublicKey, + exchangPayToAddress string, +) (*BankWithdrawalOperationPostResponse, error) { + + bankWithdrawalOperationPostResponse, status, err := common.HttpPostOrError( + tbi.exchangeBaseUrl+WITHDRAWAL_OPERATION_BY_ID_API, + map[string]string{WITHDRAWAL_ID_PATH_PARAM_NAME: string(id)}, + nil, + &BankWithdrawalOperationPostRequest{ + string(reservePubKey), + exchangPayToAddress, + }, + common.NewJsonCodec[BankWithdrawalOperationPostRequest](), + common.NewJsonCodec[BankWithdrawalOperationPostResponse](), + ) + + if err != nil { + return nil, err + } + + if status == common.HTTP_OK { + + return bankWithdrawalOperationPostResponse, nil + } + + if status == common.HTTP_NOT_FOUND { + + return nil, errors.New("HTTP 404 - The operation was not found") + } + + if status == common.HTTP_CONFLICT { + + return nil, errors.New("HTTP 409 - conflict") + } + + return nil, fmt.Errorf("HTTP %d - unexpected", status) } // abort withdrawal -func withdrawalOperationAbort(id common.WithdrawalIdentifier) error { - return errors.New("not implemented yet") +func (tbi *TalerBankIntegrationImpl) withdrawalOperationAbort( + id common.WithdrawalIdentifier, +) error { + + _, status, err := common.HttpPostOrError[any, any]( + tbi.exchangeBaseUrl+WITHDRAWAL_OPERATION_BY_ID_API, + map[string]string{WITHDRAWAL_ID_PATH_PARAM_NAME: string(id)}, + nil, + nil, + nil, + nil, + ) + + if err != nil { + return err + } + + if status == common.HTTP_NO_CONTENT { + + return nil + } + + if status == common.HTTP_NOT_FOUND { + + return errors.New("HTTP 404 - The withdrawal operation was not found") + } + + if status == common.HTTP_CONFLICT { + + return errors.New("HTTP 409 - The withdrawal operation has been confirmed previously and can’t be aborted") + } + + return fmt.Errorf("HTTP %d - unexpected", status) }