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

astronomer / astro-cli / 14252baa-4b57-41d2-897f-a56927f540e6

09 Apr 2026 07:13PM UTC coverage: 39.345% (-0.04%) from 39.386%
14252baa-4b57-41d2-897f-a56927f540e6

Pull #2080

circleci

Simpcyclassy
chore: pass workspace and deployment context to appConfig query and drop deprecated fields for Houston 2.0
Pull Request #2080: Chore/appconfig workspace deployment context

15 of 29 new or added lines in 7 files covered. (51.72%)

50 existing lines in 3 files now uncovered.

24846 of 63149 relevant lines covered (39.35%)

9.51 hits per line

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

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

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

16
        v2high "github.com/pb33f/libopenapi/datamodel/high/v2"
17
        v3high "github.com/pb33f/libopenapi/datamodel/high/v3"
18

19
        "github.com/astronomer/astro-cli/config"
20
)
21

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

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

46
// CloudCacheFileNameForDomain returns the cache file name for a specific domain.
47
// For the default domain (astronomer.io), it returns the standard cache file name
48
// for backward compatibility. For other domains, a domain-qualified name is used
49
// to avoid cache collisions between environments.
50
func CloudCacheFileNameForDomain(domain string) string {
7✔
51
        if domain == "" || domain == "astronomer.io" {
9✔
52
                return CloudCacheFileName
2✔
53
        }
2✔
54
        normalized := strings.ReplaceAll(domain, ".", "_")
5✔
55
        return fmt.Sprintf("openapi-cache-%s.json", normalized)
5✔
56
}
57

58
// CachedSpec wraps raw spec data with metadata for caching.
59
// RawSpec is stored as []byte which json.Marshal encodes as base64.
60
type CachedSpec struct {
61
        RawSpec   []byte    `json:"rawSpec"`
62
        FetchedAt time.Time `json:"fetchedAt"`
63
}
64

65
// Cache manages fetching and caching of an OpenAPI specification.
66
type Cache struct {
67
        specURL     string
68
        cachePath   string
69
        stripPrefix string // optional prefix to strip from endpoint paths (e.g. "/api/v2")
70
        authToken   string // optional auth token for fetching specs
71
        httpClient  *http.Client
72
        doc         *v3high.Document
73
        v2doc       *v2high.Swagger // non-nil if spec is Swagger 2.0
74
        rawSpec     []byte          // raw spec bytes for cache serialization
75
        fetchedAt   time.Time
76
}
77

78
// getHTTPClient returns the configured HTTP client, defaulting to http.DefaultClient.
79
func (c *Cache) getHTTPClient() *http.Client {
17✔
80
        if c.httpClient != nil {
18✔
81
                return c.httpClient
1✔
82
        }
1✔
83
        return http.DefaultClient
16✔
84
}
85

86
// SetHTTPClient configures the HTTP client used to fetch specs.
87
func (c *Cache) SetHTTPClient(client *http.Client) {
1✔
88
        c.httpClient = client
1✔
89
}
1✔
90

91
// SetStripPrefix configures a path prefix to strip from endpoint paths
92
// (e.g. "/api") so callers work with shorter paths.
UNCOV
93
func (c *Cache) SetStripPrefix(prefix string) {
×
94
        c.stripPrefix = prefix
×
95
}
×
96

97
// NewCache creates a new OpenAPI cache with default settings.
98
func NewCache() *Cache {
1✔
99
        return &Cache{
1✔
100
                specURL:   SpecURL,
1✔
101
                cachePath: filepath.Join(config.HomeConfigPath, CloudCacheFileName),
1✔
102
        }
1✔
103
}
1✔
104

105
// NewCacheWithOptions creates a new OpenAPI cache with custom settings.
106
func NewCacheWithOptions(specURL, cachePath string) *Cache {
6✔
107
        return &Cache{
6✔
108
                specURL:   specURL,
6✔
109
                cachePath: cachePath,
6✔
110
        }
6✔
111
}
6✔
112

113
// NewCacheWithAuth creates a new OpenAPI cache that sends an auth token when fetching specs.
114
func NewCacheWithAuth(specURL, cachePath, authToken string) *Cache {
2✔
115
        return &Cache{
2✔
116
                specURL:   specURL,
2✔
117
                cachePath: cachePath,
2✔
118
                authToken: authToken,
2✔
119
        }
2✔
120
}
2✔
121

122
// SpecCacheFileName returns a deterministic cache file name derived from a spec URL.
123
func SpecCacheFileName(specURL string) string {
3✔
124
        h := sha256.Sum256([]byte(specURL))
3✔
125
        return fmt.Sprintf("openapi-cache-%x.json", h[:8])
3✔
126
}
3✔
127

