Skip to content

Commit ad4542e

Browse files
Copilotintel352
andauthored
Add body_from config to step.http_call for raw body forwarding (#227)
* Initial plan * feat: add body_from config to step.http_call for raw body forwarding Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * feat: add body_from config to step.http_call for raw body forwarding Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * fix: address review feedback - fix data race in tests, update comments Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
1 parent 33dd7f9 commit ad4542e

2 files changed

Lines changed: 245 additions & 13 deletions

File tree

module/pipeline_step_http_call.go

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ type HTTPCallStep struct {
100100
method string
101101
headers map[string]string
102102
body map[string]any
103+
bodyFrom string // dot-path into pc.Current or a prior step result via "steps.<name>..."; if set, the resolved value is used as the request body (strings/[]byte sent as-is, other types JSON-marshaled)
103104
timeout time.Duration
104105
tmpl *TemplateEngine
105106
auth *oauthConfig
@@ -142,6 +143,10 @@ func NewHTTPCallStepFactory() StepFactory {
142143
step.body = body
143144
}
144145

146+
if bodyFrom, ok := config["body_from"].(string); ok {
147+
step.bodyFrom = bodyFrom
148+
}
149+
145150
if timeout, ok := config["timeout"].(string); ok && timeout != "" {
146151
if d, err := time.ParseDuration(timeout); err == nil {
147152
step.timeout = d
@@ -284,36 +289,56 @@ func (s *HTTPCallStep) getToken(ctx context.Context) (string, error) {
284289
}
285290

286291
// buildBodyReader constructs the request body reader from the step configuration.
287-
func (s *HTTPCallStep) buildBodyReader(pc *PipelineContext) (io.Reader, error) {
292+
func (s *HTTPCallStep) buildBodyReader(pc *PipelineContext) (io.Reader, bool, error) {
293+
if s.bodyFrom != "" {
294+
val := resolveBodyFrom(s.bodyFrom, pc)
295+
switch v := val.(type) {
296+
case []byte:
297+
return bytes.NewReader(v), true, nil
298+
case string:
299+
return strings.NewReader(v), true, nil
300+
case nil:
301+
return nil, true, nil
302+
default:
303+
data, marshalErr := json.Marshal(v)
304+
if marshalErr != nil {
305+
return nil, false, fmt.Errorf("http_call step %q: failed to marshal body_from value: %w", s.name, marshalErr)
306+
}
307+
return bytes.NewReader(data), false, nil
308+
}
309+
}
288310
if s.body != nil {
289311
resolvedBody, resolveErr := s.tmpl.ResolveMap(s.body, pc)
290312
if resolveErr != nil {
291-
return nil, fmt.Errorf("http_call step %q: failed to resolve body: %w", s.name, resolveErr)
313+
return nil, false, fmt.Errorf("http_call step %q: failed to resolve body: %w", s.name, resolveErr)
292314
}
293315
data, marshalErr := json.Marshal(resolvedBody)
294316
if marshalErr != nil {
295-
return nil, fmt.Errorf("http_call step %q: failed to marshal body: %w", s.name, marshalErr)
317+
return nil, false, fmt.Errorf("http_call step %q: failed to marshal body: %w", s.name, marshalErr)
296318
}
297-
return bytes.NewReader(data), nil
319+
return bytes.NewReader(data), false, nil
298320
}
299321
if s.method != "GET" && s.method != "HEAD" {
300322
data, marshalErr := json.Marshal(pc.Current)
301323
if marshalErr != nil {
302-
return nil, fmt.Errorf("http_call step %q: failed to marshal current data: %w", s.name, marshalErr)
324+
return nil, false, fmt.Errorf("http_call step %q: failed to marshal current data: %w", s.name, marshalErr)
303325
}
304-
return bytes.NewReader(data), nil
326+
return bytes.NewReader(data), false, nil
305327
}
306-
return nil, nil
328+
return nil, false, nil
307329
}
308330

309331
// buildRequest constructs the HTTP request with resolved headers and optional bearer token.
310-
func (s *HTTPCallStep) buildRequest(ctx context.Context, resolvedURL string, bodyReader io.Reader, pc *PipelineContext, bearerToken string) (*http.Request, error) {
332+
// rawBody, when true, indicates that the request body is a raw value (string/[]byte/nil,
333+
// typically provided via body_from) and should not have its Content-Type automatically
334+
// overridden with application/json.
335+
func (s *HTTPCallStep) buildRequest(ctx context.Context, resolvedURL string, bodyReader io.Reader, rawBody bool, pc *PipelineContext, bearerToken string) (*http.Request, error) {
311336
req, err := http.NewRequestWithContext(ctx, s.method, resolvedURL, bodyReader)
312337
if err != nil {
313338
return nil, fmt.Errorf("http_call step %q: failed to create request: %w", s.name, err)
314339
}
315340

316-
if bodyReader != nil {
341+
if bodyReader != nil && !rawBody {
317342
req.Header.Set("Content-Type", "application/json")
318343
}
319344
for k, v := range s.headers {
@@ -373,7 +398,7 @@ func (s *HTTPCallStep) Execute(ctx context.Context, pc *PipelineContext) (*StepR
373398
return nil, fmt.Errorf("http_call step %q: failed to resolve url: %w", s.name, err)
374399
}
375400

376-
bodyReader, err := s.buildBodyReader(pc)
401+
bodyReader, rawBody, err := s.buildBodyReader(pc)
377402
if err != nil {
378403
return nil, err
379404
}
@@ -387,7 +412,7 @@ func (s *HTTPCallStep) Execute(ctx context.Context, pc *PipelineContext) (*StepR
387412
}
388413
}
389414

390-
req, err := s.buildRequest(ctx, resolvedURL, bodyReader, pc, bearerToken)
415+
req, err := s.buildRequest(ctx, resolvedURL, bodyReader, rawBody, pc, bearerToken)
391416
if err != nil {
392417
return nil, err
393418
}
@@ -413,11 +438,11 @@ func (s *HTTPCallStep) Execute(ctx context.Context, pc *PipelineContext) (*StepR
413438
return nil, tokenErr
414439
}
415440

416-
retryBody, buildErr := s.buildBodyReader(pc)
441+
retryBody, rawBody2, buildErr := s.buildBodyReader(pc)
417442
if buildErr != nil {
418443
return nil, buildErr
419444
}
420-
retryReq, buildErr := s.buildRequest(ctx, resolvedURL, retryBody, pc, newToken)
445+
retryReq, buildErr := s.buildRequest(ctx, resolvedURL, retryBody, rawBody2, pc, newToken)
421446
if buildErr != nil {
422447
return nil, buildErr
423448
}

module/pipeline_step_http_call_test.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package module
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"fmt"
8+
"io"
79
"net/http"
810
"net/http/httptest"
911
"strings"
@@ -501,3 +503,208 @@ func TestHTTPCallStep_OAuth2_ConcurrentFetch(t *testing.T) {
501503
t.Errorf("expected exactly 1 token request via singleflight, got %d", n)
502504
}
503505
}
506+
507+
// TestHTTPCallStep_BodyFrom_String verifies that body_from with a string value sends raw bytes
508+
// without JSON-encoding and without auto-setting Content-Type: application/json.
509+
func TestHTTPCallStep_BodyFrom_String(t *testing.T) {
510+
type captured struct {
511+
body []byte
512+
contentType string
513+
}
514+
ch := make(chan captured, 1)
515+
516+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
517+
b, err := io.ReadAll(r.Body)
518+
if err != nil {
519+
t.Errorf("failed to read request body: %v", err)
520+
}
521+
ch <- captured{body: b, contentType: r.Header.Get("Content-Type")}
522+
w.WriteHeader(http.StatusOK)
523+
_, _ = w.Write([]byte(`{"ok":true}`))
524+
}))
525+
defer srv.Close()
526+
527+
factory := NewHTTPCallStepFactory()
528+
step, err := factory("body-from-string", map[string]any{
529+
"url": srv.URL,
530+
"method": "POST",
531+
"body_from": "raw_payload",
532+
}, nil)
533+
if err != nil {
534+
t.Fatalf("factory error: %v", err)
535+
}
536+
step.(*HTTPCallStep).httpClient = srv.Client()
537+
538+
pc := NewPipelineContext(nil, nil)
539+
pc.Current["raw_payload"] = `{"hello":"world"}`
540+
541+
if _, err := step.Execute(context.Background(), pc); err != nil {
542+
t.Fatalf("execute error: %v", err)
543+
}
544+
545+
got := <-ch
546+
if string(got.body) != `{"hello":"world"}` {
547+
t.Errorf("expected raw body %q, got %q", `{"hello":"world"}`, string(got.body))
548+
}
549+
// Content-Type should NOT be auto-set to application/json for raw bodies
550+
if got.contentType == "application/json" {
551+
t.Errorf("expected Content-Type not to be application/json for body_from, got %q", got.contentType)
552+
}
553+
}
554+
555+
// TestHTTPCallStep_BodyFrom_Bytes verifies that body_from with a []byte value sends raw bytes.
556+
func TestHTTPCallStep_BodyFrom_Bytes(t *testing.T) {
557+
ch := make(chan []byte, 1)
558+
559+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
560+
b, err := io.ReadAll(r.Body)
561+
if err != nil {
562+
t.Errorf("failed to read request body: %v", err)
563+
}
564+
ch <- b
565+
w.WriteHeader(http.StatusOK)
566+
_, _ = w.Write([]byte(`{}`))
567+
}))
568+
defer srv.Close()
569+
570+
factory := NewHTTPCallStepFactory()
571+
step, err := factory("body-from-bytes", map[string]any{
572+
"url": srv.URL,
573+
"method": "POST",
574+
"body_from": "raw_data",
575+
}, nil)
576+
if err != nil {
577+
t.Fatalf("factory error: %v", err)
578+
}
579+
step.(*HTTPCallStep).httpClient = srv.Client()
580+
581+
pc := NewPipelineContext(nil, nil)
582+
pc.Current["raw_data"] = []byte("binary\x00data")
583+
584+
if _, err := step.Execute(context.Background(), pc); err != nil {
585+
t.Fatalf("execute error: %v", err)
586+
}
587+
588+
gotBody := <-ch
589+
if !bytes.Equal(gotBody, []byte("binary\x00data")) {
590+
t.Errorf("expected raw bytes, got %q", string(gotBody))
591+
}
592+
}
593+
594+
// TestHTTPCallStep_BodyFrom_StepOutput verifies that body_from can resolve from step outputs.
595+
func TestHTTPCallStep_BodyFrom_StepOutput(t *testing.T) {
596+
ch := make(chan []byte, 1)
597+
598+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
599+
b, err := io.ReadAll(r.Body)
600+
if err != nil {
601+
t.Errorf("failed to read request body: %v", err)
602+
}
603+
ch <- b
604+
w.WriteHeader(http.StatusOK)
605+
_, _ = w.Write([]byte(`{}`))
606+
}))
607+
defer srv.Close()
608+
609+
factory := NewHTTPCallStepFactory()
610+
step, err := factory("body-from-step", map[string]any{
611+
"url": srv.URL,
612+
"method": "POST",
613+
"body_from": "steps.parse.raw_body",
614+
}, nil)
615+
if err != nil {
616+
t.Fatalf("factory error: %v", err)
617+
}
618+
step.(*HTTPCallStep).httpClient = srv.Client()
619+
620+
pc := NewPipelineContext(nil, nil)
621+
pc.StepOutputs["parse"] = map[string]any{
622+
"raw_body": `{"event":"push"}`,
623+
}
624+
625+
if _, err := step.Execute(context.Background(), pc); err != nil {
626+
t.Fatalf("execute error: %v", err)
627+
}
628+
629+
gotBody := <-ch
630+
if string(gotBody) != `{"event":"push"}` {
631+
t.Errorf("expected raw body from step output, got %q", string(gotBody))
632+
}
633+
}
634+
635+
// TestHTTPCallStep_BodyFrom_ContentTypeOverride verifies that Content-Type set in headers
636+
// takes effect even with body_from.
637+
func TestHTTPCallStep_BodyFrom_ContentTypeOverride(t *testing.T) {
638+
ch := make(chan string, 1)
639+
640+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
641+
ch <- r.Header.Get("Content-Type")
642+
w.WriteHeader(http.StatusOK)
643+
_, _ = w.Write([]byte(`{}`))
644+
}))
645+
defer srv.Close()
646+
647+
factory := NewHTTPCallStepFactory()
648+
step, err := factory("body-from-ct", map[string]any{
649+
"url": srv.URL,
650+
"method": "POST",
651+
"body_from": "payload",
652+
"headers": map[string]any{
653+
"Content-Type": "application/xml",
654+
},
655+
}, nil)
656+
if err != nil {
657+
t.Fatalf("factory error: %v", err)
658+
}
659+
step.(*HTTPCallStep).httpClient = srv.Client()
660+
661+
pc := NewPipelineContext(nil, nil)
662+
pc.Current["payload"] = `<root><item>1</item></root>`
663+
664+
if _, err := step.Execute(context.Background(), pc); err != nil {
665+
t.Fatalf("execute error: %v", err)
666+
}
667+
668+
gotCT := <-ch
669+
if gotCT != "application/xml" {
670+
t.Errorf("expected Content-Type application/xml, got %q", gotCT)
671+
}
672+
}
673+
674+
// TestHTTPCallStep_BodyFrom_NilValue verifies that body_from with a missing path sends no body.
675+
func TestHTTPCallStep_BodyFrom_NilValue(t *testing.T) {
676+
ch := make(chan []byte, 1)
677+
678+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
679+
b, err := io.ReadAll(r.Body)
680+
if err != nil {
681+
t.Errorf("failed to read request body: %v", err)
682+
}
683+
ch <- b
684+
w.WriteHeader(http.StatusOK)
685+
_, _ = w.Write([]byte(`{}`))
686+
}))
687+
defer srv.Close()
688+
689+
factory := NewHTTPCallStepFactory()
690+
step, err := factory("body-from-nil", map[string]any{
691+
"url": srv.URL,
692+
"method": "POST",
693+
"body_from": "nonexistent.path",
694+
}, nil)
695+
if err != nil {
696+
t.Fatalf("factory error: %v", err)
697+
}
698+
step.(*HTTPCallStep).httpClient = srv.Client()
699+
700+
pc := NewPipelineContext(nil, nil)
701+
702+
if _, err := step.Execute(context.Background(), pc); err != nil {
703+
t.Fatalf("execute error: %v", err)
704+
}
705+
706+
gotBody := <-ch
707+
if len(gotBody) != 0 {
708+
t.Errorf("expected empty body for nil body_from, got %q", string(gotBody))
709+
}
710+
}

0 commit comments

Comments
 (0)