119 lines
3.4 KiB
Go
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)
|
|
}
|