Skip to content

Commit

Permalink
feat: modulr connector (#25)
Browse files Browse the repository at this point in the history
* add structure for wise connector

* laying out structure for modulr connector

* chore: lint

* fix: lint workflow

* chore: Rename docker images

* feat: modulr readiness

Signed-off-by: Lawrence Zawila <113581282+darkmatterpool@users.noreply.github.com>

* fix: modulr linter issue

Signed-off-by: Lawrence Zawila <113581282+darkmatterpool@users.noreply.github.com>

Signed-off-by: Lawrence Zawila <113581282+darkmatterpool@users.noreply.github.com>
Co-authored-by: Geoffrey Ragot <geoffrey.ragot@gmail.com>
Co-authored-by: Lawrence Zawila <113581282+darkmatterpool@users.noreply.github.com>
  • Loading branch information
3 people authored Oct 12, 2022
1 parent f04e6b5 commit 90dc6d9
Show file tree
Hide file tree
Showing 15 changed files with 579 additions and 0 deletions.
5 changes: 5 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/numary/payments/pkg/api"
"github.com/numary/payments/pkg/bridge/cdi"
"github.com/numary/payments/pkg/bridge/connectors/dummypay"
"github.com/numary/payments/pkg/bridge/connectors/modulr"
"github.com/numary/payments/pkg/bridge/connectors/stripe"
"github.com/numary/payments/pkg/bridge/connectors/wise"
bridgeHttp "github.com/numary/payments/pkg/bridge/http"
Expand Down Expand Up @@ -288,5 +289,9 @@ func HTTPModule() fx.Option {
viper.GetBool(authBearerUseScopesFlag),
wise.NewLoader(),
),
cdi.ConnectorModule(
viper.GetBool(authBearerUseScopesFlag),
modulr.NewLoader(),
),
)
}
43 changes: 43 additions & 0 deletions pkg/bridge/connectors/modulr/client/accounts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package client

import (
"encoding/json"
"fmt"
"net/http"
)

type Account struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Balance string `json:"balance"`
Currency string `json:"currency"`
CustomerId string `json:"customerId"`
Identifiers []struct {
AccountNumber string `json:"accountNumber"`
SortCode string `json:"sortCode"`
Type string `json:"type"`
} `json:"identifiers"`
DirectDebit bool `json:"directDebit"`
CreatedDate string `json:"createdDate"`
}

func (m *Client) GetAccounts() ([]Account, error) {
resp, err := m.httpClient.Get(m.buildEndpoint("accounts"))
if err != nil {
return nil, err
}

defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

var res responseWrapper[[]Account]
if err = json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}

return res.Content, nil
}
59 changes: 59 additions & 0 deletions pkg/bridge/connectors/modulr/client/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package client

import (
"fmt"
"net/http"

"github.com/numary/payments/pkg/bridge/connectors/modulr/hmac"
)

type apiTransport struct {
apiKey string
headers map[string]string
}

func (t *apiTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Add("Authorization", t.apiKey)

return http.DefaultTransport.RoundTrip(req)
}

type responseWrapper[t any] struct {
Content t `json:"content"`
Size int `json:"size"`
TotalSize int `json:"totalSize"`
Page int `json:"page"`
TotalPages int `json:"totalPages"`
}

type Client struct {
httpClient *http.Client
endpoint string
}

func (m *Client) buildEndpoint(path string, args ...interface{}) string {
return fmt.Sprintf("%s/%s", m.endpoint, fmt.Sprintf(path, args...))
}

const sandboxApiEndpoint = "https://api-sandbox.modulrfinance.com/api-sandbox-token"

func NewClient(apiKey, apiSecret, endpoint string) (*Client, error) {
if endpoint == "" {
endpoint = sandboxApiEndpoint
}

headers, err := hmac.GenerateHeaders(apiKey, apiSecret, "", false)
if err != nil {
return nil, fmt.Errorf("failed to generate headers: %w", err)
}

return &Client{
httpClient: &http.Client{
Transport: &apiTransport{
headers: headers,
apiKey: apiKey,
},
},
endpoint: endpoint,
}, nil
}
41 changes: 41 additions & 0 deletions pkg/bridge/connectors/modulr/client/transactions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package client

import (
"encoding/json"
"fmt"
"net/http"
)

type Transaction struct {
ID string `json:"id"`
Type string `json:"type"`
Amount float64 `json:"amount"`
Credit bool `json:"credit"`
SourceID string `json:"sourceId"`
Description string `json:"description"`
PostedDate string `json:"postedDate"`
TransactionDate string `json:"transactionDate"`
Account Account `json:"account"`
AdditionalInfo interface{} `json:"additionalInfo"`
}

func (m *Client) GetTransactions(accountId string) ([]Transaction, error) {
resp, err := m.httpClient.Get(m.buildEndpoint("accounts/%s/transactions", accountId))
if err != nil {
return nil, err
}

defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

var res responseWrapper[[]Transaction]
if err = json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}

return res.Content, nil

}
19 changes: 19 additions & 0 deletions pkg/bridge/connectors/modulr/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package modulr

type Config struct {
APIKey string `json:"apiKey" bson:"apiKey"`
APISecret string `json:"apiSecret" bson:"apiSecret"`
Endpoint string `json:"endpoint" bson:"endpoint"`
}

func (c Config) Validate() error {
if c.APIKey == "" {
return ErrMissingAPIKey
}

if c.APISecret == "" {
return ErrMissingAPISecret
}

return nil
}
14 changes: 14 additions & 0 deletions pkg/bridge/connectors/modulr/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package modulr

