package zarinhub import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "card-to-iban/internal/service" ) const maxResponseBodySize = 1 << 20 type Client struct { httpClient *http.Client endpointURL string tokenProvider TokenProvider } 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, ) *Client { return &Client{ httpClient: httpClient, endpointURL: endpointURL, tokenProvider: tokenProvider, } } func (c *Client) Convert( 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 }