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

astronomer / astro-cli / 88489d08-9b6c-4c4b-8deb-9cee83b841fc

11 Feb 2026 02:53PM UTC coverage: 34.999% (+1.8%) from 33.15%
88489d08-9b6c-4c4b-8deb-9cee83b841fc

Pull #2006

circleci

jeremybeard
Feedback updates
Pull Request #2006: Add `astro api` command

2002 of 2395 new or added lines in 13 files covered. (83.59%)

22867 of 65336 relevant lines covered (35.0%)

8.42 hits per line

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

90.6
/pkg/openapi/cache.go
1
package openapi
2

3
import (
4
        "context"
5
        "encoding/json"
6
        "fmt"
7
        "io"
8
        "net/http"
9
        "os"
10
        "path/filepath"
11
        "strings"
12
        "time"
13

14
        "github.com/astronomer/astro-cli/config"
15
        "gopkg.in/yaml.v3"
16
)
17

18
const (
19
        // SpecURL is the URL to fetch the Astro Cloud API OpenAPI specification.
20
        SpecURL = "https://api.astronomer.io/spec/v1.0"
21
        // CloudCacheFileName is the name of the cache file for the Astro Cloud API.
22
        CloudCacheFileName = "openapi-cache.json"
23
        // AirflowCacheFileNameTemplate is the template for version-specific Airflow cache files.
24
        AirflowCacheFileNameTemplate = "openapi-airflow-%s-cache.json"
25
        // CacheTTL is how long the cache is valid.
26
        CacheTTL = 24 * time.Hour
27
        // FetchTimeout is the timeout for fetching the spec.
28
        FetchTimeout = 30 * time.Second
29
        // dirPermissions is the permission mode for created directories.
30
        dirPermissions = 0o755
31
        // filePermissions is the permission mode for the cache file.
32
        filePermissions = 0o600
33
)
34

35
// AirflowCacheFileNameForVersion returns the cache file name for a specific Airflow version.
36
// The version is normalized to strip build metadata (e.g., "3.1.7+astro.1" -> "3.1.7").
37
func AirflowCacheFileNameForVersion(version string) string {
7✔
38
        normalizedVersion := NormalizeAirflowVersion(version)
7✔
39
        return fmt.Sprintf(AirflowCacheFileNameTemplate, normalizedVersion)
7✔
40
}
7✔
41

42
// CachedSpec wraps the OpenAPI spec with metadata for caching.
43
type CachedSpec struct {
44
        Spec      *OpenAPISpec `json:"spec"`
45
        FetchedAt time.Time    `json:"fetchedAt"`
46
}
47

48
// Cache manages fetching and caching of an OpenAPI specification.
49
type Cache struct {
50
        specURL     string
51
        cachePath   string
52
        stripPrefix string // optional prefix to strip from endpoint paths (e.g. "/api/v2")
53
        httpClient  *http.Client
54
        spec        *OpenAPISpec
55
        fetchedAt   time.Time
56
}
57

58
// getHTTPClient returns the configured HTTP client, defaulting to http.DefaultClient.
59
func (c *Cache) getHTTPClient() *http.Client {
15✔
60
        if c.httpClient != nil {
16✔
61
                return c.httpClient
1✔
62
        }
1✔
63
        return http.DefaultClient
14✔
64
}
65

66
// SetHTTPClient configures the HTTP client used to fetch specs.
67
func (c *Cache) SetHTTPClient(client *http.Client) {
1✔
68
        c.httpClient = client
1✔
69
}
1✔
70

71
// NewCache creates a new OpenAPI cache with default settings.
72
func NewCache() *Cache {
1✔
73
        return &Cache{
1✔
74
                specURL:   SpecURL,
1✔
75
                cachePath: filepath.Join(config.HomeConfigPath, CloudCacheFileName),
1✔
76
        }
1✔
77
}
1✔
78

