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