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

astronomer / astro-cli / a129a81e-3fd6-450c-bacb-6b30caee4888

05 Feb 2026 07:33PM UTC coverage: 32.877% (+0.004%) from 32.873%
a129a81e-3fd6-450c-bacb-6b30caee4888

push

circleci

jeremybeard
Refactor options

16 of 42 new or added lines in 4 files covered. (38.1%)

1 existing line in 1 file now uncovered.

21484 of 65346 relevant lines covered (32.88%)

8.27 hits per line

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

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

3
import (
4
        "fmt"
5
        "io"
6
        "regexp"
7
        "strings"
8

9
        "github.com/astronomer/astro-cli/config"
10
        "github.com/astronomer/astro-cli/context"
11
        "github.com/astronomer/astro-cli/pkg/ansi"
12
        "github.com/astronomer/astro-cli/pkg/openapi"
13
        "github.com/spf13/cobra"
14
)
15

16
const (
17
        cloudAPIBaseURL = "https://api.astronomer.io/v1"
18
)
19

20
// CloudOptions holds all options for the cloud api command.
21
type CloudOptions struct {
22
        RequestOptions
23
}
24

25
// NewCloudCmd creates the 'astro api cloud' command.
26
//
27
//nolint:dupl
28
func NewCloudCmd(out io.Writer) *cobra.Command {
×
29
        opts := &CloudOptions{
×
NEW
30
                RequestOptions: RequestOptions{
×
NEW
31
                        Out:       out,
×
NEW
32
                        specCache: openapi.NewCache(),
×
NEW
33
                },
×
34
        }
×
35

×
36
        cmd := &cobra.Command{
×
37
                Use:   "cloud <endpoint | operation-id>",
×
38
                Short: "Make authenticated requests to the Astro Cloud API",
×
39
                Long: `Make authenticated HTTP requests to the Astro Cloud API (api.astronomer.io).
×
40

×
41
The argument can be either:
×
42
  - A path of an Astro Cloud API endpoint (e.g., /organizations/{organizationId})
×
43
  - An operation ID from the API spec (e.g., GetDeployment, ListOrganizations)
×
44

×
45
Placeholder values {organizationId} and {workspaceId} will be replaced
×
46
with values from the current context. Other path parameters can be provided
×
47
using the -p/--path-param flag.
×
48

×
49
The default HTTP request method is GET normally and POST if any parameters
×
50
were added. Override the method with --method. When using an operation ID,
×
51
the method is auto-detected from the API spec.
×
52

×
53
Pass one or more -f/--raw-field values in key=value format to add static string
×
54
parameters to the request payload. To add non-string or placeholder-determined
×
55
values, see -F/--field below.
×
56

×
57
The -F/--field flag has magic type conversion based on the format of the value:
×
58
  - literal values true, false, null, and integer numbers get converted to
×
59
    appropriate JSON types;
×
60
  - if the value starts with @, the rest of the value is interpreted as a
×
61
    filename to read the value from. Pass - to read from standard input.
×
62

×
63
To pass nested parameters in the request payload, use key[subkey]=value syntax.
×
64
To pass nested values as arrays, declare multiple fields with key[]=value1.`,
×
65
                Example: `  # List all Cloud API endpoints
×
66
  astro api cloud ls
×
67

×
68
  # Get organization details (auto-injects organizationId)
×
69
  astro api cloud /organizations/{organizationId}
×
70

×
71
  # Use operation ID with path parameters
×
72
  astro api cloud GetDeployment -p deploymentId=abc123
×
73

×
74
  # Use operation ID (organizationId auto-injected from context)
×
75
  astro api cloud ListDeployments
×
76

×
77
  # List deployments with jq filter
×
78
  astro api cloud /organizations/{organizationId}/deployments --jq '.[].name'
×
79

×
80
  # Create a resource with typed fields
×
81
  astro api cloud -X POST /organizations/{organizationId}/workspaces \
×
82
    -F name=my-workspace \
×
83
    -F description="My new workspace"
×
84

×
85
  # Use Go template for output
×
86
  astro api cloud /organizations/{organizationId}/deployments \
×
87
    --template '{{range .}}{{.name}} ({{.status}}){{"\n"}}{{end}}'
×
88

×
89
  # Generate curl command instead of executing
×
90
  astro api cloud /organizations/{organizationId}/deployments --generate
×
91

×
92
  # Include response headers
×
93
  astro api cloud /organizations/{organizationId} -i
×
94

×
95
  # Verbose mode showing full request/response
×
96
  astro api cloud /organizations/{organizationId} --verbose`,
×
97
                Args: cobra.MaximumNArgs(1),
×
98
                PreRunE: func(cmd *cobra.Command, args []string) error {
×
99
                        opts.RequestMethodPassed = cmd.Flags().Changed("method")
×
100
                        return nil
×
101
                },
×
102
                RunE: func(cmd *cobra.Command, args []string) error {
×
103
                        if len(args) == 0 {
×
104
                                // Interactive mode - show endpoint selection
×
105
                                return runCloudInteractive(opts)
×
106
                        }
×
107
                        opts.RequestPath = args[0]
×
108
                        return runCloud(opts)
×
109
                },
110
        }
111

112
        // Request flags
113
        cmd.Flags().StringVarP(&opts.RequestMethod, "method", "X", "GET", "The HTTP method for the request")
×
114
        cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a typed parameter in key=value format")
×
115
        cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in key=value format")
×
116
        cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add a HTTP request header in key:value format")
×
117
        cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The file to use as body for the HTTP request (use \"-\" for stdin)")
×
118
        cmd.Flags().StringArrayVarP(&opts.PathParams, "path-param", "p", nil, "Path parameter in key=value format (for use with operation IDs)")
×
119

×
120
        // Output flags
×
121
        cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response status line and headers in the output")
×
122
        cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results")
×
123
        cmd.Flags().BoolVar(&opts.Slurp, "slurp", false, "Use with --paginate to return an array of all pages")
×
124
        cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Do not print the response body")
×
125
        cmd.Flags().StringVarP(&opts.Template, "template", "t", "", "Format JSON output using a Go template")
×
126
        cmd.Flags().StringVarP(&opts.FilterOutput, "jq", "q", "", "Query to select values from the response using jq syntax")
×
127
        cmd.Flags().BoolVar(&opts.Verbose, "verbose", false, "Include full HTTP request and response in the output")
×
128
        cmd.Flags().DurationVar(&opts.CacheTTL, "cache", 0, "Cache the response, e.g. \"3600s\", \"60m\", \"1h\"")
×
129

×
130
        // Other flags
×
131
        cmd.Flags().BoolVar(&opts.GenerateCurl, "generate", false, "Output a curl command instead of executing the request")
×
132

×
133
        // Add list and describe subcommands
×
134
        cmd.AddCommand(NewListCmd(out, opts.specCache, "cloud"))
×
135
        cmd.AddCommand(NewDescribeCmd(out, opts.specCache, "cloud"))
×
136

×
137
        return cmd
×
138
}
139

