diff --git a/internal/api/client.go b/internal/api/client.go index a331165..56b62a6 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -12,6 +12,8 @@ import ( "net/http" "net/url" "os" + "strings" + "sync" "syscall" "time" ) @@ -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. @@ -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") @@ -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) diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 681f0de..128347a 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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 { @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 { @@ -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" { diff --git a/internal/api/projects_test.go b/internal/api/projects_test.go index e09f643..a6ae9b4 100644 --- a/internal/api/projects_test.go +++ b/internal/api/projects_test.go @@ -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) @@ -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) @@ -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) @@ -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 { @@ -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) @@ -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") diff --git a/internal/api/skill.go b/internal/api/skill.go index f73593b..884e7b0 100644 --- a/internal/api/skill.go +++ b/internal/api/skill.go @@ -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) }