From 1b86e8cc44db37b9737b74d14badfe756a262d08 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:57:10 -0700 Subject: [PATCH] feat: add url param file flag Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cli/src/internal/cmd/root.go | 3 + cli/src/internal/cmd/root_test.go | 1 + cli/src/internal/config/config.go | 1 + cli/src/internal/service/service.go | 45 ++++++++++ .../internal/service/url_param_file_test.go | 87 +++++++++++++++++++ 5 files changed, 137 insertions(+) create mode 100644 cli/src/internal/service/url_param_file_test.go diff --git a/cli/src/internal/cmd/root.go b/cli/src/internal/cmd/root.go index 35e9e2a..28aadbe 100644 --- a/cli/src/internal/cmd/root.go +++ b/cli/src/internal/cmd/root.go @@ -28,6 +28,7 @@ var ( apiVersion string clientRequestID string urlParams []string + urlParamFile string headers []string headerFile string data string @@ -193,6 +194,7 @@ Examples: // Passing --client-request-id without a value generates a fresh ID for this invocation. rootCmd.PersistentFlags().Lookup("client-request-id").NoOptDefVal = uuid.NewString() rootCmd.PersistentFlags().StringArrayVar(&urlParams, "url-param", []string{}, "Set or append a URL query parameter (repeatable, format: key=value)") + rootCmd.PersistentFlags().StringVar(&urlParamFile, "url-param-file", "", "Read URL query parameters from a file (one key=value per line; blank lines and # comments ignored). --url-param overrides on conflict.") rootCmd.PersistentFlags().StringArrayVarP(&headers, "header", "H", []string{}, "Custom headers (repeatable, format: Key:Value)") rootCmd.PersistentFlags().StringVar(&headerFile, "header-file", "", "Read headers from a file (one Key: Value per line; blank lines and # comments ignored). -H overrides on conflict.") rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "Request body (JSON string)") @@ -268,6 +270,7 @@ func snapshotConfig() config.Config { APIVersion: apiVersion, ClientRequestID: clientRequestID, URLParams: urlParams, + URLParamFile: urlParamFile, Headers: headers, HeaderFile: headerFile, Data: data, diff --git a/cli/src/internal/cmd/root_test.go b/cli/src/internal/cmd/root_test.go index a1268be..016f883 100644 --- a/cli/src/internal/cmd/root_test.go +++ b/cli/src/internal/cmd/root_test.go @@ -32,6 +32,7 @@ func resetGlobalFlags() { apiVersion = "" clientRequestID = "" urlParams = []string{} + urlParamFile = "" headers = []string{} headerFile = "" data = "" diff --git a/cli/src/internal/config/config.go b/cli/src/internal/config/config.go index 0e8015c..bea8f6e 100644 --- a/cli/src/internal/config/config.go +++ b/cli/src/internal/config/config.go @@ -14,6 +14,7 @@ type Config struct { APIVersion string ClientRequestID string URLParams []string + URLParamFile string Headers []string HeaderFile string Data string diff --git a/cli/src/internal/service/service.go b/cli/src/internal/service/service.go index c12646c..8a9196f 100644 --- a/cli/src/internal/service/service.go +++ b/cli/src/internal/service/service.go @@ -175,6 +175,40 @@ func applyURLParams(rawURL string, params []string) (string, error) { return parsed.String(), nil } +// loadURLParamFile reads URL parameters from a file, one "key=value" per line. +// Blank lines and lines beginning with "#" are ignored. +func loadURLParamFile(path string) ([]string, error) { + file, err := os.Open(path) // #nosec G304 -- User-specified file path via --url-param-file flag is intentional. + if err != nil { + return nil, fmt.Errorf("failed to open URL parameter file: %w", err) + } + defer func() { _ = file.Close() }() + + var result []string + scanner := bufio.NewScanner(file) + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid URL parameter on line %d of %s: %q (expected key=value)", lineNum, path, line) + } + key := strings.TrimSpace(parts[0]) + if key == "" { + return nil, fmt.Errorf("invalid URL parameter on line %d of %s: %q (empty parameter name)", lineNum, path, line) + } + result = append(result, key+"="+strings.TrimSpace(parts[1])) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read URL parameter file: %w", err) + } + return result, nil +} + // BuildRequestOptions constructs RequestOptions from a Config and method/URL. // The caller owns the returned Body (if it is an *os.File, it must be closed). // @@ -188,6 +222,17 @@ func (s *RequestService) BuildRequestOptions(cfg config.Config, method, url stri return client.RequestOptions{}, nil, err } + if cfg.URLParamFile != "" { + fileParams, err := loadURLParamFile(cfg.URLParamFile) + if err != nil { + return client.RequestOptions{}, nil, err + } + requestURL, err = applyURLParams(requestURL, fileParams) + if err != nil { + return client.RequestOptions{}, nil, err + } + } + requestURL, err = applyURLParams(requestURL, cfg.URLParams) if err != nil { return client.RequestOptions{}, nil, err diff --git a/cli/src/internal/service/url_param_file_test.go b/cli/src/internal/service/url_param_file_test.go new file mode 100644 index 0000000..b74fde8 --- /dev/null +++ b/cli/src/internal/service/url_param_file_test.go @@ -0,0 +1,87 @@ +package service + +import ( + "os" + "path/filepath" + "testing" + + "github.com/jongio/azd-rest/src/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeURLParamFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "params.txt") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +func TestLoadURLParamFile_ParsesAndSkipsCommentsAndBlanks(t *testing.T) { + path := writeURLParamFile(t, "# a comment\napi-version=2024-01-01\n\n$top=10\n # indented comment\nempty=\n") + + got, err := loadURLParamFile(path) + + require.NoError(t, err) + assert.Equal(t, []string{"api-version=2024-01-01", "$top=10", "empty="}, got) +} + +func TestLoadURLParamFile_MalformedLine(t *testing.T) { + path := writeURLParamFile(t, "api-version=2024-01-01\nnot-a-param\n") + + _, err := loadURLParamFile(path) + + require.Error(t, err) + assert.Contains(t, err.Error(), "line 2") +} + +func TestLoadURLParamFile_EmptyParamName(t *testing.T) { + path := writeURLParamFile(t, "=value-only\n") + + _, err := loadURLParamFile(path) + + require.Error(t, err) + assert.Contains(t, err.Error(), "empty parameter name") +} + +func TestBuildRequestOptions_URLParamFileLoadsParams(t *testing.T) { + path := writeURLParamFile(t, "api-version=2024-01-01\n$top=10\n") + cfg := config.Config{NoAuth: true, URLParamFile: path} + + opts, cleanup, err := newHeaderTestService().BuildRequestOptions(cfg, "GET", testHeaderURL) + if cleanup != nil { + defer cleanup() + } + + require.NoError(t, err) + assert.Equal(t, "https://management.azure.com/subscriptions?%24top=10&api-version=2024-01-01", opts.URL) +} + +func TestBuildRequestOptions_InlineURLParamOverridesFile(t *testing.T) { + path := writeURLParamFile(t, "filter=all\ntag=file\n") + cfg := config.Config{ + NoAuth: true, + URLParamFile: path, + URLParams: []string{"filter=active", "tag=inline"}, + } + + opts, cleanup, err := newHeaderTestService().BuildRequestOptions(cfg, "GET", "https://api.example.com/items") + if cleanup != nil { + defer cleanup() + } + + require.NoError(t, err) + assert.Equal(t, "https://api.example.com/items?filter=active&tag=inline", opts.URL) +} + +func TestBuildRequestOptions_URLParamFileMissingReturnsError(t *testing.T) { + cfg := config.Config{NoAuth: true, URLParamFile: filepath.Join(t.TempDir(), "nope.txt")} + + _, cleanup, err := newHeaderTestService().BuildRequestOptions(cfg, "GET", testHeaderURL) + if cleanup != nil { + defer cleanup() + } + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to open URL parameter file") +}