-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapi.go
More file actions
452 lines (415 loc) · 14 KB
/
api.go
File metadata and controls
452 lines (415 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// apiClient is a thin HTTP client for the partnersapi backend, authenticated
// with an `oc_live_` access token.
type apiClient struct {
baseURL string
token string
http *http.Client
}
func newAPIClient(baseURL, token string) *apiClient {
return &apiClient{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
http: &http.Client{Timeout: 30 * time.Second},
}
}
// apiError carries a non-2xx response for friendly CLI messages.
type apiError struct {
status int
body string
}
func (e *apiError) Error() string {
// Parse JSON body — many of the friendlier messages live here.
var parsed map[string]any
jsonOK := json.Unmarshal([]byte(e.body), &parsed) == nil
// Tier-gate detection. When the backend refuses a command because the
// user is on a lower plan than the feature requires, it should respond
// with HTTP 402 / 403 and a body like:
// {"success":false, "error":"plan_required",
// "required_plan":"GROWTH", "current_plan":"STARTER",
// "feature":"broadcasts.create",
// "upgrade_url":"https://app.splashifypro.com/settings/subscriptions"}
// We surface that as an upgrade prompt regardless of which command was
// called — the backend stays the source of truth for what's gated.
if jsonOK {
if errCode, _ := parsed["error"].(string); errCode == "plan_required" ||
errCode == "subscription_required" || errCode == "tier_required" {
required, _ := parsed["required_plan"].(string)
current, _ := parsed["current_plan"].(string)
feature, _ := parsed["feature"].(string)
url, _ := parsed["upgrade_url"].(string)
if url == "" {
url = "https://app.splashifypro.com/settings/subscriptions"
}
var b strings.Builder
b.WriteString("this feature requires a higher plan")
if required != "" {
b.WriteString(" (")
if current != "" {
b.WriteString("you are on ")
b.WriteString(current)
b.WriteString(", ")
}
b.WriteString("needed: ")
b.WriteString(required)
b.WriteString(")")
}
if feature != "" {
b.WriteString("\n Feature: ")
b.WriteString(feature)
}
b.WriteString("\n Upgrade your plan: ")
b.WriteString(url)
return b.String()
}
}
msg := e.body
// Surface the backend's "message" field if the body is JSON.
if jsonOK {
if m, ok := parsed["message"].(string); ok && m != "" {
msg = m
}
}
return fmt.Sprintf("HTTP %d: %s", e.status, msg)
}
// do performs a request against /api/v1 and decodes the JSON response.
func (c *apiClient) do(method, path string, body any, out any) error {
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("encode request: %w", err)
}
reader = bytes.NewReader(b)
}
req, err := http.NewRequest(method, c.baseURL+"/api/v1"+path, reader)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &apiError{status: resp.StatusCode, body: string(raw)}
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}
// callRaw performs a request against /api/v1 and returns the raw JSON body.
// It is the basis for every task command and the generic `api` passthrough.
func (c *apiClient) callRaw(method, path string, body any) (json.RawMessage, error) {
var out json.RawMessage
if err := c.do(method, path, body, &out); err != nil {
return nil, err
}
return out, nil
}
// uploadFile POSTs a multipart/form-data request with a single file part to
// the given path. Used by `splashify media upload`. Uses its own http.Client
// with a 5-minute timeout — the default 30s on `c.http` is too tight for
// even modestly-sized videos and PDFs on real networks.
//
// formField the form field name the backend expects (e.g. "file")
// filePath path to a file on disk
//
// Returns the raw JSON response body on 2xx. apiError on non-2xx (same shape
// as `do`), so the 402 plan_required upgrade-prompt logic still applies.
func (c *apiClient) uploadFile(path, formField, filePath string) (json.RawMessage, error) {
return c.uploadFileWithFields(path, formField, filePath, nil)
}
// uploadFileWithFields is uploadFile's variant that also writes extra
// text form fields alongside the file part. Used for endpoints that need
// metadata next to the upload — e.g. RCS template uploads carry a
// `media_height` field (`SHORT`/`MEDIUM`/`TALL`) describing the rich card
// media slot the file will fill.
func (c *apiClient) uploadFileWithFields(path, formField, filePath string, extra map[string]string) (json.RawMessage, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("open file: %w", err)
}
defer file.Close()
// Buffer the form in memory. Files this CLI uploads are bounded by the
// backend's per-type limits (largest is video at a few hundred MB) and
// the user's storage quota, so a bytes.Buffer is fine without streaming.
buf := &bytes.Buffer{}
w := multipart.NewWriter(buf)
for k, v := range extra {
if err := w.WriteField(k, v); err != nil {
return nil, fmt.Errorf("write form field %s: %w", k, err)
}
}
part, err := w.CreateFormFile(formField, filepath.Base(filePath))
if err != nil {
return nil, fmt.Errorf("create form file: %w", err)
}
if _, err := io.Copy(part, file); err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("close multipart writer: %w", err)
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1"+path, buf)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Accept", "application/json")
uploadClient := &http.Client{Timeout: 5 * time.Minute}
resp, err := uploadClient.Do(req)
if err != nil {
return nil, fmt.Errorf("upload failed: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, &apiError{status: resp.StatusCode, body: string(raw)}
}
return raw, nil
}
// streamSSE consumes a Server-Sent Events stream from /api/v1<path> and
// pretty-prints each `data:` payload as JSON to stdout. It uses its own
// http.Client with no timeout (SSE streams are long-lived) and respects
// the stop callback to terminate early — used by `broadcast <id>
// progress` to exit when the broadcast hits a terminal status.
//
// baseURL backend base URL (cfg.BaseURL)
// token oc_live_ access token (cfg.Token)
// path path under /api/v1 (e.g. /app/broadcasts/<id>/progress)
// once if true, exit after the first event
// max if >0, exit after this many events
// stop called for each parsed event; return true to stop
func streamSSE(baseURL, token, path string, once bool, max int, stop func(map[string]any) bool) error {
req, err := http.NewRequest(http.MethodGet, strings.TrimRight(baseURL, "/")+"/api/v1"+path, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Cache-Control", "no-cache")
// No timeout — SSE streams are long-lived.
client := &http.Client{Timeout: 0}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("connect to stream: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
raw, _ := io.ReadAll(resp.Body)
return &apiError{status: resp.StatusCode, body: string(raw)}
}
// SSE framing: events are separated by blank lines; lines starting
// with `data:` carry the payload. We accumulate `data:` lines until
// the blank-line dispatcher fires.
reader := bufio.NewReader(resp.Body)
var dataBuf strings.Builder
count := 0
dispatch := func() error {
if dataBuf.Len() == 0 {
return nil
}
payload := dataBuf.String()
dataBuf.Reset()
raw := json.RawMessage(payload)
printJSON(raw)
count++
var parsed map[string]any
_ = json.Unmarshal([]byte(payload), &parsed)
if stop != nil && parsed != nil && stop(parsed) {
return errStreamDone
}
if once {
return errStreamDone
}
if max > 0 && count >= max {
return errStreamDone
}
return nil
}
for {
line, err := reader.ReadString('\n')
if err != nil {
// EOF on a clean stream is fine — flush whatever's buffered.
if err == io.EOF {
_ = dispatch()
return nil
}
return fmt.Errorf("read stream: %w", err)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
if derr := dispatch(); derr != nil {
if derr == errStreamDone {
return nil
}
return derr
}
continue
}
if strings.HasPrefix(line, ":") {
// SSE comment / keepalive — ignore.
continue
}
if strings.HasPrefix(line, "data:") {
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if dataBuf.Len() > 0 {
dataBuf.WriteString("\n")
}
dataBuf.WriteString(payload)
}
// Other SSE fields (event:, id:, retry:) are not used by our
// /progress stream — drop them silently.
}
}
// errStreamDone is a sentinel used inside streamSSE's dispatch closure to
// unwind the read loop cleanly without bubbling a real error to the user.
var errStreamDone = fmt.Errorf("stream done")
// printJSON writes a JSON value to stdout, indented for readability.
func printJSON(raw json.RawMessage) {
if len(raw) == 0 {
fmt.Println("(empty response)")
return
}
var buf bytes.Buffer
if err := json.Indent(&buf, raw, "", " "); err != nil {
// Not valid JSON — print as-is.
fmt.Println(string(raw))
return
}
fmt.Println(buf.String())
}
// ─── Typed endpoints used by the CLI ─────────────────────────────────────────
type meResponse struct {
Success bool `json:"success"`
Email string `json:"email"`
Name string `json:"name"`
User struct {
Email string `json:"email"`
Name string `json:"name"`
} `json:"user"`
}
// me validates the token and returns the account identity. It tolerates the
// two response shapes /app/me may use (flat or nested under "user").
func (c *apiClient) me() (email, name string, err error) {
var r meResponse
if err = c.do(http.MethodGet, "/app/me", nil, &r); err != nil {
return "", "", err
}
email, name = r.Email, r.Name
if email == "" {
email = r.User.Email
}
if name == "" {
name = r.User.Name
}
return email, name, nil
}
type accessToken struct {
ID string `json:"id"`
Name string `json:"name"`
TokenPrefix string `json:"token_prefix"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at"`
ExpiresAt string `json:"expires_at"`
Revoked bool `json:"revoked"`
}
func (c *apiClient) listTokens() ([]accessToken, error) {
var r struct {
Success bool `json:"success"`
Tokens []accessToken `json:"tokens"`
}
if err := c.do(http.MethodGet, "/app/developer/access-tokens", nil, &r); err != nil {
return nil, err
}
return r.Tokens, nil
}
func (c *apiClient) createToken(name string, expiresInDays int) (id, token string, err error) {
var r struct {
Success bool `json:"success"`
ID string `json:"id"`
Token string `json:"token"`
}
body := map[string]any{"name": name, "expires_in_days": expiresInDays}
if err = c.do(http.MethodPost, "/app/developer/access-tokens", body, &r); err != nil {
return "", "", err
}
return r.ID, r.Token, nil
}
func (c *apiClient) revokeToken(id string) error {
return c.do(http.MethodDelete, "/app/developer/access-tokens/"+id, nil, nil)
}
// resolveSelfUserID returns the calling user's user_id by hitting /app/me.
// Some endpoints (e.g. /app/meta-ads/:user_id/...) need the caller's id as
// a path parameter — we resolve it once here so CLI callers don't have to.
func resolveSelfUserID(api *apiClient) (string, error) {
var r struct {
Success bool `json:"success"`
UserID string `json:"user_id"`
ID string `json:"id"`
User struct {
UserID string `json:"user_id"`
ID string `json:"id"`
} `json:"user"`
}
if err := api.do("GET", "/app/me", nil, &r); err != nil {
return "", fmt.Errorf("resolve self user_id: %w", err)
}
for _, candidate := range []string{r.UserID, r.ID, r.User.UserID, r.User.ID} {
if candidate != "" {
return candidate, nil
}
}
return "", fmt.Errorf("could not resolve user_id from /app/me — token may be invalid")
}
// eligibilityResponse mirrors GET /app/developer/cli-eligibility. The backend
// returns 200 OK in every case; the CLI inspects `Eligible` and `Reason`
// rather than parsing HTTP status codes.
type eligibilityResponse struct {
Success bool `json:"success"`
Eligible bool `json:"eligible"`
Reason string `json:"reason"`
WabaConnected bool `json:"waba_connected"`
AccountEmail string `json:"account_email"`
PlanActive bool `json:"plan_active"`
SubscriptionActive bool `json:"subscription_active"`
PlanName string `json:"plan_name"`
PlanStatus string `json:"plan_status"`
PlanExpiresAt string `json:"plan_expires_at"`
TrialEndsAt string `json:"trial_ends_at"`
UpgradeURL string `json:"upgrade_url"`
Message string `json:"message"`
}
// cliEligibility asks the backend whether this account is allowed to use the
// CLI. Returns the parsed payload so callers can branch on the reason.
func (c *apiClient) cliEligibility() (eligibilityResponse, error) {
var r eligibilityResponse
if err := c.do(http.MethodGet, "/app/developer/cli-eligibility", nil, &r); err != nil {
return r, err
}
return r, nil
}