client.go

  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}