|
1
|
// Package apiclient is a minimal HTTP client for the scuttlebot REST API. |
|
2
|
package apiclient |
|
3
|
|
|
4
|
import ( |
|
5
|
"bytes" |
|
6
|
"encoding/json" |
|
7
|
"fmt" |
|
8
|
"io" |
|
9
|
"net/http" |
|
10
|
"strings" |
|
11
|
) |
|
12
|
|
|
13
|
// Client calls the scuttlebot REST API. |
|
14
|
type Client struct { |
|
15
|
base string |
|
16
|
token string |
|
17
|
http *http.Client |
|
18
|
} |
|
19
|
|
|
20
|
// New creates a Client targeting baseURL (e.g. "http://localhost:8080") with |
|
21
|
// the given bearer token. |
|
22
|
func New(baseURL, token string) *Client { |
|
23
|
return &Client{base: baseURL, token: token, http: &http.Client{}} |
|
24
|
} |
|
25
|
|
|
26
|
// Status returns the raw JSON bytes from GET /v1/status. |
|
27
|
func (c *Client) Status() (json.RawMessage, error) { |
|
28
|
return c.get("/v1/status") |
|
29
|
} |
|
30
|
|
|
31
|
// ListAgents returns the raw JSON bytes from GET /v1/agents. |
|
32
|
func (c *Client) ListAgents() (json.RawMessage, error) { |
|
33
|
return c.get("/v1/agents") |
|
34
|
} |
|
35
|
|
|
36
|
// GetAgent returns the raw JSON bytes from GET /v1/agents/{nick}. |
|
37
|
func (c *Client) GetAgent(nick string) (json.RawMessage, error) { |
|
38
|
return c.get("/v1/agents/" + nick) |
|
39
|
} |
|
40
|
|
|
41
|
// RegisterAgent sends POST /v1/agents/register and returns raw JSON. |
|
42
|
func (c *Client) RegisterAgent(nick, agentType string, channels []string) (json.RawMessage, error) { |
|
43
|
body := map[string]any{"nick": nick} |
|
44
|
if agentType != "" { |
|
45
|
body["type"] = agentType |
|
46
|
} |
|
47
|
if len(channels) > 0 { |
|
48
|
body["channels"] = channels |
|
49
|
} |
|
50
|
return c.post("/v1/agents/register", body) |
|
51
|
} |
|
52
|
|
|
53
|
// RevokeAgent sends POST /v1/agents/{nick}/revoke. |
|
54
|
func (c *Client) RevokeAgent(nick string) error { |
|
55
|
_, err := c.post("/v1/agents/"+nick+"/revoke", nil) |
|
56
|
return err |
|
57
|
} |
|
58
|
|
|
59
|
// RotateAgent sends POST /v1/agents/{nick}/rotate and returns raw JSON. |
|
60
|
func (c *Client) RotateAgent(nick string) (json.RawMessage, error) { |
|
61
|
return c.post("/v1/agents/"+nick+"/rotate", nil) |
|
62
|
} |
|
63
|
|
|
64
|
// DeleteAgent sends DELETE /v1/agents/{nick}. |
|
65
|
func (c *Client) DeleteAgent(nick string) error { |
|
66
|
_, err := c.doNoBody("DELETE", "/v1/agents/"+nick) |
|
67
|
return err |
|
68
|
} |
|
69
|
|
|
70
|
// ChannelUsers sends GET /v1/channels/{channel}/users and returns raw JSON. |
|
71
|
func (c *Client) ChannelUsers(channel string) (json.RawMessage, error) { |
|
72
|
return c.get("/v1/channels/" + channel + "/users") |
|
73
|
} |
|
74
|
|
|
75
|
// DeleteChannel sends DELETE /v1/channels/{channel}. |
|
76
|
func (c *Client) DeleteChannel(channel string) error { |
|
77
|
channel = strings.TrimPrefix(channel, "#") |
|
78
|
_, err := c.doNoBody("DELETE", "/v1/channels/"+channel) |
|
79
|
return err |
|
80
|
} |
|
81
|
|
|
82
|
// ListChannels sends GET /v1/channels and returns raw JSON. |
|
83
|
func (c *Client) ListChannels() (json.RawMessage, error) { |
|
84
|
return c.get("/v1/channels") |
|
85
|
} |
|
86
|
|
|
87
|
// ListLLMBackends sends GET /v1/llm/backends and returns raw JSON. |
|
88
|
func (c *Client) ListLLMBackends() (json.RawMessage, error) { |
|
89
|
return c.get("/v1/llm/backends") |
|
90
|
} |
|
91
|
|
|
92
|
// GetLLMBackend sends GET /v1/llm/backends and finds the named backend, returning raw JSON. |
|
93
|
func (c *Client) GetLLMBackend(name string) (json.RawMessage, error) { |
|
94
|
raw, err := c.get("/v1/llm/backends") |
|
95
|
if err != nil { |
|
96
|
return nil, err |
|
97
|
} |
|
98
|
var resp struct { |
|
99
|
Backends []json.RawMessage `json:"backends"` |
|
100
|
} |
|
101
|
if err := json.Unmarshal(raw, &resp); err != nil { |
|
102
|
return nil, err |
|
103
|
} |
|
104
|
for _, b := range resp.Backends { |
|
105
|
var named struct { |
|
106
|
Name string `json:"name"` |
|
107
|
} |
|
108
|
if json.Unmarshal(b, &named) == nil && named.Name == name { |
|
109
|
return b, nil |
|
110
|
} |
|
111
|
} |
|
112
|
return nil, fmt.Errorf("backend %q not found", name) |
|
113
|
} |
|
114
|
|
|
115
|
// CreateLLMBackend sends POST /v1/llm/backends. |
|
116
|
func (c *Client) CreateLLMBackend(cfg map[string]any) error { |
|
117
|
_, err := c.post("/v1/llm/backends", cfg) |
|
118
|
return err |
|
119
|
} |
|
120
|
|
|
121
|
// DeleteLLMBackend sends DELETE /v1/llm/backends/{name}. |
|
122
|
func (c *Client) DeleteLLMBackend(name string) error { |
|
123
|
_, err := c.doNoBody("DELETE", "/v1/llm/backends/"+name) |
|
124
|
return err |
|
125
|
} |
|
126
|
|
|
127
|
// ListAdmins sends GET /v1/admins and returns raw JSON. |
|
128
|
func (c *Client) ListAdmins() (json.RawMessage, error) { |
|
129
|
return c.get("/v1/admins") |
|
130
|
} |
|
131
|
|
|
132
|
// AddAdmin sends POST /v1/admins and returns raw JSON. |
|
133
|
func (c *Client) AddAdmin(username, password string) (json.RawMessage, error) { |
|
134
|
return c.post("/v1/admins", map[string]string{"username": username, "password": password}) |
|
135
|
} |
|
136
|
|
|
137
|
// RemoveAdmin sends DELETE /v1/admins/{username}. |
|
138
|
func (c *Client) RemoveAdmin(username string) error { |
|
139
|
_, err := c.doNoBody("DELETE", "/v1/admins/"+username) |
|
140
|
return err |
|
141
|
} |
|
142
|
|
|
143
|
// ListAPIKeys returns GET /v1/api-keys. |
|
144
|
func (c *Client) ListAPIKeys() (json.RawMessage, error) { |
|
145
|
return c.get("/v1/api-keys") |
|
146
|
} |
|
147
|
|
|
148
|
// CreateAPIKey sends POST /v1/api-keys. |
|
149
|
func (c *Client) CreateAPIKey(name string, scopes []string, expiresIn string) (json.RawMessage, error) { |
|
150
|
body := map[string]any{"name": name, "scopes": scopes} |
|
151
|
if expiresIn != "" { |
|
152
|
body["expires_in"] = expiresIn |
|
153
|
} |
|
154
|
return c.post("/v1/api-keys", body) |
|
155
|
} |
|
156
|
|
|
157
|
// RevokeAPIKey sends DELETE /v1/api-keys/{id}. |
|
158
|
func (c *Client) RevokeAPIKey(id string) error { |
|
159
|
_, err := c.doNoBody("DELETE", "/v1/api-keys/"+id) |
|
160
|
return err |
|
161
|
} |
|
162
|
|
|
163
|
// GetTopology returns GET /v1/topology. |
|
164
|
func (c *Client) GetTopology() (json.RawMessage, error) { |
|
165
|
return c.get("/v1/topology") |
|
166
|
} |
|
167
|
|
|
168
|
// ProvisionChannel sends POST /v1/channels. |
|
169
|
func (c *Client) ProvisionChannel(name string) (json.RawMessage, error) { |
|
170
|
return c.post("/v1/channels", map[string]string{"name": name}) |
|
171
|
} |
|
172
|
|
|
173
|
// DropChannel sends DELETE /v1/topology/channels/{channel}. |
|
174
|
func (c *Client) DropChannel(channel string) error { |
|
175
|
_, err := c.doNoBody("DELETE", "/v1/topology/channels/"+strings.TrimPrefix(channel, "#")) |
|
176
|
return err |
|
177
|
} |
|
178
|
|
|
179
|
// GetConfig returns GET /v1/config. |
|
180
|
func (c *Client) GetConfig() (json.RawMessage, error) { |
|
181
|
return c.get("/v1/config") |
|
182
|
} |
|
183
|
|
|
184
|
// GetConfigHistory returns GET /v1/config/history. |
|
185
|
func (c *Client) GetConfigHistory() (json.RawMessage, error) { |
|
186
|
return c.get("/v1/config/history") |
|
187
|
} |
|
188
|
|
|
189
|
// GetSettings returns GET /v1/settings. |
|
190
|
func (c *Client) GetSettings() (json.RawMessage, error) { |
|
191
|
return c.get("/v1/settings") |
|
192
|
} |
|
193
|
|
|
194
|
// SetAdminPassword sends PUT /v1/admins/{username}/password. |
|
195
|
func (c *Client) SetAdminPassword(username, password string) error { |
|
196
|
_, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password}) |
|
197
|
return err |
|
198
|
} |
|
199
|
|
|
200
|
func (c *Client) get(path string) (json.RawMessage, error) { |
|
201
|
return c.do("GET", path, nil) |
|
202
|
} |
|
203
|
|
|
204
|
func (c *Client) post(path string, body any) (json.RawMessage, error) { |
|
205
|
var buf bytes.Buffer |
|
206
|
if body != nil { |
|
207
|
if err := json.NewEncoder(&buf).Encode(body); err != nil { |
|
208
|
return nil, err |
|
209
|
} |
|
210
|
} |
|
211
|
return c.do("POST", path, &buf) |
|
212
|
} |
|
213
|
|
|
214
|
func (c *Client) put(path string, body any) (json.RawMessage, error) { |
|
215
|
var buf bytes.Buffer |
|
216
|
if body != nil { |
|
217
|
if err := json.NewEncoder(&buf).Encode(body); err != nil { |
|
218
|
return nil, err |
|
219
|
} |
|
220
|
} |
|
221
|
return c.do("PUT", path, &buf) |
|
222
|
} |
|
223
|
|
|
224
|
func (c *Client) doNoBody(method, path string) (json.RawMessage, error) { |
|
225
|
return c.do(method, path, nil) |
|
226
|
} |
|
227
|
|
|
228
|
func (c *Client) do(method, path string, body io.Reader) (json.RawMessage, error) { |
|
229
|
req, err := http.NewRequest(method, c.base+path, body) |
|
230
|
if err != nil { |
|
231
|
return nil, err |
|
232
|
} |
|
233
|
req.Header.Set("Authorization", "Bearer "+c.token) |
|
234
|
if body != nil { |
|
235
|
req.Header.Set("Content-Type", "application/json") |
|
236
|
} |
|
237
|
|
|
238
|
resp, err := c.http.Do(req) |
|
239
|
if err != nil { |
|
240
|
return nil, fmt.Errorf("request failed: %w", err) |
|
241
|
} |
|
242
|
defer resp.Body.Close() |
|
243
|
|
|
244
|
data, err := io.ReadAll(resp.Body) |
|
245
|
if err != nil { |
|
246
|
return nil, err |
|
247
|
} |
|
248
|
|
|
249
|
if resp.StatusCode >= 400 { |
|
250
|
// Try to extract error message from JSON body. |
|
251
|
var apiErr struct { |
|
252
|
Error string `json:"error"` |
|
253
|
} |
|
254
|
if json.Unmarshal(data, &apiErr) == nil && apiErr.Error != "" { |
|
255
|
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, apiErr.Error) |
|
256
|
} |
|
257
|
return nil, fmt.Errorf("API error %d", resp.StatusCode) |
|
258
|
} |
|
259
|
|
|
260
|
if len(data) == 0 { |
|
261
|
return nil, nil |
|
262
|
} |
|
263
|
return json.RawMessage(data), nil |
|
264
|
} |
|
265
|
|