woodpecker-mcp/internal/woodpecker/client.go
2026-08-08 01:37:47 +03:00

119 lines
3.4 KiB
Go

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)
}