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
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ is structured as follows:
| `headers` | `http.Header` | The headers of the response. See applicable [Go documentation](https://pkg.go.dev/net/http#Header). |
| `header` | `func(string) string` | `headers` can be inconvenient to work with directly. This function allows you to access a header by name. |
| `body` | `map[string]any` | The body of the response, if any, unmarshaled into a map. If the response body is empty, this map will also be empty. |
| `bodyText` | `string` | The raw body of the response as a string. This is useful for non-JSON responses like plain text, tokens, or other string formats. |

## Outputs

Expand Down
34 changes: 21 additions & 13 deletions pkg/promotion/runner/builtin/http_requester.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,25 +289,33 @@ func (h *httpRequester) buildExprEnv(
// TODO(krancour): Casting as an int64 is a short-term fix here because
// deep copy of the output map will panic if any value is an int. This is
// a near-term fix and a better solution will be PR'ed soon.
"status": int64(resp.StatusCode),
"header": resp.Header.Get,
"headers": resp.Header,
"body": map[string]any{},
"status": int64(resp.StatusCode),
"header": resp.Header.Get,
"headers": resp.Header,
"body": map[string]any{},
"bodyText": string(bodyBytes),
},
}
contentType, _, _ := mime.ParseMediaType(resp.Header.Get(contentTypeHeader))
if len(bodyBytes) > 0 && (contentType == contentTypeJSON || json.Valid(bodyBytes)) {
var parsedBody any
if err := json.Unmarshal(bodyBytes, &parsedBody); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}

// Unmarshal into map[string]any or []any
switch parsedBody.(type) {
case map[string]any, []any:
env["response"].(map[string]any)["body"] = parsedBody // nolint: forcetypeassert
default:
return nil, fmt.Errorf("unexpected type when unmarshaling response: %T", parsedBody)
// Log the JSON parsing error but continue to provide bodyText
logging.LoggerFromContext(ctx).Debug(
"Failed to parse JSON response, continuing with bodyText only",
"error", err,
)
} else {
// Unmarshal into map[string]any or []any
switch parsedBody.(type) {
case map[string]any, []any:
env["response"].(map[string]any)["body"] = parsedBody // nolint: forcetypeassert
default:
logging.LoggerFromContext(ctx).Debug(
"Unexpected JSON type, continuing with bodyText only",
"type", fmt.Sprintf("%T", parsedBody),
)
}
Comment on lines +303 to +318
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't feel right. To have gotten to this chunk of code, the conditional on line +300 evaluated to true. The content type of the response was explicitly JSON or the response has already been determined to be valid JSON (regardless of what the content type said). i.e. If we're in this block of code, it's expected that the body will unmarshal correctly. If it doesn't we should be sticking with the original behavior of returning an error.

Copy link
Author

@yiran0427 yiran0427 Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh i see. is it better if i add another case for contentType == 'application/json'. then return bodyText: string(bodyBytes) and empty body?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you need to do anything except revert the changes to these lines.

I think the only change needed to this file is the one on line +296.

}
}

