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

astronomer / astro-cli / 29104111989

10 Jul 2026 03:33PM UTC coverage: 43.705% (+0.03%) from 43.672%
29104111989

Pull #2205

github

web-flow
Merge d64fea07e into 12c10591a
Pull Request #2205: Make `astro api describe` show full schema fields

119 of 191 new or added lines in 9 files covered. (62.3%)

1 existing line in 1 file now uncovered.

25687 of 58774 relevant lines covered (43.7%)

8.28 hits per line

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

85.16
/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
        localPath   string // local file path; when set, Load reads from disk (no HTTP, no caching)
70
        stripPrefix string // optional prefix to strip from endpoint paths (e.g. "/api/v2")
71
        authToken   string // optional auth token for fetching specs
72
        httpClient  *http.Client
73
        doc         *v3high.Document
74
        v2doc       *v2high.Swagger // non-nil if spec is Swagger 2.0
75
        rawSpec     []byte          // raw spec bytes for cache serialization
76
        fetchedAt   time.Time
77
}
78

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

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

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

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

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

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

123
// NewCacheForLocalFile creates a Cache that reads a spec directly from a local
124
// file path. The spec is parsed fresh on every Load call; no on-disk caching is
125
// performed.
126
func NewCacheForLocalFile(path string) *Cache {
5✔
127
        return &Cache{
5✔
128
                specURL:   path,
5✔
129
                localPath: path,
5✔
130
        }
5✔
131
}
5✔
132

133
// SpecCacheFileName returns a deterministic cache file name derived from a spec URL.
134
func SpecCacheFileName(specURL string) string {
3✔
135
        h := sha256.Sum256([]byte(specURL))
3✔
136
        return fmt.Sprintf("openapi-cache-%x.json", h[:8])
3✔
137
}
3✔
138

139
// IsLocalSpec reports whether specURL refers to a local file rather than an
140
// HTTP(S) URL.
141
func IsLocalSpec(specURL string) bool {
10✔
142
        lower := strings.ToLower(specURL)
10✔
143
        return specURL != "" &&
10✔
144
                !strings.HasPrefix(lower, "http://") &&
10✔
145
                !strings.HasPrefix(lower, "https://")
10✔
146
}
10✔
147

148
// ResolveLocalPath converts a spec URL to an absolute local file path.
149
// It handles file:// URLs, ~ home directory expansion, and relative paths.
150
func ResolveLocalPath(specURL string) (string, error) {
4✔
151
        path := specURL
4✔
152

4✔
153
        // Strip file:// scheme
4✔
154
        if strings.HasPrefix(strings.ToLower(path), "file://") {
5✔
155
                path = path[len("file://"):]
1✔
156
        }
1✔
157

158
        // Expand ~ to home directory
159
        if strings.HasPrefix(path, "~/") || path == "~" {
5✔
160
                home, err := os.UserHomeDir()
1✔
161
                if err != nil {
1✔
162
                        return "", fmt.Errorf("expanding home directory: %w", err)
×
163
                }
×
164
                path = filepath.Join(home, path[1:])
1✔
165
        }
166

167
        return filepath.Abs(path)
4✔
168
}
169

170
// NewAirflowCacheForVersion creates a new OpenAPI cache configured for a specific Airflow version.
171
// It automatically determines the correct spec URL and cache file path based on the version.
172
// Endpoint paths are stripped of their API version prefix (/api/v1 or /api/v2) so that
173
// callers work with version-agnostic paths like /dags instead of /api/v2/dags.
174
func NewAirflowCacheForVersion(version string) (*Cache, error) {
4✔
175
        specURL, err := BuildAirflowSpecURL(version)
4✔
176
        if err != nil {
5✔
177
                return nil, fmt.Errorf("building spec URL for version %s: %w", version, err)
1✔
178
        }
1✔
179

180
        cachePath := filepath.Join(config.HomeConfigPath, AirflowCacheFileNameForVersion(version))
3✔
181

3✔
182
        // Determine the API prefix to strip based on major version
3✔
183
        prefix := "/api/v2"
3✔
184
        normalized := NormalizeAirflowVersion(version)
3✔
185
        if strings.HasPrefix(normalized, "2.") {
4✔
186
                prefix = "/api/v1"
1✔
187
        }
1✔
188

189
        return &Cache{
3✔
190
                specURL:     specURL,
3✔
191
                cachePath:   cachePath,
3✔
192
                stripPrefix: prefix,
3✔
193
        }, nil
3✔
194
}
195

