173 lines
4.2 KiB
Go
173 lines
4.2 KiB
Go
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
|
|
}
|