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

astronomer / astro-cli / ad3bf534-ee25-491b-9886-17cbc3b80c2e

08 Apr 2026 04:33PM UTC coverage: 39.38% (+0.04%) from 39.344%
ad3bf534-ee25-491b-9886-17cbc3b80c2e

Pull #2075

circleci

jeremybeard
Support local spec files for --spec-url flag

Allow --spec-url to accept local file paths (absolute, relative, ~/,
file://) in addition to HTTP URLs. Local specs are read fresh on every
Load() call with no caching, which is ideal for development workflows.
Pull Request #2075: Support local spec files for --spec-url

48 of 52 new or added lines in 2 files covered. (92.31%)

6 existing lines in 1 file now uncovered.

24885 of 63192 relevant lines covered (39.38%)

9.51 hits per line

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

87.9
/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✔
NEW
162
                        return "", fmt.Errorf("expanding home directory: %w", err)
×
NEW
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
// IsLoaded returns true if a spec has been loaded.
255
func (c *Cache) IsLoaded() bool {
5✔
256
        return c.doc != nil || c.v2doc != nil
5✔
257
}
5✔
258

259
// GetServerPath returns the base path from the loaded spec.
260
// For OpenAPI 3.x it extracts the path from servers[0].url.
261
// For Swagger 2.0 it returns the basePath field.
262
func (c *Cache) GetServerPath() string {
4✔
263
        if c.doc != nil && len(c.doc.Servers) > 0 {
6✔
264
                u, err := url.Parse(c.doc.Servers[0].URL)
2✔
265
                if err == nil {
4✔
266
                        return strings.TrimSuffix(u.Path, "/")
2✔
267
                }
2✔
268
        }
269
        if c.v2doc != nil {
3✔
270
                return strings.TrimSuffix(c.v2doc.BasePath, "/")
1✔
271
        }
1✔
272
        return ""
1✔
273
}
274

275
// readCache attempts to read the cached spec from disk.
276
func (c *Cache) readCache() error {
9✔
277
        data, err := os.ReadFile(c.cachePath)
9✔
278
        if err != nil {
14✔
279
                return err
5✔
280
        }
5✔
281

282
        var cached CachedSpec
4✔
283
        if err := json.Unmarshal(data, &cached); err != nil {
5✔
284
                return err
1✔
285
        }
1✔
286

287
        v3doc, v2doc, err := parseSpec(cached.RawSpec)
3✔
288
        if err != nil {
3✔
289
                return err
×
290
        }
×
291

292
        c.doc = v3doc
3✔
293
        c.v2doc = v2doc
3✔
294
        c.rawSpec = cached.RawSpec
3✔
295
        c.fetchedAt = cached.FetchedAt
3✔
296
        return nil
3✔
297
}
298

299
// readLocalFile reads and parses a spec from the local filesystem.
300
func (c *Cache) readLocalFile() error {
5✔
301
        data, err := os.ReadFile(c.localPath)
5✔
302
        if err != nil {
6✔
303
                return fmt.Errorf("reading local spec file: %w", err)
1✔
304
        }
1✔
305

306
        v3doc, v2doc, err := parseSpec(data)
4✔
307
        if err != nil {
5✔
308
                return fmt.Errorf("parsing local spec file: %w", err)
1✔
309
        }
1✔
310

311
        c.doc = v3doc
3✔
312
        c.v2doc = v2doc
3✔
313
        c.rawSpec = data
3✔
314
        c.fetchedAt = time.Now()
3✔
315
        return nil
3✔
316
}
317

318
// saveCache saves the current spec to disk.
319
func (c *Cache) saveCache() error {
5✔
320
        cached := CachedSpec{
5✔
321
                RawSpec:   c.rawSpec,
5✔
322
                FetchedAt: c.fetchedAt,
5✔
323
        }
5✔
324

5✔
325
        data, err := json.Marshal(cached)
5✔
326
        if err != nil {
5✔
327
                return err
×
328
        }
×
329

330
        // Ensure directory exists
331
        dir := filepath.Dir(c.cachePath)
5✔
332
        if err := os.MkdirAll(dir, dirPermissions); err != nil {
5✔
333
                return err
×
334
        }
×
335

336
        return os.WriteFile(c.cachePath, data, filePermissions)
5✔
337
}
338

339
// isExpired returns true if the cached spec has expired.
340
func (c *Cache) isExpired() bool {
5✔
341
        return time.Since(c.fetchedAt) > CacheTTL
5✔
342
}
5✔
343

344
// fetchSpec fetches the OpenAPI spec from the remote URL.
345
func (c *Cache) fetchSpec() error {
15✔
346
        ctx, cancel := context.WithTimeout(context.Background(), FetchTimeout)
15✔
347
        defer cancel()
15✔
348

15✔
349
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.specURL, http.NoBody)
15✔
350
        if err != nil {
15✔
351
                return fmt.Errorf("creating request: %w", err)
×
352
        }
×
353

354
        // Accept both JSON and YAML
355
        req.Header.Set("Accept", "application/json, application/yaml, text/yaml, */*")
15✔
356

15✔
357
        if c.authToken != "" {
16✔
358
                req.Header.Set("Authorization", "Bearer "+c.authToken)
1✔
359
        }
1✔
360

361
        resp, err := c.getHTTPClient().Do(req)
15✔
362
        if err != nil {
15✔
363
                return fmt.Errorf("fetching OpenAPI spec: %w", err)
×
364
        }
×
365
        defer resp.Body.Close()
15✔
366

15✔
367
        if resp.StatusCode != http.StatusOK {
17✔
368
                return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
2✔
369
        }
2✔
370

371
        body, err := io.ReadAll(resp.Body)
13✔
372
        if err != nil {
13✔
373
                return fmt.Errorf("reading response body: %w", err)
×
374
        }
×
375

376
        v3doc, v2doc, err := parseSpec(body)
13✔
377
        if err != nil {
15✔
378
                return fmt.Errorf("parsing OpenAPI spec: %w", err)
2✔
379
        }
2✔
380

381
        c.doc = v3doc
11✔
382
        c.v2doc = v2doc
11✔
383
        c.rawSpec = body
11✔
384
        c.fetchedAt = time.Now()
11✔
385
        return nil
11✔
386
}
387