140
// runCloud executes the cloud API request.
141
func runCloud(opts *CloudOptions) error {
×
142
        // Check if we're in a cloud context
×
143
        if !context.IsCloudContext() {
×
144
                return fmt.Errorf("the 'astro api cloud' command is only available in cloud context. Run 'astro login' to connect to Astro Cloud")
×
145
        }
×
146

147
        // Get current context for auth and placeholders
148
        ctx, err := context.GetCurrentContext()
×
149
        if err != nil {
×
150
                return fmt.Errorf("getting current context: %w", err)
×
151
        }
×
152

153
        // Check for token
154
        if ctx.Token == "" {
×
155
                return fmt.Errorf("not authenticated. Run 'astro login' to authenticate")
×
156
        }
×
157

158
        // Resolve operation ID to path if needed
159
        requestPath := opts.RequestPath
×
160
        method := opts.RequestMethod
×
161
        methodFromSpec := false
×
162

×
163
        if isOperationID(requestPath) {
×
164
                endpoint, err := resolveOperationID(opts.specCache, requestPath)
×
165
                if err != nil {
×
166
                        return err
×
167
                }
×
168
                requestPath = endpoint.Path
×
169
                if !opts.RequestMethodPassed {
×
170
                        method = endpoint.Method
×
171
                        methodFromSpec = true
×
172
                }
×
173
        }
174

175
        // Apply path params from flags
176
        requestPath, err = applyPathParams(requestPath, opts.PathParams)
×
177
        if err != nil {
×
178
                return fmt.Errorf("applying path params: %w", err)
×
179
        }
×
180

181
        // Fill context placeholders in the path
182
        requestPath, err = fillPlaceholders(requestPath, &ctx)
×
183
        if err != nil {
×
184
                return fmt.Errorf("filling placeholders: %w", err)
×
185
        }
×
186

187
        // Check for any remaining unfilled path parameters
188
        if missing := findMissingPathParams(requestPath); len(missing) > 0 {
×
189
                return fmt.Errorf("missing path parameter(s): %s. Use -p/--path-param to provide them (e.g., -p %s=value)",
×
190
                        strings.Join(missing, ", "), missing[0])
×
191
        }
×
192

193
        // Parse fields into request body
194
        params, err := parseFields(opts.MagicFields, opts.RawFields)
×
195
        if err != nil {
×
196
                return fmt.Errorf("parsing fields: %w", err)
×
197
        }
×
198

199
        // Determine HTTP method (only override if not from spec and not explicitly passed)
200
        if !methodFromSpec && !opts.RequestMethodPassed && (len(params) > 0 || opts.RequestInputFile != "") {
×
201
                method = "POST"
×
202
        }
×
203

204
        // Build the full URL
205
        url := buildURL(cloudAPIBaseURL, requestPath)
×
206

×
207
        // Generate curl command if requested
×
208
        if opts.GenerateCurl {
×
209
                return generateCurl(opts.Out, method, url, ctx.Token, opts.RequestHeaders, params, opts.RequestInputFile)
×
210
        }
×
211

212
        // Build and execute the request
NEW
213
        return executeRequest(&opts.RequestOptions, method, url, ctx.Token, params)
×
214
}
215

