No One Can Stop Me Now
This commit is contained in:
172
services/setec-manager/internal/hosting/hostinger/client.go
Normal file
172
services/setec-manager/internal/hosting/hostinger/client.go
Normal file
@@ -0,0 +1,172 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user