diff --git a/cmd/wfctl/type_registry.go b/cmd/wfctl/type_registry.go index b8df25df..8bbae253 100644 --- a/cmd/wfctl/type_registry.go +++ b/cmd/wfctl/type_registry.go @@ -571,6 +571,11 @@ func KnownStepTypes() map[string]StepTypeInfo { Plugin: "pipelinesteps", ConfigKeys: []string{"url", "method", "headers", "body", "timeout", "auth"}, }, + "step.http_proxy": { + Type: "step.http_proxy", + Plugin: "pipelinesteps", + ConfigKeys: []string{"backend_url_key", "resource_key", "forward_headers", "timeout"}, + }, "step.request_parse": { Type: "step.request_parse", Plugin: "pipelinesteps", diff --git a/module/pipeline_step_http_proxy.go b/module/pipeline_step_http_proxy.go new file mode 100644 index 00000000..02de4f29 --- /dev/null +++ b/module/pipeline_step_http_proxy.go @@ -0,0 +1,226 @@ +package module + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/CrisisTextLine/modular" +) + +// HTTPProxyStep forwards the original HTTP request to a dynamically resolved +// backend URL and writes the backend response directly to the client. +// It is designed for API gateway / reverse-proxy use cases where the backend +// URL is determined by earlier pipeline steps (e.g. a database lookup). +type HTTPProxyStep struct { + name string + backendURLKey string + resourceKey string + forwardHeaders []string + timeout time.Duration + httpClient *http.Client +} + +// NewHTTPProxyStepFactory returns a StepFactory that creates HTTPProxyStep instances. +func NewHTTPProxyStepFactory() StepFactory { + return func(name string, config map[string]any, _ modular.Application) (PipelineStep, error) { + step := &HTTPProxyStep{ + name: name, + backendURLKey: "backend_url", + resourceKey: "path_params.resource", + timeout: 30 * time.Second, + httpClient: http.DefaultClient, + } + + if key, ok := config["backend_url_key"].(string); ok && key != "" { + step.backendURLKey = key + } + + if key, ok := config["resource_key"].(string); ok && key != "" { + step.resourceKey = key + } + + if headers, ok := config["forward_headers"]; ok { + switch v := headers.(type) { + case []string: + step.forwardHeaders = v + case []any: + for _, h := range v { + if s, ok := h.(string); ok { + step.forwardHeaders = append(step.forwardHeaders, s) + } + } + } + } + + if timeout, ok := config["timeout"].(string); ok && timeout != "" { + if d, err := time.ParseDuration(timeout); err == nil { + step.timeout = d + } + } + + return step, nil + } +} + +// Name returns the step name. +func (s *HTTPProxyStep) Name() string { return s.name } + +// Execute forwards the original HTTP request to the resolved backend URL and +// writes the backend response directly to the response writer. +func (s *HTTPProxyStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) { + // Resolve backend URL from pipeline context + backendURL := s.resolveStringValue(s.backendURLKey, pc) + if backendURL == "" { + return nil, fmt.Errorf("http_proxy step %q: backend URL not found at key %q in pipeline context", s.name, s.backendURLKey) + } + + // Resolve optional resource path suffix + resource := s.resolveStringValue(s.resourceKey, pc) + + // Build the target URL + targetURL := strings.TrimRight(backendURL, "/") + if resource != "" { + targetURL += "/" + strings.TrimLeft(resource, "/") + } + + // Get the original HTTP request from metadata + origReq, _ := pc.Metadata["_http_request"].(*http.Request) + + // Append original query string + if origReq != nil && origReq.URL.RawQuery != "" { + targetURL += "?" + origReq.URL.RawQuery + } + + // Determine the HTTP method + method := http.MethodGet + if origReq != nil { + method = origReq.Method + } + + // Build the request body + var bodyReader io.Reader + if origReq != nil && origReq.Body != nil { + bodyReader = origReq.Body + } + + ctx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + + proxyReq, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader) //nolint:gosec // G107: URL is dynamically resolved from pipeline context + if err != nil { + return nil, fmt.Errorf("http_proxy step %q: failed to create proxy request: %w", s.name, err) + } + + // Forward Content-Length from the original request + if origReq != nil && origReq.ContentLength > 0 { + proxyReq.ContentLength = origReq.ContentLength + } + + // Forward configured headers from the original request + if origReq != nil && len(s.forwardHeaders) > 0 { + for _, h := range s.forwardHeaders { + if vals := origReq.Header.Values(h); len(vals) > 0 { + for _, v := range vals { + proxyReq.Header.Add(h, v) + } + } + } + } + + // Execute the proxy request + resp, err := s.httpClient.Do(proxyReq) + if err != nil { + return nil, fmt.Errorf("http_proxy step %q: proxy request failed: %w", s.name, err) + } + defer resp.Body.Close() + + // Read the backend response body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("http_proxy step %q: failed to read proxy response: %w", s.name, err) + } + + // Try to write directly to the response writer if available + w, hasWriter := pc.Metadata["_http_response_writer"].(http.ResponseWriter) + if hasWriter { + // Copy backend response headers + for k, vals := range resp.Header { + for _, v := range vals { + w.Header().Add(k, v) + } + } + + // Write status code + w.WriteHeader(resp.StatusCode) + + // Write body + if len(respBody) > 0 { + if _, writeErr := w.Write(respBody); writeErr != nil { + return nil, fmt.Errorf("http_proxy step %q: failed to write response: %w", s.name, writeErr) + } + } + + // Mark response as handled + pc.Metadata["_response_handled"] = true + + return &StepResult{ + Output: map[string]any{ + "status_code": resp.StatusCode, + "proxied_to": targetURL, + }, + Stop: true, + }, nil + } + + // No response writer available — return the proxied response as output + respHeaders := make(map[string]any, len(resp.Header)) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + vals := make([]any, len(v)) + for i, hv := range v { + vals[i] = hv + } + respHeaders[k] = vals + } + } + + return &StepResult{ + Output: map[string]any{ + "status_code": resp.StatusCode, + "headers": respHeaders, + "body": string(respBody), + "proxied_to": targetURL, + }, + Stop: true, + }, nil +} + +// resolveStringValue resolves a dot-path key from the pipeline context. +// It supports nested paths like "path_params.resource" by traversing +// pc.Current as a nested map. +func (s *HTTPProxyStep) resolveStringValue(key string, pc *PipelineContext) string { + parts := strings.Split(key, ".") + var current any = pc.Current + + for _, part := range parts { + m, ok := current.(map[string]any) + if !ok { + return "" + } + current, ok = m[part] + if !ok { + return "" + } + } + + if str, ok := current.(string); ok { + return str + } + return "" +} diff --git a/module/pipeline_step_http_proxy_test.go b/module/pipeline_step_http_proxy_test.go new file mode 100644 index 00000000..c8935670 --- /dev/null +++ b/module/pipeline_step_http_proxy_test.go @@ -0,0 +1,488 @@ +package module + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHTTPProxyStep_BasicProxy(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Backend", "test") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"proxied":true}`)) + })) + defer backend.Close() + + factory := NewHTTPProxyStepFactory() + step, err := factory("proxy-test", map[string]any{}, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + step.(*HTTPProxyStep).httpClient = backend.Client() + + origReq := httptest.NewRequest("GET", "/original", nil) + recorder := httptest.NewRecorder() + + pc := NewPipelineContext(map[string]any{ + "backend_url": backend.URL, + }, map[string]any{ + "_http_request": origReq, + "_http_response_writer": recorder, + }) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + if !result.Stop { + t.Error("expected Stop=true") + } + + if result.Output["status_code"] != http.StatusOK { + t.Errorf("expected status_code 200, got %v", result.Output["status_code"]) + } + + resp := recorder.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected response status 200, got %d", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + if string(body) != `{"proxied":true}` { + t.Errorf("expected proxied body, got %q", string(body)) + } + + if resp.Header.Get("Content-Type") != "application/json" { + t.Errorf("expected Content-Type application/json, got %q", resp.Header.Get("Content-Type")) + } + + if resp.Header.Get("X-Backend") != "test" { + t.Errorf("expected X-Backend header, got %q", resp.Header.Get("X-Backend")) + } + + if pc.Metadata["_response_handled"] != true { + t.Error("expected _response_handled=true") + } +} + +func TestHTTPProxyStep_WithResourcePath(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(r.URL.Path)) + })) + defer backend.Close() + + factory := NewHTTPProxyStepFactory() + step, err := factory("proxy-resource", map[string]any{ + "resource_key": "path_params.resource", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + step.(*HTTPProxyStep).httpClient = backend.Client() + + origReq := httptest.NewRequest("GET", "/original", nil) + recorder := httptest.NewRecorder() + + pc := NewPipelineContext(map[string]any{ + "backend_url": backend.URL, + "path_params": map[string]any{ + "resource": "api/v1/users", + }, + }, map[string]any{ + "_http_request": origReq, + "_http_response_writer": recorder, + }) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + if !result.Stop { + t.Error("expected Stop=true") + } + + body, _ := io.ReadAll(recorder.Result().Body) + if string(body) != "/api/v1/users" { + t.Errorf("expected path /api/v1/users, got %q", string(body)) + } +} + +func TestHTTPProxyStep_ForwardHeaders(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(r.Header.Get("Authorization") + "|" + r.Header.Get("X-Request-Id"))) + })) + defer backend.Close() + + factory := NewHTTPProxyStepFactory() + step, err := factory("proxy-headers", map[string]any{ + "forward_headers": []any{"Authorization", "X-Request-Id"}, + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + step.(*HTTPProxyStep).httpClient = backend.Client() + + origReq := httptest.NewRequest("GET", "/original", nil) + origReq.Header.Set("Authorization", "Bearer token123") + origReq.Header.Set("X-Request-Id", "req-456") + origReq.Header.Set("X-Not-Forwarded", "should-not-appear") + recorder := httptest.NewRecorder() + + pc := NewPipelineContext(map[string]any{ + "backend_url": backend.URL, + }, map[string]any{ + "_http_request": origReq, + "_http_response_writer": recorder, + }) + + _, err = step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + body, _ := io.ReadAll(recorder.Result().Body) + if string(body) != "Bearer token123|req-456" { + t.Errorf("expected forwarded headers, got %q", string(body)) + } +} + +func TestHTTPProxyStep_ForwardQueryString(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(r.URL.RawQuery)) + })) + defer backend.Close() + + factory := NewHTTPProxyStepFactory() + step, err := factory("proxy-query", map[string]any{}, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + step.(*HTTPProxyStep).httpClient = backend.Client() + + origReq := httptest.NewRequest("GET", "/original?foo=bar&baz=qux", nil) + recorder := httptest.NewRecorder() + + pc := NewPipelineContext(map[string]any{ + "backend_url": backend.URL, + }, map[string]any{ + "_http_request": origReq, + "_http_response_writer": recorder, + }) + + _, err = step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + body, _ := io.ReadAll(recorder.Result().Body) + if string(body) != "foo=bar&baz=qux" { + t.Errorf("expected query string forwarded, got %q", string(body)) + } +} + +func TestHTTPProxyStep_POSTWithBody(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + body, _ := io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(body) + })) + defer backend.Close() + + factory := NewHTTPProxyStepFactory() + step, err := factory("proxy-post", map[string]any{}, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + step.(*HTTPProxyStep).httpClient = backend.Client() + + reqBody := `{"name":"test","data":"binary-ish"}` + origReq := httptest.NewRequest("POST", "/original", strings.NewReader(reqBody)) + origReq.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + pc := NewPipelineContext(map[string]any{ + "backend_url": backend.URL, + }, map[string]any{ + "_http_request": origReq, + "_http_response_writer": recorder, + }) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + if result.Output["status_code"] != http.StatusCreated { + t.Errorf("expected status_code 201, got %v", result.Output["status_code"]) + } + + body, _ := io.ReadAll(recorder.Result().Body) + if string(body) != reqBody { + t.Errorf("expected body passthrough, got %q", string(body)) + } +} + +func TestHTTPProxyStep_MissingBackendURL(t *testing.T) { + factory := NewHTTPProxyStepFactory() + step, err := factory("proxy-missing", map[string]any{}, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(map[string]any{}, map[string]any{}) + _, err = step.Execute(context.Background(), pc) + if err == nil { + t.Fatal("expected error for missing backend URL") + } + if !strings.Contains(err.Error(), "backend URL not found") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestHTTPProxyStep_NoResponseWriter(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("X-Custom", "value") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hello from backend")) + })) + defer backend.Close() + + factory := NewHTTPProxyStepFactory() + step, err := factory("proxy-no-writer", map[string]any{}, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + step.(*HTTPProxyStep).httpClient = backend.Client() + + origReq := httptest.NewRequest("GET", "/test", nil) + pc := NewPipelineContext(map[string]any{ + "backend_url": backend.URL, + }, map[string]any{ + "_http_request": origReq, + }) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + if !result.Stop { + t.Error("expected Stop=true") + } + if result.Output["status_code"] != http.StatusOK { + t.Errorf("expected status_code 200, got %v", result.Output["status_code"]) + } + if result.Output["body"] != "hello from backend" { + t.Errorf("expected body 'hello from backend', got %v", result.Output["body"]) + } + headers, ok := result.Output["headers"].(map[string]any) + if !ok { + t.Fatal("expected headers in output") + } + if headers["X-Custom"] != "value" { + t.Errorf("expected X-Custom header, got %v", headers["X-Custom"]) + } +} + +func TestHTTPProxyStep_CustomBackendURLKey(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + factory := NewHTTPProxyStepFactory() + step, err := factory("proxy-custom-key", map[string]any{ + "backend_url_key": "target.url", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + step.(*HTTPProxyStep).httpClient = backend.Client() + + origReq := httptest.NewRequest("GET", "/test", nil) + recorder := httptest.NewRecorder() + + pc := NewPipelineContext(map[string]any{ + "target": map[string]any{ + "url": backend.URL, + }, + }, map[string]any{ + "_http_request": origReq, + "_http_response_writer": recorder, + }) + + _, err = step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + body, _ := io.ReadAll(recorder.Result().Body) + if string(body) != "ok" { + t.Errorf("expected 'ok', got %q", string(body)) + } +} + +func TestHTTPProxyStep_CustomTimeout(t *testing.T) { + factory := NewHTTPProxyStepFactory() + step, err := factory("proxy-timeout", map[string]any{ + "timeout": "5s", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + proxyStep := step.(*HTTPProxyStep) + if proxyStep.timeout != 5*1e9 { + t.Errorf("expected 5s timeout, got %v", proxyStep.timeout) + } +} + +func TestHTTPProxyStep_NoOriginalRequest(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET (default), got %s", r.Method) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + factory := NewHTTPProxyStepFactory() + step, err := factory("proxy-no-req", map[string]any{}, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + step.(*HTTPProxyStep).httpClient = backend.Client() + + recorder := httptest.NewRecorder() + pc := NewPipelineContext(map[string]any{ + "backend_url": backend.URL, + }, map[string]any{ + "_http_response_writer": recorder, + }) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + if !result.Stop { + t.Error("expected Stop=true") + } + + body, _ := io.ReadAll(recorder.Result().Body) + if string(body) != "ok" { + t.Errorf("expected 'ok', got %q", string(body)) + } +} + +func TestHTTPProxyStep_BackendError(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("upstream error")) + })) + defer backend.Close() + + factory := NewHTTPProxyStepFactory() + step, err := factory("proxy-error", map[string]any{}, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + step.(*HTTPProxyStep).httpClient = backend.Client() + + origReq := httptest.NewRequest("GET", "/test", nil) + recorder := httptest.NewRecorder() + + pc := NewPipelineContext(map[string]any{ + "backend_url": backend.URL, + }, map[string]any{ + "_http_request": origReq, + "_http_response_writer": recorder, + }) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + // Proxy should forward error responses as-is, not return an error + if result.Output["status_code"] != http.StatusBadGateway { + t.Errorf("expected status_code 502, got %v", result.Output["status_code"]) + } + + resp := recorder.Result() + if resp.StatusCode != http.StatusBadGateway { + t.Errorf("expected response status 502, got %d", resp.StatusCode) + } +} + +func TestHTTPProxyStep_ContentLengthForwarded(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.ContentLength != 11 { + t.Errorf("expected Content-Length 11, got %d", r.ContentLength) + } + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + factory := NewHTTPProxyStepFactory() + step, err := factory("proxy-cl", map[string]any{ + "forward_headers": []any{"Content-Type"}, + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + step.(*HTTPProxyStep).httpClient = backend.Client() + + body := "hello world" + origReq := httptest.NewRequest("POST", "/test", bytes.NewReader([]byte(body))) + origReq.Header.Set("Content-Type", "text/plain") + recorder := httptest.NewRecorder() + + pc := NewPipelineContext(map[string]any{ + "backend_url": backend.URL, + }, map[string]any{ + "_http_request": origReq, + "_http_response_writer": recorder, + }) + + _, err = step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } +} + +func TestHTTPProxyStep_DefaultConfigValues(t *testing.T) { + factory := NewHTTPProxyStepFactory() + step, err := factory("proxy-defaults", map[string]any{}, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + proxyStep := step.(*HTTPProxyStep) + if proxyStep.backendURLKey != "backend_url" { + t.Errorf("expected default backend_url_key='backend_url', got %q", proxyStep.backendURLKey) + } + if proxyStep.resourceKey != "path_params.resource" { + t.Errorf("expected default resource_key='path_params.resource', got %q", proxyStep.resourceKey) + } + if proxyStep.timeout != 30*1e9 { + t.Errorf("expected default timeout=30s, got %v", proxyStep.timeout) + } +} diff --git a/plugins/pipelinesteps/plugin.go b/plugins/pipelinesteps/plugin.go index 0a980695..03dbce15 100644 --- a/plugins/pipelinesteps/plugin.go +++ b/plugins/pipelinesteps/plugin.go @@ -1,7 +1,7 @@ // Package pipelinesteps provides a plugin that registers generic pipeline step // types: validate, transform, conditional, set, log, delegate, jq, publish, -// http_call, request_parse, db_query, db_exec, db_query_cached, json_response, raw_response, -// validate_path_param, validate_pagination, validate_request_body, +// http_call, http_proxy, request_parse, db_query, db_exec, db_query_cached, json_response, +// raw_response, validate_path_param, validate_pagination, validate_request_body, // foreach, webhook_verify, base64_decode, ui_scaffold, ui_scaffold_analyze, // dlq_send, dlq_replay, retry_with_backoff, circuit_breaker (wrapping), // s3_upload, auth_validate, token_revoke, sandbox_exec. @@ -89,6 +89,7 @@ func New() *Plugin { "step.token_revoke", "step.field_reencrypt", "step.sandbox_exec", + "step.http_proxy", }, WorkflowTypes: []string{"pipeline"}, Capabilities: []plugin.CapabilityDecl{ @@ -157,6 +158,7 @@ func (p *Plugin) StepFactories() map[string]plugin.StepFactory { "step.token_revoke": wrapStepFactory(module.NewTokenRevokeStepFactory()), "step.field_reencrypt": wrapStepFactory(module.NewFieldReencryptStepFactory()), "step.sandbox_exec": wrapStepFactory(module.NewSandboxExecStepFactory()), + "step.http_proxy": wrapStepFactory(module.NewHTTPProxyStepFactory()), } } diff --git a/plugins/pipelinesteps/plugin_test.go b/plugins/pipelinesteps/plugin_test.go index b34bd635..e06c3819 100644 --- a/plugins/pipelinesteps/plugin_test.go +++ b/plugins/pipelinesteps/plugin_test.go @@ -67,6 +67,7 @@ func TestStepFactories(t *testing.T) { "step.base64_decode", "step.field_reencrypt", "step.sandbox_exec", + "step.http_proxy", } for _, stepType := range expectedSteps { diff --git a/schema/module_schema.go b/schema/module_schema.go index e14a2ae7..83c670e3 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -949,6 +949,21 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { }, }) + r.Register(&ModuleSchema{ + Type: "step.http_proxy", + Label: "HTTP Proxy", + Category: "pipeline", + Description: "Forwards the original HTTP request to a dynamically resolved backend URL and writes the response directly to the client", + Inputs: []ServiceIODef{{Name: "context", Type: "PipelineContext", Description: "Pipeline context with _http_request and _http_response_writer metadata"}}, + Outputs: []ServiceIODef{{Name: "result", Type: "StepResult", Description: "Proxy response status and target URL; Stop is always true"}}, + ConfigFields: []ConfigFieldDef{ + {Key: "backend_url_key", Label: "Backend URL Key", Type: FieldTypeString, DefaultValue: "backend_url", Description: "Dot-path in pc.Current for the backend URL (e.g. backend_url or steps.resolve.url)"}, + {Key: "resource_key", Label: "Resource Key", Type: FieldTypeString, DefaultValue: "path_params.resource", Description: "Dot-path in pc.Current for the resource path suffix"}, + {Key: "forward_headers", Label: "Forward Headers", Type: FieldTypeArray, ArrayItemType: "string", Description: "Header names to copy from the original request to the proxy request"}, + {Key: "timeout", Label: "Timeout", Type: FieldTypeString, DefaultValue: "30s", Description: "Proxy request timeout duration", Placeholder: "30s"}, + }, + }) + r.Register(&ModuleSchema{ Type: "step.delegate", Label: "Delegate", diff --git a/schema/schema.go b/schema/schema.go index 1962a40e..81d70764 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -226,6 +226,7 @@ var coreModuleTypes = []string{ "step.foreach", "step.gate", "step.http_call", + "step.http_proxy", "step.jq", "step.json_response", "step.log",