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 }