173 lines
4.2 KiB
Go
Raw Permalink Normal View History

2026-03-12 20:51:38 -07:00
package hostinger
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
"setec-manager/internal/hosting"
)
const (
defaultBaseURL = "https://developers.hostinger.com"
maxRetries = 3
)
// APIError represents an error response from the Hostinger API.
type APIError struct {
StatusCode int `json:"-"`
Message string `json:"error"`
CorrelationID string `json:"correlation_id,omitempty"`
}
func (e *APIError) Error() string {
if e.CorrelationID != "" {
return fmt.Sprintf("hostinger API error %d: %s (correlation_id: %s)", e.StatusCode, e.Message, e.CorrelationID)
}
return fmt.Sprintf("hostinger API error %d: %s", e.StatusCode, e.Message)
}
// Client is the Hostinger API client. It implements hosting.Provider.
type Client struct {
apiToken string
httpClient *http.Client
baseURL string
}
// Compile-time check that Client implements hosting.Provider.
var _ hosting.Provider = (*Client)(nil)
// New creates a new Hostinger API client with the given bearer token.
func New(token string) *Client {
return &Client{
apiToken: token,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: defaultBaseURL,
}
}
// Name returns the provider identifier.
func (c *Client) Name() string { return "hostinger" }
// DisplayName returns the human-readable provider name.
func (c *Client) DisplayName() string { return "Hostinger" }
// Configure applies the given configuration to the client.
func (c *Client) Configure(cfg hosting.ProviderConfig) error {
if cfg.APIKey == "" {
return fmt.Errorf("hostinger: API key is required")
}
c.apiToken = cfg.APIKey
if cfg.BaseURL != "" {
c.baseURL = cfg.BaseURL
}
return nil
}
// TestConnection verifies the API token by making a lightweight API call.
func (c *Client) TestConnection() error {
_, err := c.ListDomains()
return err
}
// doRequest executes an HTTP request against the Hostinger API.
// body may be nil for requests with no body. result may be nil if the
// response body should be discarded.
func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error {
url := c.baseURL + path
var rawBody []byte
if body != nil {
var err error
rawBody, err = json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request body: %w", err)
}
}
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
var bodyReader io.Reader
if rawBody != nil {
bodyReader = bytes.NewReader(rawBody)
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("execute request: %w", err)
continue
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return fmt.Errorf("read response body: %w", err)
}
// Handle rate limiting with retry.
if resp.StatusCode == http.StatusTooManyRequests {
if attempt < maxRetries {
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
time.Sleep(retryAfter)
lastErr = &APIError{StatusCode: 429, Message: "rate limited"}
continue
}
return &APIError{StatusCode: 429, Message: "rate limited after retries"}
}
// Handle error responses.
if resp.StatusCode >= 400 {
apiErr := &APIError{StatusCode: resp.StatusCode}
if jsonErr := json.Unmarshal(respBody, apiErr); jsonErr != nil {
apiErr.Message = string(respBody)
}
return apiErr
}
// Parse successful response.
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
}
return nil
}
return lastErr
}
// parseRetryAfter parses the Retry-After header value.
// Returns a default of 1 second if the header is missing or unparseable.
func parseRetryAfter(value string) time.Duration {
if value == "" {
return time.Second
}
seconds, err := strconv.Atoi(value)
if err != nil {
return time.Second
}
if seconds <= 0 {
return time.Second
}
if seconds > 60 {
seconds = 60
}
return time.Duration(seconds) * time.Second
}