• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

astronomer / astro-cli / d8c087d9-3e34-4a26-9912-4e9425408752

04 Feb 2026 08:31PM UTC coverage: 32.799% (+0.005%) from 32.794%
d8c087d9-3e34-4a26-9912-4e9425408752

push

circleci

jeremybeard
Fix linting

18 of 95 new or added lines in 9 files covered. (18.95%)

5 existing lines in 1 file now uncovered.

21211 of 64670 relevant lines covered (32.8%)

8.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/cmd/api/request.go
1
package api
2

3
import (
4
        "bytes"
5
        "context"
6
        "encoding/json"
7
        "fmt"
8
        "io"
9
        "net/http"
10
        "net/url"
11
        "os"
12
        "sort"
13
        "strings"
14
        "time"
15

16
        "github.com/fatih/color"
17
)
18

19
const (
20
        defaultTimeout     = 30 * time.Second
21
        httpStatusError    = 400
22
        httpStatusRedirect = 300
23
        maxPaginationPages = 100
24
        minTokenLength     = 8
25
)
26

27
// executeRequest builds and executes the HTTP request.
28
func executeRequest(opts *CloudOptions, method, requestURL, token string, params map[string]interface{}) error {
×
29
        // Handle pagination
×
NEW
30
        if opts.Paginate && strings.EqualFold(method, "GET") {
×
31
                return executePaginatedRequest(opts, method, requestURL, token, params)
×
32
        }
×
33

34
        return executeSingleRequest(opts, method, requestURL, token, params)
×
35
}
36

37
// executeSingleRequest executes a single HTTP request.
38
//
39
//nolint:gocognit // Complex but well-structured request handling
40
func executeSingleRequest(opts *CloudOptions, method, requestURL, token string, params map[string]interface{}) error {
×
41
        var body io.Reader
×
42
        var bodyBytes []byte
×
43

×
44
        // Handle request body
×
45
        if opts.RequestInputFile != "" {
×
46
                // Read body from file
×
47
                var err error
×
48
                bodyBytes, err = readInputFile(opts.RequestInputFile)
×
49
                if err != nil {
×
50
                        return err
×
51
                }
×
52
                body = bytes.NewReader(bodyBytes)
×
53
                // If params provided, add them as query string
×
54
                if len(params) > 0 {
×
55
                        requestURL = addQueryParams(requestURL, params)
×
56
                }
×
57
        } else if len(params) > 0 {
×
NEW
58
                if strings.EqualFold(method, "GET") {
×
59
                        // For GET requests, add params as query string
×
60
                        requestURL = addQueryParams(requestURL, params)
×
61
                } else {
×
62
                        // For other methods, JSON encode params as body
×
63
                        var err error
×
64
                        bodyBytes, err = json.Marshal(params)
×
65
                        if err != nil {
×
66
                                return fmt.Errorf("marshaling request body: %w", err)
×
67
                        }
×
68
                        body = bytes.NewReader(bodyBytes)
×
69
                }
70
        }
71

72
        // Create request
73
        ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
×
74
        defer cancel()
×
75

×
76
        req, err := http.NewRequestWithContext(ctx, strings.ToUpper(method), requestURL, body)
×
77
        if err != nil {
×
78
                return fmt.Errorf("creating request: %w", err)
×
79
        }
×
80

81
        // Set headers
82
        req.Header.Set("Authorization", token)
×
83
        req.Header.Set("Accept", "application/json")
×
84
        if body != nil {
×
85
                req.Header.Set("Content-Type", "application/json")
×
86
        }
×
87

88
        // Add custom headers
89
        for _, h := range opts.RequestHeaders {
×
90
                parts := strings.SplitN(h, ":", 2)
×
91
                if len(parts) != 2 {
×
92
                        return fmt.Errorf("invalid header format %q, expected key:value", h)
×
93
                }
×
94
                req.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
×
95
        }
96

97
        // Verbose: print request
98
        if opts.Verbose {
×
99
                printRequest(opts.Out, req, bodyBytes)
×
100
        }
×
101

102
        // Execute request
103
        resp, err := http.DefaultClient.Do(req)
×
104
        if err != nil {
×
105
                return fmt.Errorf("executing request: %w", err)
×
106
        }
×
107
        defer resp.Body.Close()
×
108

×
109
        // Read response body
×
110
        respBody, err := io.ReadAll(resp.Body)
×
111
        if err != nil {
×
112
                return fmt.Errorf("reading response: %w", err)
×
113
        }
×
114

115
        // Verbose: print response headers
116
        if opts.Verbose {
×
117
                printResponseHeaders(opts.Out, resp)
×
118
        }
×
119

120
        // Print response headers if requested
121
        if opts.ShowResponseHeaders {
×
122
                fmt.Fprintf(opts.Out, "%s %s\n", resp.Proto, resp.Status)
×
123
                printHeaders(opts.Out, resp.Header, isColorEnabled(opts.Out))
×
124
                fmt.Fprintln(opts.Out)
×
125
        }
×
126

127
        // Handle error responses
NEW
128
        if resp.StatusCode >= httpStatusError {
×
129
                if len(respBody) > 0 {
×
130
                        // Try to parse and print error message
×
131
                        var errResp map[string]interface{}
×
132
                        if err := json.Unmarshal(respBody, &errResp); err == nil {
×
133
                                if msg, ok := errResp["message"]; ok {
×
134
                                        return fmt.Errorf("API error (%d): %v", resp.StatusCode, msg)
×
135
                                }
×
136
                        }
137
                        // Print raw error response
NEW
138
                        _ = writeColorizedJSON(opts.Out, respBody, isColorEnabled(opts.Out), "  ")
×
139
                }
140
                return fmt.Errorf("API request failed with status %d", resp.StatusCode)
×
141
        }
142

143
        // Handle silent mode
144
        if opts.Silent {
×
145
                return nil
×
146
        }
×
147

148
        // Handle empty response
149
        if len(respBody) == 0 {
×
150
                return nil
×
151
        }
×
152

153
        // Process output
154
        outputOpts := OutputOptions{
×
155
                FilterOutput: opts.FilterOutput,
×
156
                Template:     opts.Template,
×
157
                ColorEnabled: isColorEnabled(opts.Out),
×
158
                Indent:       "  ",
×
159
        }
×
160

×
161
        return processOutput(respBody, opts.Out, outputOpts)
×
162
}
163