216
// isOperationID checks if the input looks like an operation ID rather than a path.
217
// Operation IDs don't contain "/" and typically are CamelCase or camelCase.
218
func isOperationID(input string) bool {
×
219
        return !strings.Contains(input, "/")
×
220
}
×
221

222
// resolveOperationID looks up an operation ID in the OpenAPI spec and returns the endpoint.
223
func resolveOperationID(specCache *openapi.Cache, operationID string) (*openapi.Endpoint, error) {
×
224
        if err := specCache.Load(false); err != nil {
×
225
                return nil, fmt.Errorf("loading OpenAPI spec: %w", err)
×
226
        }
×
227

228
        endpoint := openapi.FindEndpointByOperationID(specCache.GetEndpoints(), operationID)
×
229
        if endpoint == nil {
×
230
                return nil, fmt.Errorf("operation ID '%s' not found. Use 'astro api cloud ls' to see available endpoints", operationID)
×
231
        }
×
232

233
        return endpoint, nil
×
234
}
235

236
// applyPathParams replaces path placeholders with values from --path-param flags.
237
func applyPathParams(path string, pathParams []string) (string, error) {
×
238
        if len(pathParams) == 0 {
×
239
                return path, nil
×
240
        }
×
241

242
        // Parse path params into a map
243
        params := make(map[string]string)
×
244
        for _, p := range pathParams {
×
245
                parts := strings.SplitN(p, "=", 2)
×
246
                if len(parts) != 2 {
×
247
                        return "", fmt.Errorf("invalid path param format '%s', expected key=value", p)
×
248
                }
×
249
                params[parts[0]] = parts[1]
×
250
        }
251

252
        // Replace placeholders in the path
253
        result := placeholderRE.ReplaceAllStringFunc(path, func(match string) string {
×
254
                name := match[1 : len(match)-1]
×
255
                if val, ok := params[name]; ok {
×
256
                        return val
×
257
                }
×
258
                return match
×
259
        })
260

261
        return result, nil
×
262
}
263

