Skip to content

Commit

Permalink
feat: currencycloud connector (#54)
Browse files Browse the repository at this point in the history
Signed-off-by: Lawrence Zawila <113581282+darkmatterpool@users.noreply.github.com>
  • Loading branch information
darkmatterpool authored Nov 15, 2022
1 parent 2c8ec51 commit 58a2bca
Show file tree
Hide file tree
Showing 11 changed files with 507 additions and 0 deletions.
3 changes: 3 additions & 0 deletions internal/app/api/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strings"
"time"

"github.com/numary/payments/internal/pkg/connectors/currencycloud"

"github.com/gorilla/mux"
"github.com/numary/go-libs/oauth2/oauth2introspect"
"github.com/numary/go-libs/sharedauth"
Expand Down Expand Up @@ -65,6 +67,7 @@ func HTTPModule() fx.Option {
addConnector[modulr.Config, modulr.TaskDescriptor](modulr.NewLoader()),
addConnector[stripe.Config, stripe.TaskDescriptor](stripe.NewLoader()),
addConnector[wise.Config, wise.TaskDescriptor](wise.NewLoader()),
addConnector[currencycloud.Config, currencycloud.TaskDescriptor](currencycloud.NewLoader()),
)
}

Expand Down
50 changes: 50 additions & 0 deletions internal/pkg/connectors/currencycloud/client/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package client

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)

func (c *Client) authenticate(ctx context.Context) (string, error) {
form := make(url.Values)

form.Add("login_id", c.loginID)
form.Add("api_key", c.apiKey)

req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.buildEndpoint("v2/authenticate/api"), strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}

req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Accept", "application/json")

resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to do get request: %w", err)
}

defer resp.Body.Close()

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

//nolint:tagliatelle // allow for client code
type response struct {
AuthToken string `json:"auth_token"`
}

var res response

if err = json.NewDecoder(resp.Body).Decode(&res); err != nil {
return "", fmt.Errorf("failed to decode response body: %w", err)
}

return res.AuthToken, nil
}
53 changes: 53 additions & 0 deletions internal/pkg/connectors/currencycloud/client/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package client

import (
"context"
"fmt"
"net/http"
)

type apiTransport struct {
authToken string
}

func (t *apiTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Add("X-Auth-Token", t.authToken)

return http.DefaultTransport.RoundTrip(req)
}

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

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

const devAPIEndpoint = "https://devapi.currencycloud.com"

// NewClient creates a new client for the CurrencyCloud API.
func NewClient(ctx context.Context, loginID, apiKey, endpoint string) (*Client, error) {
if endpoint == "" {
endpoint = devAPIEndpoint
}

c := &Client{
httpClient: &http.Client{},
endpoint: endpoint,
loginID: loginID,
apiKey: apiKey,
}

authToken, err := c.authenticate(ctx)
if err != nil {
return nil, err
}

c.httpClient.Transport = &apiTransport{authToken: authToken}

return c, nil
}
60 changes: 60 additions & 0 deletions internal/pkg/connectors/currencycloud/client/transactions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package client

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

//nolint:tagliatelle // allow different styled tags in client
type Transaction struct {
ID string `json:"id"`
Currency string `json:"currency"`
Type string `json:"type"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
Action string `json:"action"`

Amount string `json:"amount"`
}

func (c *Client) GetTransactions(ctx context.Context, page int) ([]Transaction, int, error) {
if page < 1 {
return nil, 0, fmt.Errorf("page must be greater than 0")
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet,
c.buildEndpoint("v2/transactions/find?page=%d", page), http.NoBody)
if err != nil {
return nil, 0, fmt.Errorf("failed to create request: %w", err)
}

req.Header.Add("Accept", "application/json")

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, err
}

defer resp.Body.Close()

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

//nolint:tagliatelle // allow for client code
type response struct {
Transactions []Transaction `json:"transactions"`
Pagination struct {
NextPage int `json:"next_page"`
}
}

var res response
if err = json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, 0, err
}

return res.Transactions, res.Pagination.NextPage, nil
}
69 changes: 69 additions & 0 deletions internal/pkg/connectors/currencycloud/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package currencycloud

import (
"encoding/json"
"time"
)

type Config struct {
LoginID string `json:"loginID" bson:"loginID"`
APIKey string `json:"apiKey" bson:"apiKey"`
Endpoint string `json:"endpoint" bson:"endpoint"`
PollingPeriod Duration `json:"pollingPeriod" bson:"pollingPeriod"`
}

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

if c.LoginID == "" {
return ErrMissingLoginID
}

if c.PollingPeriod == 0 {
return ErrMissingPollingPeriod
}

return nil
}

type Duration time.Duration

func (d *Duration) String() string {
return time.Duration(*d).String()
}

func (d *Duration) Duration() time.Duration {
return time.Duration(*d)
}

func (d *Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(*d).String())
}

func (d *Duration) UnmarshalJSON(b []byte) error {
var durationValue interface{}

if err := json.Unmarshal(b, &durationValue); err != nil {
return err
}

switch value := durationValue.(type) {
case float64:
*d = Duration(time.Duration(value))

return nil
case string:
tmp, err := time.ParseDuration(value)
if err != nil {
return err
}

*d = Duration(tmp)

return nil
default:
return ErrDurationInvalid
}
}
39 changes: 39 additions & 0 deletions internal/pkg/connectors/currencycloud/connector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package currencycloud

import (
"context"

"github.com/numary/go-libs/sharedlogging"
"github.com/numary/payments/internal/pkg/integration"
"github.com/numary/payments/internal/pkg/task"
)

const connectorName = "currencycloud"

type Connector struct {
logger sharedlogging.Logger
cfg Config
}

func (c *Connector) Install(ctx task.ConnectorContext[TaskDescriptor]) error {
return ctx.Scheduler().Schedule(TaskDescriptor{Name: taskNameFetchTransactions}, true)
}

func (c *Connector) Uninstall(ctx context.Context) error {
return nil
}

func (c *Connector) Resolve(descriptor TaskDescriptor) task.Task {
return resolveTasks(c.logger, c.cfg)
}

var _ integration.Connector[TaskDescriptor] = &Connector{}

func newConnector(logger sharedlogging.Logger, cfg Config) *Connector {
return &Connector{
logger: logger.WithFields(map[string]any{
"component": "connector",
}),
cfg: cfg,
}
}
20 changes: 20 additions & 0 deletions internal/pkg/connectors/currencycloud/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package currencycloud

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")

// ErrMissingLoginID is returned when the login id is missing from config.
ErrMissingLoginID = errors.New("missing loginID from config")

// ErrMissingPollingPeriod is returned when the polling period is missing from config.
ErrMissingPollingPeriod = errors.New("missing pollingPeriod from config")

// ErrDurationInvalid is returned when the duration is invalid.
ErrDurationInvalid = errors.New("duration is invalid")
)
31 changes: 31 additions & 0 deletions internal/pkg/connectors/currencycloud/loader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package currencycloud

import (
"github.com/numary/go-libs/sharedlogging"
"github.com/numary/payments/internal/pkg/integration"
)

type Loader struct{}

const allowedTasks = 50

func (l *Loader) AllowTasks() int {
return allowedTasks
}

func (l *Loader) Name() string {
return connectorName
}

func (l *Loader) Load(logger sharedlogging.Logger, config Config) integration.Connector[TaskDescriptor] {
return newConnector(logger, config)
}

func (l *Loader) ApplyDefaults(cfg Config) Config {
return cfg
}

// NewLoader creates a new loader.
func NewLoader() *Loader {
return &Loader{}
}
Loading

0 comments on commit 58a2bca

Please sign in to comment.