rickub/clipublic Fork 0
1a1d4302402433417a40905bf2e2a7b85504872e
Commits
Clone
git clone https://git.rickub.com/rickub/cli.git
git clone ssh://git@rickub.com/rickub/cli.git
client.go · 182 lines · 4.9 KBGo Blame HistoryRaw
Initial import of the rickub CLI as a standalone public project 1a1d430 Olivier Girardot 13h ago1// Package api is a thin, standalone HTTP client for the rickub JSON API
2// (`/api/v1`), hand-written against the API's published OpenAPI description. It
3// imports none of the server's packages: the CLI is a pure client with a clean
4// dependency boundary.
5package api
6
7import (
8 "bytes"
9 "context"
10 "encoding/json"
11 "fmt"
12 "io"
13 "net/http"
14 "net/url"
15 "strconv"
16 "strings"
17 "time"
18)
19
20// Client talks to a rickub API host with a bearer PAT.
21type Client struct {
22 Host string // e.g. https://rickub.com (no trailing slash)
23 Token string // rickub_pat_…
24 HTTPClient *http.Client
25 // UserAgent is sent on every request.
26 UserAgent string
27}
28
29// New builds a Client with a sane default HTTP client.
30func New(host, token string) *Client {
31 return &Client{
32 Host: strings.TrimRight(host, "/"),
33 Token: token,
34 HTTPClient: &http.Client{Timeout: 30 * time.Second},
35 UserAgent: "rickub-cli",
36 }
37}
38
39// APIError is the parsed `{error:{code,message}}` envelope plus HTTP status.
40type APIError struct {
41 Status int
42 Code string
43 Message string
44}
45
46func (e *APIError) Error() string {
47 if e.Code != "" {
48 return fmt.Sprintf("%s (%s)", e.Message, e.Code)
49 }
50 if e.Message != "" {
51 return e.Message
52 }
53 return fmt.Sprintf("HTTP %d", e.Status)
54}
55
56type errorEnvelope struct {
57 Error struct {
58 Code string `json:"code"`
59 Message string `json:"message"`
60 } `json:"error"`
61}
62
63// Request is a low-level typed request description.
64type Request struct {
65 Method string
66 // Path is the API path WITHOUT the /api/v1 prefix, e.g. "/repos/o/r".
67 // Each segment must already be escaped by the caller where needed.
68 Path string
69 Query url.Values
70 // Body, if non-nil, is JSON-encoded.
71 Body any
72 // Accept overrides the Accept header (default application/json).
73 Accept string
74}
75
76// raw performs the request and returns status, body bytes, and content-type.
77// A non-2xx response is decoded into an *APIError.
78func (c *Client) raw(ctx context.Context, r Request) (int, []byte, string, error) {
79 u := c.Host + "/api/v1" + r.Path
80 if len(r.Query) > 0 {
81 u += "?" + r.Query.Encode()
82 }
83
84 var body io.Reader
85 if r.Body != nil {
86 b, err := json.Marshal(r.Body)
87 if err != nil {
88 return 0, nil, "", err
89 }
90 body = bytes.NewReader(b)
91 }
92
93 req, err := http.NewRequestWithContext(ctx, r.Method, u, body)
94 if err != nil {
95 return 0, nil, "", err
96 }
97 if c.Token != "" {
98 req.Header.Set("Authorization", "Bearer "+c.Token)
99 }
100 accept := r.Accept
101 if accept == "" {
102 accept = "application/json"
103 }
104 req.Header.Set("Accept", accept)
105 if r.Body != nil {
106 req.Header.Set("Content-Type", "application/json")
107 }
108 req.Header.Set("User-Agent", c.UserAgent)
109
110 resp, err := c.HTTPClient.Do(req)
111 if err != nil {
112 return 0, nil, "", err
113 }
114 defer resp.Body.Close()
115
116 data, err := io.ReadAll(resp.Body)
117 if err != nil {
118 return resp.StatusCode, nil, resp.Header.Get("Content-Type"), err
119 }
120
121 if resp.StatusCode >= 200 && resp.StatusCode < 300 {
122 return resp.StatusCode, data, resp.Header.Get("Content-Type"), nil
123 }
124
125 // Attempt to decode the standard error envelope.
126 apiErr := &APIError{Status: resp.StatusCode}
127 var env errorEnvelope
128 if json.Unmarshal(data, &env) == nil && env.Error.Code != "" {
129 apiErr.Code = env.Error.Code
130 apiErr.Message = env.Error.Message
131 } else {
132 apiErr.Message = strings.TrimSpace(string(data))
133 if apiErr.Message == "" {
134 apiErr.Message = http.StatusText(resp.StatusCode)
135 }
136 }
137 return resp.StatusCode, data, resp.Header.Get("Content-Type"), apiErr
138}
139
140// do performs the request and unmarshals a JSON success body into out (which
141// may be nil for empty 204 responses).
142func (c *Client) do(ctx context.Context, r Request, out any) error {
143 _, data, _, err := c.raw(ctx, r)
144 if err != nil {
145 return err
146 }
147 if out == nil || len(bytes.TrimSpace(data)) == 0 {
148 return nil
149 }
150 return json.Unmarshal(data, out)
151}
152
153// RawJSON performs a request and returns the raw (success) response body. Used
154// by the `rickub api` escape hatch and `--json` passthrough.
155func (c *Client) RawJSON(ctx context.Context, method, path string, query url.Values, body any) ([]byte, string, error) {
156 _, data, ct, err := c.raw(ctx, Request{Method: method, Path: path, Query: query, Body: body})
157 return data, ct, err
158}
159
160// RawText performs a GET requesting text/plain and returns the decoded body.
161func (c *Client) RawText(ctx context.Context, path string, query url.Values) ([]byte, error) {
162 _, data, _, err := c.raw(ctx, Request{Method: http.MethodGet, Path: path, Query: query, Accept: "text/plain"})
163 return data, err
164}
165
166// itoa is a small helper for building integer path segments.
167func itoa(n int) string { return strconv.Itoa(n) }
168
169// pageQuery builds a ?page/?per_page query, omitting zero values.
170func pageQuery(page, perPage int, extra url.Values) url.Values {
171 q := url.Values{}
172 for k, v := range extra {
173 q[k] = v
174 }
175 if page > 0 {
176 q.Set("page", strconv.Itoa(page))
177 }
178 if perPage > 0 {
179 q.Set("per_page", strconv.Itoa(perPage))
180 }
181 return q
182}