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

astronomer / astro-cli / bd0008aa-1a67-44ce-bf76-235040fa846e

05 Feb 2026 08:38PM UTC coverage: 33.306% (+0.2%) from 33.15%
bd0008aa-1a67-44ce-bf76-235040fa846e

push

circleci

jeremybeard
Add `astro api` command for authenticated Airflow and Cloud API requests

Introduce the `astro api` top-level command with two subcommands:

  astro api airflow — make requests to the Airflow REST API (local or
  deployed), with automatic version detection (2.x, 3.0.x, 3.0.3+) and
  OpenAPI spec resolution.

  astro api cloud — make requests to the Astro Cloud platform API using
  the current context's bearer token.

Both subcommands support:
  - Endpoint discovery via `ls` and `describe` (parsed from OpenAPI specs)
  - Calling endpoints by operation ID or raw path
  - Pagination (per-page streaming or --slurp into a single array)
  - Response caching with TTL and automatic stale-entry cleanup
  - jq filters and Go-template output formatting
  - Colored JSON output
  - --curl flag to emit equivalent curl commands
  - Magic field syntax for request bodies (@file, :=json, =string)
  - Custom headers and path-parameter overrides

Supporting packages:
  - pkg/openapi: OpenAPI spec fetching, caching, endpoint indexing,
    Airflow version-range mapping, and schema introspection.

Includes unit tests for request handling, output formatting, field
parsing, OpenAPI version mapping, endpoint indexing, and caching.

903 of 2396 new or added lines in 13 files covered. (37.69%)

9 existing lines in 1 file now uncovered.

21761 of 65337 relevant lines covered (33.31%)

8.3 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
        "os"
7
        "regexp"
8
        "strings"
9

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

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

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

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

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

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

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

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

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

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

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

×
NEW
70
  # Get organization details (auto-injects organizationId)
×
NEW
71
  astro api cloud /organizations/{organizationId}
×
NEW
72

×
NEW
73
  # Use operation ID with path parameters
×
NEW
74
  astro api cloud GetDeployment -p deploymentId=abc123
×
NEW
75

×
NEW
76
  # Use operation ID (organizationId auto-injected from context)
×
NEW
77
  astro api cloud ListDeployments
×
NEW
78

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

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

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

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

×
NEW
94
  # Include response headers
×
NEW
95
  astro api cloud /organizations/{organizationId} -i
×
NEW
96

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

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

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

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

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

×
NEW
139
        return cmd
×
140
}
141

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

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

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

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

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

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

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

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

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

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

206
        // Build the full URL
NEW
207
        url := buildURL(cloudAPIBaseURL, requestPath)
×
NEW
208

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

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

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

224
// resolveOperationID looks up an operation ID in the OpenAPI spec and returns the endpoint.
225
// If no exact operation ID match is found, it falls back to trying the input as a path
226
// (with "/" prepended) to handle bare path segments like "version" or "health".
227
// cmdName is used only in the error message (e.g. "cloud", "airflow").
NEW
228
func resolveOperationID(specCache *openapi.Cache, operationID, cmdName string) (*openapi.Endpoint, error) {
×
NEW
229
        if err := specCache.Load(false); err != nil {
×
NEW
230
                return nil, fmt.Errorf("loading OpenAPI spec: %w", err)
×
NEW
231
        }
×
232

NEW
233
        endpoints := specCache.GetEndpoints()
×
NEW
234

×
NEW
235
        // Try as operation ID first
×
NEW
236
        endpoint := openapi.FindEndpointByOperationID(endpoints, operationID)
×
NEW
237
        if endpoint != nil {
×
NEW
238
                return endpoint, nil
×
NEW
239
        }
×
240

241
        // Fall back to trying as a path (e.g., "version" -> "/version")
NEW
242
        pathMatches := openapi.FindEndpointByPath(endpoints, "/"+operationID)
×
NEW
243
        if len(pathMatches) > 0 {
×
NEW
244
                return &pathMatches[0], nil
×
NEW
245
        }
×
246

NEW
247
        return nil, fmt.Errorf("'%s' not found as operation ID or path. Use 'astro api %s ls' to see available endpoints", operationID, cmdName)
×
248
}
249