128
// NewAirflowCacheForVersion creates a new OpenAPI cache configured for a specific Airflow version.
129
// It automatically determines the correct spec URL and cache file path based on the version.
130
// Endpoint paths are stripped of their API version prefix (/api/v1 or /api/v2) so that
131
// callers work with version-agnostic paths like /dags instead of /api/v2/dags.
132
func NewAirflowCacheForVersion(version string) (*Cache, error) {
4✔
133
        specURL, err := BuildAirflowSpecURL(version)
4✔
134
        if err != nil {
5✔
135
                return nil, fmt.Errorf("building spec URL for version %s: %w", version, err)
1✔
136
        }
1✔
137

138
        cachePath := filepath.Join(config.HomeConfigPath, AirflowCacheFileNameForVersion(version))
3✔
139

3✔
140
        // Determine the API prefix to strip based on major version
3✔
141
        prefix := "/api/v2"
3✔
142
        normalized := NormalizeAirflowVersion(version)
3✔
143
        if strings.HasPrefix(normalized, "2.") {
4✔
144
                prefix = "/api/v1"
1✔
145
        }
1✔
146

147
        return &Cache{
3✔
148
                specURL:     specURL,
3✔
149
                cachePath:   cachePath,
3✔
150
                stripPrefix: prefix,
3✔
151
        }, nil
3✔
152
}
153

154
// Load loads the OpenAPI spec, using cache if valid or fetching if needed.
155
// If forceRefresh is true, the cache is ignored and a fresh spec is fetched.
156
func (c *Cache) Load(forceRefresh bool) error {
7✔
157
        if !forceRefresh {
13✔
158
                // Try to load from cache first
6✔
159
                if err := c.readCache(); err == nil && !c.isExpired() {
8✔
160
                        return nil
2✔
161
                }
2✔
162
        }
163

164
        // Fetch fresh spec
165
        if err := c.fetchSpec(); err != nil {
6✔
166
                // If fetch fails and we have a stale cache, use it
1✔
167
                if c.IsLoaded() {
1✔
UNCOV
168
                        return nil
×
UNCOV
169
                }
×
170
                return err
1✔
171
        }
172

173
        // Save to cache
174
        return c.saveCache()
4✔
175
}
176

177
// GetSpecURL returns the URL used to fetch the OpenAPI spec.
UNCOV
178
func (c *Cache) GetSpecURL() string {
×
UNCOV
179
        return c.specURL
×
UNCOV
180
}
×
181

182
// GetDoc returns the loaded OpenAPI document.
183
func (c *Cache) GetDoc() *v3high.Document {
4✔
184
        return c.doc
4✔
185
}
4✔
186

187
// GetEndpoints extracts all endpoints from the loaded spec.
188
// If a stripPrefix is configured, it is removed from each endpoint path.
189
func (c *Cache) GetEndpoints() []Endpoint {
4✔
190
        var endpoints []Endpoint
4✔
191
        switch {
4✔
192
        case c.doc != nil:
2✔
193
                endpoints = ExtractEndpoints(c.doc)
2✔
194
        case c.v2doc != nil:
1✔
195
                endpoints = ExtractEndpointsV2(c.v2doc)
1✔
196
        default:
1✔
197
                return nil
1✔
198
        }
199
        if c.stripPrefix != "" {
4✔
200
                for i := range endpoints {
3✔
201
                        endpoints[i].Path = strings.TrimPrefix(endpoints[i].Path, c.stripPrefix)
2✔
202
                }
2✔
203
        }
204
        return endpoints
3✔
205
}
206

207
// IsLoaded returns true if a spec has been loaded.
208
func (c *Cache) IsLoaded() bool {
4✔
209
        return c.doc != nil || c.v2doc != nil
4✔
210
}
4✔
211

212
// GetServerPath returns the base path from the loaded spec.
213
// For OpenAPI 3.x it extracts the path from servers[0].url.
214
// For Swagger 2.0 it returns the basePath field.
215
func (c *Cache) GetServerPath() string {
4✔
216
        if c.doc != nil && len(c.doc.Servers) > 0 {
6✔
217
                u, err := url.Parse(c.doc.Servers[0].URL)
2✔
218
                if err == nil {
4✔
219
                        return strings.TrimSuffix(u.Path, "/")
2✔
220
                }
2✔
221
        }
222
        if c.v2doc != nil {
3✔
223
                return strings.TrimSuffix(c.v2doc.BasePath, "/")
1✔
224
        }
1✔
225
        return ""
1✔
226
}
227

228
// readCache attempts to read the cached spec from disk.
229
func (c *Cache) readCache() error {
9✔
230
        data, err := os.ReadFile(c.cachePath)
9✔
231
        if err != nil {
14✔
232
                return err
5✔
233
        }
5✔
234

235
        var cached CachedSpec
4✔
236
        if err := json.Unmarshal(data, &cached); err != nil {
5✔
237
                return err
1✔
238
        }
1✔
239

240
        v3doc, v2doc, err := parseSpec(cached.RawSpec)
3✔
241
        if err != nil {
3✔
UNCOV
242
                return err
×
UNCOV
243
        }
×
244

245
        c.doc = v3doc
3✔
246
        c.v2doc = v2doc
3✔
247
        c.rawSpec = cached.RawSpec
3✔
248
        c.fetchedAt = cached.FetchedAt
3✔
249
        return nil
3✔
250
}
251

