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
|
|
|
|
|
|
Implement Client struct with functional options
Add client.go with:
- Client struct with baseURL, username, remoteKey, token, tokenExp,
httpClient, retryConf, logger, and sync.RWMutex for thread safety
- NewClient constructor accepting username, remoteKey, and variadic options
- Default values: 30s timeout, Checkvist base URL
Add options.go with:
- RetryConfig struct (MaxRetries, BaseDelay, MaxDelay, Jitter)
- DefaultRetryConfig() with 3 retries, 1s base, 30s max, jitter enabled
- Option type for functional options pattern
- WithHTTPClient, WithTimeout, WithRetryConfig, WithLogger, WithBaseURL
Closes checkvist-api-ymg
2026-01-14 13:25:03 +01:00
|
|
|
import (
|
2026-01-14 13:26:39 +01:00
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
Implement Client struct with functional options
Add client.go with:
- Client struct with baseURL, username, remoteKey, token, tokenExp,
httpClient, retryConf, logger, and sync.RWMutex for thread safety
- NewClient constructor accepting username, remoteKey, and variadic options
- Default values: 30s timeout, Checkvist base URL
Add options.go with:
- RetryConfig struct (MaxRetries, BaseDelay, MaxDelay, Jitter)
- DefaultRetryConfig() with 3 retries, 1s base, 30s max, jitter enabled
- Option type for functional options pattern
- WithHTTPClient, WithTimeout, WithRetryConfig, WithLogger, WithBaseURL
Closes checkvist-api-ymg
2026-01-14 13:25:03 +01:00
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
2026-01-14 13:26:39 +01:00
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
Implement Client struct with functional options
Add client.go with:
- Client struct with baseURL, username, remoteKey, token, tokenExp,
httpClient, retryConf, logger, and sync.RWMutex for thread safety
- NewClient constructor accepting username, remoteKey, and variadic options
- Default values: 30s timeout, Checkvist base URL
Add options.go with:
- RetryConfig struct (MaxRetries, BaseDelay, MaxDelay, Jitter)
- DefaultRetryConfig() with 3 retries, 1s base, 30s max, jitter enabled
- Option type for functional options pattern
- WithHTTPClient, WithTimeout, WithRetryConfig, WithLogger, WithBaseURL
Closes checkvist-api-ymg
2026-01-14 13:25:03 +01:00
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-14 12:42:00 +01:00
|
|
|
// client.go contains the Client struct, constructor, and authentication logic.
|
Implement Client struct with functional options
Add client.go with:
- Client struct with baseURL, username, remoteKey, token, tokenExp,
httpClient, retryConf, logger, and sync.RWMutex for thread safety
- NewClient constructor accepting username, remoteKey, and variadic options
- Default values: 30s timeout, Checkvist base URL
Add options.go with:
- RetryConfig struct (MaxRetries, BaseDelay, MaxDelay, Jitter)
- DefaultRetryConfig() with 3 retries, 1s base, 30s max, jitter enabled
- Option type for functional options pattern
- WithHTTPClient, WithTimeout, WithRetryConfig, WithLogger, WithBaseURL
Closes checkvist-api-ymg
2026-01-14 13:25:03 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-01-14 13:26:39 +01:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|