import "github.com/pkg/errors"

var (
// ErrMissingTask is returned when the task is missing.
ErrMissingTask = errors.New("task is not implemented")

// ErrMissingAPIKey is returned when the api key is missing from config.
ErrMissingAPIKey = errors.New("missing apiKey from config")

// ErrMissingAPISecret is returned when the api secret is missing from config.
ErrMissingAPISecret = errors.New("missing apiSecret from config")
)
58 changes: 58 additions & 0 deletions pkg/bridge/connectors/modulr/hmac/hmac.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package hmac

import (
"time"

"github.com/google/uuid"
"github.com/pkg/errors"
)

const (
authorizationHeader = "Authorization"
dateHeader = "Date"
emptyString = ""
nonceHeader = "x-mod-nonce"
retry = "x-mod-retry"
retryTrue = "true"
retryFalse = "false"
)

var ErrInvalidCredentials = errors.New("invalid api credentials")

func GenerateHeaders(apiKey string, apiSecret string, nonce string, hasRetry bool) (map[string]string, error) {
if apiKey == "" || apiSecret == "" {
return nil, ErrInvalidCredentials
}

return constructHeadersMap(apiKey, apiSecret, nonce, hasRetry, time.Now()), nil
}

func constructHeadersMap(apiKey string, apiSecret string, nonce string, hasRetry bool,
timestamp time.Time) map[string]string {
headers := make(map[string]string)
date := timestamp.Format(time.RFC1123)
nonce = generateNonceIfEmpty(nonce)

headers[dateHeader] = date
headers[authorizationHeader] = buildSignature(apiKey, apiSecret, nonce, date)
headers[nonceHeader] = nonce
headers[retry] = parseRetryBool(hasRetry)

return headers
}

func generateNonceIfEmpty(nonce string) string {
if nonce == emptyString {
nonce = uuid.New().String()
}

return nonce
}

func parseRetryBool(hasRetry bool) string {
if hasRetry {
return retryTrue
}

return retryFalse
}
55 changes: 55 additions & 0 deletions pkg/bridge/connectors/modulr/hmac/hmac_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package hmac

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestGenerateReturnsAnHMACString(t *testing.T) {
headers, _ := GenerateHeaders("api_key", "api_secret", "", false)
expectedSignature := "Signature keyId=\"api_key\",algorithm=\"hmac-sha1\",headers=\"date x-mod-nonce\",signature=\""
assert.Equal(t, expectedSignature, headers["Authorization"][0:86], "generate should return the hmac headers")
}

func TestGenerateReturnsADateHeader(t *testing.T) {
timestamp := time.Date(2020, 1, 2, 15, 4, 5, 0, time.UTC)

headers := constructHeadersMap("api_key", "api_secret", "", false, timestamp)

expectedDate := "Thu, 02 Jan 2020 15:04:05 UTC"

assert.Equal(t, expectedDate, headers["Date"])
}

func TestGenerateReturnsANonceHeaderWithExpectedValue(t *testing.T) {
nonce := "thisIsTheNonce"
headers, _ := GenerateHeaders("api_key", "api_secret", nonce, false)
assert.Equal(t, nonce, headers["x-mod-nonce"])
}

func TestGenerateReturnsARetryHeaderWithTrueIfRetryIsExpected(t *testing.T) {
headers, _ := GenerateHeaders("api_key", "api_secret", "", true)
assert.Equal(t, "true", headers["x-mod-retry"])
}

func TestGenerateReturnsARetryHeaderWithFalseIfRetryIsNotExpected(t *testing.T) {
headers, _ := GenerateHeaders("api_key", "api_secret", "", false)
assert.Equal(t, "false", headers["x-mod-retry"])
}

func TestGenerateReturnsAGeneratedNonceHeaderIfNonceIsEmpty(t *testing.T) {
headers, _ := GenerateHeaders("api_key", "api_secret", "", false)
assert.True(t, headers["x-mod-nonce"] != "", "x-mod-nonce header should have been populated")
}

func TestGenerateThrowsErrorIfApiKeyIsNull(t *testing.T) {
_, err := GenerateHeaders("", "api_secret", "", false)
assert.ErrorIs(t, err, ErrInvalidCredentials)
}

func TestGenerateThrowsErrorIfApiSecretIsNull(t *testing.T) {
_, err := GenerateHeaders("api_key", "", "", false)
assert.ErrorIs(t, err, ErrInvalidCredentials)
}
32 changes: 32 additions & 0 deletions pkg/bridge/connectors/modulr/hmac/signature_generator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package hmac

import (
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"net/url"
)

const (
algorithm = "algorithm=\"hmac-sha1\","
datePrefix = "date: "
headers = "headers=\"date x-mod-nonce\","
prefix = "signature=\""
suffix = "\""
newline = "\n"
nonceKey = "x-mod-nonce: "
keyIdPrefix = "Signature keyId=\""
)

func buildSignature(apiKey string, apiSecret string, nonce string, date string) string {
keyID := keyIdPrefix + apiKey + "\","

mac := hmac.New(sha1.New, []byte(apiSecret))
mac.Write([]byte(datePrefix + date + newline + nonceKey + nonce))

encodedMac := mac.Sum(nil)
base64Encoded := base64.StdEncoding.EncodeToString(encodedMac)
encodedSignature := prefix + url.QueryEscape(base64Encoded) + suffix

return keyID + algorithm + headers + encodedSignature
}
Loading

0 comments on commit 90dc6d9

Please sign in to comment.