252
// saveCache saves the current spec to disk.
253
func (c *Cache) saveCache() error {
5✔
254
        cached := CachedSpec{
5✔
255
                RawSpec:   c.rawSpec,
5✔
256
                FetchedAt: c.fetchedAt,
5✔
257
        }
5✔
258

5✔
259
        data, err := json.Marshal(cached)
5✔
260
        if err != nil {
5✔
UNCOV
261
                return err
×
UNCOV
262
        }
×
263

264
        // Ensure directory exists
265
        dir := filepath.Dir(c.cachePath)
5✔
266
        if err := os.MkdirAll(dir, dirPermissions); err != nil {
5✔
UNCOV
267
                return err
×
UNCOV
268
        }
×
269

270
        return os.WriteFile(c.cachePath, data, filePermissions)
5✔
271
}
272

273
// isExpired returns true if the cached spec has expired.
274
func (c *Cache) isExpired() bool {
5✔
275
        return time.Since(c.fetchedAt) > CacheTTL
5✔
276
}
5✔
277

278
// fetchSpec fetches the OpenAPI spec from the remote URL.
279
func (c *Cache) fetchSpec() error {
15✔
280
        ctx, cancel := context.WithTimeout(context.Background(), FetchTimeout)
15✔
281
        defer cancel()
15✔
282

15✔
283
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.specURL, http.NoBody)
15✔
284
        if err != nil {
15✔
UNCOV
285
                return fmt.Errorf("creating request: %w", err)
×
UNCOV
286
        }
×
287

288
        // Accept both JSON and YAML
289
        req.Header.Set("Accept", "application/json, application/yaml, text/yaml, */*")
15✔
290

15✔
291
        if c.authToken != "" {
16✔
292
                req.Header.Set("Authorization", "Bearer "+c.authToken)
1✔
293
        }
1✔
294

295
        resp, err := c.getHTTPClient().Do(req)
15✔
296
        if err != nil {
15✔
UNCOV
297
                return fmt.Errorf("fetching OpenAPI spec: %w", err)
×
UNCOV
298
        }
×
299
        defer resp.Body.Close()
15✔
300

15✔
301
        if resp.StatusCode != http.StatusOK {
17✔
302
                return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
2✔
303
        }
2✔
304

305
        body, err := io.ReadAll(resp.Body)
13✔
306
        if err != nil {
13✔
UNCOV
307
                return fmt.Errorf("reading response body: %w", err)
×
UNCOV
308
        }
×
309

310
        v3doc, v2doc, err := parseSpec(body)
13✔
311
        if err != nil {
15✔
312
                return fmt.Errorf("parsing OpenAPI spec: %w", err)
2✔
313
        }
2✔
314

315
        c.doc = v3doc
11✔
316
        c.v2doc = v2doc
11✔
317
        c.rawSpec = body
11✔
318
        c.fetchedAt = time.Now()
11✔
319
        return nil
11✔
320
}
321

322
// parseSpec parses raw bytes into an OpenAPI document using libopenapi.
323
// It detects the spec version and returns either a v3 or v2 model.
324
// This handles both JSON and YAML formats and resolves $ref references.
325
func parseSpec(data []byte) (*v3high.Document, *v2high.Swagger, error) {
36✔
326
        doc, err := newDocument(data)
36✔
327
        if err != nil {
38✔
328
                return nil, nil, fmt.Errorf("creating document: %w", err)
2✔
329
        }
2✔
330

331
        version := doc.GetVersion()
34✔
332
        if strings.HasPrefix(version, "2.") {
42✔
333
                model, err := doc.BuildV2Model()
8✔
334
                if err != nil {
8✔
UNCOV
335
                        return nil, nil, fmt.Errorf("building v2 model: %w", err)
×
UNCOV
336
                }
×
337
                if model == nil {
8✔
UNCOV
338
                        return nil, nil, fmt.Errorf("building v2 model: unknown error")
×
UNCOV
339
                }
×
340
                return nil, &model.Model, nil
8✔
341
        }
342

343
        model, err := doc.BuildV3Model()
26✔
344
        if err != nil {
26✔
UNCOV
345
                return nil, nil, fmt.Errorf("building v3 model: %w", err)
×
UNCOV
346
        }
×
347
        if model == nil {
26✔
UNCOV
348
                return nil, nil, fmt.Errorf("building v3 model: unknown error")
×
UNCOV
349
        }
×
350
        return &model.Model, nil, nil
26✔
351
}
352

353
// ClearCache removes the cached spec file.
354
func (c *Cache) ClearCache() error {
2✔
355
        return os.Remove(c.cachePath)
2✔
356
}
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc