Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions cli/src/internal/cmd/cache.go
Original file line number Diff line number Diff line change
@@ -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
},
}
}
51 changes: 51 additions & 0 deletions cli/src/internal/cmd/cache_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 7 additions & 0 deletions cli/src/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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).
Expand All @@ -253,6 +257,7 @@ Examples:
NewDoctorCommand(),
NewGraphCommand(),
NewWhoamiCommand(),
NewCacheCommand(),
)

return rootCmd
Expand Down Expand Up @@ -304,6 +309,8 @@ func snapshotConfig() config.Config {
Fail: fail,
RawOutput: rawOutput,
Compact: compact,
CacheTTL: cacheTTL,
NoCache: noCache,
}
}

Expand Down
2 changes: 2 additions & 0 deletions cli/src/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
180 changes: 180 additions & 0 deletions cli/src/internal/service/cache.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading