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}