250
// applyPathParams replaces path placeholders with values from --path-param flags.
NEW
251
func applyPathParams(path string, pathParams []string) (string, error) {
×
NEW
252
        if len(pathParams) == 0 {
×
NEW
253
                return path, nil
×
NEW
254
        }
×
255

256
        // Parse path params into a map
NEW
257
        params := make(map[string]string)
×
NEW
258
        for _, p := range pathParams {
×
NEW
259
                parts := strings.SplitN(p, "=", 2)
×
NEW
260
                if len(parts) != 2 {
×
NEW
261
                        return "", fmt.Errorf("invalid path param format '%s', expected key=value", p)
×
NEW
262
                }
×
NEW
263
                params[parts[0]] = parts[1]
×
264
        }
265

266
        // Replace placeholders in the path
NEW
267
        result := placeholderRE.ReplaceAllStringFunc(path, func(match string) string {
×
NEW
268
                name := match[1 : len(match)-1]
×
NEW
269
                if val, ok := params[name]; ok {
×
NEW
270
                        return val
×
NEW
271
                }
×
NEW
272
                return match
×
273
        })
274

NEW
275
        return result, nil
×
276
}
277

278
// runCloudInteractive runs the cloud API command in interactive mode.
NEW
279
func runCloudInteractive(opts *CloudOptions) error {
×
NEW
280
        // Check if we're in a cloud context
×
NEW
281
        if !context.IsCloudContext() {
×
NEW
282
                return fmt.Errorf("the 'astro api cloud' command is only available in cloud context. Run 'astro login' to connect to Astro Cloud")
×
NEW
283
        }
×
284

285
        // Load OpenAPI spec
NEW
286
        if err := opts.specCache.Load(false); err != nil {
×
NEW
287
                return fmt.Errorf("loading OpenAPI spec: %w", err)
×
NEW
288
        }
×
289

NEW
290
        endpoints := opts.specCache.GetEndpoints()
×
NEW
291
        if len(endpoints) == 0 {
×
NEW
292
                return fmt.Errorf("no endpoints found in API specification")
×
NEW
293
        }
×
294

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

×
NEW
299
        return nil
×
300
}
301

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

305
// fillPlaceholders replaces placeholders with values from the context.
NEW
306
func fillPlaceholders(path string, ctx *config.Context) (string, error) {
×
NEW
307
        var errs []string
×
NEW
308

×
NEW
309
        result := placeholderRE.ReplaceAllStringFunc(path, func(match string) string {
×
NEW
310
                // Extract the name without braces
×
NEW
311
                name := match[1 : len(match)-1]
×
NEW
312

×
NEW
313
                switch strings.ToLower(name) {
×
NEW
314
                case "organizationid":
×
NEW
315
                        if ctx.Organization == "" {
×
NEW
316
                                errs = append(errs, "organizationId not set in context (run 'astro organization switch')")
×
NEW
317
                                return match
×
NEW
318
                        }
×
NEW
319
                        return ctx.Organization
×
NEW
320
                case "workspaceid":
×
NEW
321
                        if ctx.Workspace == "" {
×
NEW
322
                                errs = append(errs, "workspaceId not set in context (run 'astro workspace switch')")
×
NEW
323
                                return match
×
NEW
324
                        }
×
NEW
325
                        return ctx.Workspace
×
NEW
326
                default:
×
NEW
327
                        // Keep unknown placeholders as-is (user might provide them)
×
NEW
328
                        return match
×
329
                }
330
        })
331

NEW
332
        if len(errs) > 0 {
×
NEW
333
                return result, fmt.Errorf("placeholder error: %s", strings.Join(errs, "; "))
×
NEW
334
        }
×
335

NEW
336
        return result, nil
×
337
}
338

339
// findMissingPathParams returns any unfilled path parameters in the path.
NEW
340
func findMissingPathParams(path string) []string {
×
NEW
341
        matches := placeholderRE.FindAllStringSubmatch(path, -1)
×
NEW
342
        if len(matches) == 0 {
×
NEW
343
                return nil
×
NEW
344
        }
×
345

NEW
346
        missing := make([]string, 0, len(matches))
×
NEW
347
        for _, match := range matches {
×
NEW
348
                if len(match) > 1 {
×
NEW
349
                        missing = append(missing, match[1])
×
NEW
350
                }
×
351
        }
NEW
352
        return missing
×
353
}
354

355
// buildURL constructs the full URL from base and path.
NEW
356
func buildURL(baseURL, path string) string {
×
NEW
357
        // Ensure path starts with /
×
NEW
358
        if !strings.HasPrefix(path, "/") {
×
NEW
359
                path = "/" + path
×
NEW
360
        }
×
NEW
361
        return strings.TrimSuffix(baseURL, "/") + path
×
362
}
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