diff --git a/components/payments/go.mod b/components/payments/go.mod index a5b091baa1..ac4c4f3c61 100644 --- a/components/payments/go.mod +++ b/components/payments/go.mod @@ -16,6 +16,8 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.4 github.com/jackc/pgx/v5 v5.6.0 github.com/lib/pq v1.10.9 + github.com/onsi/ginkgo/v2 v2.20.0 + github.com/onsi/gomega v1.34.1 github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.1 @@ -59,12 +61,15 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/gogo/googleapis v1.4.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/status v1.1.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/gorilla/schema v1.4.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect @@ -137,10 +142,12 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/crypto v0.26.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/net v0.28.0 // indirect golang.org/x/sys v0.24.0 // indirect golang.org/x/text v0.17.0 // indirect golang.org/x/time v0.5.0 // indirect + golang.org/x/tools v0.24.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect diff --git a/components/payments/go.sum b/components/payments/go.sum index be6b626206..bbdc9e18f8 100644 --- a/components/payments/go.sum +++ b/components/payments/go.sum @@ -607,8 +607,8 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA= +github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= @@ -743,6 +743,8 @@ github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo/v2 v2.20.0 h1:PE84V2mHqoT1sglvHc8ZdQtPcwmvvt29WLEEO3xmdZw= github.com/onsi/ginkgo/v2 v2.20.0/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= @@ -956,6 +958,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/components/payments/internal/connectors/httpwrapper/client.go b/components/payments/internal/connectors/httpwrapper/client.go new file mode 100644 index 0000000000..137baac6f0 --- /dev/null +++ b/components/payments/internal/connectors/httpwrapper/client.go @@ -0,0 +1,108 @@ +package httpwrapper + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "golang.org/x/oauth2" +) + +var ( + ErrStatusCodeUnexpected = errors.New("unexpected status code") + + defaultHttpErrorCheckerFn = func(statusCode int) error { + if statusCode >= http.StatusBadRequest { + return ErrStatusCodeUnexpected + } + return nil + } +) + +// Client is a convenience wrapper that encapsulates common code related to interacting with HTTP endpoints +type Client interface { + // Do performs an HTTP request while handling errors and unmarshaling success and error responses into the provided interfaces + // expectedBody and errorBody should be pointers to structs + Do(req *http.Request, expectedBody, errorBody any) (statusCode int, err error) +} + +type client struct { + httpClient *http.Client + + httpErrorCheckerFn func(statusCode int) error +} + +func NewClient(config *Config) (Client, error) { + if config.Timeout == 0 { + config.Timeout = 10 * time.Second + } + if config.Transport != nil { + config.Transport = otelhttp.NewTransport(config.Transport) + } else { + config.Transport = http.DefaultTransport.(*http.Transport).Clone() + } + + httpClient := &http.Client{ + Timeout: config.Timeout, + Transport: config.Transport, + } + if config.OAuthConfig != nil { + // pass a pre-configured http client to oauth lib via the context + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient) + httpClient = config.OAuthConfig.Client(ctx) + } + + if config.HttpErrorCheckerFn == nil { + config.HttpErrorCheckerFn = defaultHttpErrorCheckerFn + } + + return &client{ + httpErrorCheckerFn: config.HttpErrorCheckerFn, + httpClient: httpClient, + }, nil +} + +func (c *client) Do(req *http.Request, expectedBody, errorBody any) (int, error) { + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("failed to make request: %w", err) + } + + reqErr := c.httpErrorCheckerFn(resp.StatusCode) + // the caller doesn't care about the response body so we return early + if resp.Body == nil || (reqErr == nil && expectedBody == nil) || (reqErr != nil && errorBody == nil) { + return resp.StatusCode, reqErr + } + + defer func() { + err = resp.Body.Close() + if err != nil { + _ = err + // TODO(polo): log error + } + }() + + // TODO: reading everything into memory might not be optimal if we expect long responses + rawBody, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, fmt.Errorf("failed to read response body: %w", err) + } + + if reqErr != nil { + if err = json.Unmarshal(rawBody, errorBody); err != nil { + return resp.StatusCode, fmt.Errorf("failed to unmarshal error response with status %d: %w", resp.StatusCode, err) + } + return resp.StatusCode, reqErr + } + + // TODO: assuming json bodies for now, but may need to handle other body types + if err = json.Unmarshal(rawBody, expectedBody); err != nil { + return resp.StatusCode, fmt.Errorf("failed to unmarshal response with status %d: %w", resp.StatusCode, err) + } + return resp.StatusCode, nil +} diff --git a/components/payments/internal/connectors/httpwrapper/client_test.go b/components/payments/internal/connectors/httpwrapper/client_test.go new file mode 100644 index 0000000000..09c1aaffb2 --- /dev/null +++ b/components/payments/internal/connectors/httpwrapper/client_test.go @@ -0,0 +1,92 @@ +package httpwrapper_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestClient(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Client Suite") +} + +type successRes struct { + ID string `json:"id"` +} + +type errorRes struct { + Code string `json:"code"` +} + +var _ = Describe("ClientWrapper", func() { + var ( + config *httpwrapper.Config + client httpwrapper.Client + server *httptest.Server + ) + + BeforeEach(func() { + config = &httpwrapper.Config{Timeout: 30 * time.Millisecond} + var err error + client, err = httpwrapper.NewClient(config) + Expect(err).To(BeNil()) + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + params, err := url.ParseQuery(r.URL.RawQuery) + Expect(err).To(BeNil()) + + code := params.Get("code") + statusCode, err := strconv.Atoi(code) + Expect(err).To(BeNil()) + if statusCode == http.StatusOK { + w.Write([]byte(`{"id":"someid"}`)) + return + } + + w.WriteHeader(statusCode) + w.Write([]byte(`{"code":"err123"}`)) + })) + }) + AfterEach(func() { + server.Close() + }) + + Context("making a request with default client settings", func() { + It("unmarshals successful responses when acceptable status code seen", func(ctx SpecContext) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"?code=200", http.NoBody) + Expect(err).To(BeNil()) + + res := &successRes{} + code, doErr := client.Do(req, res, nil) + Expect(code).To(Equal(http.StatusOK)) + Expect(doErr).To(BeNil()) + Expect(res.ID).To(Equal("someid")) + }) + It("unmarshals error responses when bad status code seen", func(ctx SpecContext) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"?code=500", http.NoBody) + Expect(err).To(BeNil()) + + res := &errorRes{} + code, doErr := client.Do(req, &successRes{}, res) + Expect(code).To(Equal(http.StatusInternalServerError)) + Expect(doErr).To(MatchError(httpwrapper.ErrStatusCodeUnexpected)) + Expect(res.Code).To(Equal("err123")) + }) + It("responds with error when HTTP request fails", func(ctx SpecContext) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "notaurl", http.NoBody) + Expect(err).To(BeNil()) + + res := &errorRes{} + code, doErr := client.Do(req, &successRes{}, res) + Expect(code).To(Equal(0)) + Expect(doErr).To(MatchError(ContainSubstring("failed to make request"))) + }) + }) +}) diff --git a/components/payments/internal/connectors/httpwrapper/config.go b/components/payments/internal/connectors/httpwrapper/config.go new file mode 100644 index 0000000000..43c497d1d2 --- /dev/null +++ b/components/payments/internal/connectors/httpwrapper/config.go @@ -0,0 +1,16 @@ +package httpwrapper + +import ( + "net/http" + "time" + + "golang.org/x/oauth2/clientcredentials" +) + +type Config struct { + HttpErrorCheckerFn func(code int) error + + Timeout time.Duration + Transport http.RoundTripper + OAuthConfig *clientcredentials.Config +} diff --git a/components/payments/internal/connectors/plugins/public/bankingcircle/client/accounts.go b/components/payments/internal/connectors/plugins/public/bankingcircle/client/accounts.go index ff6ab9f45c..7f8a99af22 100644 --- a/components/payments/internal/connectors/plugins/public/bankingcircle/client/accounts.go +++ b/components/payments/internal/connectors/plugins/public/bankingcircle/client/accounts.go @@ -4,9 +4,10 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Account struct { @@ -58,24 +59,6 @@ func (c *Client) GetAccounts(ctx context.Context, page int, pageSize int, fromOp req.Header.Set("Authorization", "Bearer "+c.accessToken) - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get accounts: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - _ = err - // TODO(polo): log error - } - }() - - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read accounts response body: %w", err) - } - type response struct { Result []Account `json:"result"` PageInfo struct { @@ -84,13 +67,16 @@ func (c *Client) GetAccounts(ctx context.Context, page int, pageSize int, fromOp } `json:"pageInfo"` } - var res response - - if err = json.Unmarshal(responseBody, &res); err != nil { - return nil, fmt.Errorf("failed to unmarshal accounts response: %w", err) + res := response{Result: make([]Account, 0)} + statusCode, err := c.httpClient.Do(req, &res, nil) + switch err { + case nil: + return res.Result, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, fmt.Errorf("received status code %d for get accounts", statusCode) } - - return res.Result, nil + return nil, fmt.Errorf("failed to get accounts: %w", err) } func (c *Client) GetAccount(ctx context.Context, accountID string) (*Account, error) { @@ -109,27 +95,14 @@ func (c *Client) GetAccount(ctx context.Context, accountID string) (*Account, er } req.Header.Set("Authorization", "Bearer "+c.accessToken) - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get accounts: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - _ = err - // TODO(polo): log error - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("wrong status code: %d", resp.StatusCode) - } - var account Account - if err := json.NewDecoder(resp.Body).Decode(&account); err != nil { - return nil, fmt.Errorf("failed to decode account response: %w", err) + statusCode, err := c.httpClient.Do(req, &account, nil) + switch err { + case nil: + return &account, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, fmt.Errorf("received status code %d for get account", statusCode) } - - return &account, nil + return nil, fmt.Errorf("failed to get account: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/bankingcircle/client/auth.go b/components/payments/internal/connectors/plugins/public/bankingcircle/client/auth.go index d1db7f0b34..83675bbbb2 100644 --- a/components/payments/internal/connectors/plugins/public/bankingcircle/client/auth.go +++ b/components/payments/internal/connectors/plugins/public/bankingcircle/client/auth.go @@ -2,12 +2,12 @@ package client import ( "context" - "encoding/json" "fmt" - "io" "net/http" "strconv" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) func (c *Client) login(ctx context.Context) error { @@ -24,60 +24,36 @@ func (c *Client) login(ctx context.Context) error { req.SetBasicAuth(c.username, c.password) - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("failed to login: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - _ = err - // TODO(polo): log error - } - }() - - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read login response body: %w", err) - } - - if resp.StatusCode != http.StatusOK { - type responseError struct { - ErrorCode string `json:"errorCode"` - ErrorText string `json:"errorText"` - } - var errors []responseError - if err = json.Unmarshal(responseBody, &errors); err != nil { - return fmt.Errorf("failed to unmarshal login response: %w", err) - } - if len(errors) > 0 { - return fmt.Errorf("failed to login: %s %s", errors[0].ErrorCode, errors[0].ErrorText) - } - return fmt.Errorf("failed to login: %s", resp.Status) - } - //nolint:tagliatelle // allow for client-side structures type response struct { AccessToken string `json:"access_token"` ExpiresIn string `json:"expires_in"` } + type responseError struct { + ErrorCode string `json:"errorCode"` + ErrorText string `json:"errorText"` + } var res response - - if err = json.Unmarshal(responseBody, &res); err != nil { - return fmt.Errorf("failed to unmarshal login response: %w", err) + var errors []responseError + statusCode, err := c.httpClient.Do(req, &res, &errors) + switch err { + case nil: + // fallthrough + case httpwrapper.ErrStatusCodeUnexpected: + if len(errors) > 0 { + return fmt.Errorf("failed to login: %s %s", errors[0].ErrorCode, errors[0].ErrorText) + } + return fmt.Errorf("failed to login: %d", statusCode) } + return fmt.Errorf("failed make login request: %w", err) c.accessToken = res.AccessToken - expiresIn, err := strconv.Atoi(res.ExpiresIn) if err != nil { return fmt.Errorf("failed to convert expires_in to int: %w", err) } - c.accessTokenExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) - return nil } diff --git a/components/payments/internal/connectors/plugins/public/bankingcircle/client/client.go b/components/payments/internal/connectors/plugins/public/bankingcircle/client/client.go index 12b04d1ac1..d7af45f623 100644 --- a/components/payments/internal/connectors/plugins/public/bankingcircle/client/client.go +++ b/components/payments/internal/connectors/plugins/public/bankingcircle/client/client.go @@ -5,11 +5,11 @@ import ( "net/http" "time" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Client struct { - httpClient *http.Client + httpClient httpwrapper.Client username string password string @@ -21,8 +21,12 @@ type Client struct { accessTokenExpiresAt time.Time } -func newHTTPClient(userCertificate, userCertificateKey string) (*http.Client, error) { - cert, err := tls.X509KeyPair([]byte(userCertificate), []byte(userCertificateKey)) +func New( + username, password, + endpoint, authorizationEndpoint, + uCertificate, uCertificateKey string, +) (*Client, error) { + cert, err := tls.X509KeyPair([]byte(uCertificate), []byte(uCertificateKey)) if err != nil { return nil, err } @@ -32,18 +36,10 @@ func newHTTPClient(userCertificate, userCertificateKey string) (*http.Client, er Certificates: []tls.Certificate{cert}, } - return &http.Client{ - Timeout: 10 * time.Second, - Transport: otelhttp.NewTransport(tr), - }, nil -} - -func New( - username, password, - endpoint, authorizationEndpoint, - uCertificate, uCertificateKey string, -) (*Client, error) { - httpClient, err := newHTTPClient(uCertificate, uCertificateKey) + config := &httpwrapper.Config{ + Transport: tr, + } + httpClient, err := httpwrapper.NewClient(config) if err != nil { return nil, err } diff --git a/components/payments/internal/connectors/plugins/public/bankingcircle/client/payments.go b/components/payments/internal/connectors/plugins/public/bankingcircle/client/payments.go index 1146f32ddb..26db5469b9 100644 --- a/components/payments/internal/connectors/plugins/public/bankingcircle/client/payments.go +++ b/components/payments/internal/connectors/plugins/public/bankingcircle/client/payments.go @@ -4,9 +4,10 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) //nolint:tagliatelle // allow for client-side structures @@ -102,24 +103,6 @@ func (c *Client) GetPayments(ctx context.Context, page int, pageSize int) ([]Pay req.Header.Set("Authorization", "Bearer "+c.accessToken) - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get payments: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - _ = err - // TODO(polo): log error - } - }() - - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read payments response body: %w", err) - } - type response struct { Result []Payment `json:"result"` PageInfo struct { @@ -128,13 +111,16 @@ func (c *Client) GetPayments(ctx context.Context, page int, pageSize int) ([]Pay } `json:"pageInfo"` } - var res response - - if err = json.Unmarshal(responseBody, &res); err != nil { - return nil, fmt.Errorf("failed to unmarshal payments response: %w", err) + res := response{Result: make([]Payment, 0)} + statusCode, err := c.httpClient.Do(req, &res, nil) + switch err { + case nil: + return res.Result, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, fmt.Errorf("received status code %d for get payments", statusCode) } - - return res.Result, nil + return nil, fmt.Errorf("failed to get payments: %w", err) } type StatusResponse struct { @@ -157,28 +143,14 @@ func (c *Client) GetPaymentStatus(ctx context.Context, paymentID string) (*Statu } req.Header.Set("Authorization", "Bearer "+c.accessToken) - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get payments: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - _ = err - // TODO(polo): log error - } - }() - - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read payments response body: %w", err) - } - var res StatusResponse - if err = json.Unmarshal(responseBody, &res); err != nil { - return nil, fmt.Errorf("failed to unmarshal payments response: %w", err) + statusCode, err := c.httpClient.Do(req, &res, nil) + switch err { + case nil: + return &res, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, fmt.Errorf("received status code %d for get payment status", statusCode) } - - return &res, nil + return nil, fmt.Errorf("failed to get payments status: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/bankingcircle/client/transfer_payouts.go b/components/payments/internal/connectors/plugins/public/bankingcircle/client/transfer_payouts.go index b29cb8ade6..e2decf2e82 100644 --- a/components/payments/internal/connectors/plugins/public/bankingcircle/client/transfer_payouts.go +++ b/components/payments/internal/connectors/plugins/public/bankingcircle/client/transfer_payouts.go @@ -7,6 +7,8 @@ import ( "fmt" "net/http" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type PaymentAccount struct { @@ -56,27 +58,14 @@ func (c *Client) InitiateTransferOrPayouts(ctx context.Context, transferRequest req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.accessToken) - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to make transfer: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - _ = err - // TODO(polo): log error - } - }() - - if resp.StatusCode != http.StatusCreated { - return nil, fmt.Errorf("failed to make transfer: %w", err) - } - - var transferResponse PaymentResponse - if err := json.NewDecoder(resp.Body).Decode(&transferResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallets response body: %w", err) + var res PaymentResponse + statusCode, err := c.httpClient.Do(req, &res, nil) + switch err { + case nil: + return &res, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, fmt.Errorf("received status code %d for make payout", statusCode) } - - return &transferResponse, nil + return nil, fmt.Errorf("failed to make payout: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/currencycloud/client/accounts.go b/components/payments/internal/connectors/plugins/public/currencycloud/client/accounts.go index b15c50dd9c..fdb9cd667c 100644 --- a/components/payments/internal/connectors/plugins/public/currencycloud/client/accounts.go +++ b/components/payments/internal/connectors/plugins/public/currencycloud/client/accounts.go @@ -2,10 +2,11 @@ package client import ( "context" - "encoding/json" "fmt" "net/http" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Account struct { @@ -36,16 +37,6 @@ func (c *Client) GetAccounts(ctx context.Context, page int, pageSize int) ([]*Ac 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, unmarshalError(resp.StatusCode, resp.Body).Error() - } - //nolint:tagliatelle // allow for client code type response struct { Accounts []*Account `json:"accounts"` @@ -54,10 +45,15 @@ func (c *Client) GetAccounts(ctx context.Context, page int, pageSize int) ([]*Ac } `json:"pagination"` } - var res response - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, 0, err + res := response{Accounts: make([]*Account, 0)} + var errRes currencyCloudError + _, err = c.httpClient.Do(req, &res, &errRes) + switch err { + case nil: + return res.Accounts, res.Pagination.NextPage, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, 0, errRes.Error() } - - return res.Accounts, res.Pagination.NextPage, nil + return nil, 0, fmt.Errorf("failed to get accounts: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/currencycloud/client/auth.go b/components/payments/internal/connectors/plugins/public/currencycloud/client/auth.go index 32cf21bc35..5dba981387 100644 --- a/components/payments/internal/connectors/plugins/public/currencycloud/client/auth.go +++ b/components/payments/internal/connectors/plugins/public/currencycloud/client/auth.go @@ -9,7 +9,7 @@ import ( "strings" ) -func (c *Client) authenticate(ctx context.Context) (string, error) { +func (c *Client) authenticate(ctx context.Context, httpClient *http.Client) (string, error) { // TODO(polo): metrics // f := connectors.ClientMetrics(ctx, "currencycloud", "authenticate") // now := time.Now() @@ -29,7 +29,7 @@ func (c *Client) authenticate(ctx context.Context) (string, error) { req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := httpClient.Do(req) if err != nil { return "", fmt.Errorf("failed to do get request: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/currencycloud/client/balances.go b/components/payments/internal/connectors/plugins/public/currencycloud/client/balances.go index cf7a7f9395..b2545108e5 100644 --- a/components/payments/internal/connectors/plugins/public/currencycloud/client/balances.go +++ b/components/payments/internal/connectors/plugins/public/currencycloud/client/balances.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Balance struct { @@ -38,16 +40,6 @@ func (c *Client) GetBalances(ctx context.Context, page int, pageSize int) ([]*Ba 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, unmarshalError(resp.StatusCode, resp.Body).Error() - } - //nolint:tagliatelle // allow for client code type response struct { Balances []*Balance `json:"balances"` @@ -56,10 +48,15 @@ func (c *Client) GetBalances(ctx context.Context, page int, pageSize int) ([]*Ba } `json:"pagination"` } - var res response - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, 0, err + res := response{Balances: make([]*Balance, 0)} + var errRes currencyCloudError + _, err = c.httpClient.Do(req, &res, nil) + switch err { + case nil: + return res.Balances, res.Pagination.NextPage, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, 0, errRes.Error() } - - return res.Balances, res.Pagination.NextPage, nil + return nil, 0, fmt.Errorf("failed to get balances %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/currencycloud/client/beneficiaries.go b/components/payments/internal/connectors/plugins/public/currencycloud/client/beneficiaries.go index 2a6cd08043..7cb8ea3562 100644 --- a/components/payments/internal/connectors/plugins/public/currencycloud/client/beneficiaries.go +++ b/components/payments/internal/connectors/plugins/public/currencycloud/client/beneficiaries.go @@ -2,10 +2,11 @@ package client import ( "context" - "encoding/json" "fmt" "net/http" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Beneficiary struct { @@ -38,16 +39,6 @@ func (c *Client) GetBeneficiaries(ctx context.Context, page int, pageSize int) ( 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, unmarshalError(resp.StatusCode, resp.Body).Error() - } - //nolint:tagliatelle // allow for client code type response struct { Beneficiaries []*Beneficiary `json:"beneficiaries"` @@ -56,10 +47,15 @@ func (c *Client) GetBeneficiaries(ctx context.Context, page int, pageSize int) ( } `json:"pagination"` } - var res response - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, 0, err + res := response{Beneficiaries: make([]*Beneficiary, 0)} + var errRes currencyCloudError + _, err = c.httpClient.Do(req, &res, nil) + switch err { + case nil: + return res.Beneficiaries, res.Pagination.NextPage, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, 0, errRes.Error() } - - return res.Beneficiaries, res.Pagination.NextPage, nil + return nil, 0, fmt.Errorf("failed to get beneficiaries %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/currencycloud/client/client.go b/components/payments/internal/connectors/plugins/public/currencycloud/client/client.go index 2293339266..5aac60ad68 100644 --- a/components/payments/internal/connectors/plugins/public/currencycloud/client/client.go +++ b/components/payments/internal/connectors/plugins/public/currencycloud/client/client.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "github.com/formancehq/payments/internal/connectors/httpwrapper" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) @@ -21,7 +22,7 @@ func (t *apiTransport) RoundTrip(req *http.Request) (*http.Response, error) { } type Client struct { - httpClient *http.Client + httpClient httpwrapper.Client endpoint string loginID string apiKey string @@ -33,15 +34,6 @@ func (c *Client) buildEndpoint(path string, args ...interface{}) string { const DevAPIEndpoint = "https://devapi.currencycloud.com" -func newAuthenticatedHTTPClient(authToken string) *http.Client { - return &http.Client{ - Transport: &apiTransport{ - authToken: authToken, - underlying: otelhttp.NewTransport(http.DefaultTransport), - }, - } -} - func newHTTPClient() *http.Client { return &http.Client{ Transport: otelhttp.NewTransport(http.DefaultTransport), @@ -49,30 +41,38 @@ func newHTTPClient() *http.Client { } // New creates a new client for the CurrencyCloud API. -func New(loginID, apiKey, endpoint string) (*Client, error) { +func New(ctx context.Context, loginID, apiKey, endpoint string) (*Client, error) { if endpoint == "" { endpoint = DevAPIEndpoint } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + c := &Client{ - httpClient: newHTTPClient(), - endpoint: endpoint, - loginID: loginID, - apiKey: apiKey, + endpoint: endpoint, + loginID: loginID, + apiKey: apiKey, } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - // Tokens expire after 30 minutes of inactivity which should not be the case // for us since we're polling the API frequently. // TODO(polo): add refreh - authToken, err := c.authenticate(ctx) + authToken, err := c.authenticate(ctx, newHTTPClient()) if err != nil { return nil, err } - c.httpClient = newAuthenticatedHTTPClient(authToken) - + config := &httpwrapper.Config{ + Transport: &apiTransport{ + authToken: authToken, + underlying: otelhttp.NewTransport(http.DefaultTransport), + }, + } + httpClient, err := httpwrapper.NewClient(config) + if err != nil { + return nil, err + } + c.httpClient = httpClient return c, nil } diff --git a/components/payments/internal/connectors/plugins/public/currencycloud/client/contacts.go b/components/payments/internal/connectors/plugins/public/currencycloud/client/contacts.go index e9cd26f9f0..bd295c6671 100644 --- a/components/payments/internal/connectors/plugins/public/currencycloud/client/contacts.go +++ b/components/payments/internal/connectors/plugins/public/currencycloud/client/contacts.go @@ -2,11 +2,12 @@ package client import ( "context" - "encoding/json" "fmt" "net/http" "net/url" "strings" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Contact struct { @@ -28,28 +29,22 @@ func (c *Client) GetContactID(ctx context.Context, accountID string) (*Contact, } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - type Contacts struct { Contacts []*Contact `json:"contacts"` } - var res Contacts - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - if len(res.Contacts) == 0 { - return nil, fmt.Errorf("no contact found for account %s", accountID) + res := Contacts{Contacts: make([]*Contact, 0)} + var errRes currencyCloudError + _, err = c.httpClient.Do(req, &res, nil) + switch err { + case nil: + if len(res.Contacts) == 0 { + return nil, fmt.Errorf("no contact found for account %s", accountID) + } + return res.Contacts[0], nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error() } - - return res.Contacts[0], nil + return nil, fmt.Errorf("failed to get contacts %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/currencycloud/client/transactions.go b/components/payments/internal/connectors/plugins/public/currencycloud/client/transactions.go index bdf546cd4a..d45425c5b3 100644 --- a/components/payments/internal/connectors/plugins/public/currencycloud/client/transactions.go +++ b/components/payments/internal/connectors/plugins/public/currencycloud/client/transactions.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) //nolint:tagliatelle // allow different styled tags in client @@ -50,17 +52,6 @@ func (c *Client) GetTransactions(ctx context.Context, page int, pageSize int, up 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, unmarshalError(resp.StatusCode, resp.Body).Error() - } - //nolint:tagliatelle // allow for client code type response struct { Transactions []Transaction `json:"transactions"` @@ -69,10 +60,15 @@ func (c *Client) GetTransactions(ctx context.Context, page int, pageSize int, up } `json:"pagination"` } - var res response - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, 0, err + res := response{Transactions: make([]Transaction, 0)} + var errRes currencyCloudError + _, err = c.httpClient.Do(req, &res, nil) + switch err { + case nil: + return res.Transactions, res.Pagination.NextPage, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, 0, errRes.Error() } - - return res.Transactions, res.Pagination.NextPage, nil + return nil, 0, fmt.Errorf("failed to get transactions %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/currencycloud/plugin.go b/components/payments/internal/connectors/plugins/public/currencycloud/plugin.go index 5dcc16a9c2..b44aaaba47 100644 --- a/components/payments/internal/connectors/plugins/public/currencycloud/plugin.go +++ b/components/payments/internal/connectors/plugins/public/currencycloud/plugin.go @@ -18,7 +18,7 @@ func (p *Plugin) Install(ctx context.Context, req models.InstallRequest) (models return models.InstallResponse{}, err } - client, err := client.New(config.LoginID, config.APIKey, config.Endpoint) + client, err := client.New(ctx, config.LoginID, config.APIKey, config.Endpoint) if err != nil { return models.InstallResponse{}, err } diff --git a/components/payments/internal/connectors/plugins/public/mangopay/client/bank_accounts.go b/components/payments/internal/connectors/plugins/public/mangopay/client/bank_accounts.go index a67c6c7488..ba4adf2860 100644 --- a/components/payments/internal/connectors/plugins/public/mangopay/client/bank_accounts.go +++ b/components/payments/internal/connectors/plugins/public/mangopay/client/bank_accounts.go @@ -7,6 +7,8 @@ import ( "fmt" "net/http" "strconv" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type OwnerAddress struct { @@ -127,31 +129,17 @@ func (c *Client) createBankAccount(ctx context.Context, endpoint string, req any } httpReq.Header.Set("Content-Type", "application/json") - resp, err := c.httpClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("failed to create bank account: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO: log error - _ = err - } - }() - - if resp.StatusCode != http.StatusOK { + var bankAccount BankAccount + _, err = c.httpClient.Do(httpReq, &bankAccount, nil) + switch err { + case nil: + return &bankAccount, nil + case httpwrapper.ErrStatusCodeUnexpected: // Never retry bank account creation // TODO(polo): retry ? - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - - var bankAccount BankAccount - if err := json.NewDecoder(resp.Body).Decode(&bankAccount); err != nil { - return nil, fmt.Errorf("failed to unmarshal bank account response body: %w", err) + return nil, err } - - return &bankAccount, nil + return nil, fmt.Errorf("failed to create bank account: %w", err) } type BankAccount struct { @@ -178,27 +166,14 @@ func (c *Client) GetBankAccounts(ctx context.Context, userID string, page, pageS q.Add("Sort", "CreationDate:ASC") req.URL.RawQuery = q.Encode() - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get wallets: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - _ = err - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var bankAccounts []BankAccount - if err := json.NewDecoder(resp.Body).Decode(&bankAccounts); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallets response body: %w", err) + _, err = c.httpClient.Do(req, &bankAccounts, nil) + switch err { + case nil: + return bankAccounts, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, err } - - return bankAccounts, nil + return nil, fmt.Errorf("failed to get bank accounts: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/mangopay/client/client.go b/components/payments/internal/connectors/plugins/public/mangopay/client/client.go index 60721c6f73..78caecfac4 100644 --- a/components/payments/internal/connectors/plugins/public/mangopay/client/client.go +++ b/components/payments/internal/connectors/plugins/public/mangopay/client/client.go @@ -1,47 +1,37 @@ package client import ( - "context" - "net/http" "strings" - "time" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "github.com/formancehq/payments/internal/connectors/httpwrapper" "golang.org/x/oauth2/clientcredentials" ) // TODO(polo): Fetch Client wallets (FEES, ...) in the future type Client struct { - httpClient *http.Client + httpClient httpwrapper.Client clientID string endpoint string } -func newHTTPClient(clientID, apiKey, endpoint string) *http.Client { - config := clientcredentials.Config{ - ClientID: clientID, - ClientSecret: apiKey, - TokenURL: endpoint + "/v2.01/oauth/token", - } - - httpClient := config.Client(context.Background()) - - return &http.Client{ - Timeout: 10 * time.Second, - Transport: otelhttp.NewTransport(httpClient.Transport), - } -} - func New(clientID, apiKey, endpoint string) (*Client, error) { endpoint = strings.TrimSuffix(endpoint, "/") + config := &httpwrapper.Config{ + OAuthConfig: &clientcredentials.Config{ + ClientID: clientID, + ClientSecret: apiKey, + TokenURL: endpoint + "/v2.01/oauth/token", + }, + } + httpClient, err := httpwrapper.NewClient(config) + c := &Client{ - httpClient: newHTTPClient(clientID, apiKey, endpoint), + httpClient: httpClient, clientID: clientID, endpoint: endpoint, } - - return c, nil + return c, err } diff --git a/components/payments/internal/connectors/plugins/public/mangopay/client/error.go b/components/payments/internal/connectors/plugins/public/mangopay/client/error.go index 11fdda0b52..1807ebcb7d 100644 --- a/components/payments/internal/connectors/plugins/public/mangopay/client/error.go +++ b/components/payments/internal/connectors/plugins/public/mangopay/client/error.go @@ -1,9 +1,7 @@ package client import ( - "encoding/json" "fmt" - "io" ) type mangopayError struct { @@ -31,12 +29,3 @@ func (me *mangopayError) Error() error { return err } - -func unmarshalError(statusCode int, body io.ReadCloser) *mangopayError { - var ce mangopayError - _ = json.NewDecoder(body).Decode(&ce) - - ce.StatusCode = statusCode - - return &ce -} diff --git a/components/payments/internal/connectors/plugins/public/mangopay/client/payin.go b/components/payments/internal/connectors/plugins/public/mangopay/client/payin.go index 429ab96aa6..0791e3359f 100644 --- a/components/payments/internal/connectors/plugins/public/mangopay/client/payin.go +++ b/components/payments/internal/connectors/plugins/public/mangopay/client/payin.go @@ -2,9 +2,10 @@ package client import ( "context" - "encoding/json" "fmt" "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type PayinResponse struct { @@ -39,27 +40,14 @@ func (c *Client) GetPayin(ctx context.Context, payinID string) (*PayinResponse, return nil, fmt.Errorf("failed to create get payin request: %w", err) } - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get payin: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - _ = err - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var payinResponse PayinResponse - if err := json.NewDecoder(resp.Body).Decode(&payinResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal payin response body: %w", err) + _, err = c.httpClient.Do(req, &payinResponse, nil) + switch err { + case nil: + return &payinResponse, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, err } - - return &payinResponse, nil + return nil, fmt.Errorf("failed to get payin response: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/mangopay/client/payout.go b/components/payments/internal/connectors/plugins/public/mangopay/client/payout.go index 0142d03a7f..7862fb818e 100644 --- a/components/payments/internal/connectors/plugins/public/mangopay/client/payout.go +++ b/components/payments/internal/connectors/plugins/public/mangopay/client/payout.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type PayoutRequest struct { @@ -59,30 +61,17 @@ func (c *Client) InitiatePayout(ctx context.Context, payoutRequest *PayoutReques } req.Header.Set("Content-Type", "application/json") - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get wallets: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO: log error - _ = err - } - }() - - if resp.StatusCode != http.StatusOK { - // Never retry payout initiation - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var payoutResponse PayoutResponse - if err := json.NewDecoder(resp.Body).Decode(&payoutResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallets response body: %w", err) + _, err = c.httpClient.Do(req, &payoutResponse, nil) + switch err { + case nil: + return &payoutResponse, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + // Never retry payout initiation + return nil, err } - - return &payoutResponse, nil + return nil, fmt.Errorf("failed to get payout response: %w", err) } func (c *Client) GetPayout(ctx context.Context, payoutID string) (*PayoutResponse, error) { @@ -98,27 +87,14 @@ func (c *Client) GetPayout(ctx context.Context, payoutID string) (*PayoutRespons return nil, fmt.Errorf("failed to create get payout request: %w", err) } - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get payout: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - _ = err - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var payoutResponse PayoutResponse - if err := json.NewDecoder(resp.Body).Decode(&payoutResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal payout response body: %w", err) + _, err = c.httpClient.Do(req, &payoutResponse, nil) + switch err { + case nil: + return &payoutResponse, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, err } - - return &payoutResponse, nil + return nil, fmt.Errorf("failed to get payout response: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/mangopay/client/refund.go b/components/payments/internal/connectors/plugins/public/mangopay/client/refund.go index 78c1713539..b2e957cec8 100644 --- a/components/payments/internal/connectors/plugins/public/mangopay/client/refund.go +++ b/components/payments/internal/connectors/plugins/public/mangopay/client/refund.go @@ -2,9 +2,10 @@ package client import ( "context" - "encoding/json" "fmt" "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Refund struct { @@ -40,27 +41,14 @@ func (c *Client) GetRefund(ctx context.Context, refundID string) (*Refund, error return nil, fmt.Errorf("failed to create get refund request: %w", err) } - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get refund: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - _ = err - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var refund Refund - if err := json.NewDecoder(resp.Body).Decode(&refund); err != nil { - return nil, fmt.Errorf("failed to unmarshal refund response body: %w", err) + _, err = c.httpClient.Do(req, &refund, nil) + switch err { + case nil: + return &refund, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, err } - - return &refund, nil + return nil, fmt.Errorf("failed to get refund: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/mangopay/client/transactions.go b/components/payments/internal/connectors/plugins/public/mangopay/client/transactions.go index 6fc3f9f392..0ea70d8f7f 100644 --- a/components/payments/internal/connectors/plugins/public/mangopay/client/transactions.go +++ b/components/payments/internal/connectors/plugins/public/mangopay/client/transactions.go @@ -7,6 +7,8 @@ import ( "net/http" "strconv" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Payment struct { @@ -58,27 +60,14 @@ func (c *Client) GetTransactions(ctx context.Context, walletsID string, page, pa } req.URL.RawQuery = q.Encode() - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get transactions: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - _ = err - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var payments []Payment - if err := json.NewDecoder(resp.Body).Decode(&payments); err != nil { - return nil, fmt.Errorf("failed to unmarshal transactions response body: %w", err) + _, err = c.httpClient.Do(req, &payments, nil) + switch err { + case nil: + return payments, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, err } - - return payments, nil + return nil, fmt.Errorf("failed to get transactions: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/mangopay/client/transfer.go b/components/payments/internal/connectors/plugins/public/mangopay/client/transfer.go index d863ad7861..b437b9bcbf 100644 --- a/components/payments/internal/connectors/plugins/public/mangopay/client/transfer.go +++ b/components/payments/internal/connectors/plugins/public/mangopay/client/transfer.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Funds struct { @@ -51,27 +53,14 @@ func (c *Client) GetWalletTransfer(ctx context.Context, transferID string) (Tran return TransferResponse{}, fmt.Errorf("failed to create login request: %w", err) } - resp, err := c.httpClient.Do(req) - if err != nil { - return TransferResponse{}, fmt.Errorf("failed to get wallets: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - _ = err - } - }() - - if resp.StatusCode != http.StatusOK { - return TransferResponse{}, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var transfer TransferResponse - if err := json.NewDecoder(resp.Body).Decode(&transfer); err != nil { - return transfer, fmt.Errorf("failed to unmarshal wallets response body: %w", err) + _, err = c.httpClient.Do(req, &transfer, nil) + switch err { + case nil: + return transfer, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return transfer, err } - - return transfer, nil + return transfer, fmt.Errorf("failed to get transfer response: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/mangopay/client/users.go b/components/payments/internal/connectors/plugins/public/mangopay/client/users.go index 731646c9ed..4b7c0ebb29 100644 --- a/components/payments/internal/connectors/plugins/public/mangopay/client/users.go +++ b/components/payments/internal/connectors/plugins/public/mangopay/client/users.go @@ -2,10 +2,11 @@ package client import ( "context" - "encoding/json" "fmt" "net/http" "strconv" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type User struct { @@ -31,27 +32,14 @@ func (c *Client) GetUsers(ctx context.Context, page int, pageSize int) ([]User, q.Add("Sort", "CreationDate:ASC") req.URL.RawQuery = q.Encode() - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get users: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - _ = err - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var users []User - if err := json.NewDecoder(resp.Body).Decode(&users); err != nil { - return nil, fmt.Errorf("failed to unmarshal users response body: %w", err) + _, err = c.httpClient.Do(req, &users, nil) + switch err { + case nil: + return users, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, err } - - return users, nil + return nil, fmt.Errorf("failed to get user response: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/mangopay/client/wallets.go b/components/payments/internal/connectors/plugins/public/mangopay/client/wallets.go index 19eb4067d8..3033d4fb08 100644 --- a/components/payments/internal/connectors/plugins/public/mangopay/client/wallets.go +++ b/components/payments/internal/connectors/plugins/public/mangopay/client/wallets.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "strconv" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Wallet struct { @@ -38,29 +40,17 @@ func (c *Client) GetWallets(ctx context.Context, userID string, page, pageSize i q.Add("Sort", "CreationDate:ASC") req.URL.RawQuery = q.Encode() - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get wallets: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - _ = err - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var wallets []Wallet - if err := json.NewDecoder(resp.Body).Decode(&wallets); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallets response body: %w", err) + var errRes mangopayError + _, err = c.httpClient.Do(req, &wallets, errRes) + switch err { + case nil: + return wallets, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error() } - - return wallets, nil + return nil, fmt.Errorf("failed to get wallets %w", err) } func (c *Client) GetWallet(ctx context.Context, walletID string) (*Wallet, error) { @@ -75,27 +65,15 @@ func (c *Client) GetWallet(ctx context.Context, walletID string) (*Wallet, error return nil, fmt.Errorf("failed to create wallet request: %w", err) } - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get wallet: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - _ = err - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var wallet Wallet - if err := json.NewDecoder(resp.Body).Decode(&wallet); err != nil { - return nil, fmt.Errorf("failed to unmarshal wallet response body: %w", err) + var errRes mangopayError + _, err = c.httpClient.Do(req, &wallet, errRes) + switch err { + case nil: + return &wallet, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error() } - - return &wallet, nil + return nil, fmt.Errorf("failed to get wallet %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/mangopay/client/webhooks.go b/components/payments/internal/connectors/plugins/public/mangopay/client/webhooks.go index 35db2a6b6d..27e8583a66 100644 --- a/components/payments/internal/connectors/plugins/public/mangopay/client/webhooks.go +++ b/components/payments/internal/connectors/plugins/public/mangopay/client/webhooks.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type EventType string @@ -105,29 +107,17 @@ func (c *Client) ListAllHooks(ctx context.Context) ([]*Hook, error) { q.Add("Sort", "CreationDate:ASC") req.URL.RawQuery = q.Encode() - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get wallet: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - _ = err - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var hooks []*Hook - if err := json.NewDecoder(resp.Body).Decode(&hooks); err != nil { - return nil, fmt.Errorf("failed to unmarshal hooks response body: %w", err) + var errRes mangopayError + _, err = c.httpClient.Do(req, &hooks, errRes) + switch err { + case nil: + return hooks, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error() } - - return hooks, nil + return nil, fmt.Errorf("failed to list hooks %w", err) } type CreateHookRequest struct { @@ -156,23 +146,12 @@ func (c *Client) CreateHook(ctx context.Context, eventType EventType, URL string } req.Header.Set("Content-Type", "application/json") - resp, err := c.httpClient.Do(req) + return nil + var errRes mangopayError + _, err = c.httpClient.Do(req, nil, &errRes) if err != nil { - return fmt.Errorf("failed to create hook: %w", err) + return errRes.Error() } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - _ = err - } - }() - - if resp.StatusCode != http.StatusOK { - return unmarshalError(resp.StatusCode, resp.Body).Error() - } - return nil } @@ -202,22 +181,10 @@ func (c *Client) UpdateHook(ctx context.Context, hookID string, URL string) erro } req.Header.Set("Content-Type", "application/json") - resp, err := c.httpClient.Do(req) + var errRes mangopayError + _, err = c.httpClient.Do(req, nil, &errRes) if err != nil { - return fmt.Errorf("failed to update hook: %w", err) + return errRes.Error() } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - _ = err - } - }() - - if resp.StatusCode != http.StatusOK { - return unmarshalError(resp.StatusCode, resp.Body).Error() - } - return nil } diff --git a/components/payments/internal/connectors/plugins/public/mangopay/plugin.go b/components/payments/internal/connectors/plugins/public/mangopay/plugin.go index 9ea4acb51c..9729611091 100644 --- a/components/payments/internal/connectors/plugins/public/mangopay/plugin.go +++ b/components/payments/internal/connectors/plugins/public/mangopay/plugin.go @@ -13,7 +13,7 @@ type Plugin struct { client *client.Client } -func (p *Plugin) Install(ctx context.Context, req models.InstallRequest) (models.InstallResponse, error) { +func (p *Plugin) Install(_ context.Context, req models.InstallRequest) (models.InstallResponse, error) { config, err := unmarshalAndValidateConfig(req.Config) if err != nil { return models.InstallResponse{}, err diff --git a/components/payments/internal/connectors/plugins/public/modulr/client/accounts.go b/components/payments/internal/connectors/plugins/public/modulr/client/accounts.go index b9a1b27975..d26b835bcc 100644 --- a/components/payments/internal/connectors/plugins/public/modulr/client/accounts.go +++ b/components/payments/internal/connectors/plugins/public/modulr/client/accounts.go @@ -2,11 +2,12 @@ package client import ( "context" - "encoding/json" "fmt" "net/http" "strconv" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) //nolint:tagliatelle // allow for clients @@ -47,23 +48,17 @@ func (c *Client) GetAccounts(ctx context.Context, page, pageSize int, fromCreate } req.URL.RawQuery = q.Encode() - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // TODO(polo): retryable errors - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var res responseWrapper[[]Account] - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err + var errRes modulrError + _, err = c.httpClient.Do(req, &res, &errRes) + switch err { + case nil: + return &res, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error() } - - return &res, nil + return nil, fmt.Errorf("failed to get accounts: %w", err) } func (c *Client) GetAccount(ctx context.Context, accountID string) (*Account, error) { @@ -77,21 +72,15 @@ func (c *Client) GetAccount(ctx context.Context, accountID string) (*Account, er return nil, fmt.Errorf("failed to create accounts request: %w", err) } - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // TODO(polo): retryable errors - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var res Account - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err + var errRes modulrError + _, err = c.httpClient.Do(req, &res, &errRes) + switch err { + case nil: + return &res, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error() } - - return &res, nil + return nil, fmt.Errorf("failed to get account: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/modulr/client/beneficiaries.go b/components/payments/internal/connectors/plugins/public/modulr/client/beneficiaries.go index 3c148a3873..69296ee994 100644 --- a/components/payments/internal/connectors/plugins/public/modulr/client/beneficiaries.go +++ b/components/payments/internal/connectors/plugins/public/modulr/client/beneficiaries.go @@ -2,11 +2,12 @@ package client import ( "context" - "encoding/json" "fmt" "net/http" "strconv" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Beneficiary struct { @@ -15,13 +16,13 @@ type Beneficiary struct { Created string `json:"created"` } -func (m *Client) GetBeneficiaries(ctx context.Context, page, pageSize int, modifiedSince time.Time) (*responseWrapper[[]Beneficiary], error) { +func (c *Client) GetBeneficiaries(ctx context.Context, page, pageSize int, modifiedSince time.Time) (*responseWrapper[[]Beneficiary], error) { // TODO(polo): add metrics // f := connectors.ClientMetrics(ctx, "modulr", "list_beneficiaries") // now := time.Now() // defer f(ctx, now) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.buildEndpoint("beneficiaries"), http.NoBody) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.buildEndpoint("beneficiaries"), http.NoBody) if err != nil { return nil, fmt.Errorf("failed to create accounts request: %w", err) } @@ -34,22 +35,15 @@ func (m *Client) GetBeneficiaries(ctx context.Context, page, pageSize int, modif } req.URL.RawQuery = q.Encode() - resp, err := m.httpClient.Do(req) - if err != nil { - return nil, err - } - - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // TODO(polo): retryable errors - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var res responseWrapper[[]Beneficiary] - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err + var errRes modulrError + _, err = c.httpClient.Do(req, &res, &errRes) + switch err { + case nil: + return &res, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error() } - - return &res, nil + return nil, fmt.Errorf("failed to get beneficiaries %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/modulr/client/client.go b/components/payments/internal/connectors/plugins/public/modulr/client/client.go index 2c62d3f951..1b5f30b33a 100644 --- a/components/payments/internal/connectors/plugins/public/modulr/client/client.go +++ b/components/payments/internal/connectors/plugins/public/modulr/client/client.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + "github.com/formancehq/payments/internal/connectors/httpwrapper" "github.com/formancehq/payments/internal/connectors/plugins/public/modulr/client/hmac" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) @@ -30,7 +31,7 @@ type responseWrapper[t any] struct { } type Client struct { - httpClient *http.Client + httpClient httpwrapper.Client endpoint string } @@ -50,16 +51,21 @@ func New(apiKey, apiSecret, endpoint string) (*Client, error) { if err != nil { return nil, fmt.Errorf("failed to generate headers: %w", err) } + config := &httpwrapper.Config{ + Transport: &apiTransport{ + headers: headers, + apiKey: apiKey, + underlying: otelhttp.NewTransport(http.DefaultTransport), + }, + } + httpClient, err := httpwrapper.NewClient(config) + if err != nil { + return nil, fmt.Errorf("failed to create modulr client: %w", err) + } return &Client{ - httpClient: &http.Client{ - Transport: &apiTransport{ - headers: headers, - apiKey: apiKey, - underlying: otelhttp.NewTransport(http.DefaultTransport), - }, - }, - endpoint: endpoint, + httpClient: httpClient, + endpoint: endpoint, }, nil } diff --git a/components/payments/internal/connectors/plugins/public/modulr/client/error.go b/components/payments/internal/connectors/plugins/public/modulr/client/error.go index ad62e6eb24..dff0488b07 100644 --- a/components/payments/internal/connectors/plugins/public/modulr/client/error.go +++ b/components/payments/internal/connectors/plugins/public/modulr/client/error.go @@ -1,9 +1,7 @@ package client import ( - "encoding/json" "fmt" - "io" ) type modulrError struct { @@ -25,23 +23,3 @@ func (me *modulrError) Error() error { return err } - -func unmarshalError(statusCode int, body io.ReadCloser) *modulrError { - var ces []modulrError - _ = json.NewDecoder(body).Decode(&ces) - - if len(ces) == 0 { - return &modulrError{ - StatusCode: statusCode, - } - } - - return &modulrError{ - StatusCode: statusCode, - Field: ces[0].Field, - Code: ces[0].Code, - Message: ces[0].Message, - ErrorCode: ces[0].ErrorCode, - SourceService: ces[0].SourceService, - } -} diff --git a/components/payments/internal/connectors/plugins/public/modulr/client/payout.go b/components/payments/internal/connectors/plugins/public/modulr/client/payout.go index 2a10526695..83e5b3ef71 100644 --- a/components/payments/internal/connectors/plugins/public/modulr/client/payout.go +++ b/components/payments/internal/connectors/plugins/public/modulr/client/payout.go @@ -4,7 +4,10 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type PayoutRequest struct { @@ -39,23 +42,23 @@ func (c *Client) InitiatePayout(ctx context.Context, payoutRequest *PayoutReques return nil, err } - resp, err := c.httpClient.Post(c.buildEndpoint("payments"), "application/json", bytes.NewBuffer(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.buildEndpoint("payments"), bytes.NewBuffer(body)) if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusCreated { - // TODO(polo): retryable errors - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() + return nil, fmt.Errorf("failed to create payout request: %w", err) } + req.Header.Set("Content-Type", "application/json") var res PayoutResponse - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err + var errRes modulrError + _, err = c.httpClient.Do(req, &res, &errRes) + switch err { + case nil: + return &res, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error() } - - return &res, nil + return nil, fmt.Errorf("failed to create payout %w", err) } func (c *Client) GetPayout(ctx context.Context, payoutID string) (PayoutResponse, error) { @@ -64,20 +67,20 @@ func (c *Client) GetPayout(ctx context.Context, payoutID string) (PayoutResponse // now := time.Now() // defer f(ctx, now) - resp, err := c.httpClient.Get(c.buildEndpoint("payments?id=%s", payoutID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.buildEndpoint("payments?id=%s", payoutID), nil) if err != nil { - return PayoutResponse{}, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return PayoutResponse{}, unmarshalError(resp.StatusCode, resp.Body).Error() + return PayoutResponse{}, fmt.Errorf("failed to create get payout request: %w", err) } var res PayoutResponse - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return PayoutResponse{}, err + var errRes modulrError + _, err = c.httpClient.Do(req, &res, &errRes) + switch err { + case nil: + return res, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return PayoutResponse{}, errRes.Error() } - - return res, nil + return PayoutResponse{}, fmt.Errorf("failed to get payout %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/modulr/client/transactions.go b/components/payments/internal/connectors/plugins/public/modulr/client/transactions.go index 79ed38ff81..ad381a17c2 100644 --- a/components/payments/internal/connectors/plugins/public/modulr/client/transactions.go +++ b/components/payments/internal/connectors/plugins/public/modulr/client/transactions.go @@ -7,6 +7,8 @@ import ( "net/http" "strconv" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) //nolint:tagliatelle // allow different styled tags in client @@ -23,13 +25,13 @@ type Transaction struct { AdditionalInfo interface{} `json:"additionalInfo"` } -func (m *Client) GetTransactions(ctx context.Context, accountID string, page, pageSize int, fromTransactionDate time.Time) (*responseWrapper[[]Transaction], error) { +func (c *Client) GetTransactions(ctx context.Context, accountID string, page, pageSize int, fromTransactionDate time.Time) (*responseWrapper[[]Transaction], error) { // TODO(polo): add metrics // f := connectors.ClientMetrics(ctx, "modulr", "list_transactions") // now := time.Now() // defer f(ctx, now) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.buildEndpoint("accounts/%s/transactions", accountID), http.NoBody) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.buildEndpoint("accounts/%s/transactions", accountID), http.NoBody) if err != nil { return nil, fmt.Errorf("failed to create accounts request: %w", err) } @@ -42,22 +44,15 @@ func (m *Client) GetTransactions(ctx context.Context, accountID string, page, pa } req.URL.RawQuery = q.Encode() - resp, err := m.httpClient.Do(req) - if err != nil { - return nil, err - } - - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // TODO(polo): retryable errors - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var res responseWrapper[[]Transaction] - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err + var errRes modulrError + _, err = c.httpClient.Do(req, &res, &errRes) + switch err { + case nil: + return &res, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error() } - - return &res, nil + return nil, fmt.Errorf("failed to get transactions %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/modulr/client/transfer.go b/components/payments/internal/connectors/plugins/public/modulr/client/transfer.go index 3253791c03..9e15fe97dd 100644 --- a/components/payments/internal/connectors/plugins/public/modulr/client/transfer.go +++ b/components/payments/internal/connectors/plugins/public/modulr/client/transfer.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type DestinationType string @@ -60,23 +62,17 @@ func (c *Client) InitiateTransfer(ctx context.Context, transferRequest *Transfer } req.Header.Set("Content-Type", "application/json") - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to initiate transfer: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusCreated { - // TODO(polo): retryable errors - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - var res TransferResponse - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err + var errRes modulrError + _, err = c.httpClient.Do(req, &res, &errRes) + switch err { + case nil: + return &res, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error() } - - return &res, nil + return nil, fmt.Errorf("failed to initiate transfer: %w", err) } func (c *Client) GetTransfer(ctx context.Context, transferID string) (TransferResponse, error) { @@ -85,25 +81,23 @@ func (c *Client) GetTransfer(ctx context.Context, transferID string) (TransferRe // now := time.Now() // defer f(ctx, now) - resp, err := c.httpClient.Get(c.buildEndpoint("payments?id=%s", transferID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.buildEndpoint("payments?id=%s", transferID), nil) if err != nil { - return TransferResponse{}, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // TODO(polo): retryable errors - return TransferResponse{}, unmarshalError(resp.StatusCode, resp.Body).Error() + return TransferResponse{}, fmt.Errorf("failed to create get transfer request: %w", err) } var res getTransferResponse - if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { - return TransferResponse{}, err - } - - if len(res.Content) == 0 { - return TransferResponse{}, fmt.Errorf("transfer not found") + var errRes modulrError + _, err = c.httpClient.Do(req, &res, &errRes) + switch err { + case nil: + if len(res.Content) == 0 { + return TransferResponse{}, fmt.Errorf("transfer not found") + } + return res.Content[0], nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return TransferResponse{}, errRes.Error() } - - return res.Content[0], nil + return TransferResponse{}, fmt.Errorf("failed to get transactions %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/moneycorp/client/accounts.go b/components/payments/internal/connectors/plugins/public/moneycorp/client/accounts.go index 8a502223c8..10e015ab1d 100644 --- a/components/payments/internal/connectors/plugins/public/moneycorp/client/accounts.go +++ b/components/payments/internal/connectors/plugins/public/moneycorp/client/accounts.go @@ -2,10 +2,11 @@ package client import ( "context" - "encoding/json" "fmt" "net/http" "strconv" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type accountsResponse struct { @@ -20,7 +21,8 @@ type Account struct { } func (c *Client) GetAccounts(ctx context.Context, page int, pageSize int) ([]*Account, error) { - // TODO(polo): metrics + // TODO(polo, crimson): metrics + // metrics can also be embedded in wrapper // f := connectors.ClientMetrics(ctx, "moneycorp", "list_accounts") // now := time.Now() // defer f(ctx, now) @@ -31,6 +33,7 @@ func (c *Client) GetAccounts(ctx context.Context, page int, pageSize int) ([]*Ac return nil, fmt.Errorf("failed to create accounts request: %w", err) } + // TODO generic headers can be set in wrapper req.Header.Set("Content-Type", "application/json") q := req.URL.Query() @@ -39,33 +42,15 @@ func (c *Client) GetAccounts(ctx context.Context, page int, pageSize int) ([]*Ac q.Add("sortBy", "id.asc") req.URL.RawQuery = q.Encode() - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get accounts: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - // c.logger.Error(err) - _ = err - } - }() - - if resp.StatusCode == http.StatusNotFound { - return []*Account{}, nil - } - - if resp.StatusCode != http.StatusOK { + accounts := accountsResponse{Accounts: make([]*Account, 0)} + var errRes moneycorpError + _, err = c.httpClient.Do(req, &accounts, &errRes) + switch err { + case nil: + return accounts.Accounts, nil + case httpwrapper.ErrStatusCodeUnexpected: // TODO(polo): retryable errors - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() + return nil, errRes.Error() } - - var accounts accountsResponse - if err := json.NewDecoder(resp.Body).Decode(&accounts); err != nil { - return nil, fmt.Errorf("failed to unmarshal accounts response body: %w", err) - } - - return accounts.Accounts, nil + return nil, fmt.Errorf("failed to get accounts: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/moneycorp/client/auth.go b/components/payments/internal/connectors/plugins/public/moneycorp/client/auth.go index 9bbbee83fb..5bd5d4f2b5 100644 --- a/components/payments/internal/connectors/plugins/public/moneycorp/client/auth.go +++ b/components/payments/internal/connectors/plugins/public/moneycorp/client/auth.go @@ -79,6 +79,7 @@ func (t *apiTransport) login(ctx context.Context) error { req.Header.Set("Content-Type", "application/json") + // TODO: default client doesn't have a timeout, so we should be careful about using it here resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("failed to login: %w", err) diff --git a/components/payments/internal/connectors/plugins/public/moneycorp/client/balances.go b/components/payments/internal/connectors/plugins/public/moneycorp/client/balances.go index f0096d5205..d104b5edc7 100644 --- a/components/payments/internal/connectors/plugins/public/moneycorp/client/balances.go +++ b/components/payments/internal/connectors/plugins/public/moneycorp/client/balances.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type balancesResponse struct { @@ -36,32 +38,16 @@ func (c *Client) GetAccountBalances(ctx context.Context, accountID string) ([]*B } req.Header.Set("Content-Type", "application/json") - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get account balances: %w", err) - } + balances := balancesResponse{Balances: make([]*Balance, 0)} + var errRes moneycorpError - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - // c.logger.Error(err) - _ = err - } - }() - - if resp.StatusCode == http.StatusNotFound { - return []*Balance{}, nil + _, err = c.httpClient.Do(req, &balances, &errRes) + switch err { + case nil: + return balances.Balances, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error() } - - if resp.StatusCode != http.StatusOK { - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() - } - - var balances balancesResponse - if err := json.NewDecoder(resp.Body).Decode(&balances); err != nil { - return nil, fmt.Errorf("failed to unmarshal balances response body: %w", err) - } - - return balances.Balances, nil + return nil, fmt.Errorf("failed to get account balances: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/moneycorp/client/client.go b/components/payments/internal/connectors/plugins/public/moneycorp/client/client.go index 45c1d3bb14..d524d6992d 100644 --- a/components/payments/internal/connectors/plugins/public/moneycorp/client/client.go +++ b/components/payments/internal/connectors/plugins/public/moneycorp/client/client.go @@ -3,35 +3,41 @@ package client import ( "net/http" "strings" - "time" + "github.com/formancehq/payments/internal/connectors/httpwrapper" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) type Client struct { - httpClient *http.Client + httpClient httpwrapper.Client endpoint string } -func newHTTPClient(clientID, apiKey, endpoint string) *http.Client { - return &http.Client{ - Timeout: 10 * time.Second, +func New(clientID, apiKey, endpoint string) (*Client, error) { + config := &httpwrapper.Config{ Transport: &apiTransport{ clientID: clientID, apiKey: apiKey, endpoint: endpoint, underlying: otelhttp.NewTransport(http.DefaultTransport), }, - } -} + HttpErrorCheckerFn: func(statusCode int) error { + if statusCode == http.StatusNotFound { + return nil + } + if statusCode >= http.StatusBadRequest { + return httpwrapper.ErrStatusCodeUnexpected + } + return nil -func New(clientID, apiKey, endpoint string) (*Client, error) { + }, + } endpoint = strings.TrimSuffix(endpoint, "/") + httpClient, err := httpwrapper.NewClient(config) c := &Client{ - httpClient: newHTTPClient(clientID, apiKey, endpoint), + httpClient: httpClient, endpoint: endpoint, } - - return c, nil + return c, err } diff --git a/components/payments/internal/connectors/plugins/public/moneycorp/client/recipients.go b/components/payments/internal/connectors/plugins/public/moneycorp/client/recipients.go index 0d7eb13212..8d19d468d3 100644 --- a/components/payments/internal/connectors/plugins/public/moneycorp/client/recipients.go +++ b/components/payments/internal/connectors/plugins/public/moneycorp/client/recipients.go @@ -2,10 +2,11 @@ package client import ( "context" - "encoding/json" "fmt" "net/http" "strconv" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type recipientsResponse struct { @@ -41,33 +42,15 @@ func (c *Client) GetRecipients(ctx context.Context, accountID string, page int, q.Add("sortBy", "createdAt.asc") req.URL.RawQuery = q.Encode() - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get accounts: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - // c.logger.Error(err) - _ = err - } - }() - - if resp.StatusCode == http.StatusNotFound { - return []*Recipient{}, nil - } - - if resp.StatusCode != http.StatusOK { + recipients := recipientsResponse{Recipients: make([]*Recipient, 0)} + var errRes moneycorpError + _, err = c.httpClient.Do(req, &recipients, &errRes) + switch err { + case nil: + return recipients.Recipients, nil + case httpwrapper.ErrStatusCodeUnexpected: // TODO(polo): retryable errors - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() + return nil, errRes.Error() } - - var recipients recipientsResponse - if err := json.NewDecoder(resp.Body).Decode(&recipients); err != nil { - return nil, fmt.Errorf("failed to unmarshal recipients response body: %w", err) - } - - return recipients.Recipients, nil + return nil, fmt.Errorf("failed to get recipients %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/moneycorp/client/transactions.go b/components/payments/internal/connectors/plugins/public/moneycorp/client/transactions.go index d5d3927253..60b14d3c59 100644 --- a/components/payments/internal/connectors/plugins/public/moneycorp/client/transactions.go +++ b/components/payments/internal/connectors/plugins/public/moneycorp/client/transactions.go @@ -9,6 +9,8 @@ import ( "net/http" "strconv" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type transactionsResponse struct { @@ -84,33 +86,15 @@ func (c *Client) GetTransactions(ctx context.Context, accountID string, page, pa q.Add("sortBy", "createdAt.asc") req.URL.RawQuery = q.Encode() - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get transactions: %w", err) - } - - defer func() { - err = resp.Body.Close() - if err != nil { - // TODO(polo): log error - // c.logger.Error(err) - _ = err - } - }() - - if resp.StatusCode == http.StatusNotFound { - return []*Transaction{}, nil - } - - if resp.StatusCode != http.StatusOK { + transactions := transactionsResponse{Transactions: make([]*Transaction, 0)} + var errRes moneycorpError + _, err = c.httpClient.Do(req, &transactions, &errRes) + switch err { + case nil: + return transactions.Transactions, nil + case httpwrapper.ErrStatusCodeUnexpected: // TODO(polo): retryable errors - return nil, unmarshalError(resp.StatusCode, resp.Body).Error() + return nil, errRes.Error() } - - var transactions transactionsResponse - if err := json.NewDecoder(resp.Body).Decode(&transactions); err != nil { - return nil, fmt.Errorf("failed to unmarshal transactions response body: %w", err) - } - - return transactions.Transactions, nil + return nil, fmt.Errorf("failed to get transactions %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/moneycorp/plugin.go b/components/payments/internal/connectors/plugins/public/moneycorp/plugin.go index 9e35440aca..cd58829a9c 100644 --- a/components/payments/internal/connectors/plugins/public/moneycorp/plugin.go +++ b/components/payments/internal/connectors/plugins/public/moneycorp/plugin.go @@ -12,7 +12,7 @@ type Plugin struct { client *client.Client } -func (p *Plugin) Install(ctx context.Context, req models.InstallRequest) (models.InstallResponse, error) { +func (p *Plugin) Install(_ context.Context, req models.InstallRequest) (models.InstallResponse, error) { config, err := unmarshalAndValidateConfig(req.Config) if err != nil { return models.InstallResponse{}, err diff --git a/components/payments/internal/connectors/plugins/public/wise/client/balances.go b/components/payments/internal/connectors/plugins/public/wise/client/balances.go index cfe49d8997..0c974e897c 100644 --- a/components/payments/internal/connectors/plugins/public/wise/client/balances.go +++ b/components/payments/internal/connectors/plugins/public/wise/client/balances.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Balance struct { @@ -34,64 +36,52 @@ type Balance struct { Visible bool `json:"visible"` } -func (w *Client) GetBalances(ctx context.Context, profileID uint64) ([]Balance, error) { +func (c *Client) GetBalances(ctx context.Context, profileID uint64) ([]Balance, error) { // TODO(polo): metrics // f := connectors.ClientMetrics(ctx, "wise", "list_balances") // now := time.Now() // defer f(ctx, now) req, err := http.NewRequestWithContext(ctx, - http.MethodGet, w.endpoint(fmt.Sprintf("v4/profiles/%d/balances?types=STANDARD", profileID)), http.NoBody) - if err != nil { - return nil, err - } - - res, err := w.httpClient.Do(req) + http.MethodGet, c.endpoint(fmt.Sprintf("v4/profiles/%d/balances?types=STANDARD", profileID)), http.NoBody) if err != nil { return nil, err } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, unmarshalError(res.StatusCode, res.Body).Error() - } var balances []Balance - err = json.NewDecoder(res.Body).Decode(&balances) - if err != nil { - return nil, fmt.Errorf("failed to decode account: %w", err) + var errRes wiseErrors + statusCode, err := c.httpClient.Do(req, &balances, &errRes) + switch err { + case nil: + return balances, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return balances, errRes.Error(statusCode).Error() } - - return balances, nil + return balances, fmt.Errorf("failed to get balances: %w", err) } -func (w *Client) GetBalance(ctx context.Context, profileID uint64, balanceID uint64) (*Balance, error) { +func (c *Client) GetBalance(ctx context.Context, profileID uint64, balanceID uint64) (*Balance, error) { // TODO(polo): metrics // f := connectors.ClientMetrics(ctx, "wise", "list_balances") // now := time.Now() // defer f(ctx, now) req, err := http.NewRequestWithContext(ctx, - http.MethodGet, w.endpoint(fmt.Sprintf("v4/profiles/%d/balances/%d", profileID, balanceID)), http.NoBody) + http.MethodGet, c.endpoint(fmt.Sprintf("v4/profiles/%d/balances/%d", profileID, balanceID)), http.NoBody) if err != nil { return nil, err } - res, err := w.httpClient.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, unmarshalError(res.StatusCode, res.Body).Error() - } - var balance Balance - err = json.NewDecoder(res.Body).Decode(&balance) - if err != nil { - return nil, fmt.Errorf("failed to decode account: %w", err) + var errRes wiseErrors + statusCode, err := c.httpClient.Do(req, &balance, &errRes) + switch err { + case nil: + return &balance, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error(statusCode).Error() } - - return &balance, nil + return nil, fmt.Errorf("failed to get balances: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/wise/client/client.go b/components/payments/internal/connectors/plugins/public/wise/client/client.go index 687afd2d82..c43b1ad54c 100644 --- a/components/payments/internal/connectors/plugins/public/wise/client/client.go +++ b/components/payments/internal/connectors/plugins/public/wise/client/client.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" + "github.com/formancehq/payments/internal/connectors/httpwrapper" lru "github.com/hashicorp/golang-lru/v2" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) @@ -22,7 +23,7 @@ func (t *apiTransport) RoundTrip(req *http.Request) (*http.Response, error) { } type Client struct { - httpClient *http.Client + httpClient httpwrapper.Client recipientAccountsCache *lru.Cache[uint64, *RecipientAccount] } @@ -31,17 +32,18 @@ func (w *Client) endpoint(path string) string { return fmt.Sprintf("%s/%s", apiEndpoint, path) } -func New(apiKey string) *Client { +func New(apiKey string) (*Client, error) { recipientsCache, _ := lru.New[uint64, *RecipientAccount](2048) - httpClient := &http.Client{ + config := &httpwrapper.Config{ Transport: &apiTransport{ APIKey: apiKey, underlying: otelhttp.NewTransport(http.DefaultTransport), }, } + httpClient, err := httpwrapper.NewClient(config) return &Client{ httpClient: httpClient, recipientAccountsCache: recipientsCache, - } + }, err } diff --git a/components/payments/internal/connectors/plugins/public/wise/client/error.go b/components/payments/internal/connectors/plugins/public/wise/client/error.go index 13f3813d28..61d3b47150 100644 --- a/components/payments/internal/connectors/plugins/public/wise/client/error.go +++ b/components/payments/internal/connectors/plugins/public/wise/client/error.go @@ -1,15 +1,21 @@ package client import ( - "encoding/json" "fmt" - "io" ) type wiseErrors struct { Errors []*wiseError `json:"errors"` } +func (we *wiseErrors) Error(statusCode int) *wiseError { + if len(we.Errors) == 0 { + return &wiseError{StatusCode: statusCode} + } + we.Errors[0].StatusCode = statusCode + return we.Errors[0] +} + type wiseError struct { StatusCode int `json:"-"` Code string `json:"code"` @@ -23,20 +29,3 @@ func (me *wiseError) Error() error { return fmt.Errorf("%s: %s", me.Code, me.Message) } - -func unmarshalError(statusCode int, body io.ReadCloser) *wiseError { - var ces wiseErrors - _ = json.NewDecoder(body).Decode(&ces) - - if len(ces.Errors) == 0 { - return &wiseError{ - StatusCode: statusCode, - } - } - - return &wiseError{ - StatusCode: statusCode, - Code: ces.Errors[0].Code, - Message: ces.Errors[0].Message, - } -} diff --git a/components/payments/internal/connectors/plugins/public/wise/client/payouts.go b/components/payments/internal/connectors/plugins/public/wise/client/payouts.go index 6bdb41fa73..18dac3df10 100644 --- a/components/payments/internal/connectors/plugins/public/wise/client/payouts.go +++ b/components/payments/internal/connectors/plugins/public/wise/client/payouts.go @@ -5,9 +5,10 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Payout struct { @@ -60,49 +61,38 @@ func (t *Payout) UnmarshalJSON(data []byte) error { return nil } -func (w *Client) GetPayout(ctx context.Context, payoutID string) (*Payout, error) { +func (c *Client) GetPayout(ctx context.Context, payoutID string) (*Payout, error) { // TODO(polo): metrics // f := connectors.ClientMetrics(ctx, "wise", "get_payout") // now := time.Now() // defer f(ctx, now) req, err := http.NewRequestWithContext(ctx, - http.MethodGet, w.endpoint("v1/transfers/"+payoutID), http.NoBody) - if err != nil { - return nil, err - } - - res, err := w.httpClient.Do(req) + http.MethodGet, c.endpoint("v1/transfers/"+payoutID), http.NoBody) if err != nil { return nil, err } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, unmarshalError(res.StatusCode, res.Body).Error() - } - - body, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } var payout Payout - err = json.Unmarshal(body, &payout) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal transfer: %w", err) + var errRes wiseErrors + statusCode, err := c.httpClient.Do(req, &payout, &errRes) + switch err { + case nil: + return &payout, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error(statusCode).Error() } - - return &payout, nil + return nil, fmt.Errorf("failed to get payout: %w", err) } -func (w *Client) CreatePayout(ctx context.Context, quote Quote, targetAccount uint64, transactionID string) (*Payout, error) { +func (c *Client) CreatePayout(ctx context.Context, quote Quote, targetAccount uint64, transactionID string) (*Payout, error) { // TODO(polo): metrics // f := connectors.ClientMetrics(ctx, "wise", "initiate_payout") // now := time.Now() // defer f(ctx, now) - req, err := json.Marshal(map[string]interface{}{ + reqBody, err := json.Marshal(map[string]interface{}{ "targetAccount": targetAccount, "quoteUuid": quote.ID.String(), "customerTransactionId": transactionID, @@ -111,21 +101,21 @@ func (w *Client) CreatePayout(ctx context.Context, quote Quote, targetAccount ui return nil, err } - res, err := w.httpClient.Post(w.endpoint("v1/transfers"), "application/json", bytes.NewBuffer(req)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("v1/transfers"), bytes.NewBuffer(reqBody)) if err != nil { return nil, err } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, unmarshalError(res.StatusCode, res.Body).Error() - } + req.Header.Set("Content-Type", "application/json") - var response Payout - err = json.NewDecoder(res.Body).Decode(&response) - if err != nil { - return nil, fmt.Errorf("failed to get response from transfer: %w", err) + var payout Payout + var errRes wiseErrors + statusCode, err := c.httpClient.Do(req, &payout, &errRes) + switch err { + case nil: + return &payout, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error(statusCode).Error() } - - return &response, nil + return nil, fmt.Errorf("failed to make payout: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/wise/client/profiles.go b/components/payments/internal/connectors/plugins/public/wise/client/profiles.go index 133a70ddd7..0c97fd337c 100644 --- a/components/payments/internal/connectors/plugins/public/wise/client/profiles.go +++ b/components/payments/internal/connectors/plugins/public/wise/client/profiles.go @@ -2,10 +2,10 @@ package client import ( "context" - "encoding/json" "fmt" - "io" "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Profile struct { @@ -13,34 +13,26 @@ type Profile struct { Type string `json:"type"` } -func (w *Client) GetProfiles(ctx context.Context) ([]Profile, error) { +func (c *Client) GetProfiles(ctx context.Context) ([]Profile, error) { // TODO(polo): metrics // f := connectors.ClientMetrics(ctx, "wise", "list_profiles") // now := time.Now() // defer f(ctx, now) var profiles []Profile - - res, err := w.httpClient.Get(w.endpoint("v2/profiles")) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("v2/profiles"), http.NoBody) if err != nil { return profiles, err } - defer res.Body.Close() - - body, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if res.StatusCode != http.StatusOK { - return nil, unmarshalError(res.StatusCode, res.Body).Error() + var errRes wiseErrors + statusCode, err := c.httpClient.Do(req, &profiles, &errRes) + switch err { + case nil: + return profiles, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return profiles, errRes.Error(statusCode).Error() } - - err = json.Unmarshal(body, &profiles) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal profiles: %w", err) - } - - return profiles, nil + return profiles, fmt.Errorf("failed to get profiles: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/wise/client/quotes.go b/components/payments/internal/connectors/plugins/public/wise/client/quotes.go index 321bd2c96b..04e83d070b 100644 --- a/components/payments/internal/connectors/plugins/public/wise/client/quotes.go +++ b/components/payments/internal/connectors/plugins/public/wise/client/quotes.go @@ -5,9 +5,9 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" + "github.com/formancehq/payments/internal/connectors/httpwrapper" "github.com/google/uuid" ) @@ -15,42 +15,42 @@ type Quote struct { ID uuid.UUID `json:"id"` } -func (w *Client) CreateQuote(ctx context.Context, profileID, currency string, amount json.Number) (Quote, error) { +func (c *Client) CreateQuote(ctx context.Context, profileID, currency string, amount json.Number) (Quote, error) { // TODO(polo): metrics // f := connectors.ClientMetrics(ctx, "wise", "create_quote") // now := time.Now() // defer f(ctx, now) - var response Quote + var quote Quote - req, err := json.Marshal(map[string]interface{}{ + reqBody, err := json.Marshal(map[string]interface{}{ "sourceCurrency": currency, "targetCurrency": currency, "sourceAmount": amount, }) if err != nil { - return response, err + return quote, err } - res, err := w.httpClient.Post(w.endpoint("v3/profiles/"+profileID+"/quotes"), "application/json", bytes.NewBuffer(req)) + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + c.endpoint("v3/profiles/"+profileID+"/quotes"), + bytes.NewBuffer(reqBody), + ) if err != nil { - return response, err + return quote, err } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return response, unmarshalError(res.StatusCode, res.Body).Error() - } - - body, err := io.ReadAll(res.Body) - if err != nil { - return response, fmt.Errorf("failed to read response body: %w", err) + req.Header.Set("Content-Type", "application/json") + + var errRes wiseErrors + statusCode, err := c.httpClient.Do(req, "e, &errRes) + switch err { + case nil: + return quote, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return quote, errRes.Error(statusCode).Error() } - - err = json.Unmarshal(body, &response) - if err != nil { - return response, fmt.Errorf("failed to get response from quote: %w", err) - } - - return response, nil + return quote, fmt.Errorf("failed to get response from quote: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/wise/client/recipient_accounts.go b/components/payments/internal/connectors/plugins/public/wise/client/recipient_accounts.go index 17bfd70ab9..e8e8e7a54d 100644 --- a/components/payments/internal/connectors/plugins/public/wise/client/recipient_accounts.go +++ b/components/payments/internal/connectors/plugins/public/wise/client/recipient_accounts.go @@ -2,10 +2,10 @@ package client import ( "context" - "encoding/json" "fmt" - "io" "net/http" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type RecipientAccountsResponse struct { @@ -24,14 +24,14 @@ type RecipientAccount struct { } `json:"name"` } -func (w *Client) GetRecipientAccounts(ctx context.Context, profileID uint64, pageSize int, seekPositionForNext uint64) (*RecipientAccountsResponse, error) { +func (c *Client) GetRecipientAccounts(ctx context.Context, profileID uint64, pageSize int, seekPositionForNext uint64) (*RecipientAccountsResponse, error) { // TODO(polo): metrics // f := connectors.ClientMetrics(ctx, "wise", "list_recipient_accounts") // now := time.Now() // defer f(ctx, now) req, err := http.NewRequestWithContext(ctx, - http.MethodGet, w.endpoint("v2/accounts"), http.NoBody) + http.MethodGet, c.endpoint("v2/accounts"), http.NoBody) if err != nil { return nil, err } @@ -45,87 +45,51 @@ func (w *Client) GetRecipientAccounts(ctx context.Context, profileID uint64, pag } req.URL.RawQuery = q.Encode() - res, err := w.httpClient.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, unmarshalError(res.StatusCode, res.Body).Error() - } - - body, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var recipientAccounts *RecipientAccountsResponse - err = json.Unmarshal(body, &recipientAccounts) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal transfers: %w", err) + var accounts RecipientAccountsResponse + var errRes wiseErrors + statusCode, err := c.httpClient.Do(req, &accounts, &errRes) + switch err { + case nil: + return &accounts, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error(statusCode).Error() } - - return recipientAccounts, nil + return nil, fmt.Errorf("failed to get recipient accounts: %w", err) } -func (w *Client) GetRecipientAccount(ctx context.Context, accountID uint64) (*RecipientAccount, error) { +func (c *Client) GetRecipientAccount(ctx context.Context, accountID uint64) (*RecipientAccount, error) { // TODO(polo): metrics // f := connectors.ClientMetrics(ctx, "wise", "get_recipient_account") // now := time.Now() // defer f(ctx, now) - if rc, ok := w.recipientAccountsCache.Get(accountID); ok { + if rc, ok := c.recipientAccountsCache.Get(accountID); ok { return rc, nil } req, err := http.NewRequestWithContext(ctx, - http.MethodGet, w.endpoint(fmt.Sprintf("v1/accounts/%d", accountID)), http.NoBody) - if err != nil { - return nil, err - } - - res, err := w.httpClient.Do(req) + http.MethodGet, c.endpoint(fmt.Sprintf("v1/accounts/%d", accountID)), http.NoBody) if err != nil { return nil, err } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - type errorResponse struct { - Errors []struct { - Code string `json:"code"` - Message string `json:"message"` - } - } - - var e errorResponse - err = json.NewDecoder(res.Body).Decode(&e) - if err != nil { - return nil, fmt.Errorf("failed to decode error response: %w", err) - } - - if len(e.Errors) == 0 { - return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode) - } - switch e.Errors[0].Code { - case "RECIPIENT_MISSING": + var res RecipientAccount + var errRes wiseErrors + statusCode, err := c.httpClient.Do(req, &res, &errRes) + switch err { + case nil: + c.recipientAccountsCache.Add(accountID, &res) + return &res, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + e := errRes.Error(statusCode) + if e.Code == "RECIPIENT_MISSING" { // This is a valid response, we just don't have the account amoungs // our recipients. return &RecipientAccount{}, nil } - - return nil, fmt.Errorf("unexpected status code: %d with err: %v", res.StatusCode, e) + return nil, e.Error() } - - var account RecipientAccount - err = json.NewDecoder(res.Body).Decode(&account) - if err != nil { - return nil, fmt.Errorf("failed to decode account: %w", err) - } - - w.recipientAccountsCache.Add(accountID, &account) - - return &account, nil + return nil, fmt.Errorf("failed to get recipient account: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/wise/client/transfers.go b/components/payments/internal/connectors/plugins/public/wise/client/transfers.go index ccc6194039..c028ac4554 100644 --- a/components/payments/internal/connectors/plugins/public/wise/client/transfers.go +++ b/components/payments/internal/connectors/plugins/public/wise/client/transfers.go @@ -5,9 +5,10 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type Transfer struct { @@ -60,14 +61,14 @@ func (t *Transfer) UnmarshalJSON(data []byte) error { return nil } -func (w *Client) GetTransfers(ctx context.Context, profileID uint64, offset int, limit int) ([]Transfer, error) { +func (c *Client) GetTransfers(ctx context.Context, profileID uint64, offset int, limit int) ([]Transfer, error) { // TODO(polo): metrics // f := connectors.ClientMetrics(ctx, "wise", "list_transfers") // now := time.Now() // defer f(ctx, now) req, err := http.NewRequestWithContext(ctx, - http.MethodGet, w.endpoint("v1/transfers"), http.NoBody) + http.MethodGet, c.endpoint("v1/transfers"), http.NoBody) if err != nil { return nil, err } @@ -78,32 +79,23 @@ func (w *Client) GetTransfers(ctx context.Context, profileID uint64, offset int, q.Add("offset", fmt.Sprintf("%d", offset)) req.URL.RawQuery = q.Encode() - res, err := w.httpClient.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, unmarshalError(res.StatusCode, res.Body).Error() - } - - body, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - var transfers []Transfer - - err = json.Unmarshal(body, &transfers) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal transfers: %w", err) + var errRes wiseErrors + statusCode, err := c.httpClient.Do(req, &transfers, &errRes) + switch err { + case nil: + // fallthrough + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return transfers, errRes.Error(statusCode).Error() + default: + return transfers, fmt.Errorf("failed to get transfers: %w", err) } for i, transfer := range transfers { var sourceProfileID, targetProfileID uint64 if transfer.SourceAccount != 0 { - recipientAccount, err := w.GetRecipientAccount(ctx, transfer.SourceAccount) + recipientAccount, err := c.GetRecipientAccount(ctx, transfer.SourceAccount) if err != nil { return nil, fmt.Errorf("failed to get source profile id: %w", err) } @@ -112,7 +104,7 @@ func (w *Client) GetTransfers(ctx context.Context, profileID uint64, offset int, } if transfer.TargetAccount != 0 { - recipientAccount, err := w.GetRecipientAccount(ctx, transfer.TargetAccount) + recipientAccount, err := c.GetRecipientAccount(ctx, transfer.TargetAccount) if err != nil { return nil, fmt.Errorf("failed to get target profile id: %w", err) } @@ -129,7 +121,7 @@ func (w *Client) GetTransfers(ctx context.Context, profileID uint64, offset int, // Do nothing case sourceProfileID == targetProfileID && sourceProfileID != 0: // Same profile id for target and source - balances, err := w.GetBalances(ctx, sourceProfileID) + balances, err := c.GetBalances(ctx, sourceProfileID) if err != nil { return nil, fmt.Errorf("failed to get balances: %w", err) } @@ -144,7 +136,7 @@ func (w *Client) GetTransfers(ctx context.Context, profileID uint64, offset int, } default: if sourceProfileID != 0 { - balances, err := w.GetBalances(ctx, sourceProfileID) + balances, err := c.GetBalances(ctx, sourceProfileID) if err != nil { return nil, fmt.Errorf("failed to get balances: %w", err) } @@ -156,7 +148,7 @@ func (w *Client) GetTransfers(ctx context.Context, profileID uint64, offset int, } if targetProfileID != 0 { - balances, err := w.GetBalances(ctx, targetProfileID) + balances, err := c.GetBalances(ctx, targetProfileID) if err != nil { return nil, fmt.Errorf("failed to get balances: %w", err) } @@ -169,55 +161,42 @@ func (w *Client) GetTransfers(ctx context.Context, profileID uint64, offset int, } } - return transfers, nil } -func (w *Client) GetTransfer(ctx context.Context, transferID string) (*Transfer, error) { +func (c *Client) GetTransfer(ctx context.Context, transferID string) (*Transfer, error) { // TODO(polo): metrics // f := connectors.ClientMetrics(ctx, "wise", "get_transfer") // now := time.Now() // defer f(ctx, now) req, err := http.NewRequestWithContext(ctx, - http.MethodGet, w.endpoint("v1/transfers/"+transferID), http.NoBody) - if err != nil { - return nil, err - } - - res, err := w.httpClient.Do(req) + http.MethodGet, c.endpoint("v1/transfers/"+transferID), http.NoBody) if err != nil { return nil, err } - body, err := io.ReadAll(res.Body) - if err != nil { - res.Body.Close() - - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if err = res.Body.Close(); err != nil { - return nil, fmt.Errorf("failed to close response body: %w", err) - } - var transfer Transfer - err = json.Unmarshal(body, &transfer) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal transfer: %w", err) - } - - return &transfer, nil + var errRes wiseErrors + statusCode, err := c.httpClient.Do(req, &transfer, &errRes) + switch err { + case nil: + return &transfer, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error(statusCode).Error() + } + return nil, fmt.Errorf("failed to get transfer: %w", err) } -func (w *Client) CreateTransfer(ctx context.Context, quote Quote, targetAccount uint64, transactionID string) (*Transfer, error) { +func (c *Client) CreateTransfer(ctx context.Context, quote Quote, targetAccount uint64, transactionID string) (*Transfer, error) { // TODO(polo): metrics // metrics.GetMetricsRegistry().ConnectorPSPCalls().Add(ctx, 1, metric.WithAttributes([]attribute.KeyValue{ // attribute.String("connector", "wise"), // attribute.String("operation", "initiate_transfer"), // }...)) - req, err := json.Marshal(map[string]interface{}{ + reqBody, err := json.Marshal(map[string]interface{}{ "targetAccount": targetAccount, "quoteUuid": quote.ID.String(), "customerTransactionId": transactionID, @@ -226,22 +205,21 @@ func (w *Client) CreateTransfer(ctx context.Context, quote Quote, targetAccount return nil, err } - res, err := w.httpClient.Post(w.endpoint("v1/transfers"), "application/json", bytes.NewBuffer(req)) + req, err := http.NewRequestWithContext(ctx, + http.MethodPost, c.endpoint("v1/transfers"), bytes.NewBuffer(reqBody)) if err != nil { return nil, err } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode) - } - - var response Transfer - err = json.NewDecoder(res.Body).Decode(&response) - if err != nil { - return nil, fmt.Errorf("failed to get response from transfer: %w", err) - } - - return &response, nil + var transfer Transfer + var errRes wiseErrors + statusCode, err := c.httpClient.Do(req, &transfer, &errRes) + switch err { + case nil: + return &transfer, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error(statusCode).Error() + } + return nil, fmt.Errorf("failed to create transfer: %w", err) } diff --git a/components/payments/internal/connectors/plugins/public/wise/client/webhooks.go b/components/payments/internal/connectors/plugins/public/wise/client/webhooks.go index 12aeb9e370..a69e6390ea 100644 --- a/components/payments/internal/connectors/plugins/public/wise/client/webhooks.go +++ b/components/payments/internal/connectors/plugins/public/wise/client/webhooks.go @@ -7,6 +7,8 @@ import ( "fmt" "net/http" "time" + + "github.com/formancehq/payments/internal/connectors/httpwrapper" ) type webhookSubscription struct { @@ -36,8 +38,8 @@ type webhookSubscriptionResponse struct { CreatedAt string `json:"created_at"` } -func (w *Client) CreateWebhook(ctx context.Context, profileID uint64, name, triggerOn, url, version string) (*webhookSubscriptionResponse, error) { - req, err := json.Marshal(webhookSubscription{ +func (c *Client) CreateWebhook(ctx context.Context, profileID uint64, name, triggerOn, url, version string) (*webhookSubscriptionResponse, error) { + reqBody, err := json.Marshal(webhookSubscription{ Name: name, TriggerOn: triggerOn, Delivery: struct { @@ -52,73 +54,65 @@ func (w *Client) CreateWebhook(ctx context.Context, profileID uint64, name, trig return nil, err } - res, err := w.httpClient.Post( - w.endpoint(fmt.Sprintf("/v3/profiles/%d/subscriptions", profileID)), - "application/json", - bytes.NewBuffer(req), + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.endpoint(fmt.Sprintf("/v3/profiles/%d/subscriptions", profileID)), + bytes.NewBuffer(reqBody), ) if err != nil { return nil, err } - defer res.Body.Close() - - if res.StatusCode != http.StatusCreated { - return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode) - } - - var response webhookSubscriptionResponse - err = json.NewDecoder(res.Body).Decode(&response) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) + req.Header.Set("Content-Type", "application/json") + + var res webhookSubscriptionResponse + var errRes wiseErrors + statusCode, err := c.httpClient.Do(req, &res, &errRes) + switch err { + case nil: + return &res, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return nil, errRes.Error(statusCode).Error() } - - return &response, nil + return nil, fmt.Errorf("failed to create subscription: %w", err) } -func (w *Client) ListWebhooksSubscription(ctx context.Context, profileID uint64) ([]webhookSubscriptionResponse, error) { +func (c *Client) ListWebhooksSubscription(ctx context.Context, profileID uint64) ([]webhookSubscriptionResponse, error) { req, err := http.NewRequestWithContext(ctx, - http.MethodGet, w.endpoint(fmt.Sprintf("/v3/profiles/%d/subscriptions", profileID)), http.NoBody) - if err != nil { - return nil, err - } - - res, err := w.httpClient.Do(req) + http.MethodGet, c.endpoint(fmt.Sprintf("/v3/profiles/%d/subscriptions", profileID)), http.NoBody) if err != nil { return nil, err } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode) + var res []webhookSubscriptionResponse + var errRes wiseErrors + statusCode, err := c.httpClient.Do(req, &res, &errRes) + switch err { + case nil: + return res, nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return res, errRes.Error(statusCode).Error() } - - var response []webhookSubscriptionResponse - err = json.NewDecoder(res.Body).Decode(&response) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - return response, nil + return res, fmt.Errorf("failed to get subscription: %w", err) } -func (w *Client) DeleteWebhooks(ctx context.Context, profileID uint64, subscriptionID string) error { +func (c *Client) DeleteWebhooks(ctx context.Context, profileID uint64, subscriptionID string) error { req, err := http.NewRequestWithContext(ctx, - http.MethodDelete, w.endpoint(fmt.Sprintf("/v3/profiles/%d/subscriptions/%s", profileID, subscriptionID)), http.NoBody) + http.MethodDelete, c.endpoint(fmt.Sprintf("/v3/profiles/%d/subscriptions/%s", profileID, subscriptionID)), http.NoBody) if err != nil { return err } - res, err := w.httpClient.Do(req) - if err != nil { - return err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusNoContent { - return fmt.Errorf("unexpected status code: %d", res.StatusCode) + var errRes wiseErrors + statusCode, err := c.httpClient.Do(req, nil, &errRes) + switch err { + case nil: + return nil + case httpwrapper.ErrStatusCodeUnexpected: + // TODO(polo): retryable errors + return errRes.Error(statusCode).Error() } - - return nil + return fmt.Errorf("failed to get subscription: %w", err) } type transferStateChangedWebhookPayload struct { @@ -139,14 +133,14 @@ type transferStateChangedWebhookPayload struct { SentAt string `json:"sent_at"` } -func (w *Client) TranslateTransferStateChangedWebhook(ctx context.Context, payload []byte) (Transfer, error) { +func (c *Client) TranslateTransferStateChangedWebhook(ctx context.Context, payload []byte) (Transfer, error) { var transferStatedChangedEvent transferStateChangedWebhookPayload err := json.Unmarshal(payload, &transferStatedChangedEvent) if err != nil { return Transfer{}, err } - transfer, err := w.GetTransfer(ctx, fmt.Sprint(transferStatedChangedEvent.Data.Resource.ID)) + transfer, err := c.GetTransfer(ctx, fmt.Sprint(transferStatedChangedEvent.Data.Resource.ID)) if err != nil { return Transfer{}, err } @@ -181,7 +175,7 @@ type balanceUpdateWebhookPayload struct { SentAt string `json:"sent_at"` } -func (w *Client) TranslateBalanceUpdateWebhook(ctx context.Context, payload []byte) (balanceUpdateWebhookPayload, error) { +func (c *Client) TranslateBalanceUpdateWebhook(ctx context.Context, payload []byte) (balanceUpdateWebhookPayload, error) { var balanceUpdateEvent balanceUpdateWebhookPayload err := json.Unmarshal(payload, &balanceUpdateEvent) if err != nil { diff --git a/components/payments/internal/connectors/plugins/public/wise/plugin.go b/components/payments/internal/connectors/plugins/public/wise/plugin.go index 70e0ac628f..27b9ec93cc 100644 --- a/components/payments/internal/connectors/plugins/public/wise/plugin.go +++ b/components/payments/internal/connectors/plugins/public/wise/plugin.go @@ -3,6 +3,7 @@ package wise import ( "context" "errors" + "fmt" "github.com/formancehq/payments/internal/connectors/plugins" "github.com/formancehq/payments/internal/connectors/plugins/public/wise/client" @@ -20,7 +21,10 @@ func (p *Plugin) Install(ctx context.Context, req models.InstallRequest) (models return models.InstallResponse{}, err } - client := client.New(config.APIKey) + client, err := client.New(config.APIKey) + if err != nil { + return models.InstallResponse{}, fmt.Errorf("failed to install wise plugin %w", err) + } p.client = client p.config = config