client.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package lunatask
  6
  7import (
  8	"context"
  9	"errors"
 10	"fmt"
 11	"io"
 12	"net/http"
 13)
 14
 15// API error types for typed error handling
 16var (
 17	// ErrBadRequest indicates invalid, malformed, or missing parameters (400)
 18	ErrBadRequest = errors.New("bad request")
 19	// ErrUnauthorized indicates missing, wrong, or revoked access token (401)
 20	ErrUnauthorized = errors.New("unauthorized")
 21	// ErrPaymentRequired indicates a subscription is required (402)
 22	ErrPaymentRequired = errors.New("subscription required")
 23	// ErrNotFound indicates the specified entity could not be found (404)
 24	ErrNotFound = errors.New("not found")
 25	// ErrUnprocessableEntity indicates the provided entity is not valid (422)
 26	ErrUnprocessableEntity = errors.New("unprocessable entity")
 27	// ErrServerError indicates an internal server error (500)
 28	ErrServerError = errors.New("server error")
 29	// ErrServiceUnavailable indicates temporary maintenance (503)
 30	ErrServiceUnavailable = errors.New("service unavailable")
 31	// ErrTimeout indicates request timed out (524)
 32	ErrTimeout = errors.New("request timed out")
 33)
 34
 35// APIError wraps an API error with status code and response body
 36type APIError struct {
 37	StatusCode int
 38	Body       string
 39	Err        error
 40}
 41
 42func (e *APIError) Error() string {
 43	if e.Body != "" {
 44		return fmt.Sprintf("%s (status %d): %s", e.Err.Error(), e.StatusCode, e.Body)
 45	}
 46	return fmt.Sprintf("%s (status %d)", e.Err.Error(), e.StatusCode)
 47}
 48
 49func (e *APIError) Unwrap() error {
 50	return e.Err
 51}
 52
 53// newAPIError creates an APIError from an HTTP status code
 54func newAPIError(statusCode int, body string) *APIError {
 55	var err error
 56	switch statusCode {
 57	case http.StatusBadRequest:
 58		err = ErrBadRequest
 59	case http.StatusUnauthorized:
 60		err = ErrUnauthorized
 61	case http.StatusPaymentRequired:
 62		err = ErrPaymentRequired
 63	case http.StatusNotFound:
 64		err = ErrNotFound
 65	case http.StatusUnprocessableEntity:
 66		err = ErrUnprocessableEntity
 67	case http.StatusInternalServerError:
 68		err = ErrServerError
 69	case http.StatusServiceUnavailable:
 70		err = ErrServiceUnavailable
 71	case 524:
 72		err = ErrTimeout
 73	default:
 74		err = fmt.Errorf("unexpected status %d", statusCode)
 75	}
 76	return &APIError{StatusCode: statusCode, Body: body, Err: err}
 77}
 78
 79// Client handles communication with the Lunatask API
 80type Client struct {
 81	AccessToken string
 82	BaseURL     string
 83	HTTPClient  *http.Client
 84}
 85
 86// NewClient creates a new Lunatask API client
 87func NewClient(accessToken string) *Client {
 88	return &Client{
 89		AccessToken: accessToken,
 90		BaseURL:     "https://api.lunatask.app/v1",
 91		HTTPClient:  &http.Client{},
 92	}
 93}
 94
 95// doRequest performs an HTTP request and handles common response processing
 96func (c *Client) doRequest(req *http.Request) ([]byte, error) {
 97	req.Header.Set("Authorization", "bearer "+c.AccessToken)
 98
 99	resp, err := c.HTTPClient.Do(req)
100	if err != nil {
101		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
102	}
103	defer func() { _ = resp.Body.Close() }()
104
105	body, err := io.ReadAll(resp.Body)
106	if err != nil {
107		return nil, fmt.Errorf("failed to read response body: %w", err)
108	}
109
110	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
111		return nil, newAPIError(resp.StatusCode, string(body))
112	}
113
114	return body, nil
115}
116
117// Ping verifies the access token is valid by calling the /ping endpoint.
118// Returns nil on success, or an error (typically ErrUnauthorized) on failure.
119func (c *Client) Ping(ctx context.Context) error {
120	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/ping", nil)
121	if err != nil {
122		return fmt.Errorf("failed to create HTTP request: %w", err)
123	}
124
125	_, err = c.doRequest(req)
126	return err
127}