1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package lunatask
6
7import (
8 "context"
9 "errors"
10 "fmt"
11 "io"
12 "net/http"
13)
14
15// API error types for typed error handling
16var (
17 // ErrBadRequest indicates invalid, malformed, or missing parameters (400)
18 ErrBadRequest = errors.New("bad request")
19 // ErrUnauthorized indicates missing, wrong, or revoked access token (401)
20 ErrUnauthorized = errors.New("unauthorized")
21 // ErrPaymentRequired indicates a subscription is required (402)
22 ErrPaymentRequired = errors.New("subscription required")
23 // ErrNotFound indicates the specified entity could not be found (404)
24 ErrNotFound = errors.New("not found")
25 // ErrUnprocessableEntity indicates the provided entity is not valid (422)
26 ErrUnprocessableEntity = errors.New("unprocessable entity")
27 // ErrServerError indicates an internal server error (500)
28 ErrServerError = errors.New("server error")
29 // ErrServiceUnavailable indicates temporary maintenance (503)
30 ErrServiceUnavailable = errors.New("service unavailable")
31 // ErrTimeout indicates request timed out (524)
32 ErrTimeout = errors.New("request timed out")
33)
34
35// APIError wraps an API error with status code and response body
36type APIError struct {
37 StatusCode int
38 Body string
39 Err error
40}
41
42func (e *APIError) Error() string {
43 if e.Body != "" {
44 return fmt.Sprintf("%s (status %d): %s", e.Err.Error(), e.StatusCode, e.Body)
45 }
46 return fmt.Sprintf("%s (status %d)", e.Err.Error(), e.StatusCode)
47}
48
49func (e *APIError) Unwrap() error {
50 return e.Err
51}
52
53// newAPIError creates an APIError from an HTTP status code
54func newAPIError(statusCode int, body string) *APIError {
55 var err error
56 switch statusCode {
57 case http.StatusBadRequest:
58 err = ErrBadRequest
59 case http.StatusUnauthorized:
60 err = ErrUnauthorized
61 case http.StatusPaymentRequired:
62 err = ErrPaymentRequired
63 case http.StatusNotFound:
64 err = ErrNotFound
65 case http.StatusUnprocessableEntity:
66 err = ErrUnprocessableEntity
67 case http.StatusInternalServerError:
68 err = ErrServerError
69 case http.StatusServiceUnavailable:
70 err = ErrServiceUnavailable
71 case 524:
72 err = ErrTimeout
73 default:
74 err = fmt.Errorf("unexpected status %d", statusCode)
75 }
76 return &APIError{StatusCode: statusCode, Body: body, Err: err}
77}
78
79// Client handles communication with the Lunatask API
80type Client struct {
81 AccessToken string
82 BaseURL string
83 HTTPClient *http.Client
84}
85
86// NewClient creates a new Lunatask API client
87func NewClient(accessToken string) *Client {
88 return &Client{
89 AccessToken: accessToken,
90 BaseURL: "https://api.lunatask.app/v1",
91 HTTPClient: &http.Client{},
92 }
93}
94
95// doRequest performs an HTTP request and handles common response processing
96func (c *Client) doRequest(req *http.Request) ([]byte, error) {
97 req.Header.Set("Authorization", "bearer "+c.AccessToken)
98
99 resp, err := c.HTTPClient.Do(req)
100 if err != nil {
101 return nil, fmt.Errorf("failed to send HTTP request: %w", err)
102 }
103 defer func() { _ = resp.Body.Close() }()
104
105 body, err := io.ReadAll(resp.Body)
106 if err != nil {
107 return nil, fmt.Errorf("failed to read response body: %w", err)
108 }
109
110 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
111 return nil, newAPIError(resp.StatusCode, string(body))
112 }
113
114 return body, nil
115}
116
117// Ping verifies the access token is valid by calling the /ping endpoint.
118// Returns nil on success, or an error (typically ErrUnauthorized) on failure.
119func (c *Client) Ping(ctx context.Context) error {
120 req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/ping", nil)
121 if err != nil {
122 return fmt.Errorf("failed to create HTTP request: %w", err)
123 }
124
125 _, err = c.doRequest(req)
126 return err
127}