388
// parseSpec parses raw bytes into an OpenAPI document using libopenapi.
389
// It detects the spec version and returns either a v3 or v2 model.
390
// This handles both JSON and YAML formats and resolves $ref references.
391
func parseSpec(data []byte) (*v3high.Document, *v2high.Swagger, error) {
40✔
392
        doc, err := newDocument(data)
40✔
393
        if err != nil {
43✔
394
                return nil, nil, fmt.Errorf("creating document: %w", err)
3✔
395
        }
3✔
396

397
        version := doc.GetVersion()
37✔
398
        if strings.HasPrefix(version, "2.") {
45✔
399
                model, err := doc.BuildV2Model()
8✔
400
                if err != nil {
8✔
401
                        return nil, nil, fmt.Errorf("building v2 model: %w", err)
×
402
                }
×
403
                if model == nil {
8✔
404
                        return nil, nil, fmt.Errorf("building v2 model: unknown error")
×
405
                }
×
406
                return nil, &model.Model, nil
8✔
407
        }
408

409
        model, err := doc.BuildV3Model()
29✔
410
        if err != nil {
29✔
411
                return nil, nil, fmt.Errorf("building v3 model: %w", err)
×
412
        }
×
413
        if model == nil {
29✔
414
                return nil, nil, fmt.Errorf("building v3 model: unknown error")
×
415
        }
×
416
        return &model.Model, nil, nil
29✔
417
}
418

419
// ClearCache removes the cached spec file.
420
func (c *Cache) ClearCache() error {
2✔
421
        return os.Remove(c.cachePath)
2✔
422
}
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