package main import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "path" "strings" "time" ) const maxResponseBytes = 64 << 20 type Client struct { cfg Config http *http.Client token string apiKey string timeout time.Duration } func newClient(cfg Config, token, apiKey string, timeout time.Duration) *Client { if timeout <= 0 { timeout = 30 * time.Second } return &Client{ cfg: cfg, http: &http.Client{Timeout: timeout}, token: token, apiKey: apiKey, timeout: timeout, } } func (c *Client) cmsURL(endpoint string) string { return joinURL(effectiveBaseURL(c.cfg), "/api", endpoint) } func (c *Client) finderURL(endpoint string) string { return joinURL(effectiveFinderURL(c.cfg), "/api", endpoint) } // finderAdminURL is deliberately based on BaseURL, never FinderURL. The // backoffice owns the writeradmin surface and is the only supported write path. func (c *Client) finderAdminURL(endpoint string) string { return joinURL(effectiveBaseURL(c.cfg), "/catalog/admin", endpoint) } func joinURL(base string, parts ...string) string { u, err := url.Parse(strings.TrimRight(base, "/")) if err != nil || u.Scheme == "" || u.Host == "" { return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(path.Join(parts...), "/") } joined := strings.TrimRight(u.Path, "/") for _, part := range parts { joined = path.Join(joined, "/"+strings.Trim(part, "/")) } u.Path = joined return u.String() } func escapePathSegment(value string) string { return url.PathEscape(value) } func (c *Client) request(ctx context.Context, method, endpoint string, body []byte, contentType string) ([]byte, int, error) { var reader io.Reader if body != nil { reader = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, endpoint, reader) if err != nil { return nil, 0, err } req.Header.Set("Accept", "application/json, application/jsonl+json, text/plain") if body != nil { if contentType == "" { contentType = "application/json" } req.Header.Set("Content-Type", contentType) } if c.apiKey != "" { req.Header.Set("X-API-Key", c.apiKey) } else if c.token != "" { tokenType := c.cfg.TokenType if tokenType == "" { tokenType = "Bearer" } req.Header.Set("Authorization", tokenType+" "+c.token) } resp, err := c.http.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) if err != nil { return nil, resp.StatusCode, err } if len(responseBody) > maxResponseBytes { return nil, resp.StatusCode, fmt.Errorf("response exceeds %d MiB", maxResponseBytes/(1<<20)) } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return responseBody, resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: responseBody} } return responseBody, resp.StatusCode, nil } type HTTPError struct { StatusCode int Body []byte } func (e *HTTPError) Error() string { body := strings.TrimSpace(string(e.Body)) if body == "" { return fmt.Sprintf("HTTP %d", e.StatusCode) } var payload struct { Error string `json:"error"` ErrorDescription string `json:"error_description"` Message string `json:"message"` } if json.Unmarshal(e.Body, &payload) == nil { for _, msg := range []string{payload.ErrorDescription, payload.Message, payload.Error} { if msg != "" { return fmt.Sprintf("HTTP %d: %s", e.StatusCode, msg) } } } if len(body) > 500 { body = body[:500] + "…" } return fmt.Sprintf("HTTP %d: %s", e.StatusCode, body) } func (c *Client) get(ctx context.Context, endpoint string) ([]byte, error) { body, _, err := c.request(ctx, http.MethodGet, endpoint, nil, "") return body, err } func (c *Client) postJSON(ctx context.Context, endpoint string, value any) ([]byte, error) { body, err := json.Marshal(value) if err != nil { return nil, err } return c.postBody(ctx, endpoint, body, "application/json") } func (c *Client) postBody(ctx context.Context, endpoint string, body []byte, contentType string) ([]byte, error) { response, _, err := c.request(ctx, http.MethodPost, endpoint, body, contentType) return response, err } func (c *Client) putBody(ctx context.Context, endpoint string, body []byte, contentType string) ([]byte, error) { response, _, err := c.request(ctx, http.MethodPut, endpoint, body, contentType) return response, err } func (c *Client) patchBody(ctx context.Context, endpoint string, body []byte, contentType string) ([]byte, error) { response, _, err := c.request(ctx, http.MethodPatch, endpoint, body, contentType) return response, err } func (c *Client) delete(ctx context.Context, endpoint string) ([]byte, error) { response, _, err := c.request(ctx, http.MethodDelete, endpoint, nil, "") return response, err } func (c *Client) deleteBody(ctx context.Context, endpoint string, body []byte, contentType string) ([]byte, error) { response, _, err := c.request(ctx, http.MethodDelete, endpoint, body, contentType) return response, err }