164
// executePaginatedRequest handles paginated GET requests.
165
func executePaginatedRequest(opts *CloudOptions, method, requestURL, token string, params map[string]interface{}) error {
×
166
        var allPages []json.RawMessage
×
167
        currentOffset := 0
×
168
        pageNum := 0
×
169

×
170
        for {
×
171
                pageNum++
×
172
                // Build URL with current offset
×
173
                pageURL := addOffsetToURL(requestURL, currentOffset)
×
174

×
175
                // Make request
×
176
                respBody, totalCount, limit, err := fetchPage(opts, method, pageURL, token, params)
×
177
                if err != nil {
×
178
                        return err
×
179
                }
×
180

181
                if len(respBody) == 0 {
×
182
                        break
×
183
                }
184

185
                if opts.Slurp {
×
186
                        // Collect pages for slurping
×
187
                        allPages = append(allPages, respBody)
×
188
                } else {
×
189
                        // Print each page immediately
×
190
                        outputOpts := OutputOptions{
×
191
                                FilterOutput: opts.FilterOutput,
×
192
                                Template:     opts.Template,
×
193
                                ColorEnabled: isColorEnabled(opts.Out),
×
194
                                Indent:       "  ",
×
195
                        }
×
196
                        if err := processOutput(respBody, opts.Out, outputOpts); err != nil {
×
197
                                return err
×
198
                        }
×
199
                }
200

201
                // Check if there are more pages
202
                currentOffset += limit
×
203
                if totalCount <= 0 || currentOffset >= totalCount {
×
204
                        break
×
205
                }
206

207
                // Safety limit to prevent infinite loops
NEW
208
                if pageNum >= maxPaginationPages {
×
209
                        fmt.Fprintf(opts.Out, "\nWarning: reached maximum page limit (100)\n")
×
210
                        break
×
211
                }
212
        }
213

214
        // If slurping, combine all pages and output
215
        if opts.Slurp && len(allPages) > 0 {
×
216
                combined, err := combinePages(allPages)
×
217
                if err != nil {
×
218
                        return fmt.Errorf("combining pages: %w", err)
×
219
                }
×
220
                outputOpts := OutputOptions{
×
221
                        FilterOutput: opts.FilterOutput,
×
222
                        Template:     opts.Template,
×
223
                        ColorEnabled: isColorEnabled(opts.Out),
×
224
                        Indent:       "  ",
×
225
                }
×
226
                return processOutput(combined, opts.Out, outputOpts)
×
227
        }
228

229
        return nil
×
230
}
231

