diff --git a/cli/src/internal/cmd/root.go b/cli/src/internal/cmd/root.go index 35e9e2a..692035c 100644 --- a/cli/src/internal/cmd/root.go +++ b/cli/src/internal/cmd/root.go @@ -26,6 +26,7 @@ var ( scope string noAuth bool apiVersion string + baseURL string clientRequestID string urlParams []string headers []string @@ -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() @@ -266,6 +268,7 @@ func snapshotConfig() config.Config { Scope: scope, NoAuth: noAuth, APIVersion: apiVersion, + BaseURL: baseURL, ClientRequestID: clientRequestID, URLParams: urlParams, Headers: headers, diff --git a/cli/src/internal/cmd/root_test.go b/cli/src/internal/cmd/root_test.go index a1268be..77f99df 100644 --- a/cli/src/internal/cmd/root_test.go +++ b/cli/src/internal/cmd/root_test.go @@ -30,6 +30,7 @@ func resetGlobalFlags() { scope = "" noAuth = false apiVersion = "" + baseURL = "" clientRequestID = "" urlParams = []string{} headers = []string{} @@ -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"} diff --git a/cli/src/internal/config/config.go b/cli/src/internal/config/config.go index 0e8015c..13f3380 100644 --- a/cli/src/internal/config/config.go +++ b/cli/src/internal/config/config.go @@ -12,6 +12,7 @@ type Config struct { Scope string NoAuth bool APIVersion string + BaseURL string ClientRequestID string URLParams []string Headers []string diff --git a/cli/src/internal/service/service.go b/cli/src/internal/service/service.go index c12646c..4c3e9d7 100644 --- a/cli/src/internal/service/service.go +++ b/cli/src/internal/service/service.go @@ -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. @@ -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 } @@ -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 { diff --git a/cli/src/internal/service/service_test.go b/cli/src/internal/service/service_test.go index ce3633d..04882d7 100644 --- a/cli/src/internal/service/service_test.go +++ b/cli/src/internal/service/service_test.go @@ -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) +}