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"), lunatask.UserAgent("MyApp/1.0"))
  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	"runtime/debug"
 22	"sync"
 23)
 24
 25// API error types for typed error handling.
 26var (
 27	// ErrBadRequest indicates invalid, malformed, or missing parameters (400).
 28	ErrBadRequest = errors.New("bad request")
 29	// ErrUnauthorized indicates missing, wrong, or revoked access token (401).
 30	ErrUnauthorized = errors.New("unauthorized")
 31	// ErrPaymentRequired indicates a subscription is required (402).
 32	ErrPaymentRequired = errors.New("subscription required")
 33	// ErrNotFound indicates the specified entity could not be found (404).
 34	ErrNotFound = errors.New("not found")
 35	// ErrUnprocessableEntity indicates the provided entity is not valid (422).
 36	ErrUnprocessableEntity = errors.New("unprocessable entity")
 37	// ErrServerError indicates an internal server error (500).
 38	ErrServerError = errors.New("server error")
 39	// ErrServiceUnavailable indicates temporary maintenance (503).
 40	ErrServiceUnavailable = errors.New("service unavailable")
 41	// ErrTimeout indicates request timed out (524).
 42	ErrTimeout = errors.New("request timed out")
 43	// ErrUnexpectedStatus indicates an unexpected HTTP status code.
 44	ErrUnexpectedStatus = errors.New("unexpected status")
 45)
 46
 47// APIError wraps an API error with HTTP status and response body.
 48// Use [errors.Is] with the sentinel errors (e.g., [ErrNotFound]) for
 49// control flow, or type-assert to access StatusCode and Body.
 50type APIError struct {
 51	StatusCode int
 52	Body       string
 53	Err        error
 54}
 55
 56func (e *APIError) Error() string {
 57	if e.Body != "" {
 58		return fmt.Sprintf("%s (status %d): %s", e.Err.Error(), e.StatusCode, e.Body)
 59	}
 60
 61	return fmt.Sprintf("%s (status %d)", e.Err.Error(), e.StatusCode)
 62}
 63
 64func (e *APIError) Unwrap() error {
 65	return e.Err
 66}
 67
 68// newAPIError creates an APIError from an HTTP status code.
 69func newAPIError(statusCode int, body string) *APIError {
 70	var err error
 71
 72	switch statusCode {
 73	case http.StatusBadRequest:
 74		err = ErrBadRequest
 75	case http.StatusUnauthorized:
 76		err = ErrUnauthorized
 77	case http.StatusPaymentRequired:
 78		err = ErrPaymentRequired
 79	case http.StatusNotFound:
 80		err = ErrNotFound
 81	case http.StatusUnprocessableEntity:
 82		err = ErrUnprocessableEntity
 83	case http.StatusInternalServerError:
 84		err = ErrServerError
 85	case http.StatusServiceUnavailable:
 86		err = ErrServiceUnavailable
 87	case statusCloudflareTimeout:
 88		err = ErrTimeout
 89	default:
 90		err = ErrUnexpectedStatus
 91	}
 92
 93	return &APIError{StatusCode: statusCode, Body: body, Err: err}
 94}
 95
 96const (
 97	// DefaultBaseURL is the default Lunatask API base URL.
 98	DefaultBaseURL = "https://api.lunatask.app/v1"
 99	// statusCloudflareTimeout is Cloudflare's "A Timeout Occurred" status.
100	statusCloudflareTimeout = 524
101	// modulePath is the import path used to find this module's version.
102	modulePath = "git.secluded.site/go-lunatask"
103)
104
105var (
106	versionOnce sync.Once //nolint:gochecknoglobals
107	version     = "unknown"
108)
109
110// getVersion returns the library version, determined from build info.
111func getVersion() string {
112	versionOnce.Do(func() {
113		buildInfo, ok := debug.ReadBuildInfo()
114		if !ok {
115			return
116		}
117
118		// When used as a dependency, find our version in the deps list.
119		for _, dep := range buildInfo.Deps {
120			if dep.Path == modulePath {
121				version = dep.Version
122
123				return
124			}
125		}
126
127		// When running as main module (e.g., tests), use main module version.
128		if buildInfo.Main.Path == modulePath && buildInfo.Main.Version != "" && buildInfo.Main.Version != "(devel)" {
129			version = buildInfo.Main.Version
130		}
131	})
132
133	return version
134}
135
136// Client handles communication with the Lunatask API.
137// A Client is safe for concurrent use.
138type Client struct {
139	accessToken string
140	baseURL     string
141	httpClient  *http.Client
142	userAgent   string
143}
144
145// Option configures a Client.
146type Option func(*Client)
147
148// HTTPClient sets a custom HTTP client.
149func HTTPClient(client *http.Client) Option {
150	return func(c *Client) {
151		c.httpClient = client
152	}
153}
154
155// BaseURL sets a custom base URL (useful for testing).
156func BaseURL(url string) Option {
157	return func(c *Client) {
158		c.baseURL = url
159	}
160}
161
162// UserAgent sets a custom User-Agent prefix.
163// The library identifier (go-lunatask/version) is always appended.
164func UserAgent(ua string) Option {
165	return func(c *Client) {
166		c.userAgent = ua
167	}
168}
169
170// NewClient creates a new Lunatask API client.
171// Generate an access token in the Lunatask desktop app under Settings → Access tokens.
172func NewClient(accessToken string, opts ...Option) *Client {
173	client := &Client{
174		accessToken: accessToken,
175		baseURL:     DefaultBaseURL,
176		httpClient:  &http.Client{},
177		userAgent:   "",
178	}
179	for _, opt := range opts {
180		opt(client)
181	}
182
183	return client
184}
185
186// PingResponse represents the response from the /ping endpoint.
187type PingResponse struct {
188	Message string `json:"message"`
189}
190
191// Ping verifies the access token is valid by calling the /ping endpoint.
192// Returns the ping response on success, or an error (typically [ErrUnauthorized]) on failure.
193func (c *Client) Ping(ctx context.Context) (*PingResponse, error) {
194	resp, _, err := doJSON[PingResponse](ctx, c, http.MethodGet, "/ping", nil)
195
196	return resp, err
197}
198
199// doRequest performs an HTTP request and handles common response processing.
200// Returns the response body and status code. 204 No Content returns nil body.
201func (c *Client) doRequest(req *http.Request) ([]byte, int, error) {
202	req.Header.Set("Authorization", "bearer "+c.accessToken)
203
204	ua := "go-lunatask/" + getVersion()
205	if c.userAgent != "" {
206		ua = c.userAgent + " " + ua
207	}
208
209	req.Header.Set("User-Agent", ua)
210
211	resp, err := c.httpClient.Do(req)
212	if err != nil {
213		return nil, 0, fmt.Errorf("failed to send HTTP request: %w", err)
214	}
215
216	defer func() { _ = resp.Body.Close() }()
217
218	body, err := io.ReadAll(resp.Body)
219	if err != nil {
220		return nil, resp.StatusCode, fmt.Errorf("failed to read response body: %w", err)
221	}
222
223	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
224		return nil, resp.StatusCode, newAPIError(resp.StatusCode, string(body))
225	}
226
227	// 204 No Content is a valid success with no body
228	if resp.StatusCode == http.StatusNoContent {
229		return nil, resp.StatusCode, nil
230	}
231
232	return body, resp.StatusCode, nil
233}
234
235// doJSON performs an HTTP request with optional JSON body and unmarshals the response.
236// Returns (nil, true, nil) for 204 No Content responses.
237func doJSON[T any](ctx context.Context, client *Client, method, path string, body any) (*T, bool, error) {
238	var reqBody io.Reader
239
240	if body != nil {
241		data, err := json.Marshal(body)
242		if err != nil {
243			return nil, false, fmt.Errorf("failed to marshal request body: %w", err)
244		}
245
246		reqBody = bytes.NewReader(data)
247	}
248
249	req, err := http.NewRequestWithContext(ctx, method, client.baseURL+path, reqBody)
250	if err != nil {
251		return nil, false, fmt.Errorf("failed to create HTTP request: %w", err)
252	}
253
254	if body != nil {
255		req.Header.Set("Content-Type", "application/json")
256	}
257
258	respBody, statusCode, err := client.doRequest(req)
259	if err != nil {
260		return nil, false, err
261	}
262
263	if statusCode == http.StatusNoContent {
264		return nil, true, nil
265	}
266
267	var result T
268	if err := json.Unmarshal(respBody, &result); err != nil {
269		return nil, false, fmt.Errorf("failed to parse response: %w", err)
270	}
271
272	return &result, false, nil
273}