Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"net/http"
"net/url"
"os"
"strings"
"sync"
"syscall"
"time"
)
Expand All @@ -21,6 +23,8 @@ type Client struct {
baseURL string
apiKey string
httpClient *http.Client
prefix string // resolved API prefix: "/v1" or "/api"
prefixOnce sync.Once // ensures prefix detection runs once
}

// New creates an API client.
Expand All @@ -32,6 +36,16 @@ func New(baseURL, apiKey string) *Client {
}
}

// newWithPrefix creates a client with a pre-set prefix (for testing).
func newWithPrefix(baseURL, apiKey, prefix string) *Client {
return &Client{
baseURL: baseURL,
apiKey: apiKey,
httpClient: buildHTTPClient(),
prefix: prefix,
}
}

func buildHTTPClient() *http.Client {
client := &http.Client{Timeout: 30 * time.Second}
f := os.Getenv("SSL_CERT_FILE")
Expand Down Expand Up @@ -93,9 +107,44 @@ func (c *Client) networkError(err error) error {
return fmt.Errorf("could not reach gateway at %s: %w", host, err)
}

// resolvePrefix probes the server once to determine whether it supports
// /v1 (new) or /api (legacy). All subsequent requests use the resolved prefix.
func (c *Client) resolvePrefix(ctx context.Context) {
if c.prefix != "" {
return
}
c.prefixOnce.Do(func() {
c.prefix = "/v1"
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/v1/health", nil)
if err != nil {
return
}
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.httpClient.Do(req)
if resp != nil {
resp.Body.Close()
}
if err != nil || resp.StatusCode == http.StatusNotFound {
c.prefix = "/api"
}
})
}

// applyPrefix replaces the /v1 prefix in path with the resolved prefix.
func (c *Client) applyPrefix(path string) string {
if c.prefix == "/api" && strings.HasPrefix(path, "/v1/") {
return "/api" + path[3:]
}
return path
}

// do executes an HTTP request and decodes the JSON response.
// For 204 responses, result should be nil.
func (c *Client) do(ctx context.Context, method, path string, body any, result any) error {
c.resolvePrefix(ctx)
path = c.applyPrefix(path)
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
Expand Down
24 changes: 12 additions & 12 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func TestDoSetsAuthHeader(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "oc_testkey123")
client := newWithPrefix(srv.URL, "oc_testkey123", "/v1")
var result map[string]any
_ = client.do(context.Background(), http.MethodGet, "/test", nil, &result)

Expand All @@ -36,7 +36,7 @@ func TestDoOmitsAuthHeaderWhenNoKey(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "")
client := newWithPrefix(srv.URL, "", "/v1")
var result map[string]any
_ = client.do(context.Background(), http.MethodGet, "/test", nil, &result)

Expand All @@ -56,7 +56,7 @@ func TestDoSendsJSONBody(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "")
client := newWithPrefix(srv.URL, "", "/v1")
body := map[string]string{"name": "test"}
var result map[string]any
_ = client.do(context.Background(), http.MethodPost, "/test", body, &result)
Expand All @@ -78,7 +78,7 @@ func TestDoNoContentTypeWithoutBody(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "")
client := newWithPrefix(srv.URL, "", "/v1")
var result map[string]any
_ = client.do(context.Background(), http.MethodGet, "/test", nil, &result)

Expand All @@ -93,7 +93,7 @@ func TestDoHandles204(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "")
client := newWithPrefix(srv.URL, "", "/v1")
err := client.do(context.Background(), http.MethodDelete, "/test", nil, nil)
if err != nil {
t.Errorf("204 should not return error, got %v", err)
Expand All @@ -107,7 +107,7 @@ func TestDoDecodesSuccessResponse(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "")
client := newWithPrefix(srv.URL, "", "/v1")
var agent Agent
err := client.do(context.Background(), http.MethodGet, "/test", nil, &agent)
if err != nil {
Expand All @@ -125,7 +125,7 @@ func TestDoReturnsAPIErrorWithMessage(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "")
client := newWithPrefix(srv.URL, "", "/v1")
err := client.do(context.Background(), http.MethodPost, "/test", nil, nil)

var apiErr *APIError
Expand All @@ -147,7 +147,7 @@ func TestDoReturnsAPIErrorFallbackMessage(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "")
client := newWithPrefix(srv.URL, "", "/v1")
err := client.do(context.Background(), http.MethodGet, "/test", nil, nil)

var apiErr *APIError
Expand All @@ -169,7 +169,7 @@ func TestDoReturnsAPIError401(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "")
client := newWithPrefix(srv.URL, "", "/v1")
err := client.do(context.Background(), http.MethodGet, "/test", nil, nil)

var apiErr *APIError
Expand All @@ -188,7 +188,7 @@ func TestDoReturnsAPIError404(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "")
client := newWithPrefix(srv.URL, "", "/v1")
err := client.do(context.Background(), http.MethodGet, "/test", nil, nil)

var apiErr *APIError
Expand Down Expand Up @@ -219,7 +219,7 @@ func TestDoSendsCorrectMethod(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "")
client := newWithPrefix(srv.URL, "", "/v1")
_ = client.do(context.Background(), method, "/test", nil, nil)

if gotMethod != method {
Expand All @@ -238,7 +238,7 @@ func TestDoSendsCorrectPath(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "")
client := newWithPrefix(srv.URL, "", "/v1")
_ = client.do(context.Background(), http.MethodGet, "/v1/agents", nil, nil)

if gotPath != "/v1/agents" {
Expand Down
12 changes: 6 additions & 6 deletions internal/api/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func TestListProjects(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "oc_test")
client := newWithPrefix(srv.URL, "oc_test", "/v1")
projects, err := client.ListProjects(context.Background())
if err != nil {
t.Fatal(err)
Expand All @@ -46,7 +46,7 @@ func TestGetProject(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "oc_test")
client := newWithPrefix(srv.URL, "oc_test", "/v1")
project, err := client.GetProject(context.Background(), "p1")
if err != nil {
t.Fatal(err)
Expand All @@ -71,7 +71,7 @@ func TestCreateProject(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "oc_test")
client := newWithPrefix(srv.URL, "oc_test", "/v1")
project, err := client.CreateProject(context.Background(), CreateProjectInput{Name: "Beta"})
if err != nil {
t.Fatal(err)
Expand All @@ -97,7 +97,7 @@ func TestUpdateProject(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "oc_test")
client := newWithPrefix(srv.URL, "oc_test", "/v1")
name := "Renamed"
project, err := client.UpdateProject(context.Background(), "p1", UpdateProjectInput{Name: &name})
if err != nil {
Expand All @@ -120,7 +120,7 @@ func TestDeleteProject(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "oc_test")
client := newWithPrefix(srv.URL, "oc_test", "/v1")
err := client.DeleteProject(context.Background(), "p1")
if err != nil {
t.Errorf("expected no error, got %v", err)
Expand All @@ -134,7 +134,7 @@ func TestListProjectsError(t *testing.T) {
}))
defer srv.Close()

client := New(srv.URL, "oc_test")
client := newWithPrefix(srv.URL, "oc_test", "/v1")
_, err := client.ListProjects(context.Background())
if err == nil {
t.Error("expected error")
Expand Down
4 changes: 3 additions & 1 deletion internal/api/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (

// GetGatewaySkill fetches the gateway skill markdown from the API.
func (c *Client) GetGatewaySkill(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/skill/gateway", nil)
c.resolvePrefix(ctx)
path := c.applyPrefix("/v1/skill/gateway")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
Expand Down
Loading