264
// runCloudInteractive runs the cloud API command in interactive mode.
265
func runCloudInteractive(opts *CloudOptions) error {
×
266
        // Check if we're in a cloud context
×
267
        if !context.IsCloudContext() {
×
268
                return fmt.Errorf("the 'astro api cloud' command is only available in cloud context. Run 'astro login' to connect to Astro Cloud")
×
269
        }
×
270

271
        // Load OpenAPI spec
272
        if err := opts.specCache.Load(false); err != nil {
×
273
                return fmt.Errorf("loading OpenAPI spec: %w", err)
×
274
        }
×
275

276
        endpoints := opts.specCache.GetEndpoints()
×
277
        if len(endpoints) == 0 {
×
278
                return fmt.Errorf("no endpoints found in API specification")
×
279
        }
×
280

281
        // Show endpoint selection
282
        fmt.Fprintf(opts.Out, "\nFound %d endpoints. Use '%s' to list them.\n", len(endpoints), ansi.Bold("astro api cloud ls"))
×
283
        fmt.Fprintf(opts.Out, "Run '%s' to make a request.\n\n", ansi.Bold("astro api cloud <endpoint>"))
×
284

×
285
        return nil
×
286
}
287

288
// placeholderRE matches placeholders like {organizationId}, {workspaceId}, {dag_id}, etc.
289
var placeholderRE = regexp.MustCompile(`\{([a-zA-Z][a-zA-Z0-9_]*)\}`)
290

291
// fillPlaceholders replaces placeholders with values from the context.
292
func fillPlaceholders(path string, ctx *config.Context) (string, error) {
×
293
        var errs []string
×
294

×
295
        result := placeholderRE.ReplaceAllStringFunc(path, func(match string) string {
×
296
                // Extract the name without braces
×
297
                name := match[1 : len(match)-1]
×
298

×
299
                switch strings.ToLower(name) {
×
300
                case "organizationid":
×
301
                        if ctx.Organization == "" {
×
302
                                errs = append(errs, "organizationId not set in context (run 'astro organization switch')")
×
303
                                return match
×
304
                        }
×
305
                        return ctx.Organization
×
306
                case "workspaceid":
×
307
                        if ctx.Workspace == "" {
×
308
                                errs = append(errs, "workspaceId not set in context (run 'astro workspace switch')")
×
309
                                return match
×
310
                        }
×
311
                        return ctx.Workspace
×
312
                default:
×
313
                        // Keep unknown placeholders as-is (user might provide them)
×
314
                        return match
×
315
                }
316
        })
317

318
        if len(errs) > 0 {
×
319
                return result, fmt.Errorf("placeholder error: %s", strings.Join(errs, "; "))
×
320
        }
×
321

322
        return result, nil
×
323
}
324

325
// findMissingPathParams returns any unfilled path parameters in the path.
326
func findMissingPathParams(path string) []string {
×
327
        matches := placeholderRE.FindAllStringSubmatch(path, -1)
×
328
        if len(matches) == 0 {
×
329
                return nil
×
330
        }
×
331

332
        missing := make([]string, 0, len(matches))
×
333
        for _, match := range matches {
×
334
                if len(match) > 1 {
×
335
                        missing = append(missing, match[1])
×
336
                }
×
337
        }
338
        return missing
×
339
}
340

341
// buildURL constructs the full URL from base and path.
342
func buildURL(baseURL, path string) string {
×
343
        // Ensure path starts with /
×
344
        if !strings.HasPrefix(path, "/") {
×
345
                path = "/" + path
×
346
        }
×
347
        return strings.TrimSuffix(baseURL, "/") + path
×
348
}
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