232
// fetchPage fetches a single page and returns the body, totalCount, and limit.
NEW
233
func fetchPage(opts *CloudOptions, method, requestURL, token string, params map[string]interface{}) (body []byte, totalCount, limit int, err error) {
×
234
        // Add params as query string for GET
×
235
        if len(params) > 0 {
×
236
                requestURL = addQueryParams(requestURL, params)
×
237
        }
×
238

239
        // Create request
240
        ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
×
241
        defer cancel()
×
242

×
NEW
243
        req, err := http.NewRequestWithContext(ctx, strings.ToUpper(method), requestURL, http.NoBody)
×
244
        if err != nil {
×
245
                return nil, 0, 0, fmt.Errorf("creating request: %w", err)
×
246
        }
×
247

248
        // Set headers
249
        req.Header.Set("Authorization", token)
×
250
        req.Header.Set("Accept", "application/json")
×
251

×
252
        // Add custom headers
×
253
        for _, h := range opts.RequestHeaders {
×
254
                parts := strings.SplitN(h, ":", 2)
×
255
                if len(parts) == 2 {
×
256
                        req.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
×
257
                }
×
258
        }
259

260
        // Verbose: print request
261
        if opts.Verbose {
×
262
                printRequest(opts.Out, req, nil)
×
263
        }
×
264

265
        // Execute request
266
        resp, err := http.DefaultClient.Do(req)
×
267
        if err != nil {
×
268
                return nil, 0, 0, fmt.Errorf("executing request: %w", err)
×
269
        }
×
270
        defer resp.Body.Close()
×
271

×
272
        // Read response body
×
273
        respBody, err := io.ReadAll(resp.Body)
×
274
        if err != nil {
×
275
                return nil, 0, 0, fmt.Errorf("reading response: %w", err)
×
276
        }
×
277

278
        // Verbose: print response headers
279
        if opts.Verbose {
×
280
                printResponseHeaders(opts.Out, resp)
×
281
        }
×
282

283
        // Handle error responses
NEW
284
        if resp.StatusCode >= httpStatusError {
×
285
                return nil, 0, 0, fmt.Errorf("API request failed with status %d", resp.StatusCode)
×
286
        }
×
287

288
        // Parse response to get pagination info
289
        var pageInfo struct {
×
290
                TotalCount int `json:"totalCount"`
×
291
                Offset     int `json:"offset"`
×
292
                Limit      int `json:"limit"`
×
293
        }
×
294
        if err := json.Unmarshal(respBody, &pageInfo); err == nil {
×
295
                return respBody, pageInfo.TotalCount, pageInfo.Limit, nil
×
296
        }
×
297

298
        // No pagination info found
299
        return respBody, 0, 0, nil
×
300
}
301

302
// addOffsetToURL adds or updates the offset query parameter.
303
func addOffsetToURL(requestURL string, offset int) string {
×
304
        if offset == 0 {
×
305
                return requestURL
×
306
        }
×
307

308
        u, err := url.Parse(requestURL)
×
309
        if err != nil {
×
310
                return requestURL
×
311
        }
×
312

313
        q := u.Query()
×
314
        q.Set("offset", fmt.Sprintf("%d", offset))
×
315
        u.RawQuery = q.Encode()
×
316
        return u.String()
×
317
}
318

319
// combinePages combines multiple paginated responses into a single array.
320
func combinePages(pages []json.RawMessage) ([]byte, error) {
×
321
        if len(pages) == 0 {
×
322
                return []byte("[]"), nil
×
323
        }
×
324

325
        // Try to find the main array field in the responses (e.g., "organizations", "deployments")
326
        var firstPage map[string]interface{}
×
327
        if err := json.Unmarshal(pages[0], &firstPage); err != nil {
×
328
                // If not an object, just return array of pages
×
329
                result, err := json.Marshal(pages)
×
330
                if err != nil {
×
331
                        return nil, err
×
332
                }
×
333
                return result, nil
×
334
        }
335

336
        // Find the array field (skip pagination fields)
337
        var arrayField string
×
338
        for key, val := range firstPage {
×
339
                if key == "totalCount" || key == "offset" || key == "limit" {
×
340
                        continue
×
341
                }
342
                if _, ok := val.([]interface{}); ok {
×
343
                        arrayField = key
×
344
                        break
×
345
                }
346
        }
347

348
        if arrayField == "" {
×
349
                // No array field found, return array of pages
×
350
                result, err := json.Marshal(pages)
×
351
                if err != nil {
×
352
                        return nil, err
×
353
                }
×
354
                return result, nil
×
355
        }
356

357
        // Combine all items from the array field
358
        var allItems []interface{}
×
359
        for _, page := range pages {
×
360
                var pageData map[string]interface{}
×
361
                if err := json.Unmarshal(page, &pageData); err != nil {
×
362
                        continue
×
363
                }
364
                if items, ok := pageData[arrayField].([]interface{}); ok {
×
365
                        allItems = append(allItems, items...)
×
366
                }
×
367
        }
368

369
        // Return combined result with just the array
370
        result := map[string]interface{}{
×
371
                arrayField: allItems,
×
372
        }
×
373
        return json.Marshal(result)
×
374
}
375

