feat: implement TorrentClaw Go API client v0.1.0
This commit is contained in:
commit
f6f24c2c3f
39 changed files with 5067 additions and 0 deletions
367
client.go
Normal file
367
client.go
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
package torrentclaw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Version is the library version, used in the default User-Agent header.
|
||||
const Version = "0.2.0"
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://torrentclaw.com"
|
||||
defaultTimeout = 15 * time.Second
|
||||
defaultUserAgent = "torrentclaw-go-client/" + Version
|
||||
|
||||
defaultMaxRetries = 3
|
||||
defaultRetryBaseWait = 1 * time.Second
|
||||
defaultRetryMaxWait = 30 * time.Second
|
||||
|
||||
headerAPIKey = "X-API-Key"
|
||||
headerAuthorization = "Authorization"
|
||||
headerSearchSource = "X-Search-Source"
|
||||
headerUserAgent = "User-Agent"
|
||||
headerDebridProvider = "X-Debrid-Provider"
|
||||
headerDebridKey = "X-Debrid-Key"
|
||||
|
||||
searchSource = "go-client"
|
||||
)
|
||||
|
||||
// Client is a TorrentClaw API client. Use [NewClient] to create one.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
bearerToken string
|
||||
userAgent string
|
||||
httpClient *http.Client
|
||||
maxRetries int
|
||||
retryBaseWait time.Duration
|
||||
retryMaxWait time.Duration
|
||||
}
|
||||
|
||||
// Option configures a [Client].
|
||||
type Option func(*Client)
|
||||
|
||||
// WithBaseURL sets a custom API base URL. The default is https://torrentclaw.com.
|
||||
func WithBaseURL(u string) Option {
|
||||
return func(c *Client) { c.baseURL = u }
|
||||
}
|
||||
|
||||
// WithAPIKey sets the API key sent as the X-API-Key header.
|
||||
func WithAPIKey(key string) Option {
|
||||
return func(c *Client) { c.apiKey = key }
|
||||
}
|
||||
|
||||
// WithBearerToken sets a bearer token sent as the Authorization header.
|
||||
// If both WithBearerToken and WithAPIKey are used, the bearer token takes precedence.
|
||||
func WithBearerToken(token string) Option {
|
||||
return func(c *Client) { c.bearerToken = token }
|
||||
}
|
||||
|
||||
// WithUserAgent sets a custom User-Agent header.
|
||||
func WithUserAgent(ua string) Option {
|
||||
return func(c *Client) { c.userAgent = ua }
|
||||
}
|
||||
|
||||
// WithHTTPClient sets a custom *http.Client for all requests.
|
||||
func WithHTTPClient(hc *http.Client) Option {
|
||||
return func(c *Client) { c.httpClient = hc }
|
||||
}
|
||||
|
||||
// WithTimeout sets the HTTP client timeout. The default is 15 seconds.
|
||||
// This option is safe to use regardless of option ordering; it sets the
|
||||
// timeout on the client's internal HTTP client.
|
||||
func WithTimeout(d time.Duration) Option {
|
||||
return func(c *Client) {
|
||||
if c.httpClient == nil {
|
||||
c.httpClient = &http.Client{}
|
||||
}
|
||||
c.httpClient.Timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetry configures the retry policy for transient errors (429, 5xx).
|
||||
// maxRetries is the maximum number of retries (0 disables retrying).
|
||||
// baseWait is the initial wait duration before the first retry.
|
||||
// maxWait caps the exponential backoff duration.
|
||||
func WithRetry(maxRetries int, baseWait, maxWait time.Duration) Option {
|
||||
return func(c *Client) {
|
||||
c.maxRetries = maxRetries
|
||||
c.retryBaseWait = baseWait
|
||||
c.retryMaxWait = maxWait
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient creates a new TorrentClaw API client with the given options.
|
||||
func NewClient(opts ...Option) *Client {
|
||||
c := &Client{
|
||||
baseURL: defaultBaseURL,
|
||||
userAgent: defaultUserAgent,
|
||||
httpClient: &http.Client{
|
||||
Timeout: defaultTimeout,
|
||||
},
|
||||
maxRetries: defaultMaxRetries,
|
||||
retryBaseWait: defaultRetryBaseWait,
|
||||
retryMaxWait: defaultRetryMaxWait,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// doJSON performs an HTTP GET request, retries on transient errors, and
|
||||
// decodes the JSON response into dst.
|
||||
func (c *Client) doJSON(ctx context.Context, path string, query url.Values, dst any) error {
|
||||
u, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("torrentclaw: invalid base URL: %w", err)
|
||||
}
|
||||
u.Path = path
|
||||
if query != nil {
|
||||
u.RawQuery = query.Encode()
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
attempts := 1 + c.maxRetries
|
||||
for i := range attempts {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("torrentclaw: failed to create request: %w", err)
|
||||
}
|
||||
c.setHeaders(req)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("torrentclaw: request failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
err := json.NewDecoder(resp.Body).Decode(dst)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("torrentclaw: failed to decode response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
body := readErrorBody(resp)
|
||||
resp.Body.Close()
|
||||
|
||||
apiErr := newAPIError(resp.StatusCode, body)
|
||||
lastErr = apiErr
|
||||
|
||||
if !apiErr.IsRetryable() || i == attempts-1 {
|
||||
return apiErr
|
||||
}
|
||||
|
||||
wait := c.backoffDuration(i)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// doRaw performs an HTTP GET request, retries on transient errors, and
|
||||
// returns the raw response body bytes.
|
||||
func (c *Client) doRaw(ctx context.Context, path string, query url.Values) ([]byte, error) {
|
||||
u, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("torrentclaw: invalid base URL: %w", err)
|
||||
}
|
||||
u.Path = path
|
||||
if query != nil {
|
||||
u.RawQuery = query.Encode()
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
attempts := 1 + c.maxRetries
|
||||
for i := range attempts {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("torrentclaw: failed to create request: %w", err)
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("torrentclaw: request failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("torrentclaw: failed to read response body: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
body := readErrorBody(resp)
|
||||
resp.Body.Close()
|
||||
|
||||
apiErr := newAPIError(resp.StatusCode, body)
|
||||
lastErr = apiErr
|
||||
|
||||
if !apiErr.IsRetryable() || i == attempts-1 {
|
||||
return nil, apiErr
|
||||
}
|
||||
|
||||
wait := c.backoffDuration(i)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// setHeaders applies common headers to an outgoing request.
|
||||
func (c *Client) setHeaders(req *http.Request) {
|
||||
req.Header.Set(headerUserAgent, c.userAgent)
|
||||
req.Header.Set(headerSearchSource, searchSource)
|
||||
if c.bearerToken != "" {
|
||||
req.Header.Set(headerAuthorization, "Bearer "+c.bearerToken)
|
||||
} else if c.apiKey != "" {
|
||||
req.Header.Set(headerAPIKey, c.apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
// backoffDuration computes the wait time for retry attempt i using
|
||||
// exponential backoff capped at retryMaxWait.
|
||||
func (c *Client) backoffDuration(attempt int) time.Duration {
|
||||
wait := time.Duration(float64(c.retryBaseWait) * math.Pow(2, float64(attempt)))
|
||||
if wait > c.retryMaxWait {
|
||||
wait = c.retryMaxWait
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
// readErrorBody reads up to 512 bytes of the response body for error context.
|
||||
// For server errors (5xx), an empty string is returned to avoid leaking internals.
|
||||
func readErrorBody(resp *http.Response) string {
|
||||
if resp.StatusCode >= 500 {
|
||||
return ""
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// addIntParam adds an integer query parameter if the value is non-zero.
|
||||
func addIntParam(q url.Values, key string, val int) {
|
||||
if val != 0 {
|
||||
q.Set(key, strconv.Itoa(val))
|
||||
}
|
||||
}
|
||||
|
||||
// addFloatParam adds a float query parameter if the value is non-zero.
|
||||
func addFloatParam(q url.Values, key string, val float64) {
|
||||
if val != 0 {
|
||||
q.Set(key, strconv.FormatFloat(val, 'f', -1, 64))
|
||||
}
|
||||
}
|
||||
|
||||
// addStringParam adds a string query parameter if the value is non-empty.
|
||||
func addStringParam(q url.Values, key, val string) {
|
||||
if val != "" {
|
||||
q.Set(key, val)
|
||||
}
|
||||
}
|
||||
|
||||
// addBoolParam adds a boolean query parameter if the value is true.
|
||||
func addBoolParam(q url.Values, key string, val bool) {
|
||||
if val {
|
||||
q.Set(key, "true")
|
||||
}
|
||||
}
|
||||
|
||||
// doPost performs an HTTP POST request with a JSON body, retries on transient
|
||||
// errors, and decodes the JSON response into dst. Extra headers (e.g. debrid
|
||||
// provider credentials) are applied on top of the common headers.
|
||||
func (c *Client) doPost(ctx context.Context, path string, body any, dst any, extraHeaders map[string]string) error {
|
||||
u, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("torrentclaw: invalid base URL: %w", err)
|
||||
}
|
||||
u.Path = path
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("torrentclaw: failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
attempts := 1 + c.maxRetries
|
||||
for i := range attempts {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("torrentclaw: failed to create request: %w", err)
|
||||
}
|
||||
c.setHeaders(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
for k, v := range extraHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("torrentclaw: request failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
err := json.NewDecoder(resp.Body).Decode(dst)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("torrentclaw: failed to decode response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
errBody := readErrorBody(resp)
|
||||
resp.Body.Close()
|
||||
|
||||
apiErr := newAPIError(resp.StatusCode, errBody)
|
||||
lastErr = apiErr
|
||||
|
||||
if !apiErr.IsRetryable() || i == attempts-1 {
|
||||
return apiErr
|
||||
}
|
||||
|
||||
wait := c.backoffDuration(i)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue