package api import ( "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "os" "strconv" "strings" "github.com/kb-search/kb/internal/config" ) // FileUpload describes a file to include in a multipart request. type FileUpload struct { FieldName string FileName string Reader io.Reader } // Package-level version info, set once by cmd.init via SetVersionInfo. var ( clientVersion string minEngineVersion string ) // SetVersionInfo configures the client and minimum engine version for compatibility checking. // Called once from cmd package initialization. func SetVersionInfo(cv, minEV string) { clientVersion = cv minEngineVersion = minEV } // Client is an HTTP client for the kb-search engine API. type Client struct { baseURL string apiKey string httpClient *http.Client versionChecked bool } // NewClient creates a Client from the current configuration. func NewClient() *Client { cfg := config.Get() return &Client{ baseURL: cfg.EngineURL, apiKey: cfg.APIKey, httpClient: &http.Client{}, } } func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, error) { url := c.baseURL + path req, err := http.NewRequest(method, url, body) if err != nil { return nil, err } if c.apiKey != "" { req.Header.Set("Authorization", "Bearer "+c.apiKey) } return req, nil } func (c *Client) do(req *http.Request) (*http.Response, error) { c.checkEngineVersion() resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("Cannot reach engine at %s: %v", c.baseURL, err) } return resp, nil } func (c *Client) checkEngineVersion() { if c.versionChecked { return } c.versionChecked = true minVer := minEngineVersion if minVer == "" || minVer == "dev" { return } statusReq, err := c.newRequest(http.MethodGet, "/api/v1/status", nil) if err != nil { return } resp, err := c.httpClient.Do(statusReq) if err != nil { return // unreachable — let the actual request surface the error } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return // auth error or other issue — let the actual request surface it } var status struct { Version string `json:"version"` } if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { return } if !semverAtLeast(status.Version, minVer) { fmt.Fprintf(os.Stderr, "Error: kb client v%s requires engine v%s+ (connected engine is v%s)\nUpdate your engine image to engine-v%s or later.\n", clientVersion, minVer, status.Version, minVer) os.Exit(1) } } // semverAtLeast returns true if version >= minimum, comparing major.minor.patch. func semverAtLeast(version, minimum string) bool { parse := func(s string) (int, int, int) { s = strings.TrimPrefix(s, "v") parts := strings.SplitN(s, ".", 3) var major, minor, patch int if len(parts) >= 1 { major, _ = strconv.Atoi(parts[0]) } if len(parts) >= 2 { minor, _ = strconv.Atoi(parts[1]) } if len(parts) >= 3 { patch, _ = strconv.Atoi(parts[2]) } return major, minor, patch } vMaj, vMin, vPat := parse(version) mMaj, mMin, mPat := parse(minimum) if vMaj != mMaj { return vMaj > mMaj } if vMin != mMin { return vMin > mMin } return vPat >= mPat } // Get performs a GET request to the given path. func (c *Client) Get(path string) (*http.Response, error) { req, err := c.newRequest(http.MethodGet, path, nil) if err != nil { return nil, err } return c.do(req) } // Post performs a POST request with a JSON body. func (c *Client) Post(path string, body interface{}) (*http.Response, error) { data, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } req, err := c.newRequest(http.MethodPost, path, bytes.NewReader(data)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") return c.do(req) } // PostMultipart performs a POST request with multipart/form-data encoding. func (c *Client) PostMultipart(path string, fields map[string]string, file *FileUpload) (*http.Response, error) { var buf bytes.Buffer w := multipart.NewWriter(&buf) for k, v := range fields { if err := w.WriteField(k, v); err != nil { return nil, fmt.Errorf("failed to write field %s: %w", k, err) } } if file != nil { part, err := w.CreateFormFile(file.FieldName, file.FileName) if err != nil { return nil, fmt.Errorf("failed to create form file: %w", err) } if _, err := io.Copy(part, file.Reader); err != nil { return nil, fmt.Errorf("failed to copy file data: %w", err) } } if err := w.Close(); err != nil { return nil, fmt.Errorf("failed to close multipart writer: %w", err) } req, err := c.newRequest(http.MethodPost, path, &buf) if err != nil { return nil, err } req.Header.Set("Content-Type", w.FormDataContentType()) return c.do(req) } // Delete performs a DELETE request to the given path. func (c *Client) Delete(path string) (*http.Response, error) { req, err := c.newRequest(http.MethodDelete, path, nil) if err != nil { return nil, err } return c.do(req) } // Put performs a PUT request with a JSON body. func (c *Client) Put(path string, body interface{}) (*http.Response, error) { data, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } req, err := c.newRequest(http.MethodPut, path, bytes.NewReader(data)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") return c.do(req) } // Patch performs a PATCH request with a JSON body. func (c *Client) Patch(path string, body interface{}) (*http.Response, error) { data, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } req, err := c.newRequest(http.MethodPatch, path, bytes.NewReader(data)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") return c.do(req) } // DecodeJSON reads the response body and decodes it into target. func DecodeJSON(resp *http.Response, target interface{}) error { defer resp.Body.Close() return json.NewDecoder(resp.Body).Decode(target) } // CheckError returns a formatted error for non-2xx responses, or nil on success. func CheckError(resp *http.Response) error { if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var errResp struct { Detail string `json:"detail"` } if json.Unmarshal(body, &errResp) == nil && errResp.Detail != "" { return fmt.Errorf("API error (%d): %s", resp.StatusCode, errResp.Detail) } return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) }