Files
card-to-iban/backend/internal/zarinhub/client.go

303 lines
6.0 KiB
Go

package zarinhub
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"time"
"card-to-iban/internal/service"
)
const (
maxResponseBodySize = 1 << 20
retryBaseDelay = 200 * time.Millisecond
retryMaxDelay = 2 * time.Second
)
type Client struct {
httpClient *http.Client
endpointURL string
tokenProvider TokenProvider
maxRetries int
retryWait func(ctx context.Context, delay time.Duration) bool
logger *slog.Logger
}
var _ service.CardToIBANClient = (*Client)(nil)
type TokenProvider interface {
AccessToken(ctx context.Context) (string, error)
Invalidate(token string)
}
func NewClient(
httpClient *http.Client,
endpointURL string,
tokenProvider TokenProvider,
maxRetries int,
loggers ...*slog.Logger,
) *Client {
if maxRetries < 0 {
maxRetries = 0
}
var logger *slog.Logger
if len(loggers) > 0 && loggers[0] != nil {
logger = loggers[0]
}
if httpClient == nil {
httpClient = http.DefaultClient
}
client := &Client{
httpClient: httpClient,
endpointURL: endpointURL,
tokenProvider: tokenProvider,
maxRetries: maxRetries,
logger: logger,
}
if _, ok := httpClient.Transport.(*RetryTransport); !ok && maxRetries > 0 {
rt := NewRetryTransport(
httpClient.Transport,
httpClient.Timeout,
maxRetries,
logger,
)
rt.retryWait = func(ctx context.Context, delay time.Duration) bool {
if client.retryWait != nil {
return client.retryWait(ctx, delay)
}
return waitForRetry(ctx, delay)
}
client.httpClient = &http.Client{
Transport: rt,
}
} else if rt, ok := httpClient.Transport.(*RetryTransport); ok {
origWait := rt.retryWait
rt.retryWait = func(ctx context.Context, delay time.Duration) bool {
if client.retryWait != nil {
return client.retryWait(ctx, delay)
}
if origWait != nil {
return origWait(ctx, delay)
}
return waitForRetry(ctx, delay)
}
}
return client
}
func (c *Client) log() *slog.Logger {
if c.logger != nil {
return c.logger
}
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func isRetryable(err error) bool {
return errors.Is(err, ErrTimeout) ||
errors.Is(err, ErrUnavailable)
}
func retryDelay(attempt int) time.Duration {
delay := retryBaseDelay
for i := 0; i < attempt; i++ {
delay *= 2
if delay >= retryMaxDelay {
return retryMaxDelay
}
}
return delay
}
func waitForRetry(
ctx context.Context,
delay time.Duration,
) bool {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
func (c *Client) Convert(
ctx context.Context,
cardNumber string,
) (service.ConversionResult, error) {
return c.convertOnce(ctx, cardNumber)
}
func (c *Client) convertOnce(
ctx context.Context,
cardNumber string,
) (service.ConversionResult, error) {
accessToken, err := c.tokenProvider.AccessToken(ctx)
if err != nil {
return service.ConversionResult{},
fmt.Errorf("get zarinhub access token: %w", err)
}
result, err := c.convert(ctx, cardNumber, accessToken)
if !errors.Is(err, ErrAuthentication) ||
result.UpstreamHTTPStatus != http.StatusUnauthorized {
return result, err
}
c.tokenProvider.Invalidate(accessToken)
accessToken, err = c.tokenProvider.AccessToken(ctx)
if err != nil {
return result, fmt.Errorf("refresh zarinhub access token: %w", err)
}
return c.convert(ctx, cardNumber, accessToken)
}
func (c *Client) convert(
ctx context.Context,
cardNumber string,
accessToken string,
) (service.ConversionResult, error) {
payload, err := json.Marshal(convertRequest{
Card: cardNumber,
})
if err != nil {
return service.ConversionResult{},
fmt.Errorf("marshal zarinhub request: %w", err)
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.endpointURL,
bytes.NewReader(payload),
)
if err != nil {
return service.ConversionResult{},
fmt.Errorf("create zarinhub request: %w", err)
}
req.Header.Set(
"Authorization",
"Bearer "+accessToken,
)
req.Header.Set(
"Content-Type",
"application/json",
)
resp, err := c.httpClient.Do(req)
if err != nil {
switch {
case errors.Is(err, context.DeadlineExceeded):
return service.ConversionResult{},
fmt.Errorf("%w: %v", ErrTimeout, err)
case errors.Is(err, context.Canceled):
return service.ConversionResult{},
fmt.Errorf("zarinhub request canceled: %w", err)
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return service.ConversionResult{},
fmt.Errorf("%w: %v", ErrTimeout, err)
}
return service.ConversionResult{},
fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer resp.Body.Close()
result := service.ConversionResult{
UpstreamHTTPStatus: resp.StatusCode,
}
body, err := io.ReadAll(
io.LimitReader(resp.Body, maxResponseBodySize+1),
)
if err != nil {
return result,
fmt.Errorf(
"%w: failed to read response body: %v",
ErrInvalidResponse,
err,
)
}
if len(body) > maxResponseBodySize {
return result,
fmt.Errorf(
"%w: response body is too large",
ErrInvalidResponse,
)
}
result.RawResponse = body
switch {
case resp.StatusCode == http.StatusUnauthorized ||
resp.StatusCode == http.StatusForbidden:
return result, ErrAuthentication
case resp.StatusCode == http.StatusRequestTimeout:
return result, ErrTimeout
case resp.StatusCode == http.StatusTooManyRequests:
return result, ErrUnavailable
case resp.StatusCode >= http.StatusInternalServerError:
return result, ErrUnavailable
case resp.StatusCode < http.StatusOK ||
resp.StatusCode >= http.StatusMultipleChoices:
return result, ErrRejected
}
var decoded convertResponse
if err := json.Unmarshal(body, &decoded); err != nil {
return result,
fmt.Errorf("%w: %v", ErrInvalidResponse, err)
}
if !decoded.Meta.IsSuccess {
return result, ErrRejected
}
if decoded.Data.IBAN == "" {
return result,
fmt.Errorf(
"%w: successful response did not contain iban",
ErrInvalidResponse,
)
}
result.IBAN = decoded.Data.IBAN
return result, nil
}