79
// NewCacheWithOptions creates a new OpenAPI cache with custom settings.
80
func NewCacheWithOptions(specURL, cachePath string) *Cache {
5✔
81
        return &Cache{
5✔
82
                specURL:   specURL,
5✔
83
                cachePath: cachePath,
5✔
84
        }
5✔
85
}
5✔
86

87
// NewAirflowCacheForVersion creates a new OpenAPI cache configured for a specific Airflow version.
88
// It automatically determines the correct spec URL and cache file path based on the version.
89
// Endpoint paths are stripped of their API version prefix (/api/v1 or /api/v2) so that
90
// callers work with version-agnostic paths like /dags instead of /api/v2/dags.
91
func NewAirflowCacheForVersion(version string) (*Cache, error) {
4✔
92
        specURL, err := BuildAirflowSpecURL(version)
4✔
93
        if err != nil {
5✔
94
                return nil, fmt.Errorf("building spec URL for version %s: %w", version, err)
1✔
95
        }
1✔
96

97
        cachePath := filepath.Join(config.HomeConfigPath, AirflowCacheFileNameForVersion(version))
3✔
98

3✔
99
        // Determine the API prefix to strip based on major version
3✔
100
        prefix := "/api/v2"
3✔
101
        normalized := NormalizeAirflowVersion(version)
3✔
102
        if strings.HasPrefix(normalized, "2.") {
4✔
103
                prefix = "/api/v1"
1✔
104
        }
1✔
105

106
        return &Cache{
3✔
107
                specURL:     specURL,
3✔
108
                cachePath:   cachePath,
3✔
109
                stripPrefix: prefix,
3✔
110
        }, nil
3✔
111
}
112

113
// Load loads the OpenAPI spec, using cache if valid or fetching if needed.
114
// If forceRefresh is true, the cache is ignored and a fresh spec is fetched.
115
func (c *Cache) Load(forceRefresh bool) error {
7✔
116
        if !forceRefresh {
13✔
117
                // Try to load from cache first
6✔
118
                if err := c.readCache(); err == nil && !c.isExpired() {
8✔
119
                        return nil
2✔
120
                }
2✔
121
        }
122

123
        // Fetch fresh spec
124
        if err := c.fetchSpec(); err != nil {
6✔
125
                // If fetch fails and we have a stale cache, use it
1✔
126
                if c.spec != nil {
1✔
NEW
127
                        return nil
×
NEW
128
                }
×
129
                return err
1✔
130
        }
131

132
        // Save to cache
133
        return c.saveCache()
4✔
134
}
135

136
// GetSpec returns the loaded OpenAPI spec.
137
func (c *Cache) GetSpec() *OpenAPISpec {
4✔
138
        return c.spec
4✔
139
}
4✔
140

141
// GetEndpoints extracts all endpoints from the loaded spec.
142
// If a stripPrefix is configured, it is removed from each endpoint path.
143
func (c *Cache) GetEndpoints() []Endpoint {
3✔
144
        if c.spec == nil {
4✔
145
                return nil
1✔
146
        }
1✔
147
        endpoints := ExtractEndpoints(c.spec)
2✔
148
        if c.stripPrefix != "" {
3✔
149
                for i := range endpoints {
3✔
150
                        endpoints[i].Path = strings.TrimPrefix(endpoints[i].Path, c.stripPrefix)
2✔
151
                }
2✔
152
        }
153
        return endpoints
2✔
154
}
155

156
// IsLoaded returns true if a spec has been loaded.
157
func (c *Cache) IsLoaded() bool {
2✔
158
        return c.spec != nil
2✔
159
}
2✔
160

161
// readCache attempts to read the cached spec from disk.
162
func (c *Cache) readCache() error {
9✔
163
        data, err := os.ReadFile(c.cachePath)
9✔
164
        if err != nil {
14✔
165
                return err
5✔
166
        }
5✔
167

168
        var cached CachedSpec
4✔
169
        if err := json.Unmarshal(data, &cached); err != nil {
5✔
170
                return err
1✔
171
        }
1✔
172

173
        c.spec = cached.Spec
3✔
174
        c.fetchedAt = cached.FetchedAt
3✔
175
        return nil
3✔
176
}
177

