summaryrefslogtreecommitdiff
path: root/backend-go/internal/esi/client.go
diff options
context:
space:
mode:
authorkboz <kutayboz@gmail.com>2026-09-27 22:36:34 +0200
committerkboz <kutayboz@gmail.com>2026-09-27 22:36:34 +0200
commit1b59961ce2385252e3346ea6107b15a2c1feafe6 (patch)
treefbe9e0cb968c47cff353e937e4dc835a64058c60 /backend-go/internal/esi/client.go
parent29c4b57db9a34e0f815d93cac6a2cae543778a93 (diff)
downloadhaul-1b59961ce2385252e3346ea6107b15a2c1feafe6.tar.gz
feat(backend): port route-planning backend to Go
Add a Go implementation mirroring the Python pipeline (loader, graph, pathfinder, arbitrage, market, route, manager) under internal/core, with ESI client/API integration and setup endpoints. Reuses backend/src as the working directory so existing data/cache paths keep working.
Diffstat (limited to 'backend-go/internal/esi/client.go')
-rw-r--r--backend-go/internal/esi/client.go203
1 files changed, 203 insertions, 0 deletions
diff --git a/backend-go/internal/esi/client.go b/backend-go/internal/esi/client.go
new file mode 100644
index 0000000..6a3b5fd
--- /dev/null
+++ b/backend-go/internal/esi/client.go
@@ -0,0 +1,203 @@
+package esi
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ EsiBaseURL = "https://esi.evetech.net/latest"
+ UserAgent = "Haul (Hyperspace Asset Unloading and Loading) - Go Port"
+ ErrorLimitThresh = 50
+)
+
+type AuthData struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresAt float64 `json:"expires_at"`
+}
+
+type EsiClient struct {
+ HttpClient *http.Client
+ AuthFilePath string
+ TokenMutex sync.Mutex
+ ClientID string
+ SecretKey string
+}
+
+func NewEsiClient(authFilePath string) *EsiClient {
+ clientID := os.Getenv("CLIENT_ID")
+ secretKey := os.Getenv("SECRET_KEY")
+
+ // Default to generic timeout
+ client := &http.Client{
+ Timeout: 10 * time.Second,
+ }
+
+ return &EsiClient{
+ HttpClient: client,
+ AuthFilePath: authFilePath,
+ ClientID: clientID,
+ SecretKey: secretKey,
+ }
+}
+
+// GetAccessToken checks if the token is valid, refreshing if needed or forced.
+func (c *EsiClient) GetAccessToken(forceRefresh bool) (string, error) {
+ c.TokenMutex.Lock()
+ defer c.TokenMutex.Unlock()
+
+ data, err := os.ReadFile(c.AuthFilePath)
+ if err != nil {
+ return "", fmt.Errorf("failed to read auth file: %w", err)
+ }
+
+ var auth AuthData
+ if err := json.Unmarshal(data, &auth); err != nil {
+ return "", fmt.Errorf("failed to parse auth file: %w", err)
+ }
+
+ now := float64(time.Now().Unix())
+ // Refresh if expired or soon to expire (within 20 mins)
+ if forceRefresh || auth.ExpiresAt < now+1200 {
+ return c.refreshAccessToken(auth.RefreshToken)
+ }
+
+ return auth.AccessToken, nil
+}
+
+func (c *EsiClient) refreshAccessToken(refreshToken string) (string, error) {
+ if c.ClientID == "" || c.SecretKey == "" {
+ return "", errors.New("CLIENT_ID and SECRET_KEY environment variables not set")
+ }
+
+ tokenURL := "https://login.eveonline.com/v2/oauth/token"
+ data := url.Values{}
+ data.Set("grant_type", "refresh_token")
+ data.Set("refresh_token", refreshToken)
+
+ req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
+ if err != nil {
+ return "", err
+ }
+
+ authStr := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", c.ClientID, c.SecretKey)))
+ req.Header.Add("Authorization", "Basic "+authStr)
+ req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Add("Host", "login.eveonline.com")
+
+ resp, err := c.HttpClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("failed to refresh token, status %d: %s", resp.StatusCode, string(bodyBytes))
+ }
+
+ var respData struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int64 `json:"expires_in"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil {
+ return "", err
+ }
+
+ // Update auth file
+ newAuth := AuthData{
+ AccessToken: respData.AccessToken,
+ RefreshToken: respData.RefreshToken,
+ ExpiresAt: float64(time.Now().Unix() + respData.ExpiresIn),
+ }
+
+ // Ensure cache dir exists
+ os.MkdirAll(filepath.Dir(c.AuthFilePath), 0755)
+
+ outBytes, err := json.MarshalIndent(newAuth, "", " ")
+ if err != nil {
+ return "", err
+ }
+ if err := os.WriteFile(c.AuthFilePath, outBytes, 0644); err != nil {
+ return "", err
+ }
+
+ return newAuth.AccessToken, nil
+}
+
+func (c *EsiClient) doRequest(method, endpoint string, body []byte, allowRetry bool) (*http.Response, error) {
+ url := fmt.Sprintf("%s%s", EsiBaseURL, endpoint)
+
+ for attempt := 1; attempt <= 3; attempt++ {
+ token, err := c.GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ var reqBody io.Reader
+ if body != nil {
+ reqBody = bytes.NewReader(body)
+ }
+
+ req, err := http.NewRequest(method, url, reqBody)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Cache-Control", "no-cache")
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("User-Agent", UserAgent)
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ resp, err := c.HttpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ // Check ESI Error Limit
+ if remainStr := resp.Header.Get("x-esi-error-limit-remain"); remainStr != "" {
+ if remain, err := strconv.Atoi(remainStr); err == nil && remain < ErrorLimitThresh {
+ resp.Body.Close()
+ return nil, fmt.Errorf("ESI error limit threshold reached: %d remaining", remain)
+ }
+ }
+
+ if resp.StatusCode == 401 && allowRetry && attempt == 1 {
+ resp.Body.Close()
+ _, _ = c.GetAccessToken(true) // Force refresh
+ continue
+ }
+
+ if resp.StatusCode == 429 && allowRetry {
+ resp.Body.Close()
+ time.Sleep(time.Duration(2*attempt) * time.Second)
+ continue
+ }
+
+ if resp.StatusCode == 403 {
+ resp.Body.Close()
+ return nil, errors.New("permission denied (403). Check ESI scopes")
+ }
+
+ return resp, nil
+ }
+
+ return nil, errors.New("max retries exceeded")
+} \ No newline at end of file