• 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

2.84
/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
        // CacheFileName is the name of the cache file for the Astro Cloud API.
22
        CacheFileName = "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 {
4✔
38
        normalizedVersion := NormalizeAirflowVersion(version)
4✔
39
        return fmt.Sprintf(AirflowCacheFileNameTemplate, normalizedVersion)
4✔
40
}
4✔
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
        spec        *OpenAPISpec
54
        fetchedAt   time.Time
55
}
56

57
// NewCache creates a new OpenAPI cache with default settings.
58
func NewCache() *Cache {
×
59
        return &Cache{
×
60
                specURL:   SpecURL,
×
61
                cachePath: filepath.Join(config.HomeConfigPath, CacheFileName),
×
62
        }
×
63
}
×
64

65
// NewCacheWithOptions creates a new OpenAPI cache with custom settings.
66
func NewCacheWithOptions(specURL, cachePath string) *Cache {
×
67
        return &Cache{
×
68
                specURL:   specURL,
×
69
                cachePath: cachePath,
×
70
        }
×
71
}
×
72

73
// NewAirflowCacheForVersion creates a new OpenAPI cache configured for a specific Airflow version.
74
// It automatically determines the correct spec URL and cache file path based on the version.
75
// Endpoint paths are stripped of their API version prefix (/api/v1 or /api/v2) so that
76
// callers work with version-agnostic paths like /dags instead of /api/v2/dags.
77
func NewAirflowCacheForVersion(version string) (*Cache, error) {
×
78
        specURL, err := BuildAirflowSpecURL(version)
×
79
        if err != nil {
×
80
                return nil, fmt.Errorf("building spec URL for version %s: %w", version, err)
×
81
        }
×
82

83
        cachePath := filepath.Join(config.HomeConfigPath, AirflowCacheFileNameForVersion(version))
×
84

×
NEW
85
        // Determine the API prefix to strip based on major version
×
NEW
86
        prefix := "/api/v2"
×
NEW
87
        normalized := NormalizeAirflowVersion(version)
×
NEW
88
        if strings.HasPrefix(normalized, "2.") {
×
NEW
89
                prefix = "/api/v1"
×
NEW
90
        }
×
91

92
        return &Cache{
×
NEW
93
                specURL:     specURL,
×
NEW
94
                cachePath:   cachePath,
×
NEW
95
                stripPrefix: prefix,
×
UNCOV
96
        }, nil
×
97
}
98

99
// Load loads the OpenAPI spec, using cache if valid or fetching if needed.
100
// If forceRefresh is true, the cache is ignored and a fresh spec is fetched.
101
func (c *Cache) Load(forceRefresh bool) error {
×
102
        if !forceRefresh {
×
103
                // Try to load from cache first
×
104
                if err := c.readCache(); err == nil && !c.isExpired() {
×
105
                        return nil
×
106
                }
×
107
        }
108

109
        // Fetch fresh spec
110
        if err := c.fetchSpec(); err != nil {
×
111
                // If fetch fails and we have a stale cache, use it
×
112
                if c.spec != nil {
×
113
                        return nil
×
114
                }
×
115
                return err
×
116
        }
117

118
        // Save to cache
119
        return c.saveCache()
×
120
}
121

122
// GetSpec returns the loaded OpenAPI spec.
123
func (c *Cache) GetSpec() *OpenAPISpec {
×
124
        return c.spec
×
125
}
×
126

127
// GetEndpoints extracts all endpoints from the loaded spec.
128
// If a stripPrefix is configured, it is removed from each endpoint path.
129
func (c *Cache) GetEndpoints() []Endpoint {
×
130
        if c.spec == nil {
×
131
                return nil
×
132
        }
×
NEW
133
        endpoints := ExtractEndpoints(c.spec)
×
NEW
134
        if c.stripPrefix != "" {
×
NEW
135
                for i := range endpoints {
×
NEW
136
                        endpoints[i].Path = strings.TrimPrefix(endpoints[i].Path, c.stripPrefix)
×
NEW
137
                }
×
138
        }
NEW
139
        return endpoints
×
140
}
141

