// Package api is a thin, standalone HTTP client for the rickub JSON API // (`/api/v1`), hand-written against the API's published OpenAPI description. It // imports none of the server's packages: the CLI is a pure client with a clean // dependency boundary. package api import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) // Client talks to a rickub API host with a bearer PAT. type Client struct { Host string // e.g. https://rickub.com (no trailing slash) Token string // rickub_pat_… HTTPClient *http.Client // UserAgent is sent on every request. UserAgent string } // New builds a Client with a sane default HTTP client. func New(host, token string) *Client { return &Client{ Host: strings.TrimRight(host, "/"), Token: token, HTTPClient: &http.Client{Timeout: 30 * time.Second}, UserAgent: "rickub-cli", } } // APIError is the parsed `{error:{code,message}}` envelope plus HTTP status. type APIError struct { Status int Code string Message string } func (e *APIError) Error() string { if e.Code != "" { return fmt.Sprintf("%s (%s)", e.Message, e.Code) } if e.Message != "" { return e.Message } return fmt.Sprintf("HTTP %d", e.Status) } type errorEnvelope struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } // Request is a low-level typed request description. type Request struct { Method string // Path is the API path WITHOUT the /api/v1 prefix, e.g. "/repos/o/r". // Each segment must already be escaped by the caller where needed. Path string Query url.Values // Body, if non-nil, is JSON-encoded. Body any // Accept overrides the Accept header (default application/json). Accept string } // raw performs the request and returns status, body bytes, and content-type. // A non-2xx response is decoded into an *APIError. func (c *Client) raw(ctx context.Context, r Request) (int, []byte, string, error) { u := c.Host + "/api/v1" + r.Path if len(r.Query) > 0 { u += "?" + r.Query.Encode() } var body io.Reader if r.Body != nil { b, err := json.Marshal(r.Body) if err != nil { return 0, nil, "", err } body = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, r.Method, u, body) if err != nil { return 0, nil, "", err } if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } accept := r.Accept if accept == "" { accept = "application/json" } req.Header.Set("Accept", accept) if r.Body != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("User-Agent", c.UserAgent) resp, err := c.HTTPClient.Do(req) if err != nil { return 0, nil, "", err } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return resp.StatusCode, nil, resp.Header.Get("Content-Type"), err } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return resp.StatusCode, data, resp.Header.Get("Content-Type"), nil } // Attempt to decode the standard error envelope. apiErr := &APIError{Status: resp.StatusCode} var env errorEnvelope if json.Unmarshal(data, &env) == nil && env.Error.Code != "" { apiErr.Code = env.Error.Code apiErr.Message = env.Error.Message } else { apiErr.Message = strings.TrimSpace(string(data)) if apiErr.Message == "" { apiErr.Message = http.StatusText(resp.StatusCode) } } return resp.StatusCode, data, resp.Header.Get("Content-Type"), apiErr } // do performs the request and unmarshals a JSON success body into out (which // may be nil for empty 204 responses). func (c *Client) do(ctx context.Context, r Request, out any) error { _, data, _, err := c.raw(ctx, r) if err != nil { return err } if out == nil || len(bytes.TrimSpace(data)) == 0 { return nil } return json.Unmarshal(data, out) } // RawJSON performs a request and returns the raw (success) response body. Used // by the `rickub api` escape hatch and `--json` passthrough. func (c *Client) RawJSON(ctx context.Context, method, path string, query url.Values, body any) ([]byte, string, error) { _, data, ct, err := c.raw(ctx, Request{Method: method, Path: path, Query: query, Body: body}) return data, ct, err } // RawText performs a GET requesting text/plain and returns the decoded body. func (c *Client) RawText(ctx context.Context, path string, query url.Values) ([]byte, error) { _, data, _, err := c.raw(ctx, Request{Method: http.MethodGet, Path: path, Query: query, Accept: "text/plain"}) return data, err } // itoa is a small helper for building integer path segments. func itoa(n int) string { return strconv.Itoa(n) } // pageQuery builds a ?page/?per_page query, omitting zero values. func pageQuery(page, perPage int, extra url.Values) url.Values { q := url.Values{} for k, v := range extra { q[k] = v } if page > 0 { q.Set("page", strconv.Itoa(page)) } if perPage > 0 { q.Set("per_page", strconv.Itoa(perPage)) } return q }