178
// saveCache saves the current spec to disk.
179
func (c *Cache) saveCache() error {
5✔
180
        cached := CachedSpec{
5✔
181
                Spec:      c.spec,
5✔
182
                FetchedAt: c.fetchedAt,
5✔
183
        }
5✔
184

5✔
185
        data, err := json.Marshal(cached)
5✔
186
        if err != nil {
5✔
NEW
187
                return err
×
NEW
188
        }
×
189

190
        // Ensure directory exists
191
        dir := filepath.Dir(c.cachePath)
5✔
192
        if err := os.MkdirAll(dir, dirPermissions); err != nil {
5✔
NEW
193
                return err
×
NEW
194
        }
×
195

196
        return os.WriteFile(c.cachePath, data, filePermissions)
5✔
197
}
198

199
// isExpired returns true if the cached spec has expired.
200
func (c *Cache) isExpired() bool {
5✔
201
        return time.Since(c.fetchedAt) > CacheTTL
5✔
202
}
5✔
203

204
// fetchSpec fetches the OpenAPI spec from the remote URL.
205
func (c *Cache) fetchSpec() error {
13✔
206
        ctx, cancel := context.WithTimeout(context.Background(), FetchTimeout)
13✔
207
        defer cancel()
13✔
208

13✔
209
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.specURL, http.NoBody)
13✔
210
        if err != nil {
13✔
NEW
211
                return fmt.Errorf("creating request: %w", err)
×
NEW
212
        }
×
213

214
        // Accept both JSON and YAML
215
        req.Header.Set("Accept", "application/json, application/yaml, text/yaml, */*")
13✔
216

13✔
217
        resp, err := c.getHTTPClient().Do(req)
13✔
218
        if err != nil {
13✔
NEW
219
                return fmt.Errorf("fetching OpenAPI spec: %w", err)
×
NEW
220
        }
×
221
        defer resp.Body.Close()
13✔
222

13✔
223
        if resp.StatusCode != http.StatusOK {
15✔
224
                return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
2✔
225
        }
2✔
226

227
        body, err := io.ReadAll(resp.Body)
11✔
228
        if err != nil {
11✔
NEW
229
                return fmt.Errorf("reading response body: %w", err)
×
NEW
230
        }
×
231

232
        var spec OpenAPISpec
11✔
233
        contentType := resp.Header.Get("Content-Type")
11✔
234

11✔
235
        // Try to parse based on content type, fallback to trying both formats
11✔
236
        switch {
11✔
237
        case strings.Contains(contentType, "yaml") || strings.Contains(contentType, "yml"):
1✔
238
                if err := yaml.Unmarshal(body, &spec); err != nil {
1✔
NEW
239
                        return fmt.Errorf("parsing OpenAPI spec as YAML: %w", err)
×
NEW
240
                }
×
241
        case strings.Contains(contentType, "json"):
7✔
242
                if err := json.Unmarshal(body, &spec); err != nil {
8✔
243
                        return fmt.Errorf("parsing OpenAPI spec as JSON: %w", err)
1✔
244
                }
1✔
245
        default:
3✔
246
                // Content-Type not helpful, try YAML first (more common for OpenAPI), then JSON
3✔
247
                if err := yaml.Unmarshal(body, &spec); err != nil {
4✔
248
                        if err := json.Unmarshal(body, &spec); err != nil {
2✔
249
                                return fmt.Errorf("parsing OpenAPI spec (tried YAML and JSON): %w", err)
1✔
250
                        }
1✔
251
                }
252
        }
253

254
        c.spec = &spec
9✔
255
        c.fetchedAt = time.Now()
9✔
256
        return nil
9✔
257
}
258

259
// ClearCache removes the cached spec file.
260
func (c *Cache) ClearCache() error {
2✔
261
        return os.Remove(c.cachePath)
2✔
262
}
2✔
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