196
// Load loads the OpenAPI spec, using cache if valid or fetching if needed.
197
// If forceRefresh is true, the cache is ignored and a fresh spec is fetched.
198
// For local files, the spec is always read fresh from disk.
199
func (c *Cache) Load(forceRefresh bool) error {
12✔
200
        if c.localPath != "" {
17✔
201
                return c.readLocalFile()
5✔
202
        }
5✔
203

204
        if !forceRefresh {
13✔
205
                // Try to load from cache first
6✔
206
                if err := c.readCache(); err == nil && !c.isExpired() {
8✔
207
                        return nil
2✔
208
                }
2✔
209
        }
210

211
        // Fetch fresh spec
212
        if err := c.fetchSpec(); err != nil {
6✔
213
                // If fetch fails and we have a stale cache, use it
1✔
214
                if c.IsLoaded() {
1✔
215
                        return nil
×
216
                }
×
217
                return err
1✔
218
        }
219

220
        // Save to cache
221
        return c.saveCache()
4✔
222
}
223

224
// GetSpecURL returns the URL used to fetch the OpenAPI spec.
225
func (c *Cache) GetSpecURL() string {
×
226
        return c.specURL
×
227
}
×
228

229
// GetDoc returns the loaded OpenAPI document.
230
func (c *Cache) GetDoc() *v3high.Document {
7✔
231
        return c.doc
7✔
232
}
7✔
233

234
// GetEndpoints extracts all endpoints from the loaded spec.
235
// If a stripPrefix is configured, it is removed from each endpoint path.
236
func (c *Cache) GetEndpoints() []Endpoint {
5✔
237
        var endpoints []Endpoint
5✔
238
        switch {
5✔
239
        case c.doc != nil:
3✔
240
                endpoints = ExtractEndpoints(c.doc)
3✔
241
        case c.v2doc != nil:
1✔
242
                endpoints = ExtractEndpointsV2(c.v2doc)
1✔
243
        default:
1✔
244
                return nil
1✔
245
        }
246
        if c.stripPrefix != "" {
5✔
247
                for i := range endpoints {
3✔
248
                        endpoints[i].Path = strings.TrimPrefix(endpoints[i].Path, c.stripPrefix)
2✔
249
                }
2✔
250
        }
251
        return endpoints
4✔
252
}
253

254
// GetSchemas returns a registry of named component schemas from the loaded
255
// spec, keyed by schema name (e.g. "CreateDeploymentRequest"). It is used to
256
// resolve $ref references lazily when displaying endpoint details.
NEW
257
func (c *Cache) GetSchemas() map[string]*Schema {
×
NEW
258
        switch {
×
NEW
259
        case c.doc != nil:
×
NEW
260
                return ExtractComponentSchemas(c.doc)
×
NEW
261
        case c.v2doc != nil:
×
NEW
262
                return ExtractDefinitionsV2(c.v2doc)
×
NEW
263
        default:
×
NEW
264
                return nil
×
265
        }
266
}
267

268
// IsLoaded returns true if a spec has been loaded.
269
func (c *Cache) IsLoaded() bool {
5✔
270
        return c.doc != nil || c.v2doc != nil
5✔
271
}
5✔
272

273
// GetServerPath returns the base path from the loaded spec.
274
// For OpenAPI 3.x it extracts the path from servers[0].url.
275
// For Swagger 2.0 it returns the basePath field.
276
func (c *Cache) GetServerPath() string {
4✔
277
        if c.doc != nil && len(c.doc.Servers) > 0 {
6✔
278
                u, err := url.Parse(c.doc.Servers[0].URL)
2✔
279
                if err == nil {
4✔
280
                        return strings.TrimSuffix(u.Path, "/")
2✔
281
                }
2✔
282
        }
283
        if c.v2doc != nil {
3✔
284
                return strings.TrimSuffix(c.v2doc.BasePath, "/")
1✔
285
        }
1✔
286
        return ""
1✔
287
}
288

289
// readCache attempts to read the cached spec from disk.
290
func (c *Cache) readCache() error {
9✔
291
        data, err := os.ReadFile(c.cachePath)
9✔
292
        if err != nil {
14✔
293
                return err
5✔
294
        }
5✔
295

296
        var cached CachedSpec
4✔
297
        if err := json.Unmarshal(data, &cached); err != nil {
5✔
298
                return err
1✔
299
        }
1✔
300

301
        v3doc, v2doc, err := parseSpec(cached.RawSpec)
3✔
302
        if err != nil {
3✔
303
                return err
×
304
        }
×
305

306
        c.doc = v3doc
3✔
307
        c.v2doc = v2doc
3✔
308
        c.rawSpec = cached.RawSpec
3✔
309
        c.fetchedAt = cached.FetchedAt
3✔
310
        return nil
3✔
311
}
312

