feat(bugseti): add HubService HTTP request helpers
Add doRequest() and doJSON() methods for hub API communication. doRequest builds full URLs, sets bearer auth and JSON headers, tracks connected state. doJSON handles status codes: 401 unauthorised, 409 ConflictError, 404 NotFoundError, and generic errors for other 4xx/5xx responses. Co-Authored-By: Virgil <virgil@lethean.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
336766d13d
commit
50829dc3ba
2 changed files with 142 additions and 0 deletions
|
|
@ -2,9 +2,12 @@
|
||||||
package bugseti
|
package bugseti
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -122,6 +125,83 @@ func generateClientID() string {
|
||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doRequest builds and executes an HTTP request against the hub API.
|
||||||
|
// It returns the raw *http.Response and any transport-level error.
|
||||||
|
func (h *HubService) doRequest(method, path string, body interface{}) (*http.Response, error) {
|
||||||
|
hubURL := h.config.GetHubURL()
|
||||||
|
if hubURL == "" {
|
||||||
|
return nil, fmt.Errorf("hub URL not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
fullURL := hubURL + "/api/bugseti" + path
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, fullURL, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
token := h.config.GetHubToken()
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.connected = false
|
||||||
|
h.mu.Unlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
h.connected = true
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// doJSON executes an HTTP request and decodes the JSON response into dest.
|
||||||
|
// It handles common error status codes with typed errors.
|
||||||
|
func (h *HubService) doJSON(method, path string, body, dest interface{}) error {
|
||||||
|
resp, err := h.doRequest(method, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode == http.StatusUnauthorized:
|
||||||
|
return fmt.Errorf("unauthorised")
|
||||||
|
case resp.StatusCode == http.StatusConflict:
|
||||||
|
return &ConflictError{StatusCode: resp.StatusCode}
|
||||||
|
case resp.StatusCode == http.StatusNotFound:
|
||||||
|
return &NotFoundError{StatusCode: resp.StatusCode}
|
||||||
|
case resp.StatusCode >= 400:
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("hub error %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
if dest != nil {
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
|
||||||
|
return fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// loadPendingOps is a no-op placeholder (disk persistence comes in Task 7).
|
// loadPendingOps is a no-op placeholder (disk persistence comes in Task 7).
|
||||||
func (h *HubService) loadPendingOps() {}
|
func (h *HubService) loadPendingOps() {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package bugseti
|
package bugseti
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -46,3 +49,62 @@ func TestNewHubService_Good_ReusesClientID(t *testing.T) {
|
||||||
h := NewHubService(cfg)
|
h := NewHubService(cfg)
|
||||||
assert.Equal(t, "existing-client-id", h.GetClientID())
|
assert.Equal(t, "existing-client-id", h.GetClientID())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- doRequest ----
|
||||||
|
|
||||||
|
func TestDoRequest_Good(t *testing.T) {
|
||||||
|
var gotAuth string
|
||||||
|
var gotContentType string
|
||||||
|
var gotAccept string
|
||||||
|
var gotBody map[string]string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
gotContentType = r.Header.Get("Content-Type")
|
||||||
|
gotAccept = r.Header.Get("Accept")
|
||||||
|
|
||||||
|
if r.Body != nil {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "test-token-123"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
body := map[string]string{"key": "value"}
|
||||||
|
resp, err := h.doRequest("POST", "/test", body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Equal(t, "Bearer test-token-123", gotAuth)
|
||||||
|
assert.Equal(t, "application/json", gotContentType)
|
||||||
|
assert.Equal(t, "application/json", gotAccept)
|
||||||
|
assert.Equal(t, "value", gotBody["key"])
|
||||||
|
assert.True(t, h.IsConnected())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_Bad_NoHubURL(t *testing.T) {
|
||||||
|
h := testHubService(t, "")
|
||||||
|
|
||||||
|
resp, err := h.doRequest("GET", "/test", nil)
|
||||||
|
assert.Nil(t, resp)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "hub URL not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_Bad_NetworkError(t *testing.T) {
|
||||||
|
// Point to a port where nothing is listening.
|
||||||
|
h := testHubService(t, "http://127.0.0.1:1")
|
||||||
|
|
||||||
|
resp, err := h.doRequest("GET", "/test", nil)
|
||||||
|
assert.Nil(t, resp)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.False(t, h.IsConnected())
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue