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

astronomer / astro-cli / 7ad3521d-16b3-40c1-8bae-3435cbf5c859

14 Feb 2026 02:21AM UTC coverage: 35.009% (+1.9%) from 33.15%
7ad3521d-16b3-40c1-8bae-3435cbf5c859

Pull #2006

circleci

jeremybeard
Use kin-openapi for OpenAPI library
Pull Request #2006: Add `astro api` command

2029 of 2451 new or added lines in 13 files covered. (82.78%)

3 existing lines in 1 file now uncovered.

22893 of 65392 relevant lines covered (35.01%)

8.43 hits per line

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

87.74
/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
        "github.com/getkin/kin-openapi/openapi3"
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
// CloudCacheFileNameForDomain returns the cache file name for a specific domain.
43
// For the default domain (astronomer.io), it returns the standard cache file name
44
// for backward compatibility. For other domains, a domain-qualified name is used
45
// to avoid cache collisions between environments.
46
func CloudCacheFileNameForDomain(domain string) string {
7✔
47
        if domain == "" || domain == "astronomer.io" {
9✔
48
                return CloudCacheFileName
2✔
49
        }
2✔
50
        normalized := strings.ReplaceAll(domain, ".", "_")
5✔
51
        return fmt.Sprintf("openapi-cache-%s.json", normalized)
5✔
52
}
53

54
// CachedSpec wraps raw spec data with metadata for caching.
55
type CachedSpec struct {
56
        RawSpec   json.RawMessage `json:"rawSpec"`
57
        FetchedAt time.Time       `json:"fetchedAt"`
58
}
59

60
// Cache manages fetching and caching of an OpenAPI specification.
61
type Cache struct {
62
        specURL     string
63
        cachePath   string
64
        stripPrefix string // optional prefix to strip from endpoint paths (e.g. "/api/v2")
65
        httpClient  *http.Client
66
        spec        *openapi3.T
67
        fetchedAt   time.Time
68
}
69

70
// getHTTPClient returns the configured HTTP client, defaulting to http.DefaultClient.
71
func (c *Cache) getHTTPClient() *http.Client {
15✔
72
        if c.httpClient != nil {
16✔
73
                return c.httpClient
1✔
74
        }
1✔
75
        return http.DefaultClient
14✔
76
}
77

78
// SetHTTPClient configures the HTTP client used to fetch specs.
79
func (c *Cache) SetHTTPClient(client *http.Client) {
1✔
80
        c.httpClient = client
1✔
81
}
1✔
82

83
// NewCache creates a new OpenAPI cache with default settings.
84
func NewCache() *Cache {
1✔
85
        return &Cache{
1✔
86
                specURL:   SpecURL,
1✔
87
                cachePath: filepath.Join(config.HomeConfigPath, CloudCacheFileName),
1✔
88
        }
1✔
89
}
1✔
90

91
// NewCacheWithOptions creates a new OpenAPI cache with custom settings.
92
func NewCacheWithOptions(specURL, cachePath string) *Cache {
5✔
93
        return &Cache{
5✔
94
                specURL:   specURL,
5✔
95
                cachePath: cachePath,
5✔
96
        }
5✔
97
}
5✔
98

99
// NewAirflowCacheForVersion creates a new OpenAPI cache configured for a specific Airflow version.
100
// It automatically determines the correct spec URL and cache file path based on the version.
101
// Endpoint paths are stripped of their API version prefix (/api/v1 or /api/v2) so that
102
// callers work with version-agnostic paths like /dags instead of /api/v2/dags.
103
func NewAirflowCacheForVersion(version string) (*Cache, error) {
4✔
104
        specURL, err := BuildAirflowSpecURL(version)
4✔
105
        if err != nil {
5✔
106
                return nil, fmt.Errorf("building spec URL for version %s: %w", version, err)
1✔
107
        }
1✔
108

109
        cachePath := filepath.Join(config.HomeConfigPath, AirflowCacheFileNameForVersion(version))
3✔
110

3✔
111
        // Determine the API prefix to strip based on major version
3✔
112
        prefix := "/api/v2"
3✔
113
        normalized := NormalizeAirflowVersion(version)
3✔
114
        if strings.HasPrefix(normalized, "2.") {
4✔
115
                prefix = "/api/v1"
1✔
116
        }
1✔
117

118
        return &Cache{
3✔
119
                specURL:     specURL,
3✔
120
                cachePath:   cachePath,
3✔
121
                stripPrefix: prefix,
3✔
122
        }, nil
3✔
123
}
124

125
// Load loads the OpenAPI spec, using cache if valid or fetching if needed.
126
// If forceRefresh is true, the cache is ignored and a fresh spec is fetched.
127
func (c *Cache) Load(forceRefresh bool) error {
7✔
128
        if !forceRefresh {
13✔
129
                // Try to load from cache first
6✔
130
                if err := c.readCache(); err == nil && !c.isExpired() {
8✔
131
                        return nil
2✔
132
                }
2✔
133
        }
134

135
        // Fetch fresh spec
136
        if err := c.fetchSpec(); err != nil {
6✔
137
                // If fetch fails and we have a stale cache, use it
1✔
138
                if c.spec != nil {
1✔
NEW
139
                        return nil
×
NEW
140
                }
×
141
                return err
1✔
142
        }
143

144
        // Save to cache
145
        return c.saveCache()
4✔
146
}
147

