diff --git a/cli/src/internal/cmd/cache.go b/cli/src/internal/cmd/cache.go new file mode 100644 index 0000000..a440a7c --- /dev/null +++ b/cli/src/internal/cmd/cache.go @@ -0,0 +1,59 @@ +// Package cmd provides CLI commands for the azd rest extension. +package cmd + +import ( + "fmt" + + "github.com/jongio/azd-rest/src/internal/service" + "github.com/spf13/cobra" +) + +// NewCacheCommand returns the cache subcommand group, which inspects and purges +// the on-disk response cache used by --cache-ttl. +func NewCacheCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "cache", + Short: "Manage the on-disk response cache", + Long: `Manage the on-disk cache used by --cache-ttl. + +Responses are cached only when you pass --cache-ttl on a GET request. Cached +bodies can contain sensitive data, so entries are stored with owner-only +permissions and caching stays off unless you opt in per call.`, + } + cmd.AddCommand(newCacheClearCommand(), newCachePathCommand()) + return cmd +} + +// newCacheClearCommand returns "cache clear", which removes every cached entry. +func newCacheClearCommand() *cobra.Command { + return &cobra.Command{ + Use: "clear", + Short: "Remove all cached responses", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + dir, err := service.ClearCache() + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Cleared response cache at %s\n", dir) + return nil + }, + } +} + +// newCachePathCommand returns "cache path", which prints the cache directory. +func newCachePathCommand() *cobra.Command { + return &cobra.Command{ + Use: "path", + Short: "Print the response cache directory", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + dir, err := service.CacheDir() + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), dir) + return nil + }, + } +} diff --git a/cli/src/internal/cmd/cache_test.go b/cli/src/internal/cmd/cache_test.go new file mode 100644 index 0000000..4709880 --- /dev/null +++ b/cli/src/internal/cmd/cache_test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/jongio/azd-rest/src/internal/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// isolateCmdCacheDir points the user cache directory at a temp dir so the cache +// subcommand tests never touch a real user cache. It sets the variable +// os.UserCacheDir reads on each supported OS. +func isolateCmdCacheDir(t *testing.T) { + t.Helper() + tmp := t.TempDir() + t.Setenv("LocalAppData", tmp) // Windows + t.Setenv("XDG_CACHE_HOME", tmp) // Linux + t.Setenv("HOME", tmp) // macOS and Unix fallback +} + +func TestCachePathCommand(t *testing.T) { + isolateCmdCacheDir(t) + want, err := service.CacheDir() + require.NoError(t, err) + + cmd := NewCacheCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{"path"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, want, strings.TrimSpace(out.String())) +} + +func TestCacheClearCommand(t *testing.T) { + isolateCmdCacheDir(t) + dir, err := service.CacheDir() + require.NoError(t, err) + + cmd := NewCacheCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{"clear"}) + + require.NoError(t, cmd.Execute()) + assert.Contains(t, out.String(), "Cleared response cache") + assert.Contains(t, out.String(), dir) +} diff --git a/cli/src/internal/cmd/root.go b/cli/src/internal/cmd/root.go index 35e9e2a..345a5c0 100644 --- a/cli/src/internal/cmd/root.go +++ b/cli/src/internal/cmd/root.go @@ -64,6 +64,8 @@ var ( fail bool rawOutput bool compact bool + cacheTTL string + noCache bool ) // httpMethodDef defines one HTTP method subcommand for the table-driven factory (#68). @@ -229,6 +231,8 @@ Examples: rootCmd.PersistentFlags().BoolVar(&fail, "fail", false, "Exit with code 22 when the response status is 400 or higher (the response body is still printed)") rootCmd.PersistentFlags().BoolVarP(&rawOutput, "raw-output", "r", false, "With --query, print a string result unquoted and an array of strings one per line (like jq -r)") rootCmd.PersistentFlags().BoolVarP(&compact, "compact", "c", false, "Minify JSON output to a single line (applies to auto and json formats and --query results)") + rootCmd.PersistentFlags().StringVar(&cacheTTL, "cache-ttl", "", "Cache successful GET responses on disk and serve repeats within this window (Go duration, e.g. 30s, 5m, 1h; 0 or empty disables caching)") + rootCmd.PersistentFlags().BoolVar(&noCache, "no-cache", false, "Bypass the cached response and refresh the entry with a fresh request (only meaningful with --cache-ttl)") // Record the extension's own persistent flag names (those not added by the // SDK) so environment-variable defaults apply only to them (#172). @@ -253,6 +257,7 @@ Examples: NewDoctorCommand(), NewGraphCommand(), NewWhoamiCommand(), + NewCacheCommand(), ) return rootCmd @@ -304,6 +309,8 @@ func snapshotConfig() config.Config { Fail: fail, RawOutput: rawOutput, Compact: compact, + CacheTTL: cacheTTL, + NoCache: noCache, } } diff --git a/cli/src/internal/config/config.go b/cli/src/internal/config/config.go index 0e8015c..742aa5b 100644 --- a/cli/src/internal/config/config.go +++ b/cli/src/internal/config/config.go @@ -50,6 +50,8 @@ type Config struct { Fail bool RawOutput bool Compact bool + CacheTTL string + NoCache bool } // Defaults returns a Config populated with the default flag values. diff --git a/cli/src/internal/service/cache.go b/cli/src/internal/service/cache.go new file mode 100644 index 0000000..bf2e09e --- /dev/null +++ b/cli/src/internal/service/cache.go @@ -0,0 +1,180 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/jongio/azd-core/auth" + "github.com/jongio/azd-rest/src/internal/client" + "github.com/jongio/azd-rest/src/internal/config" +) + +// cacheContext holds everything derived from a request that the cache needs: +// the directory to read and write, the file key, and the final URL used for +// write-out expansion on a cache hit. +type cacheContext struct { + dir string + key string + finalURL string +} + +// newCacheContext resolves the final URL and scope the same way the request +// builder does, then derives the cache directory and key. It mirrors +// BuildRequestOptions so a cache hit keys off the exact request that would have +// been sent. Scope detection here never acquires a token. +func newCacheContext(cfg config.Config, method, rawURL string) (cacheContext, error) { + finalURL, err := applyAPIVersion(rawURL, cfg.APIVersion) + if err != nil { + return cacheContext{}, err + } + finalURL, err = applyURLParams(finalURL, cfg.URLParams) + if err != nil { + return cacheContext{}, err + } + + scope := cfg.Scope + if scope == "" && !cfg.NoAuth { + if detected, detectErr := auth.DetectScope(finalURL); detectErr == nil { + scope = detected + } + } + + dir, err := CacheDir() + if err != nil { + return cacheContext{}, err + } + return cacheContext{dir: dir, key: cacheKey(method, finalURL, scope), finalURL: finalURL}, nil +} + +// cacheConfigError signals that --cache-ttl was given an invalid duration. It +// reports exit code 2 (the invalid-configuration code) through the ExitCoder +// contract so main can distinguish it from a request failure. +type cacheConfigError struct{ err error } + +func (e *cacheConfigError) Error() string { return e.err.Error() } + +// ExitCode returns 2 for an invalid --cache-ttl value. +func (e *cacheConfigError) ExitCode() int { return 2 } + +// cacheEnvelope is the on-disk representation of a cached response. Only the +// fields needed to reconstruct output are stored; timing is intentionally +// dropped because it describes the original request, not the cached read. +type cacheEnvelope struct { + StatusCode int `json:"status_code"` + Status string `json:"status"` + Headers http.Header `json:"headers"` + Body []byte `json:"body"` +} + +// parseCacheTTL interprets the raw --cache-ttl value. An empty value or "0" +// means caching is off. Any other value must be a positive Go duration +// (for example 30s, 5m, 1h). A malformed or negative value is a configuration +// error that exits with code 2. +func parseCacheTTL(raw string) (time.Duration, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || trimmed == "0" { + return 0, nil + } + ttl, err := time.ParseDuration(trimmed) + if err != nil { + return 0, &cacheConfigError{fmt.Errorf("invalid --cache-ttl %q: %w", raw, err)} + } + if ttl < 0 { + return 0, &cacheConfigError{fmt.Errorf("invalid --cache-ttl %q: duration must not be negative", raw)} + } + return ttl, nil +} + +// CacheDir returns the directory that holds cached responses. It lives under +// the user cache directory so it does not clutter the working tree and is +// scoped per user. +func CacheDir() (string, error) { + base, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("failed to resolve user cache directory: %w", err) + } + return filepath.Join(base, "azd-rest", "cache"), nil +} + +// ClearCache removes every cached response. It returns the directory that was +// cleared so callers can report it. Removing a directory that does not exist is +// not an error. +func ClearCache() (string, error) { + dir, err := CacheDir() + if err != nil { + return "", err + } + if err := os.RemoveAll(dir); err != nil { + return "", fmt.Errorf("failed to clear cache at %s: %w", dir, err) + } + return dir, nil +} + +// cacheKey derives a stable file name for a request from its method, final URL, +// and scope. Including the scope keeps responses for different audiences from +// colliding even when the URL is identical. +func cacheKey(method, finalURL, scope string) string { + sum := sha256.Sum256([]byte(strings.ToUpper(method) + "\n" + finalURL + "\n" + scope)) + return hex.EncodeToString(sum[:]) + ".json" +} + +// readCache returns the cached response for key when a fresh entry exists. A +// missing file or an entry older than ttl reports ok=false so the caller falls +// back to the network. A corrupt entry is treated as a miss rather than an +// error so a bad file never blocks a request. +func readCache(dir, key string, ttl time.Duration) (*client.Response, bool) { + path := filepath.Join(dir, key) + info, err := os.Stat(path) + if err != nil { + return nil, false + } + if ttl <= 0 || time.Since(info.ModTime()) > ttl { + return nil, false + } + data, err := os.ReadFile(path) // #nosec G304 -- path is a sha256 hex key under the app cache dir. + if err != nil { + return nil, false + } + var env cacheEnvelope + if err := json.Unmarshal(data, &env); err != nil { + return nil, false + } + return &client.Response{ + StatusCode: env.StatusCode, + Status: env.Status, + Headers: env.Headers, + Body: env.Body, + }, true +} + +// writeCache stores resp under key with owner-only permissions. Cached bodies +// can hold sensitive data, so the directory is 0700 and the file is 0600. A +// write failure is returned so the caller can surface a note without failing +// the request. +func writeCache(dir, key string, resp *client.Response) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + env := cacheEnvelope{ + StatusCode: resp.StatusCode, + Status: resp.Status, + Headers: resp.Headers, + Body: resp.Body, + } + data, err := json.Marshal(env) + if err != nil { + return fmt.Errorf("failed to encode cache entry: %w", err) + } + path := filepath.Join(dir, key) + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("failed to write cache entry: %w", err) + } + return nil +} diff --git a/cli/src/internal/service/cache_test.go b/cli/src/internal/service/cache_test.go new file mode 100644 index 0000000..59f2c8f --- /dev/null +++ b/cli/src/internal/service/cache_test.go @@ -0,0 +1,306 @@ +package service + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "sync/atomic" + "testing" + "time" + + "github.com/jongio/azd-rest/src/internal/client" + "github.com/jongio/azd-rest/src/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// cacheExitCoder mirrors the cmd.ExitCoder contract so tests can assert the +// exit code an error carries without importing the cmd package. +type cacheExitCoder interface{ ExitCode() int } + +// isolateCacheDir points the user cache directory at a temp dir for the test so +// cache reads and writes never touch a real user cache. It sets the variable +// os.UserCacheDir reads on each supported OS and returns the resolved cache dir. +func isolateCacheDir(t *testing.T) string { + t.Helper() + tmp := t.TempDir() + t.Setenv("LocalAppData", tmp) // Windows + t.Setenv("XDG_CACHE_HOME", tmp) // Linux + t.Setenv("HOME", tmp) // macOS ($HOME/Library/Caches) and Unix fallback + dir, err := CacheDir() + require.NoError(t, err) + return dir +} + +func TestParseCacheTTL(t *testing.T) { + tests := []struct { + name string + raw string + want time.Duration + wantErr bool + }{ + {"empty is off", "", 0, false}, + {"zero is off", "0", 0, false}, + {"whitespace is off", " ", 0, false}, + {"seconds", "30s", 30 * time.Second, false}, + {"minutes", "5m", 5 * time.Minute, false}, + {"negative rejected", "-3s", 0, true}, + {"garbage rejected", "later", 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseCacheTTL(tt.raw) + if tt.wantErr { + require.Error(t, err) + var coder cacheExitCoder + require.True(t, errors.As(err, &coder), "error should carry an exit code") + assert.Equal(t, 2, coder.ExitCode()) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCacheKey(t *testing.T) { + base := cacheKey("GET", "https://management.azure.com/subs?api-version=2020-01-01", "scope-a") + + t.Run("stable for identical input", func(t *testing.T) { + assert.Equal(t, base, cacheKey("GET", "https://management.azure.com/subs?api-version=2020-01-01", "scope-a")) + }) + t.Run("method is case-insensitive", func(t *testing.T) { + assert.Equal(t, base, cacheKey("get", "https://management.azure.com/subs?api-version=2020-01-01", "scope-a")) + }) + t.Run("scope changes the key", func(t *testing.T) { + assert.NotEqual(t, base, cacheKey("GET", "https://management.azure.com/subs?api-version=2020-01-01", "scope-b")) + }) + t.Run("url changes the key", func(t *testing.T) { + assert.NotEqual(t, base, cacheKey("GET", "https://management.azure.com/other", "scope-a")) + }) + t.Run("key is a json file name", func(t *testing.T) { + assert.True(t, len(base) > 5 && base[len(base)-5:] == ".json") + }) +} + +func TestWriteReadCacheRoundTrip(t *testing.T) { + dir := t.TempDir() + key := cacheKey("GET", "https://example.com/data", "") + want := &client.Response{ + StatusCode: 200, + Status: "200 OK", + Headers: http.Header{"Content-Type": {"application/json"}, "X-Request-Id": {"abc"}}, + Body: []byte(`{"name":"kv"}`), + } + + require.NoError(t, writeCache(dir, key, want)) + + got, hit := readCache(dir, key, time.Minute) + require.True(t, hit) + assert.Equal(t, want.StatusCode, got.StatusCode) + assert.Equal(t, want.Status, got.Status) + assert.Equal(t, want.Body, got.Body) + assert.Equal(t, "application/json", got.Headers.Get("Content-Type")) + assert.Equal(t, "abc", got.Headers.Get("X-Request-Id")) +} + +func TestReadCacheExpired(t *testing.T) { + dir := t.TempDir() + key := cacheKey("GET", "https://example.com/data", "") + require.NoError(t, writeCache(dir, key, &client.Response{StatusCode: 200, Body: []byte(`{}`)})) + + // A zero TTL means every entry is already stale. + _, hit := readCache(dir, key, 0) + assert.False(t, hit) + + // A tiny TTL expires after a short sleep. + time.Sleep(10 * time.Millisecond) + _, hit = readCache(dir, key, time.Millisecond) + assert.False(t, hit) +} + +func TestReadCacheMissOnAbsentOrCorrupt(t *testing.T) { + dir := t.TempDir() + key := cacheKey("GET", "https://example.com/data", "") + + _, hit := readCache(dir, key, time.Minute) + assert.False(t, hit, "absent entry is a miss") + + require.NoError(t, os.MkdirAll(dir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, key), []byte("not json"), 0o600)) + _, hit = readCache(dir, key, time.Minute) + assert.False(t, hit, "corrupt entry is a miss, not an error") +} + +func TestWriteCacheFilePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix file permission bits do not apply on Windows") + } + dir := t.TempDir() + key := cacheKey("GET", "https://example.com/data", "") + require.NoError(t, writeCache(dir, key, &client.Response{StatusCode: 200, Body: []byte(`{}`)})) + + info, err := os.Stat(filepath.Join(dir, key)) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestClearCache(t *testing.T) { + dir := isolateCacheDir(t) + key := cacheKey("GET", "https://example.com/data", "") + require.NoError(t, writeCache(dir, key, &client.Response{StatusCode: 200, Body: []byte(`{}`)})) + require.FileExists(t, filepath.Join(dir, key)) + + cleared, err := ClearCache() + require.NoError(t, err) + assert.Equal(t, dir, cleared) + assert.NoFileExists(t, filepath.Join(dir, key)) + + // Clearing an already-empty cache is not an error. + _, err = ClearCache() + require.NoError(t, err) +} + +func TestExecute_CacheTTL_ServesSecondFromCache(t *testing.T) { + isolateCacheDir(t) + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + n := hits.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf(w, `{"hit":%d}`, n) + })) + defer srv.Close() + + run := func() string { + tmp := filepath.Join(t.TempDir(), "out.json") + cfg := config.Defaults() + cfg.NoAuth = true + cfg.OutputFile = tmp + cfg.OutputFormat = "raw" + cfg.CacheTTL = "5m" + require.NoError(t, newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/items")) + out, err := os.ReadFile(tmp) + require.NoError(t, err) + return string(out) + } + + first := run() + second := run() + + assert.Equal(t, int32(1), hits.Load(), "second identical GET should be served from cache") + assert.Equal(t, first, second) + assert.Contains(t, first, `"hit":1`) +} + +func TestExecute_NoCache_ForcesFreshAndRefreshes(t *testing.T) { + isolateCacheDir(t) + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + n := hits.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf(w, `{"hit":%d}`, n) + })) + defer srv.Close() + + run := func(noCache bool) string { + tmp := filepath.Join(t.TempDir(), "out.json") + cfg := config.Defaults() + cfg.NoAuth = true + cfg.OutputFile = tmp + cfg.OutputFormat = "raw" + cfg.CacheTTL = "5m" + cfg.NoCache = noCache + require.NoError(t, newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/items")) + out, err := os.ReadFile(tmp) + require.NoError(t, err) + return string(out) + } + + run(false) // warm the cache (hit 1) + fresh := run(true) + assert.Equal(t, int32(2), hits.Load(), "--no-cache must hit the network") + assert.Contains(t, fresh, `"hit":2`) + + // The --no-cache call refreshed the entry, so the next cached read returns hit 2. + cached := run(false) + assert.Equal(t, int32(2), hits.Load(), "cached read after refresh should not hit the network") + assert.Contains(t, cached, `"hit":2`) +} + +func TestExecute_CacheTTL_ErrorStatusNotCached(t *testing.T) { + isolateCacheDir(t) + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"missing"}`)) + })) + defer srv.Close() + + run := func() { + tmp := filepath.Join(t.TempDir(), "out.json") + cfg := config.Defaults() + cfg.NoAuth = true + cfg.OutputFile = tmp + cfg.CacheTTL = "5m" + require.NoError(t, newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/items")) + } + + run() + run() + assert.Equal(t, int32(2), hits.Load(), "a non-2xx response must never be cached") +} + +func TestExecute_CacheTTL_NonGetNotCached(t *testing.T) { + isolateCacheDir(t) + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + run := func() { + tmp := filepath.Join(t.TempDir(), "out.json") + cfg := config.Defaults() + cfg.NoAuth = true + cfg.OutputFile = tmp + cfg.CacheTTL = "5m" + require.NoError(t, newTestService().Execute(context.Background(), cfg, "POST", srv.URL+"/items")) + } + + run() + run() + assert.Equal(t, int32(2), hits.Load(), "only GET responses are cached") +} + +func TestExecute_InvalidCacheTTL_ExitsTwoBeforeRequest(t *testing.T) { + isolateCacheDir(t) + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := config.Defaults() + cfg.NoAuth = true + cfg.CacheTTL = "nope" + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/items") + require.Error(t, err) + var coder cacheExitCoder + require.True(t, errors.As(err, &coder)) + assert.Equal(t, 2, coder.ExitCode()) + assert.Equal(t, int32(0), hits.Load(), "an invalid --cache-ttl must fail before any network call") +} diff --git a/cli/src/internal/service/service.go b/cli/src/internal/service/service.go index c12646c..8cb17f5 100644 --- a/cli/src/internal/service/service.go +++ b/cli/src/internal/service/service.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "net/http" "net/url" "os" "sort" @@ -402,6 +403,34 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method, fmt.Fprintf(os.Stderr, "%s: %s\n", clientRequestIDHeader, cfg.ClientRequestID) } + // --cache-ttl (#283): parse first so an invalid duration fails fast with + // exit code 2 before any request work happens. + cacheTTL, err := parseCacheTTL(cfg.CacheTTL) + if err != nil { + return err + } + + // Caching applies only to a single GET. Repeat runs measure latency and + // always hit the network. On a hit within the window the cached status, + // headers, and body are served with no network call or token acquisition. + cacheEnabled := cacheTTL > 0 && cfg.Repeat == 1 && strings.EqualFold(method, http.MethodGet) + var cacheCtx cacheContext + cacheReady := false + if cacheEnabled { + if derived, buildErr := newCacheContext(cfg, method, url); buildErr == nil { + cacheCtx = derived + cacheReady = true + if !cfg.NoCache { + if cached, hit := readCache(cacheCtx.dir, cacheCtx.key, cacheTTL); hit { + if cfg.Verbose { + writeDiagnostic(os.Stderr, cfg.Silent, "> served from cache (max age %s)\n", cacheTTL) + } + return s.handleResponse(cfg, method, cacheCtx.finalURL, cached) + } + } + } + } + opts, cleanup, err := s.BuildRequestOptions(cfg, method, url) if err != nil { return err @@ -436,6 +465,22 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method, return err } + // --cache-ttl (#283): store only successful GET responses. A write failure + // is a note, not a request failure, so the response is still served. + if cacheReady && resp.StatusCode >= 200 && resp.StatusCode < 300 { + if writeErr := writeCache(cacheCtx.dir, cacheCtx.key, resp); writeErr != nil { + writeDiagnostic(os.Stderr, cfg.Silent, "Warning: failed to write response cache: %v\n", writeErr) + } + } + + return s.handleResponse(cfg, opts.Method, opts.URL, resp) +} + +// handleResponse runs the shared post-response pipeline for both live and +// cached responses: apply --query, emit throttle and header diagnostics, write +// the body, expand --write-out, and honor --fail. method and finalURL feed +// --write-out so a cache hit reports the same request metadata as a live call. +func (s *RequestService) handleResponse(cfg config.Config, method, finalURL string, resp *client.Response) error { if cfg.Query != "" { if err := applyQueryToResponse(resp, cfg.Query); err != nil { return err @@ -457,7 +502,7 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method, } if cfg.WriteOut != "" { - fmt.Fprint(os.Stderr, ExpandWriteOut(cfg.WriteOut, opts.Method, opts.URL, resp)) + fmt.Fprint(os.Stderr, ExpandWriteOut(cfg.WriteOut, method, finalURL, resp)) } // --fail (#233): after the body and metadata have been written, return a diff --git a/web/src/pages/reference.astro b/web/src/pages/reference.astro index 446eed5..5fbf8ef 100644 --- a/web/src/pages/reference.astro +++ b/web/src/pages/reference.astro @@ -126,6 +126,8 @@ const base = import.meta.env.BASE_URL;
--repeat--allow-host*. matches subdomains). Env: AZD_REST_ALLOWED_HOSTS (comma separated)--fail22 when the response status is 400 or higher. The response body is still printed, so error details stay visible.--cache-ttlGET responses on disk and serve them until the entry is older than this window. Examples: 30s, 5m, 1h. 0 disables caching. Only GET requests with a 2xx status are cached, and only when --repeat is 1. A cache hit skips token acquisition. An invalid value exits with code 2.--no-cache--cache-ttl is set.
+ Manage the on-disk response cache used by --cache-ttl. Cached entries are
+ keyed by request method, final URL, and resolved scope, and are stored under the user
+ cache directory (azd-rest/cache) with owner-only file permissions.
+
| Subcommand | Description |
|---|---|
cache path | Print the directory where cached responses are stored |
cache clear | Delete all cached responses |