From 75bafd10c8defa42ba5889a9af4ddeb084d2ef49 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:17:38 +0000 Subject: [PATCH] feat(ansible): support form-urlencoded uri bodies Co-Authored-By: Virgil --- mock_ssh_test.go | 9 +++-- modules.go | 85 +++++++++++++++++++++++++++++++++++++++++++-- modules_adv_test.go | 22 ++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/mock_ssh_test.go b/mock_ssh_test.go index 0ce632f..63a0624 100644 --- a/mock_ssh_test.go +++ b/mock_ssh_test.go @@ -1679,8 +1679,13 @@ func moduleURIWithClient(_ *Executor, client sshRunner, args map[string]any) (*T } if bodyText != "" { curlOpts = append(curlOpts, "-d", sprintf("%q", bodyText)) - if bodyFormat == "json" && !hasHeaderIgnoreCase(headersMap(args), "Content-Type") { - curlOpts = append(curlOpts, "-H", "\"Content-Type: application/json\"") + if !hasHeaderIgnoreCase(headersMap(args), "Content-Type") { + switch bodyFormat { + case "json": + curlOpts = append(curlOpts, "-H", "\"Content-Type: application/json\"") + case "form-urlencoded", "form_urlencoded", "form": + curlOpts = append(curlOpts, "-H", "\"Content-Type: application/x-www-form-urlencoded\"") + } } } } diff --git a/modules.go b/modules.go index 63ed0b6..75cdec8 100644 --- a/modules.go +++ b/modules.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io/fs" + "net/url" "os" "path" "path/filepath" @@ -1298,8 +1299,13 @@ func (e *Executor) moduleURI(ctx context.Context, client sshExecutorClient, args } if bodyText != "" { curlOpts = append(curlOpts, "-d", sprintf("%q", bodyText)) - if bodyFormat == "json" && !hasHeaderIgnoreCase(headersMap(args), "Content-Type") { - curlOpts = append(curlOpts, "-H", "\"Content-Type: application/json\"") + if !hasHeaderIgnoreCase(headersMap(args), "Content-Type") { + switch bodyFormat { + case "json": + curlOpts = append(curlOpts, "-H", "\"Content-Type: application/json\"") + case "form-urlencoded", "form_urlencoded", "form": + curlOpts = append(curlOpts, "-H", "\"Content-Type: application/x-www-form-urlencoded\"") + } } } } @@ -1361,11 +1367,86 @@ func renderURIBody(body any, bodyFormat string) (string, error) { } return string(data), nil } + case "form-urlencoded", "form_urlencoded", "form": + return renderURIBodyFormEncoded(body), nil default: return sprintf("%v", body), nil } } +func renderURIBodyFormEncoded(body any) string { + values := url.Values{} + + switch v := body.(type) { + case map[string]any: + keys := make([]string, 0, len(v)) + for key := range v { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + appendFormValue(values, key, v[key]) + } + case map[any]any: + keys := make([]string, 0, len(v)) + for key := range v { + if s, ok := key.(string); ok { + keys = append(keys, s) + } + } + sort.Strings(keys) + for _, key := range keys { + appendFormValue(values, key, v[key]) + } + case map[string]string: + keys := make([]string, 0, len(v)) + for key := range v { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + values.Add(key, v[key]) + } + case []any: + for _, item := range v { + if pair, ok := item.(map[string]any); ok { + key := getStringArg(pair, "key", "") + if key == "" { + key = getStringArg(pair, "name", "") + } + if key != "" { + appendFormValue(values, key, pair["value"]) + } + } + } + case string: + return v + default: + return sprintf("%v", body) + } + + return values.Encode() +} + +func appendFormValue(values url.Values, key string, value any) { + switch v := value.(type) { + case nil: + values.Add(key, "") + case string: + values.Add(key, v) + case []string: + for _, item := range v { + values.Add(key, item) + } + case []any: + for _, item := range v { + values.Add(key, sprintf("%v", item)) + } + default: + values.Add(key, sprintf("%v", v)) + } +} + func headersMap(args map[string]any) map[string]any { headers, _ := args["headers"].(map[string]any) return headers diff --git a/modules_adv_test.go b/modules_adv_test.go index f09f2cc..efd053d 100644 --- a/modules_adv_test.go +++ b/modules_adv_test.go @@ -1251,6 +1251,28 @@ func TestModulesAdv_ModuleURI_Good_PostWithBodyAndHeaders(t *testing.T) { assert.True(t, mock.containsSubstring("Authorization")) } +func TestModulesAdv_ModuleURI_Good_FormURLEncodedBody(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`curl.*form\.example\.com`, "created\n201", "", 0) + + result, err := moduleURIWithClient(e, mock, map[string]any{ + "url": "https://form.example.com/submit", + "method": "POST", + "body_format": "form-urlencoded", + "body": map[string]any{ + "name": "Alice Example", + "scope": []any{"read", "write"}, + }, + "status_code": 201, + }) + + require.NoError(t, err) + assert.False(t, result.Failed) + assert.Equal(t, 201, result.RC) + assert.True(t, mock.containsSubstring(`-d "name=Alice+Example&scope=read&scope=write"`)) + assert.True(t, mock.containsSubstring("Content-Type: application/x-www-form-urlencoded")) +} + func TestModulesAdv_ModuleURI_Good_WrongStatusCode(t *testing.T) { e, mock := newTestExecutorWithMock("host1") mock.expectCommand(`curl`, "Not Found\n404", "", 0)