package woodpecker import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "strings" "time" ) const ( // defaultTimeout bounds a single API request. defaultTimeout = 30 * time.Second // maxResponseBytes guards against unbounded response bodies. maxResponseBytes = 10 << 20 // 10 MiB ) // Client is an authenticated Woodpecker CI REST API client. type Client struct { baseURL string // e.g. "https://woodpecker.example.com/api" token string http *http.Client } // Option configures a Client. type Option func(*Client) // WithHTTPClient replaces the default HTTP client (useful for tests). func WithHTTPClient(hc *http.Client) Option { return func(c *Client) { c.http = hc } } // NewClient creates a client for the given API base URL (already including // the "/api" prefix, see config.Config.APIBaseURL). func NewClient(apiBaseURL, token string, opts ...Option) (*Client, error) { if strings.TrimSpace(apiBaseURL) == "" { return nil, errors.New("woodpecker: empty API base URL") } if token == "" { return nil, errors.New("woodpecker: empty token") } c := &Client{ baseURL: strings.TrimSuffix(apiBaseURL, "/"), token: token, http: &http.Client{Timeout: defaultTimeout}, } for _, opt := range opts { opt(c) } return c, nil } // Get performs an authenticated GET against endpoint (e.g. // "/repos/lookup/owner%2Frepo") and decodes the JSON response into out. // A nil out (or an empty response body) skips decoding. // // Every failure is returned as an *APIError so callers can render a // client-facing error context. func (c *Client) Get(ctx context.Context, endpoint string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+endpoint, nil) if err != nil { return &APIError{Status: 500, Message: err.Error()} } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.token) resp, err := c.http.Do(req) if err != nil { // Transport-level failures (DNS, connection refused, timeout, ...) // surface as 500, matching the original client's behaviour. return &APIError{Status: 500, Message: err.Error()} } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { return &APIError{Status: 500, Message: "failed to read response body: " + err.Error()} } if resp.StatusCode < 200 || resp.StatusCode > 299 { return &APIError{Status: resp.StatusCode, Message: errorMessage(resp.StatusCode, body)} } if out == nil || len(bytes.TrimSpace(body)) == 0 { return nil } if err := json.Unmarshal(body, out); err != nil { return &APIError{Status: 500, Message: "failed to decode response: " + err.Error()} } return nil } // errorMessage extracts a human-readable message from a non-2xx response. // It prefers the "error"/"message" fields Woodpecker returns, falling back // to the raw body and finally to the HTTP status text. func errorMessage(status int, body []byte) string { var payload struct { Error string `json:"error"` Message string `json:"message"` } if err := json.Unmarshal(body, &payload); err == nil { if payload.Error != "" { return payload.Error } if payload.Message != "" { return payload.Message } } if text := strings.TrimSpace(string(body)); text != "" { return text } return http.StatusText(status) }