142
// IsLoaded returns true if a spec has been loaded.
143
func (c *Cache) IsLoaded() bool {
×
144
        return c.spec != nil
×
145
}
×
146

147
// readCache attempts to read the cached spec from disk.
148
func (c *Cache) readCache() error {
×
149
        data, err := os.ReadFile(c.cachePath)
×
150
        if err != nil {
×
151
                return err
×
152
        }
×
153

154
        var cached CachedSpec
×
155
        if err := json.Unmarshal(data, &cached); err != nil {
×
156
                return err
×
157
        }
×
158

159
        c.spec = cached.Spec
×
160
        c.fetchedAt = cached.FetchedAt
×
161
        return nil
×
162
}
163

164
// saveCache saves the current spec to disk.
165
func (c *Cache) saveCache() error {
×
166
        cached := CachedSpec{
×
167
                Spec:      c.spec,
×
168
                FetchedAt: c.fetchedAt,
×
169
        }
×
170

×
171
        data, err := json.Marshal(cached)
×
172
        if err != nil {
×
173
                return err
×
174
        }
×
175

176
        // Ensure directory exists
177
        dir := filepath.Dir(c.cachePath)
×
178
        if err := os.MkdirAll(dir, dirPermissions); err != nil {
×
179
                return err
×
180
        }
×
181

182
        return os.WriteFile(c.cachePath, data, filePermissions)
×
183
}
184

185
// isExpired returns true if the cached spec has expired.
186
func (c *Cache) isExpired() bool {
×
187
        return time.Since(c.fetchedAt) > CacheTTL
×
188
}
×
189

190
// fetchSpec fetches the OpenAPI spec from the remote URL.
191
func (c *Cache) fetchSpec() error {
×
192
        ctx, cancel := context.WithTimeout(context.Background(), FetchTimeout)
×
193
        defer cancel()
×
194

×
195
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.specURL, http.NoBody)
×
196
        if err != nil {
×
197
                return fmt.Errorf("creating request: %w", err)
×
198
        }
×
199

200
        // Accept both JSON and YAML
201
        req.Header.Set("Accept", "application/json, application/yaml, text/yaml, */*")
×
202

×
203
        resp, err := http.DefaultClient.Do(req)
×
204
        if err != nil {
×
205
                return fmt.Errorf("fetching OpenAPI spec: %w", err)
×
206
        }
×
207
        defer resp.Body.Close()
×
208

×
209
        if resp.StatusCode != http.StatusOK {
×
210
                return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
×
211
        }
×
212

213
        body, err := io.ReadAll(resp.Body)
×
214
        if err != nil {
×
215
                return fmt.Errorf("reading response body: %w", err)
×
216
        }
×
217

218
        var spec OpenAPISpec
×
219
        contentType := resp.Header.Get("Content-Type")
×
220

×
221
        // Try to parse based on content type, fallback to trying both formats
×
222
        switch {
×
223
        case strings.Contains(contentType, "yaml") || strings.Contains(contentType, "yml"):
×
224
                if err := yaml.Unmarshal(body, &spec); err != nil {
×
225
                        return fmt.Errorf("parsing OpenAPI spec as YAML: %w", err)
×
226
                }
×
227
        case strings.Contains(contentType, "json"):
×
228
                if err := json.Unmarshal(body, &spec); err != nil {
×
229
                        return fmt.Errorf("parsing OpenAPI spec as JSON: %w", err)
×
230
                }
×
231
        default:
×
232
                // Content-Type not helpful, try YAML first (more common for OpenAPI), then JSON
×
233
                if err := yaml.Unmarshal(body, &spec); err != nil {
×
234
                        if err := json.Unmarshal(body, &spec); err != nil {
×
235
                                return fmt.Errorf("parsing OpenAPI spec (tried YAML and JSON): %w", err)
×
236
                        }
×
237
                }
238
        }
239

240
        c.spec = &spec
×
241
        c.fetchedAt = time.Now()
×
242
        return nil
×
243
}
244

245
// ClearCache removes the cached spec file.
246
func (c *Cache) ClearCache() error {
×
247
        return os.Remove(c.cachePath)
×
248
}
×
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