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
3 changes: 3 additions & 0 deletions cli/src/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ var (
scope string
noAuth bool
apiVersion string
baseURL string
clientRequestID string
urlParams []string
headers []string
Expand Down Expand Up @@ -189,6 +190,7 @@ Examples:
rootCmd.PersistentFlags().StringVarP(&scope, "scope", "s", "", "OAuth scope for authentication (auto-detected if not provided)")
rootCmd.PersistentFlags().BoolVar(&noAuth, "no-auth", false, "Skip authentication (no bearer token)")
rootCmd.PersistentFlags().StringVar(&apiVersion, "api-version", "", "Set or replace the api-version query parameter")
rootCmd.PersistentFlags().StringVar(&baseURL, "base-url", "", "Resolve relative request paths against this base URL")
rootCmd.PersistentFlags().StringVar(&clientRequestID, "client-request-id", "", "Set the x-ms-client-request-id header for Azure request correlation. Pass the flag without a value to generate a random ID.")
// Passing --client-request-id without a value generates a fresh ID for this invocation.
rootCmd.PersistentFlags().Lookup("client-request-id").NoOptDefVal = uuid.NewString()
Expand Down Expand Up @@ -266,6 +268,7 @@ func snapshotConfig() config.Config {
Scope: scope,
NoAuth: noAuth,
APIVersion: apiVersion,
BaseURL: baseURL,
ClientRequestID: clientRequestID,
URLParams: urlParams,
Headers: headers,
Expand Down
8 changes: 8 additions & 0 deletions cli/src/internal/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func resetGlobalFlags() {
scope = ""
noAuth = false
apiVersion = ""
baseURL = ""
clientRequestID = ""
urlParams = []string{}
headers = []string{}
Expand Down Expand Up @@ -101,6 +102,13 @@ func TestSnapshotConfig_Silent(t *testing.T) {
assert.True(t, cfg.Silent, "snapshotConfig should carry the silent flag")
}

func TestSnapshotConfig_BaseURL(t *testing.T) {
resetGlobalFlags()
baseURL = "https://management.azure.com"
cfg := snapshotConfig()
assert.Equal(t, "https://management.azure.com", cfg.BaseURL)
}

func TestBuildRequestOptions_Headers(t *testing.T) {
resetGlobalFlags()
headers = []string{"X-Custom: value1", "Authorization: Bearer token"}
Expand Down
1 change: 1 addition & 0 deletions cli/src/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type Config struct {
Scope string
NoAuth bool
APIVersion string
BaseURL string
ClientRequestID string
URLParams []string
Headers []string
Expand Down
36 changes: 34 additions & 2 deletions cli/src/internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,33 @@ func applyAPIVersion(rawURL, apiVersion string) (string, error) {
return parsed.String(), nil
}

func resolveRequestURL(rawURL, baseURL string) (string, error) {
if baseURL == "" {
return rawURL, nil
}

request, err := url.Parse(rawURL)
if err != nil {
return "", fmt.Errorf("invalid request URL: %w", err)
}
if request.IsAbs() {
return rawURL, nil
}

base, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid --base-url: %w", err)
}
if base.Scheme == "" || base.Host == "" {
return "", fmt.Errorf("--base-url must include scheme and host")
}
if !strings.HasPrefix(rawURL, "/") && base.Path != "" && !strings.HasSuffix(base.Path, "/") {
base.Path += "/"
}

return base.ResolveReference(request).String(), nil
}

// applyURLParams sets or appends query parameters from repeatable key=value flags.
// The first occurrence of a key replaces any existing value on the URL; further
// occurrences of the same key append, so multi-valued parameters are possible.
Expand Down Expand Up @@ -183,7 +210,12 @@ func applyURLParams(rawURL string, params []string) (string, error) {
// the file after the request completes. The returned cleanup function handles
// this - call it on error paths. On success paths the caller should defer it.
func (s *RequestService) BuildRequestOptions(cfg config.Config, method, url string) (client.RequestOptions, func(), error) {
requestURL, err := applyAPIVersion(url, cfg.APIVersion)
requestURL, err := resolveRequestURL(url, cfg.BaseURL)
if err != nil {
return client.RequestOptions{}, nil, err
}

requestURL, err = applyAPIVersion(requestURL, cfg.APIVersion)
if err != nil {
return client.RequestOptions{}, nil, err
}
Expand Down Expand Up @@ -361,7 +393,7 @@ func (s *RequestService) BuildRequestOptions(cfg config.Config, method, url stri
}

// Check if auth should be skipped
opts.SkipAuth = client.ShouldSkipAuth(url, opts.Headers, cfg.NoAuth)
opts.SkipAuth = client.ShouldSkipAuth(requestURL, opts.Headers, cfg.NoAuth)

// Create token provider only when authentication is needed
if !opts.SkipAuth {
Expand Down
37 changes: 37 additions & 0 deletions cli/src/internal/service/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,3 +296,40 @@ func TestBuildRequestOptions_AllowHostUnsetAllowsAny(t *testing.T) {
}
require.NoError(t, err)
}

func TestResolveRequestURL_BaseURLWithLeadingSlash(t *testing.T) {
got, err := resolveRequestURL("/subscriptions", "https://management.azure.com")
require.NoError(t, err)
assert.Equal(t, "https://management.azure.com/subscriptions", got)
}

func TestResolveRequestURL_BaseURLWithRelativePath(t *testing.T) {
got, err := resolveRequestURL("users?api-version=1", "https://example.com/api")
require.NoError(t, err)
assert.Equal(t, "https://example.com/api/users?api-version=1", got)
}

func TestResolveRequestURL_AbsoluteURLIgnoresBaseURL(t *testing.T) {
got, err := resolveRequestURL("https://graph.microsoft.com/v1.0/me", "not a base URL")
require.NoError(t, err)
assert.Equal(t, "https://graph.microsoft.com/v1.0/me", got)
}

func TestResolveRequestURL_BaseURLRequiresSchemeAndHost(t *testing.T) {
_, err := resolveRequestURL("/subscriptions", "management.azure.com")
require.Error(t, err)
assert.Contains(t, err.Error(), "--base-url must include scheme and host")
}

func TestBuildRequestOptions_BaseURLBeforeQueryFlags(t *testing.T) {
svc := newTestService()
cfg := baseTestConfig(t)
cfg.BaseURL = "https://management.azure.com"
cfg.APIVersion = "2024-01-01"
cfg.URLParams = []string{"filter=active"}

opts, cleanup, err := svc.BuildRequestOptions(cfg, "GET", "/subscriptions")
require.NoError(t, err)
defer cleanup()
assert.Equal(t, "https://management.azure.com/subscriptions?api-version=2024-01-01&filter=active", opts.URL)
}
Loading