313
// readLocalFile reads and parses a spec from the local filesystem.
314
func (c *Cache) readLocalFile() error {
5✔
315
        data, err := os.ReadFile(c.localPath)
5✔
316
        if err != nil {
6✔
317
                return fmt.Errorf("reading local spec file: %w", err)
1✔
318
        }
1✔
319

320
        v3doc, v2doc, err := parseSpec(data)
4✔
321
        if err != nil {
5✔
322
                return fmt.Errorf("parsing local spec file: %w", err)
1✔
323
        }
1✔
324

325
        c.doc = v3doc
3✔
326
        c.v2doc = v2doc
3✔
327
        c.rawSpec = data
3✔
328
        c.fetchedAt = time.Now()
3✔
329
        return nil
3✔
330
}
331

332
// saveCache saves the current spec to disk.
333
func (c *Cache) saveCache() error {
5✔
334
        cached := CachedSpec{
5✔
335
                RawSpec:   c.rawSpec,
5✔
336
                FetchedAt: c.fetchedAt,
5✔
337
        }
5✔
338

5✔
339
        data, err := json.Marshal(cached)
5✔
340
        if err != nil {
5✔
341
                return err
×
342
        }
×
343

344
        // Ensure directory exists
345
        dir := filepath.Dir(c.cachePath)
5✔
346
        if err := os.MkdirAll(dir, dirPermissions); err != nil {
5✔
347
                return err
×
348
        }
×
349

350
        return os.WriteFile(c.cachePath, data, filePermissions)
5✔
351
}
352

353
// isExpired returns true if the cached spec has expired.
354
func (c *Cache) isExpired() bool {
5✔
355
        return time.Since(c.fetchedAt) > CacheTTL
5✔
356
}
5✔
357

358
// fetchSpec fetches the OpenAPI spec from the remote URL.
359
func (c *Cache) fetchSpec() error {
15✔
360
        ctx, cancel := context.WithTimeout(context.Background(), FetchTimeout)
15✔
361
        defer cancel()
15✔
362

15✔
363
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.specURL, http.NoBody)
15✔
364
        if err != nil {
15✔
365
                return fmt.Errorf("creating request: %w", err)
×
366
        }
×
367

368
        // Accept both JSON and YAML
369
        req.Header.Set("Accept", "application/json, application/yaml, text/yaml, */*")
15✔
370

15✔
371
        if c.authToken != "" {
16✔
372
                req.Header.Set("Authorization", "Bearer "+c.authToken)
1✔
373
        }
1✔
374

375
        resp, err := c.getHTTPClient().Do(req)
15✔
376
        if err != nil {
15✔
377
                return fmt.Errorf("fetching OpenAPI spec: %w", err)
×
378
        }
×
379
        defer resp.Body.Close()
15✔
380

15✔
381
        if resp.StatusCode != http.StatusOK {
17✔
382
                return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
2✔
383
        }
2✔
384

385
        body, err := io.ReadAll(resp.Body)
13✔
386
        if err != nil {
13✔
387
                return fmt.Errorf("reading response body: %w", err)
×
388
        }
×
389

390
        v3doc, v2doc, err := parseSpec(body)
13✔
391
        if err != nil {
15✔
392
                return fmt.Errorf("parsing OpenAPI spec: %w", err)
2✔
393
        }
2✔
394

395
        c.doc = v3doc
11✔
396
        c.v2doc = v2doc
11✔
397
        c.rawSpec = body
11✔
398
        c.fetchedAt = time.Now()
11✔
399
        return nil
11✔
400
}
401

402
// parseSpec parses raw bytes into an OpenAPI document using libopenapi.
403
// It detects the spec version and returns either a v3 or v2 model.
404
// This handles both JSON and YAML formats and resolves $ref references.
405
func parseSpec(data []byte) (*v3high.Document, *v2high.Swagger, error) {
40✔
406
        doc, err := newDocument(data)
40✔
407
        if err != nil {
43✔
408
                return nil, nil, fmt.Errorf("creating document: %w", err)
3✔
409
        }
3✔
410

411
        version := doc.GetVersion()
37✔
412
        if strings.HasPrefix(version, "2.") {
45✔
413
                model, err := doc.BuildV2Model()
8✔
414
                if err != nil {
8✔
415
                        return nil, nil, fmt.Errorf("building v2 model: %w", err)
×
416
                }
×
417
                if model == nil {
8✔
418
                        return nil, nil, fmt.Errorf("building v2 model: unknown error")
×
419
                }
×
420
                return nil, &model.Model, nil
8✔
421
        }
422

423
        model, err := doc.BuildV3Model()
29✔
424
        if err != nil {
29✔
425
                return nil, nil, fmt.Errorf("building v3 model: %w", err)
×
426
        }
×
427
        if model == nil {
29✔
428
                return nil, nil, fmt.Errorf("building v3 model: unknown error")
×
429
        }
×
430
        return &model.Model, nil, nil
29✔
431
}
432

433
// ClearCache removes the cached spec file.
434
func (c *Cache) ClearCache() error {
2✔
435
        return os.Remove(c.cachePath)
2✔
436
}
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