Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(payments): v3 http wrapper #1688

Merged
merged 7 commits into from
Sep 19, 2024
7 changes: 7 additions & 0 deletions components/payments/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions components/payments/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
108 changes: 108 additions & 0 deletions components/payments/internal/connectors/httpwrapper/client.go
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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")))
})
})
})
16 changes: 16 additions & 0 deletions components/payments/internal/connectors/httpwrapper/config.go
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"

"github.com/formancehq/payments/internal/connectors/httpwrapper"
)

type Account struct {
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -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)
}
Loading