package cli import ( "bytes" "context" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "path/filepath" "strings" "time" ) // APIClient is a REST client for the Multica server API. // Used by ctrl subcommands (agent, runtime, status, etc.). // // TODO: Add Authorization header support. Agent routes (/api/agents/...) // require JWT auth via middleware.Auth, but this client currently sends // no auth token. CLI agent commands will fail with 401 until this is added. type APIClient struct { BaseURL string WorkspaceID string Token string AgentID string // When set, requests are attributed to this agent instead of the user. TaskID string // When set, sent as X-Task-ID for agent-task validation. HTTPClient *http.Client } // NewAPIClient creates a new API client for ctrl commands. func NewAPIClient(baseURL, workspaceID, token string) *APIClient { return &APIClient{ BaseURL: strings.TrimRight(baseURL, "/"), WorkspaceID: workspaceID, Token: token, HTTPClient: &http.Client{Timeout: 15 * time.Second}, } } func (c *APIClient) setHeaders(req *http.Request) { if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } if c.WorkspaceID != "" { req.Header.Set("X-Workspace-ID", c.WorkspaceID) } if c.AgentID != "" { req.Header.Set("X-Agent-ID", c.AgentID) } if c.TaskID != "" { req.Header.Set("X-Task-ID", c.TaskID) } } // GetJSON performs a GET request and decodes the JSON response. func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) if err != nil { return err } c.setHeaders(req) resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return fmt.Errorf("GET %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data))) } if out == nil { return nil } return json.NewDecoder(resp.Body).Decode(out) } // GetJSONWithHeaders performs a GET request, decodes the JSON response, and // returns the response headers. Useful when callers need header values like // X-Total-Count for pagination. func (c *APIClient) GetJSONWithHeaders(ctx context.Context, path string, out any) (http.Header, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) if err != nil { return nil, err } c.setHeaders(req) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return nil, fmt.Errorf("GET %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data))) } if out != nil { if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return resp.Header, err } } return resp.Header, nil } // DeleteJSON performs a DELETE request. func (c *APIClient) DeleteJSON(ctx context.Context, path string) error { req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, nil) if err != nil { return err } c.setHeaders(req) resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return fmt.Errorf("DELETE %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data))) } return nil } // PostJSON performs a POST request with a JSON body. func (c *APIClient) PostJSON(ctx context.Context, path string, body any, out any) error { data, err := json.Marshal(body) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(data)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") c.setHeaders(req) resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return fmt.Errorf("POST %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(respData))) } if out == nil { return nil } return json.NewDecoder(resp.Body).Decode(out) } // PutJSON performs a PUT request with a JSON body. func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) error { data, err := json.Marshal(body) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.BaseURL+path, bytes.NewReader(data)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") c.setHeaders(req) resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return fmt.Errorf("PUT %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(respData))) } if out == nil { return nil } return json.NewDecoder(resp.Body).Decode(out) } // UploadFile uploads a file via multipart form to /api/upload-file. // It returns the attachment ID from the server response. func (c *APIClient) UploadFile(ctx context.Context, fileData []byte, filename string, issueID string) (string, error) { var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", filepath.Base(filename)) if err != nil { return "", fmt.Errorf("create form file: %w", err) } if _, err := part.Write(fileData); err != nil { return "", fmt.Errorf("write file data: %w", err) } if issueID != "" { if err := writer.WriteField("issue_id", issueID); err != nil { return "", fmt.Errorf("write issue_id field: %w", err) } } if err := writer.Close(); err != nil { return "", fmt.Errorf("close multipart writer: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/upload-file", &body) if err != nil { return "", err } req.Header.Set("Content-Type", writer.FormDataContentType()) c.setHeaders(req) resp, err := c.HTTPClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode >= 400 { respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return "", fmt.Errorf("upload file returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))) } var result map[string]any if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("decode upload response: %w", err) } id, _ := result["id"].(string) if id == "" { return "", fmt.Errorf("upload response missing attachment id") } return id, nil } // DownloadFile downloads a file from the given URL and returns the response body. // This is used for downloading attachments via their signed download_url. // Downloads are limited to 100 MB to match the upload size limit. func (c *APIClient) DownloadFile(ctx context.Context, downloadURL string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) if err != nil { return nil, err } resp, err := c.HTTPClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return nil, fmt.Errorf("download returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } const maxDownloadSize = 100 << 20 // 100 MB return io.ReadAll(io.LimitReader(resp.Body, maxDownloadSize)) } // HealthCheck hits the /health endpoint and returns the response body. func (c *APIClient) HealthCheck(ctx context.Context) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil) if err != nil { return "", err } resp, err := c.HTTPClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) if resp.StatusCode >= 400 { return "", fmt.Errorf("health check returned %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) } return strings.TrimSpace(string(data)), nil }