lunatask.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5// Package lunatask provides a Go client for the Lunatask API.
  6//
  7//	client := lunatask.NewClient(os.Getenv("LUNATASK_TOKEN"))
  8//	if _, err := client.Ping(ctx); err != nil {
  9//		log.Fatal(err)
 10//	}
 11package lunatask
 12
 13import (
 14	"bytes"
 15	"context"
 16	"encoding/json"
 17	"errors"
 18	"fmt"
 19	"io"
 20	"net/http"
 21)
 22
 23// API error types for typed error handling.
 24var (
 25	// ErrBadRequest indicates invalid, malformed, or missing parameters (400).
 26	ErrBadRequest = errors.New("bad request")
 27	// ErrUnauthorized indicates missing, wrong, or revoked access token (401).
 28	ErrUnauthorized = errors.New("unauthorized")
 29	// ErrPaymentRequired indicates a subscription is required (402).
 30	ErrPaymentRequired = errors.New("subscription required")
 31	// ErrNotFound indicates the specified entity could not be found (404).
 32	ErrNotFound = errors.New("not found")
 33	// ErrUnprocessableEntity indicates the provided entity is not valid (422).
 34	ErrUnprocessableEntity = errors.New("unprocessable entity")
 35	// ErrServerError indicates an internal server error (500).
 36	ErrServerError = errors.New("server error")
 37	// ErrServiceUnavailable indicates temporary maintenance (503).
 38	ErrServiceUnavailable = errors.New("service unavailable")
 39	// ErrTimeout indicates request timed out (524).
 40	ErrTimeout = errors.New("request timed out")
 41	// ErrUnexpectedStatus indicates an unexpected HTTP status code.
 42	ErrUnexpectedStatus = errors.New("unexpected status")
 43)
 44
 45// APIError wraps an API error with HTTP status and response body.
 46// Use [errors.Is] with the sentinel errors (e.g., [ErrNotFound]) for
 47// control flow, or type-assert to access StatusCode and Body.
 48type APIError struct {
 49	StatusCode int
 50	Body       string
 51	Err        error
 52}
 53
 54func (e *APIError) Error() string {
 55	if e.Body != "" {
 56		return fmt.Sprintf("%s (status %d): %s", e.Err.Error(), e.StatusCode, e.Body)
 57	}
 58
 59	return fmt.Sprintf("%s (status %d)", e.Err.Error(), e.StatusCode)
 60}
 61
 62func (e *APIError) Unwrap() error {
 63	return e.Err
 64}
 65
 66// newAPIError creates an APIError from an HTTP status code.
 67func newAPIError(statusCode int, body string) *APIError {
 68	var err error
 69
 70	switch statusCode {
 71	case http.StatusBadRequest:
 72		err = ErrBadRequest
 73	case http.StatusUnauthorized:
 74		err = ErrUnauthorized
 75	case http.StatusPaymentRequired:
 76		err = ErrPaymentRequired
 77	case http.StatusNotFound:
 78		err = ErrNotFound
 79	case http.StatusUnprocessableEntity:
 80		err = ErrUnprocessableEntity
 81	case http.StatusInternalServerError:
 82		err = ErrServerError
 83	case http.StatusServiceUnavailable:
 84		err = ErrServiceUnavailable
 85	case statusCloudflareTimeout:
 86		err = ErrTimeout
 87	default:
 88		err = ErrUnexpectedStatus
 89	}
 90
 91	return &APIError{StatusCode: statusCode, Body: body, Err: err}
 92}
 93
 94const (
 95	// DefaultBaseURL is the default Lunatask API base URL.
 96	DefaultBaseURL = "https://api.lunatask.app/v1"
 97	// statusCloudflareTimeout is Cloudflare's "A Timeout Occurred" status.
 98	statusCloudflareTimeout = 524
 99)
100
101// Client handles communication with the Lunatask API.
102// A Client is safe for concurrent use.
103type Client struct {
104	accessToken string
105	baseURL     string
106	httpClient  *http.Client
107}
108
109// Option configures a Client.
110type Option func(*Client)
111
112// WithHTTPClient sets a custom HTTP client.
113func WithHTTPClient(client *http.Client) Option {
114	return func(c *Client) {
115		c.httpClient = client
116	}
117}
118
119// WithBaseURL sets a custom base URL (useful for testing).
120func WithBaseURL(url string) Option {
121	return func(c *Client) {
122		c.baseURL = url
123	}
124}
125
126// NewClient creates a new Lunatask API client.
127// Generate an access token in the Lunatask desktop app under Settings → Access tokens.
128func NewClient(accessToken string, opts ...Option) *Client {
129	client := &Client{
130		accessToken: accessToken,
131		baseURL:     DefaultBaseURL,
132		httpClient:  &http.Client{}, //nolint:exhaustruct
133	}
134	for _, opt := range opts {
135		opt(client)
136	}
137
138	return client
139}
140
141// PingResponse represents the response from the /ping endpoint.
142type PingResponse struct {
143	Message string `json:"message"`
144}
145
146// Ping verifies the access token is valid by calling the /ping endpoint.
147// Returns the ping response on success, or an error (typically [ErrUnauthorized]) on failure.
148func (c *Client) Ping(ctx context.Context) (*PingResponse, error) {
149	resp, _, err := doJSON[PingResponse](ctx, c, http.MethodGet, "/ping", nil)
150
151	return resp, err
152}
153
154// doRequest performs an HTTP request and handles common response processing.
155// Returns the response body and status code. 204 No Content returns nil body.
156func (c *Client) doRequest(req *http.Request) ([]byte, int, error) {
157	req.Header.Set("Authorization", "bearer "+c.accessToken)
158
159	resp, err := c.httpClient.Do(req)
160	if err != nil {
161		return nil, 0, fmt.Errorf("failed to send HTTP request: %w", err)
162	}
163
164	defer func() { _ = resp.Body.Close() }()
165
166	body, err := io.ReadAll(resp.Body)
167	if err != nil {
168		return nil, resp.StatusCode, fmt.Errorf("failed to read response body: %w", err)
169	}
170
171	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
172		return nil, resp.StatusCode, newAPIError(resp.StatusCode, string(body))
173	}
174
175	// 204 No Content is a valid success with no body
176	if resp.StatusCode == http.StatusNoContent {
177		return nil, resp.StatusCode, nil
178	}
179
180	return body, resp.StatusCode, nil
181}
182
183// doJSON performs an HTTP request with optional JSON body and unmarshals the response.
184// Returns (nil, true, nil) for 204 No Content responses.
185func doJSON[T any](ctx context.Context, client *Client, method, path string, body any) (*T, bool, error) {
186	var reqBody io.Reader
187
188	if body != nil {
189		data, err := json.Marshal(body)
190		if err != nil {
191			return nil, false, fmt.Errorf("failed to marshal request body: %w", err)
192		}
193
194		reqBody = bytes.NewReader(data)
195	}
196
197	req, err := http.NewRequestWithContext(ctx, method, client.baseURL+path, reqBody)
198	if err != nil {
199		return nil, false, fmt.Errorf("failed to create HTTP request: %w", err)
200	}
201
202	if body != nil {
203		req.Header.Set("Content-Type", "application/json")
204	}
205
206	respBody, statusCode, err := client.doRequest(req)
207	if err != nil {
208		return nil, false, err
209	}
210
211	if statusCode == http.StatusNoContent {
212		return nil, true, nil
213	}
214
215	var result T
216	if err := json.Unmarshal(respBody, &result); err != nil {
217		return nil, false, fmt.Errorf("failed to parse response: %w", err)
218	}
219
220	return &result, false, nil
221}