package airwallex import ( "bytes" "context" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "strings" "sync" "time" "golang.org/x/net/proxy" ) // Client is the core HTTP client for Airwallex API. type Client struct { ClientID string APIKey string LoginAs string // optional x-login-as header BaseURL string // e.g. "https://api.airwallex.com" ProxyURL string // socks5:// or http:// proxy Token string TokenExpiry time.Time HTTPClient *http.Client mu sync.Mutex } // NewClient creates a new Airwallex API client with optional proxy support. func NewClient(clientID, apiKey, baseURL, loginAs, proxyURL string) *Client { baseURL = strings.TrimRight(baseURL, "/") httpClient := &http.Client{Timeout: 30 * time.Second} if proxyURL != "" { parsed, err := url.Parse(proxyURL) if err == nil { switch parsed.Scheme { case "socks5", "socks5h": dialer, err := proxy.FromURL(parsed, proxy.Direct) if err == nil { contextDialer, ok := dialer.(proxy.ContextDialer) if ok { httpClient.Transport = &http.Transport{ DialContext: contextDialer.DialContext, } } else { httpClient.Transport = &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialer.Dial(network, addr) }, } } } case "http", "https": httpClient.Transport = &http.Transport{ Proxy: http.ProxyURL(parsed), } } } } return &Client{ ClientID: clientID, APIKey: apiKey, BaseURL: baseURL, LoginAs: loginAs, ProxyURL: proxyURL, HTTPClient: httpClient, } } // Authenticate obtains an access token from the Airwallex API. func (c *Client) Authenticate() error { c.mu.Lock() defer c.mu.Unlock() authURL := c.BaseURL + "/api/v1/authentication/login" req, err := http.NewRequest(http.MethodPost, authURL, bytes.NewBufferString("{}")) if err != nil { return fmt.Errorf("airwallex: failed to create auth request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("x-client-id", c.ClientID) req.Header.Set("x-api-key", c.APIKey) if c.LoginAs != "" { req.Header.Set("x-login-as", c.LoginAs) } resp, err := c.HTTPClient.Do(req) if err != nil { return fmt.Errorf("airwallex: auth request failed: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("airwallex: failed to read auth response: %w", err) } if resp.StatusCode != http.StatusCreated { return fmt.Errorf("airwallex: auth failed with status %d: %s", resp.StatusCode, string(body)) } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { return fmt.Errorf("airwallex: failed to parse auth response: %w", err) } token, ok := result["token"].(string) if !ok || token == "" { return fmt.Errorf("airwallex: no token in auth response") } c.Token = token // Parse expires_at or default to 28 minutes from now. if expiresAt, ok := result["expires_at"].(string); ok && expiresAt != "" { if t, err := time.Parse(time.RFC3339, expiresAt); err == nil { c.TokenExpiry = t } else { c.TokenExpiry = time.Now().Add(28 * time.Minute) } } else { c.TokenExpiry = time.Now().Add(28 * time.Minute) } return nil } // IsTokenValid checks if the current token is non-empty and not expired. func (c *Client) IsTokenValid() bool { return c.Token != "" && time.Now().Before(c.TokenExpiry) } // Request performs an authenticated API request and returns the parsed JSON response. func (c *Client) Request(method, path string, params url.Values, body interface{}) (map[string]interface{}, error) { data, statusCode, err := c.doRequest(method, path, params, body) if err != nil { return nil, err } // On 401, re-authenticate and retry once. if statusCode == http.StatusUnauthorized { if err := c.Authenticate(); err != nil { return nil, err } data, statusCode, err = c.doRequest(method, path, params, body) if err != nil { return nil, err } } if statusCode >= 400 { return nil, parseAPIError(data, statusCode) } var result map[string]interface{} if err := json.Unmarshal(data, &result); err != nil { return nil, fmt.Errorf("airwallex: failed to parse response: %w", err) } return result, nil } // RequestRaw performs an authenticated API request and returns the raw response bytes and status code. func (c *Client) RequestRaw(method, path string, params url.Values, body interface{}) ([]byte, int, error) { data, statusCode, err := c.doRequest(method, path, params, body) if err != nil { return nil, 0, err } // On 401, re-authenticate and retry once. if statusCode == http.StatusUnauthorized { if err := c.Authenticate(); err != nil { return nil, 0, err } data, statusCode, err = c.doRequest(method, path, params, body) if err != nil { return nil, 0, err } } return data, statusCode, nil } // doRequest performs the actual HTTP request with current auth token. func (c *Client) doRequest(method, path string, params url.Values, body interface{}) ([]byte, int, error) { if !c.IsTokenValid() { if err := c.Authenticate(); err != nil { return nil, 0, err } } // Build full URL, avoiding double slashes. path = strings.TrimLeft(path, "/") fullURL := c.BaseURL + "/" + path var reqBody io.Reader if body != nil && (method == http.MethodPost || method == http.MethodPut) { jsonBytes, err := json.Marshal(body) if err != nil { return nil, 0, fmt.Errorf("airwallex: failed to marshal request body: %w", err) } reqBody = bytes.NewBuffer(jsonBytes) } req, err := http.NewRequest(method, fullURL, reqBody) if err != nil { return nil, 0, fmt.Errorf("airwallex: failed to create request: %w", err) } // Append query params for GET requests. if method == http.MethodGet && params != nil { req.URL.RawQuery = params.Encode() } req.Header.Set("Authorization", "Bearer "+c.Token) req.Header.Set("Content-Type", "application/json") if c.LoginAs != "" { req.Header.Set("x-login-as", c.LoginAs) } resp, err := c.HTTPClient.Do(req) if err != nil { return nil, 0, fmt.Errorf("airwallex: request failed: %w", err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return nil, 0, fmt.Errorf("airwallex: failed to read response: %w", err) } return data, resp.StatusCode, nil } // parseAPIError attempts to parse an error response into an APIError. func parseAPIError(data []byte, statusCode int) *APIError { var apiErr APIError if err := json.Unmarshal(data, &apiErr); err != nil || apiErr.Message == "" { apiErr = APIError{ Code: "UNKNOWN", Message: string(data), } } apiErr.StatusCode = statusCode return &apiErr }