diff --git a/README.md b/README.md index c2cac09..cde76ec 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Built-in Model Context Protocol server for AI agent integration. Copilot and oth ### 🔄 All HTTP Methods -GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS with JSON body support from inline data or files. +GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, and custom methods through `request ` with JSON body support from inline data or files. ### 📊 Verbose Diagnostics Request/response details, traceparent injection for distributed tracing, and redacted sensitive headers in logs. diff --git a/cli/docs/cli-reference.md b/cli/docs/cli-reference.md index b7a5f27..79dc1c1 100644 --- a/cli/docs/cli-reference.md +++ b/cli/docs/cli-reference.md @@ -37,6 +37,7 @@ azd rest version | `delete` | Execute a DELETE request | | `head` | Execute a HEAD request | | `options` | Execute an OPTIONS request | +| `request` | Execute a request with a custom HTTP method | | `scope` | Preview the detected OAuth scope and auth mode for a URL | | `version` | Display the extension version | @@ -165,6 +166,24 @@ azd rest options [flags] azd rest options https://api.example.com/resource ``` +### `azd rest request ` + +Execute a request with a custom HTTP method while keeping the same auth, retry, formatting, and safety behavior as the named method commands. + +**Usage:** +```bash +azd rest request [flags] +``` + +**Examples:** +```bash +# Send an uncommon method name +azd rest request PURGE https://management.azure.com/... --api-version 2024-01-01 + +# Lowercase input is normalized before the request is sent +azd rest request merge https://api.example.com/resource --no-auth +``` + --- ## Global Flags diff --git a/cli/src/internal/cmd/root.go b/cli/src/internal/cmd/root.go index 7c1a516..8a807cd 100644 --- a/cli/src/internal/cmd/root.go +++ b/cli/src/internal/cmd/root.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "os" + "strings" "time" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -118,6 +119,36 @@ func NewHeadCommand() *cobra.Command { return newHTTPMethodCommand(httpMethods[5 // NewOptionsCommand returns the OPTIONS subcommand. func NewOptionsCommand() *cobra.Command { return newHTTPMethodCommand(httpMethods[6]) } +// NewRequestCommand returns the generic request subcommand for uncommon HTTP methods. +func NewRequestCommand() *cobra.Command { + return &cobra.Command{ + Use: "request ", + Short: "Execute a request with a custom HTTP method", + Long: `Execute a request with any HTTP method while reusing the same authentication, +retry, formatting, and safety behavior as the named method commands. + +Examples: + azd rest request PURGE https://management.azure.com/... --api-version 2024-01-01 + azd rest request merge https://api.example.com/resource --no-auth`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + method, err := normalizeRequestMethod(args[0]) + if err != nil { + return err + } + return executeRequest(cmd, method, args[1]) + }, + } +} + +func normalizeRequestMethod(method string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(method)) + if normalized == "" { + return "", fmt.Errorf("method is required") + } + return normalized, nil +} + // NewRootCmd creates the root command for azd rest func NewRootCmd() *cobra.Command { rootCmd, _ := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ @@ -235,6 +266,7 @@ Examples: // Add non-HTTP-method subcommands rootCmd.AddCommand( + NewRequestCommand(), NewScopeCommand(), azdext.NewVersionCommand("jongio.azd.rest", version.Version, &outputFormat), azdext.NewMetadataCommand("1.0", "jongio.azd.rest", NewRootCmd), diff --git a/cli/src/internal/cmd/root_test.go b/cli/src/internal/cmd/root_test.go index a1268be..998b361 100644 --- a/cli/src/internal/cmd/root_test.go +++ b/cli/src/internal/cmd/root_test.go @@ -78,12 +78,59 @@ func TestNewRootCmd(t *testing.T) { } } - expectedCommands := []string{"get", "post", "put", "patch", "delete", "head", "options", "scope", "version"} + expectedCommands := []string{"get", "post", "put", "patch", "delete", "head", "options", "request", "scope", "version"} for _, expected := range expectedCommands { assert.True(t, subcommandNames[expected], "Subcommand %s should be present", expected) } } +func TestNormalizeRequestMethod(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "uppercase is unchanged", input: "PURGE", want: "PURGE"}, + {name: "lowercase is uppercased", input: "merge", want: "MERGE"}, + {name: "spaces are trimmed", input: " link ", want: "LINK"}, + {name: "empty is rejected", input: " ", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeRequestMethod(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRequestCommandExecutesCustomMethod(t *testing.T) { + resetGlobalFlags() + var gotMethod string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + noAuth = true + outputFile = filepath.Join(t.TempDir(), "response.json") + + cmd := NewRequestCommand() + cmd.SetArgs([]string{"purge", server.URL + "/cache"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "PURGE", gotMethod) +} + func TestNewRootCmd_SilentFlag(t *testing.T) { resetGlobalFlags() cmd := NewRootCmd() diff --git a/cli/src/internal/skills/azd-rest/SKILL.md b/cli/src/internal/skills/azd-rest/SKILL.md index edbafe3..a045c1d 100644 --- a/cli/src/internal/skills/azd-rest/SKILL.md +++ b/cli/src/internal/skills/azd-rest/SKILL.md @@ -29,6 +29,8 @@ azd rest [flags] Supported HTTP methods: `get`, `post`, `put`, `patch`, `delete`, `head`, `options` +Use `azd rest request ` for uncommon method names such as `PURGE`, `MERGE`, or `LINK`. + Use `azd rest scope ` to preview the detected OAuth scope and auth mode for a URL without sending a request. ## Flags