376
// readInputFile reads the request body from a file or stdin.
377
func readInputFile(filename string) ([]byte, error) {
×
378
        if filename == "-" {
×
379
                return io.ReadAll(os.Stdin)
×
380
        }
×
381
        return os.ReadFile(filename)
×
382
}
383

384
// addQueryParams adds params to the URL as query string.
385
func addQueryParams(requestURL string, params map[string]interface{}) string {
×
386
        if len(params) == 0 {
×
387
                return requestURL
×
388
        }
×
389

390
        u, err := url.Parse(requestURL)
×
391
        if err != nil {
×
392
                return requestURL
×
393
        }
×
394

395
        q := u.Query()
×
396
        for key, value := range params {
×
397
                addQueryParam(q, key, value)
×
398
        }
×
399
        u.RawQuery = q.Encode()
×
400

×
401
        return u.String()
×
402
}
403

404
// addQueryParam recursively adds a parameter to the query.
405
func addQueryParam(q url.Values, key string, value interface{}) {
×
406
        switch v := value.(type) {
×
407
        case string:
×
408
                q.Add(key, v)
×
409
        case int:
×
410
                q.Add(key, fmt.Sprintf("%d", v))
×
411
        case bool:
×
412
                q.Add(key, fmt.Sprintf("%v", v))
×
413
        case nil:
×
414
                q.Add(key, "")
×
415
        case []interface{}:
×
416
                for _, item := range v {
×
417
                        addQueryParam(q, key+"[]", item)
×
418
                }
×
419
        case map[string]interface{}:
×
420
                for subkey, subvalue := range v {
×
421
                        addQueryParam(q, subkey, subvalue)
×
422
                }
×
423
        default:
×
424
                q.Add(key, fmt.Sprintf("%v", v))
×
425
        }
426
}
427

428
// generateCurl generates a curl command for the request.
429
func generateCurl(out io.Writer, method, requestURL, token string, headers []string, params map[string]interface{}, inputFile string) error {
×
NEW
430
        parts := make([]string, 0, 10+len(headers)*2)
×
431
        parts = append(parts, "curl")
×
432

×
433
        // Method
×
434
        if method != "GET" {
×
435
                parts = append(parts, "-X", method)
×
436
        }
×
437

438
        // URL (with query params for GET, or when input file is used)
NEW
439
        if strings.EqualFold(method, "GET") && len(params) > 0 {
×
440
                requestURL = addQueryParams(requestURL, params)
×
441
        } else if inputFile != "" && len(params) > 0 {
×
442
                // When using input file, add params as query string
×
443
                requestURL = addQueryParams(requestURL, params)
×
444
        }
×
445
        // URL and standard headers
NEW
446
        parts = append(parts,
×
NEW
447
                fmt.Sprintf("'%s'", requestURL),
×
NEW
448
                "-H", fmt.Sprintf("'Authorization: %s'", token),
×
NEW
449
                "-H", "'Accept: application/json'",
×
NEW
450
        )
×
451

×
452
        // Custom headers
×
453
        for _, h := range headers {
×
454
                parts = append(parts, "-H", fmt.Sprintf("'%s'", h))
×
455
        }
×
456

457
        // Body for non-GET requests
NEW
458
        if !strings.EqualFold(method, "GET") {
×
459
                if inputFile != "" {
×
460
                        // Read body from input file
×
461
                        bodyBytes, err := readInputFile(inputFile)
×
462
                        if err != nil {
×
463
                                return fmt.Errorf("reading input file: %w", err)
×
464
                        }
×
NEW
465
                        parts = append(parts, "-H", "'Content-Type: application/json'", "-d", fmt.Sprintf("'%s'", string(bodyBytes)))
×
466
                } else if len(params) > 0 {
×
467
                        bodyBytes, err := json.Marshal(params)
×
468
                        if err != nil {
×
469
                                return fmt.Errorf("marshaling request body: %w", err)
×
470
                        }
×
NEW
471
                        parts = append(parts, "-H", "'Content-Type: application/json'", "-d", fmt.Sprintf("'%s'", string(bodyBytes)))
×
472
                }
473
        }
474

475
        fmt.Fprintln(out, strings.Join(parts, " \\\n  "))
×
476
        return nil
×
477
}
478

