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}