checkvist-api/client.go

422 lines
11 KiB
Go
Raw Normal View History

2026-01-14 12:42:00 +01:00
// Package checkvist provides a type-safe, idiomatic Go client for the Checkvist API.
//
// This package allows Go applications to interact with Checkvist checklists,
// tasks, and notes. It handles authentication, automatic token renewal,
// and provides fluent interfaces for task creation and filtering.
//
// Basic usage:
//
// client := checkvist.NewClient(username, remoteKey)
// checklists, err := client.Checklists().List(ctx)
package checkvist
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/rand"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
2026-01-14 12:42:00 +01:00
// client.go contains the Client struct, constructor, and authentication logic.
const (
// DefaultBaseURL is the default base URL for the Checkvist API.
DefaultBaseURL = "https://checkvist.com"
// DefaultTimeout is the default timeout for HTTP requests.
DefaultTimeout = 30 * time.Second
)
// Client is the Checkvist API client.
type Client struct {
// baseURL is the base URL for API requests.
baseURL string
// username is the user's email address.
username string
// remoteKey is the API key (remote key) for authentication.
remoteKey string
// token is the current authentication token.
token string
// tokenExp is the expiration time of the current token.
tokenExp time.Time
// httpClient is the HTTP client used for requests.
httpClient *http.Client
// retryConf is the retry configuration for failed requests.
retryConf RetryConfig
// logger is the logger for debug and error messages.
logger *slog.Logger
// mu protects token and tokenExp for concurrent access.
mu sync.RWMutex
}
// NewClient creates a new Checkvist API client.
//
// The username should be the user's email address, and remoteKey is the API key
// which can be obtained from Checkvist settings.
//
// Example:
//
// client := checkvist.NewClient("user@example.com", "your-api-key")
//
// With options:
//
// client := checkvist.NewClient("user@example.com", "your-api-key",
// checkvist.WithTimeout(60 * time.Second),
// checkvist.WithRetryConfig(checkvist.RetryConfig{MaxRetries: 5}),
// )
func NewClient(username, remoteKey string, opts ...Option) *Client {
c := &Client{
baseURL: DefaultBaseURL,
username: username,
remoteKey: remoteKey,
httpClient: &http.Client{
Timeout: DefaultTimeout,
},
retryConf: DefaultRetryConfig(),
logger: slog.Default(),
}
for _, opt := range opts {
opt(c)
}
return c
}
// authResponse represents the response from the authentication endpoint.
type authResponse struct {
Token string `json:"token"`
}
// Authenticate performs explicit authentication with the Checkvist API.
// This is optional - the client will automatically authenticate when needed.
// Use this to verify credentials or to pre-authenticate before making requests.
func (c *Client) Authenticate(ctx context.Context) error {
return c.authenticate(ctx, "")
}
// AuthenticateWith2FA performs authentication with a 2FA token.
func (c *Client) AuthenticateWith2FA(ctx context.Context, twoFAToken string) error {
return c.authenticate(ctx, twoFAToken)
}
// authenticate performs the actual authentication request.
func (c *Client) authenticate(ctx context.Context, twoFAToken string) error {
data := url.Values{}
data.Set("username", c.username)
data.Set("remote_key", c.remoteKey)
if twoFAToken != "" {
data.Set("totp", twoFAToken)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/auth/login.json?version=2",
strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("creating auth request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("auth request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return NewAPIError(resp, string(body))
}
var authResp authResponse
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
return fmt.Errorf("decoding auth response: %w", err)
}
c.mu.Lock()
c.token = authResp.Token
// Token is valid for 1 day, but we refresh earlier to be safe
c.tokenExp = time.Now().Add(23 * time.Hour)
c.mu.Unlock()
c.logger.Debug("authenticated successfully", "username", c.username)
return nil
}
// refreshToken renews the authentication token.
func (c *Client) refreshToken(ctx context.Context) error {
c.mu.RLock()
currentToken := c.token
c.mu.RUnlock()
if currentToken == "" {
return c.Authenticate(ctx)
}
data := url.Values{}
data.Set("old_token", currentToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/auth/refresh_token.json?version=2",
strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("creating refresh request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("refresh request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// If refresh fails, try full authentication
c.logger.Debug("token refresh failed, attempting full authentication")
return c.Authenticate(ctx)
}
var authResp authResponse
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
return fmt.Errorf("decoding refresh response: %w", err)
}
c.mu.Lock()
c.token = authResp.Token
// Refreshed tokens can be valid for up to 90 days, but we refresh more frequently
c.tokenExp = time.Now().Add(23 * time.Hour)
c.mu.Unlock()
c.logger.Debug("token refreshed successfully")
return nil
}
// ensureAuthenticated ensures the client has a valid authentication token.
// This is called automatically before each API request.
func (c *Client) ensureAuthenticated(ctx context.Context) error {
c.mu.RLock()
token := c.token
tokenExp := c.tokenExp
c.mu.RUnlock()
if token == "" {
return c.Authenticate(ctx)
}
// Refresh token if it will expire within the next hour
if time.Now().Add(1 * time.Hour).After(tokenExp) {
return c.refreshToken(ctx)
}
return nil
}
// getToken returns the current authentication token.
// Thread-safe.
func (c *Client) getToken() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.token
}
// CurrentUser returns information about the currently authenticated user.
func (c *Client) CurrentUser(ctx context.Context) (*User, error) {
if err := c.ensureAuthenticated(ctx); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
c.baseURL+"/auth/curr_user.json", nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Client-Token", c.getToken())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, NewAPIError(resp, string(body))
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return &user, nil
}
// doRequest performs an HTTP request with automatic authentication and retry logic.
// It handles JSON marshaling of the request body and unmarshaling of the response.
func (c *Client) doRequest(ctx context.Context, method, path string, body any, result any) error {
if err := c.ensureAuthenticated(ctx); err != nil {
return err
}
var bodyReader io.Reader
if body != nil {
bodyBytes, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshaling request body: %w", err)
}
bodyReader = bytes.NewReader(bodyBytes)
}
var lastErr error
for attempt := 0; attempt <= c.retryConf.MaxRetries; attempt++ {
if attempt > 0 {
delay := c.calculateRetryDelay(attempt)
c.logger.Debug("retrying request",
"attempt", attempt,
"delay", delay,
"path", path,
)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
// Reset body reader for retry
if body != nil {
bodyBytes, _ := json.Marshal(body)
bodyReader = bytes.NewReader(bodyBytes)
}
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Client-Token", c.getToken())
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
c.logger.Debug("sending request",
"method", method,
"path", path,
"attempt", attempt,
)
resp, err := c.httpClient.Do(req)
if err != nil {
if c.shouldRetry(err, nil) {
lastErr = err
continue
}
return fmt.Errorf("request failed: %w", err)
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("reading response body: %w", err)
continue
}
c.logger.Debug("received response",
"status", resp.StatusCode,
"path", path,
)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("decoding response: %w", err)
}
}
return nil
}
apiErr := NewAPIError(resp, string(respBody))
if c.shouldRetry(nil, resp) {
lastErr = apiErr
continue
}
return apiErr
}
if lastErr != nil {
return fmt.Errorf("request failed after %d retries: %w", c.retryConf.MaxRetries, lastErr)
}
return errors.New("request failed: unknown error")
}
// shouldRetry determines if a request should be retried based on the error or response.
func (c *Client) shouldRetry(err error, resp *http.Response) bool {
if err != nil {
// Retry on network errors (timeout, connection reset, etc.)
return true
}
if resp != nil {
// Retry on rate limiting
if resp.StatusCode == http.StatusTooManyRequests {
return true
}
// Retry on server errors
if resp.StatusCode >= 500 {
return true
}
}
return false
}
// calculateRetryDelay calculates the delay before the next retry attempt
// using exponential backoff with optional jitter.
func (c *Client) calculateRetryDelay(attempt int) time.Duration {
// Exponential backoff: baseDelay * 2^attempt
delay := c.retryConf.BaseDelay * time.Duration(1<<uint(attempt))
// Cap at max delay
if delay > c.retryConf.MaxDelay {
delay = c.retryConf.MaxDelay
}
// Add jitter (0-25% of delay)
if c.retryConf.Jitter {
jitter := time.Duration(rand.Int63n(int64(delay / 4)))
delay += jitter
}
return delay
}
// doGet performs a GET request and decodes the response into result.
func (c *Client) doGet(ctx context.Context, path string, result any) error {
return c.doRequest(ctx, http.MethodGet, path, nil, result)
}
// doPost performs a POST request with a JSON body and decodes the response.
func (c *Client) doPost(ctx context.Context, path string, body any, result any) error {
return c.doRequest(ctx, http.MethodPost, path, body, result)
}
// doPut performs a PUT request with a JSON body and decodes the response.
func (c *Client) doPut(ctx context.Context, path string, body any, result any) error {
return c.doRequest(ctx, http.MethodPut, path, body, result)
}
// doDelete performs a DELETE request.
func (c *Client) doDelete(ctx context.Context, path string) error {
return c.doRequest(ctx, http.MethodDelete, path, nil, nil)
}