Expand Down
87 changes: 78 additions & 9 deletions pkg/promotion/runner/builtin/http_requester_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ func Test_httpRequester_run(t *testing.T) {
Name: "theMeaningOfLife",
FromExpression: "response.body.theMeaningOfLife",
},
{
Name: "bodyText",
FromExpression: "response.bodyText",
},
},
},
assertions: func(t *testing.T, res promotion.StepResult, err error) {
Expand All @@ -240,6 +244,7 @@ func Test_httpRequester_run(t *testing.T) {
map[string]any{
"status": int64(http.StatusOK),
"theMeaningOfLife": nil,
"bodyText": "",
},
res.Output,
)
Expand All @@ -263,6 +268,10 @@ func Test_httpRequester_run(t *testing.T) {
Name: "theMeaningOfLife",
FromExpression: "response.body.theMeaningOfLife",
},
{
Name: "bodyText",
FromExpression: "response.bodyText",
},
},
},
assertions: func(t *testing.T, res promotion.StepResult, err error) {
Expand All @@ -273,6 +282,7 @@ func Test_httpRequester_run(t *testing.T) {
map[string]any{
"status": int64(http.StatusOK),
"theMeaningOfLife": nil,
"bodyText": "this is just a regular string",
},
res.Output,
)
Expand All @@ -296,6 +306,10 @@ func Test_httpRequester_run(t *testing.T) {
Name: "theMeaningOfLife",
FromExpression: "response.body.theMeaningOfLife",
},
{
Name: "bodyText",
FromExpression: "response.bodyText",
},
},
},
assertions: func(t *testing.T, res promotion.StepResult, err error) {
Expand All @@ -306,6 +320,7 @@ func Test_httpRequester_run(t *testing.T) {
map[string]any{
"status": int64(http.StatusOK),
"theMeaningOfLife": float64(42),
"bodyText": `{"theMeaningOfLife": 42}`,
},
res.Output,
)
Expand All @@ -329,6 +344,10 @@ func Test_httpRequester_run(t *testing.T) {
Name: "theMeaningOfLife",
FromExpression: "response.body[0].theMeaningOfLife",
},
{
Name: "bodyText",
FromExpression: "response.bodyText",
},
},
},
assertions: func(t *testing.T, res promotion.StepResult, err error) {
Expand All @@ -339,6 +358,7 @@ func Test_httpRequester_run(t *testing.T) {
map[string]any{
"status": int64(http.StatusOK),
"theMeaningOfLife": float64(42),
"bodyText": `[{"theMeaningOfLife": 42}]`,
},
res.Output,
)
Expand Down Expand Up @@ -543,6 +563,11 @@ func Test_httpRequester_buildExprEnv(t *testing.T) {
body, ok := bodyAny.(map[string]any)
require.True(t, ok)
require.Empty(t, body)
bodyTextAny, ok := env["response"].(map[string]any)["bodyText"]
require.True(t, ok)
bodyText, ok := bodyTextAny.(string)
require.True(t, ok)
require.Equal(t, "", bodyText)
},
},
{
Expand All @@ -554,11 +579,18 @@ func Test_httpRequester_buildExprEnv(t *testing.T) {
},
assertions: func(t *testing.T, env map[string]any, err error) {
require.NoError(t, err)

bodyAny, ok := env["response"].(map[string]any)["body"]
require.True(t, ok)
body, ok := bodyAny.(map[string]any)
require.True(t, ok)
require.Equal(t, map[string]any{"foo": "bar"}, body)

bodyTextAny, ok := env["response"].(map[string]any)["bodyText"]
require.True(t, ok)
bodyText, ok := bodyTextAny.(string)
require.True(t, ok)
require.Equal(t, `{"foo": "bar"}`, bodyText)
},
},
{
Expand All @@ -570,17 +602,24 @@ func Test_httpRequester_buildExprEnv(t *testing.T) {
},
assertions: func(t *testing.T, env map[string]any, err error) {
require.NoError(t, err)
bodyAny, ok := env["response"].(map[string]any)["body"]
require.True(t, ok)

// Check if interface is of type []any
bodyAny, ok := env["response"].(map[string]any)["body"]
require.True(t, ok)
body, ok := bodyAny.([]any)
require.True(t, ok)
require.Len(t, body, 2)

firstItem, ok := body[0].(map[string]any)
require.True(t, ok)
require.Equal(t, map[string]any{"foo1": "bar1"}, firstItem)

// Check bodyText contains the raw JSON array string
bodyTextAny, ok := env["response"].(map[string]any)["bodyText"]
require.True(t, ok)
bodyText, ok := bodyTextAny.(string)
require.True(t, ok)
require.Equal(t, `[{"foo1": "bar1"}, {"foo2": "bar2"}]`, bodyText)
},
},
{
Expand All @@ -590,9 +629,21 @@ func Test_httpRequester_buildExprEnv(t *testing.T) {
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"foo":`)),
},
assertions: func(t *testing.T, _ map[string]any, err error) {
require.Error(t, err)
require.ErrorContains(t, err, "failed to parse response")
assertions: func(t *testing.T, env map[string]any, err error) {
require.NoError(t, err)
require.NotNil(t, env)

bodyTextAny, ok := env["response"].(map[string]any)["bodyText"]
require.True(t, ok)
bodyText, ok := bodyTextAny.(string)
require.True(t, ok)
require.Equal(t, `{"foo":`, bodyText)

bodyAny, ok := env["response"].(map[string]any)["body"]
require.True(t, ok)
body, ok := bodyAny.(map[string]any)
require.True(t, ok)
require.Empty(t, body)
},
},
{
Expand All @@ -602,9 +653,21 @@ func Test_httpRequester_buildExprEnv(t *testing.T) {
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`"foo"`)),
},
assertions: func(t *testing.T, _ map[string]any, err error) {
require.Error(t, err)
require.ErrorContains(t, err, "unexpected type when unmarshaling")
assertions: func(t *testing.T, env map[string]any, err error) {
require.NoError(t, err)
require.NotNil(t, env)

bodyAny, ok := env["response"].(map[string]any)["body"]
require.True(t, ok)
body, ok := bodyAny.(map[string]any)
require.True(t, ok)
require.Empty(t, body)

bodyTextAny, ok := env["response"].(map[string]any)["bodyText"]
require.True(t, ok)
bodyText, ok := bodyTextAny.(string)
require.True(t, ok)
require.Equal(t, `"foo"`, bodyText)
},
},
{
Expand All @@ -616,12 +679,18 @@ func Test_httpRequester_buildExprEnv(t *testing.T) {
},
assertions: func(t *testing.T, env map[string]any, err error) {
require.NoError(t, err)

bodyAny, ok := env["response"].(map[string]any)["body"]
require.True(t, ok)

body, ok := bodyAny.(map[string]any)
require.True(t, ok)
require.Equal(t, map[string]any{"foo": "bar"}, body)

bodyTextAny, ok := env["response"].(map[string]any)["bodyText"]
require.True(t, ok)
bodyText, ok := bodyTextAny.(string)
require.True(t, ok)
require.Equal(t, `{"foo": "bar"}`, bodyText)
},
},
}
Expand Down