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