148
// GetSpecURL returns the URL used to fetch the OpenAPI spec.
NEW
149
func (c *Cache) GetSpecURL() string {
×
NEW
150
        return c.specURL
×
NEW
151
}
×
152

153
// GetSpec returns the loaded OpenAPI spec.
154
func (c *Cache) GetSpec() *openapi3.T {
4✔
155
        return c.spec
4✔
156
}
4✔
157

158
// GetEndpoints extracts all endpoints from the loaded spec.
159
// If a stripPrefix is configured, it is removed from each endpoint path.
160
func (c *Cache) GetEndpoints() []Endpoint {
3✔
161
        if c.spec == nil {
4✔
162
                return nil
1✔
163
        }
1✔
164
        endpoints := ExtractEndpoints(c.spec)
2✔
165
        if c.stripPrefix != "" {
3✔
166
                for i := range endpoints {
3✔
167
                        endpoints[i].Path = strings.TrimPrefix(endpoints[i].Path, c.stripPrefix)
2✔
168
                }
2✔
169
        }
170
        return endpoints
2✔
171
}
172

173
// IsLoaded returns true if a spec has been loaded.
174
func (c *Cache) IsLoaded() bool {
2✔
175
        return c.spec != nil
2✔
176
}
2✔
177

178
// readCache attempts to read the cached spec from disk.
179
func (c *Cache) readCache() error {
9✔
180
        data, err := os.ReadFile(c.cachePath)
9✔
181
        if err != nil {
14✔
182
                return err
5✔
183
        }
5✔
184

185
        var cached CachedSpec
4✔
186
        if err := json.Unmarshal(data, &cached); err != nil {
5✔
187
                return err
1✔
188
        }
1✔
189

190
        spec, err := parseSpec(cached.RawSpec)
3✔
191
        if err != nil {
3✔
NEW
192
                return err
×
NEW
193
        }
×
194

195
        c.spec = spec
3✔
196
        c.fetchedAt = cached.FetchedAt
3✔
197
        return nil
3✔
198
}
199

200
// saveCache saves the current spec to disk.
201
func (c *Cache) saveCache() error {
5✔
202
        specBytes, err := json.Marshal(c.spec)
5✔
203
        if err != nil {
5✔
NEW
204
                return err
×
NEW
205
        }
×
206

207
        cached := CachedSpec{
5✔
208
                RawSpec:   specBytes,
5✔
209
                FetchedAt: c.fetchedAt,
5✔
210
        }
5✔
211

5✔
212
        data, err := json.Marshal(cached)
5✔
213
        if err != nil {
5✔
NEW
214
                return err
×
NEW
215
        }
×
216

217
        // Ensure directory exists
218
        dir := filepath.Dir(c.cachePath)
5✔
219
        if err := os.MkdirAll(dir, dirPermissions); err != nil {
5✔
NEW
220
                return err
×
NEW
221
        }
×
222

223
        return os.WriteFile(c.cachePath, data, filePermissions)
5✔
224
}
225

226
// isExpired returns true if the cached spec has expired.
227
func (c *Cache) isExpired() bool {
5✔
228
        return time.Since(c.fetchedAt) > CacheTTL
5✔
229
}
5✔
230

231
// fetchSpec fetches the OpenAPI spec from the remote URL.
232
func (c *Cache) fetchSpec() error {
13✔
233
        ctx, cancel := context.WithTimeout(context.Background(), FetchTimeout)
13✔
234
        defer cancel()
13✔
235

13✔
236
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.specURL, http.NoBody)
13✔
237
        if err != nil {
13✔
NEW
238
                return fmt.Errorf("creating request: %w", err)
×
NEW
239
        }
×
240

241
        // Accept both JSON and YAML
242
        req.Header.Set("Accept", "application/json, application/yaml, text/yaml, */*")
13✔
243

13✔
244
        resp, err := c.getHTTPClient().Do(req)
13✔
245
        if err != nil {
13✔
NEW
246
                return fmt.Errorf("fetching OpenAPI spec: %w", err)
×
NEW
247
        }
×
248
        defer resp.Body.Close()
13✔
249

13✔
250
        if resp.StatusCode != http.StatusOK {
15✔
251
                return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
2✔
252
        }
2✔
253

254
        body, err := io.ReadAll(resp.Body)
11✔
255
        if err != nil {
11✔
NEW
256
                return fmt.Errorf("reading response body: %w", err)
×
NEW
257
        }
×
258

259
        spec, err := parseSpec(body)
11✔
260
        if err != nil {
13✔
261
                return fmt.Errorf("parsing OpenAPI spec: %w", err)
2✔
262
        }
2✔
263

264
        c.spec = spec
9✔
265
        c.fetchedAt = time.Now()
9✔
266
        return nil
9✔
267
}
268

269
// parseSpec parses raw bytes into an OpenAPI spec using kin-openapi's loader.
270
// This handles both JSON and YAML formats and resolves $ref references.
271
func parseSpec(data []byte) (*openapi3.T, error) {
14✔
272
        loader := openapi3.NewLoader()
14✔
273
        return loader.LoadFromData(data)
14✔
274
}
14✔
275

276
// ClearCache removes the cached spec file.
277
func (c *Cache) ClearCache() error {
2✔
278
        return os.Remove(c.cachePath)
2✔
279
}
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