479
// printRequest prints the request details for verbose mode.
480
func printRequest(out io.Writer, req *http.Request, body []byte) {
×
481
        fmt.Fprintf(out, "%s %s %s\n", color.CyanString(">"), color.GreenString(req.Method), req.URL.String())
×
482
        fmt.Fprintf(out, "%s Host: %s\n", color.CyanString(">"), req.Host)
×
483

×
484
        // Print headers
×
NEW
485
        keys := make([]string, 0, len(req.Header))
×
486
        for k := range req.Header {
×
487
                keys = append(keys, k)
×
488
        }
×
489
        sort.Strings(keys)
×
490

×
491
        for _, k := range keys {
×
492
                // Mask authorization header value
×
493
                val := strings.Join(req.Header[k], ", ")
×
NEW
494
                if strings.EqualFold(k, "authorization") {
×
495
                        val = maskToken(val)
×
496
                }
×
497
                fmt.Fprintf(out, "%s %s: %s\n", color.CyanString(">"), k, val)
×
498
        }
499

500
        fmt.Fprintln(out, color.CyanString(">"))
×
501

×
502
        // Print body if present
×
503
        if len(body) > 0 {
×
504
                var prettyBody bytes.Buffer
×
505
                if err := json.Indent(&prettyBody, body, "", "  "); err == nil {
×
506
                        for _, line := range strings.Split(prettyBody.String(), "\n") {
×
507
                                fmt.Fprintf(out, "%s %s\n", color.CyanString(">"), line)
×
508
                        }
×
509
                }
510
        }
511
        fmt.Fprintln(out)
×
512
}
513

514
// printResponseHeaders prints response headers for verbose mode.
515
func printResponseHeaders(out io.Writer, resp *http.Response) {
×
516
        statusColor := color.GreenString
×
NEW
517
        if resp.StatusCode >= httpStatusError {
×
518
                statusColor = color.RedString
×
NEW
519
        } else if resp.StatusCode >= httpStatusRedirect {
×
520
                statusColor = color.YellowString
×
521
        }
×
522

523
        fmt.Fprintf(out, "%s %s\n", color.CyanString("<"), statusColor("%s %s", resp.Proto, resp.Status))
×
524

×
525
        // Print headers
×
NEW
526
        keys := make([]string, 0, len(resp.Header))
×
527
        for k := range resp.Header {
×
528
                keys = append(keys, k)
×
529
        }
×
530
        sort.Strings(keys)
×
531

×
532
        for _, k := range keys {
×
533
                fmt.Fprintf(out, "%s %s: %s\n", color.CyanString("<"), k, strings.Join(resp.Header[k], ", "))
×
534
        }
×
535
        fmt.Fprintln(out, color.CyanString("<"))
×
536
        fmt.Fprintln(out)
×
537
}
538

539
// printHeaders prints HTTP headers.
540
func printHeaders(w io.Writer, headers http.Header, colorize bool) {
×
NEW
541
        names := make([]string, 0, len(headers))
×
542
        for name := range headers {
×
543
                if name == "Status" {
×
544
                        continue
×
545
                }
546
                names = append(names, name)
×
547
        }
548
        sort.Strings(names)
×
549

×
550
        var headerColor, headerColorReset string
×
551
        if colorize {
×
552
                headerColor = "\x1b[1;34m" // bright blue
×
553
                headerColorReset = "\x1b[m"
×
554
        }
×
555

556
        for _, name := range names {
×
557
                fmt.Fprintf(w, "%s%s%s: %s\n", headerColor, name, headerColorReset, strings.Join(headers[name], ", "))
×
558
        }
×
559
}
560

561
// maskToken masks most of a token for display.
562
func maskToken(token string) string {
×
563
        // Remove "Bearer " prefix if present
×
564
        token = strings.TrimPrefix(token, "Bearer ")
×
565

×
NEW
566
        if len(token) <= minTokenLength {
×
567
                return "****"
×
568
        }
×
569
        return token[:4] + "..." + token[len(token)-4:]
×
570
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc