From 174ecfb2bf51ae2550540e00ad73565610593367 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 08:03:04 +0000 Subject: [PATCH 1/3] Initial plan From 18f577def9d467f0c837e0389fabb1b189a0172f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 08:28:53 +0000 Subject: [PATCH 2/3] Add status_from field to step.json_response for dynamic HTTP status codes Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> Agent-Logs-Url: https://github.com/GoCodeAlone/workflow/sessions/d57a0530-2320-4994-afe5-88b8d46b2a56 --- DOCUMENTATION.md | 2 +- module/pipeline_step_json_response.go | 59 +++++--- module/pipeline_step_json_response_test.go | 161 +++++++++++++++++++++ schema/step_schema_builtins.go | 1 + 4 files changed, 205 insertions(+), 18 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index e2beb773..a1024902 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -154,7 +154,7 @@ flowchart TD | `step.db_query_cached` | Executes a cached SQL SELECT query | pipelinesteps | | `step.db_create_partition` | Creates a time-based table partition | pipelinesteps | | `step.db_sync_partitions` | Ensures future partitions exist for a partitioned table | pipelinesteps | -| `step.json_response` | Writes HTTP JSON response with custom status code and headers | pipelinesteps | +| `step.json_response` | Writes HTTP JSON response with custom status code and headers. Supports `status_from` to dynamically resolve the HTTP status code from the pipeline context at runtime | pipelinesteps | | `step.raw_response` | Writes a raw HTTP response with arbitrary content type | pipelinesteps | | `step.pipeline_output` | Marks structured data as the pipeline's return value for extraction by `engine.ExecutePipeline()` | pipelinesteps | | `step.json_parse` | Parses a JSON string (or `[]byte`) in the pipeline context into a structured object | pipelinesteps | diff --git a/module/pipeline_step_json_response.go b/module/pipeline_step_json_response.go index 689b8206..eee01f0f 100644 --- a/module/pipeline_step_json_response.go +++ b/module/pipeline_step_json_response.go @@ -13,13 +13,14 @@ import ( // JSONResponseStep writes an HTTP JSON response with a custom status code and stops the pipeline. type JSONResponseStep struct { - name string - status int - headers map[string]string - body map[string]any - bodyRaw any // for non-map bodies (arrays, literals) - bodyFrom string - tmpl *TemplateEngine + name string + status int + statusFrom string + headers map[string]string + body map[string]any + bodyRaw any // for non-map bodies (arrays, literals) + bodyFrom string + tmpl *TemplateEngine } // NewJSONResponseStepFactory returns a StepFactory that creates JSONResponseStep instances. @@ -54,28 +55,52 @@ func NewJSONResponseStepFactory() StepFactory { bodyRaw = config["body"] } bodyFrom, _ := config["body_from"].(string) + statusFrom, _ := config["status_from"].(string) return &JSONResponseStep{ - name: name, - status: status, - headers: headers, - body: body, - bodyRaw: bodyRaw, - bodyFrom: bodyFrom, - tmpl: NewTemplateEngine(), + name: name, + status: status, + statusFrom: statusFrom, + headers: headers, + body: body, + bodyRaw: bodyRaw, + bodyFrom: bodyFrom, + tmpl: NewTemplateEngine(), }, nil } } func (s *JSONResponseStep) Name() string { return s.name } +// resolveStatus returns the effective HTTP status code for the response. +// If status_from is set, it resolves the value from the pipeline context and +// converts it to an integer. If the resolved value is not a valid integer, +// it falls back to the static status (or 200 by default). +func (s *JSONResponseStep) resolveStatus(pc *PipelineContext) int { + if s.statusFrom != "" { + if val := resolveBodyFrom(s.statusFrom, pc); val != nil { + switch v := val.(type) { + case int: + return v + case float64: + return int(v) + case int64: + return int(v) + } + } + } + return s.status +} + func (s *JSONResponseStep) Execute(_ context.Context, pc *PipelineContext) (*StepResult, error) { + status := s.resolveStatus(pc) + w, ok := pc.Metadata["_http_response_writer"].(http.ResponseWriter) if !ok { // No response writer — return the body as output without writing HTTP responseBody := s.resolveResponseBody(pc) output := map[string]any{ - "status": s.status, + "status": status, } if responseBody != nil { output["body"] = responseBody @@ -98,7 +123,7 @@ func (s *JSONResponseStep) Execute(_ context.Context, pc *PipelineContext) (*Ste } // Write status code - w.WriteHeader(s.status) + w.WriteHeader(status) // Write body if responseBody != nil { @@ -112,7 +137,7 @@ func (s *JSONResponseStep) Execute(_ context.Context, pc *PipelineContext) (*Ste return &StepResult{ Output: map[string]any{ - "status": s.status, + "status": status, }, Stop: true, }, nil diff --git a/module/pipeline_step_json_response_test.go b/module/pipeline_step_json_response_test.go index bf603489..c914fe82 100644 --- a/module/pipeline_step_json_response_test.go +++ b/module/pipeline_step_json_response_test.go @@ -476,3 +476,164 @@ func TestJSONResponseStep_DefaultStatus(t *testing.T) { t.Errorf("expected default status 200, got %d", resp.StatusCode) } } + +func TestJSONResponseStep_StatusFrom(t *testing.T) { + factory := NewJSONResponseStepFactory() + step, err := factory("proxy-respond", map[string]any{ + "status_from": "steps.call_upstream.status_code", + "body_from": "steps.call_upstream.body", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + recorder := httptest.NewRecorder() + pc := NewPipelineContext(nil, map[string]any{ + "_http_response_writer": recorder, + }) + pc.MergeStepOutput("call_upstream", map[string]any{ + "status_code": 404, + "body": map[string]any{"error": "not found"}, + }) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + if !result.Stop { + t.Error("expected Stop=true") + } + + resp := recorder.Result() + if resp.StatusCode != 404 { + t.Errorf("expected status 404 from status_from, got %d", resp.StatusCode) + } + + if result.Output["status"] != 404 { + t.Errorf("expected output status=404, got %v", result.Output["status"]) + } +} + +func TestJSONResponseStep_StatusFromFloat64(t *testing.T) { + // JSON numbers are often decoded as float64; ensure conversion works. + factory := NewJSONResponseStepFactory() + step, err := factory("proxy-respond", map[string]any{ + "status_from": "steps.upstream.status_code", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + recorder := httptest.NewRecorder() + pc := NewPipelineContext(nil, map[string]any{ + "_http_response_writer": recorder, + }) + pc.MergeStepOutput("upstream", map[string]any{ + "status_code": float64(503), + }) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + resp := recorder.Result() + if resp.StatusCode != 503 { + t.Errorf("expected status 503 from float64 status_from, got %d", resp.StatusCode) + } + if result.Output["status"] != 503 { + t.Errorf("expected output status=503, got %v", result.Output["status"]) + } +} + +func TestJSONResponseStep_StatusFromFallback(t *testing.T) { + // When status_from cannot be resolved, fall back to static status. + factory := NewJSONResponseStepFactory() + step, err := factory("fallback-respond", map[string]any{ + "status": 201, + "status_from": "steps.nonexistent.status_code", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + recorder := httptest.NewRecorder() + pc := NewPipelineContext(nil, map[string]any{ + "_http_response_writer": recorder, + }) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + resp := recorder.Result() + if resp.StatusCode != 201 { + t.Errorf("expected fallback status 201, got %d", resp.StatusCode) + } + if result.Output["status"] != 201 { + t.Errorf("expected output status=201, got %v", result.Output["status"]) + } +} + +func TestJSONResponseStep_StatusFromPrecedence(t *testing.T) { + // status_from takes precedence over static status when resolved. + factory := NewJSONResponseStepFactory() + step, err := factory("precedence-respond", map[string]any{ + "status": 200, + "status_from": "steps.upstream.status_code", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + recorder := httptest.NewRecorder() + pc := NewPipelineContext(nil, map[string]any{ + "_http_response_writer": recorder, + }) + pc.MergeStepOutput("upstream", map[string]any{ + "status_code": 422, + }) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + resp := recorder.Result() + if resp.StatusCode != 422 { + t.Errorf("expected status_from 422 to take precedence, got %d", resp.StatusCode) + } + if result.Output["status"] != 422 { + t.Errorf("expected output status=422, got %v", result.Output["status"]) + } +} + +func TestJSONResponseStep_StatusFromNoWriter(t *testing.T) { + // Verify status_from is reflected in output even without an HTTP writer. + factory := NewJSONResponseStepFactory() + step, err := factory("no-writer-status-from", map[string]any{ + "status_from": "steps.upstream.status_code", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, map[string]any{}) + pc.MergeStepOutput("upstream", map[string]any{ + "status_code": 503, + }) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + if !result.Stop { + t.Error("expected Stop=true even without writer") + } + if result.Output["status"] != 503 { + t.Errorf("expected status=503, got %v", result.Output["status"]) + } +} diff --git a/schema/step_schema_builtins.go b/schema/step_schema_builtins.go index fd20c754..dfe23129 100644 --- a/schema/step_schema_builtins.go +++ b/schema/step_schema_builtins.go @@ -94,6 +94,7 @@ func (r *StepSchemaRegistry) registerBuiltins() { Description: "Sends a JSON HTTP response and terminates pipeline execution.", ConfigFields: []ConfigFieldDef{ {Key: "status", Type: FieldTypeNumber, Description: "HTTP status code", Required: true}, + {Key: "status_from", Type: FieldTypeString, Description: "Template path to resolve HTTP status code dynamically (e.g. 'steps.call_upstream.status_code'). Takes precedence over 'status' when resolved to a valid integer."}, {Key: "body", Type: FieldTypeJSON, Description: "Response body (static JSON object or template expression)"}, {Key: "body_from", Type: FieldTypeString, Description: "Template expression to build body from step outputs (e.g. 'steps.query.rows')"}, {Key: "headers", Type: FieldTypeMap, Description: "Additional response headers"}, From feb1e97f05c42facb1638af4558ea63ea15d05be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:18:40 +0000 Subject: [PATCH 3/3] Apply review feedback: validate status_from range, make status optional, update schemas and tests Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> Agent-Logs-Url: https://github.com/GoCodeAlone/workflow/sessions/d591ca56-9504-43b4-8afb-21808a4d9e47 --- module/pipeline_step_json_response.go | 22 +++- module/pipeline_step_json_response_test.go | 128 +++++++++++++++++++++ schema/module_schema.go | 1 + schema/step_schema_builtins.go | 4 +- schema/testdata/editor-schemas.golden.json | 7 ++ 5 files changed, 155 insertions(+), 7 deletions(-) diff --git a/module/pipeline_step_json_response.go b/module/pipeline_step_json_response.go index eee01f0f..048abd21 100644 --- a/module/pipeline_step_json_response.go +++ b/module/pipeline_step_json_response.go @@ -74,18 +74,30 @@ func (s *JSONResponseStep) Name() string { return s.name } // resolveStatus returns the effective HTTP status code for the response. // If status_from is set, it resolves the value from the pipeline context and -// converts it to an integer. If the resolved value is not a valid integer, -// it falls back to the static status (or 200 by default). +// converts it to an integer. The resolved value must be a whole number within +// the valid HTTP status code range (100–599); otherwise it falls back to the +// static status (or 200 by default). func (s *JSONResponseStep) resolveStatus(pc *PipelineContext) int { if s.statusFrom != "" { if val := resolveBodyFrom(s.statusFrom, pc); val != nil { + var code int + valid := false switch v := val.(type) { case int: - return v + code = v + valid = true case float64: - return int(v) + // Only accept whole numbers — reject 404.9, etc. + if v == float64(int(v)) { + code = int(v) + valid = true + } case int64: - return int(v) + code = int(v) + valid = true + } + if valid && code >= 100 && code <= 599 { + return code } } } diff --git a/module/pipeline_step_json_response_test.go b/module/pipeline_step_json_response_test.go index c914fe82..99b6eb02 100644 --- a/module/pipeline_step_json_response_test.go +++ b/module/pipeline_step_json_response_test.go @@ -637,3 +637,131 @@ func TestJSONResponseStep_StatusFromNoWriter(t *testing.T) { t.Errorf("expected status=503, got %v", result.Output["status"]) } } + +func TestJSONResponseStep_StatusFromFractionalFloat(t *testing.T) { + // A fractional float (e.g. 404.9) is not a valid HTTP status code; fall back. + factory := NewJSONResponseStepFactory() + step, err := factory("fractional-status", map[string]any{ + "status": 201, + "status_from": "steps.upstream.status_code", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + recorder := httptest.NewRecorder() + pc := NewPipelineContext(nil, map[string]any{ + "_http_response_writer": recorder, + }) + pc.MergeStepOutput("upstream", map[string]any{ + "status_code": float64(404.9), + }) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + resp := recorder.Result() + if resp.StatusCode != 201 { + t.Errorf("expected fallback to static status 201 for fractional float, got %d", resp.StatusCode) + } + if result.Output["status"] != 201 { + t.Errorf("expected output status=201, got %v", result.Output["status"]) + } +} + +func TestJSONResponseStep_StatusFromOutOfRange(t *testing.T) { + // Out-of-range status codes (< 100 or > 599) must fall back to static status. + tests := []struct { + name string + code int + static int + }{ + {"zero", 0, 200}, + {"negative", -1, 200}, + {"too-large", 9999, 404}, + {"boundary-low", 99, 200}, + {"boundary-high", 600, 200}, + } + + factory := NewJSONResponseStepFactory() + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + step, err := factory("out-of-range-status", map[string]any{ + "status": tc.static, + "status_from": "steps.upstream.code", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + recorder := httptest.NewRecorder() + pc := NewPipelineContext(nil, map[string]any{ + "_http_response_writer": recorder, + }) + pc.MergeStepOutput("upstream", map[string]any{ + "code": tc.code, + }) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + resp := recorder.Result() + if resp.StatusCode != tc.static { + t.Errorf("code %d: expected fallback to %d, got %d", tc.code, tc.static, resp.StatusCode) + } + if result.Output["status"] != tc.static { + t.Errorf("code %d: expected output status=%d, got %v", tc.code, tc.static, result.Output["status"]) + } + }) + } +} + +func TestJSONResponseStep_StatusFromBoundaryValid(t *testing.T) { + // Boundary values within valid HTTP range (100–599) should be accepted. + tests := []struct { + name string + code int + }{ + {"min", 100}, + {"max", 599}, + {"common", 200}, + } + + factory := NewJSONResponseStepFactory() + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + step, err := factory("boundary-valid", map[string]any{ + "status": 200, + "status_from": "steps.upstream.code", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + recorder := httptest.NewRecorder() + pc := NewPipelineContext(nil, map[string]any{ + "_http_response_writer": recorder, + }) + pc.MergeStepOutput("upstream", map[string]any{ + "code": tc.code, + }) + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + resp := recorder.Result() + if resp.StatusCode != tc.code { + t.Errorf("code %d: expected status %d, got %d", tc.code, tc.code, resp.StatusCode) + } + if result.Output["status"] != tc.code { + t.Errorf("code %d: expected output status=%d, got %v", tc.code, tc.code, result.Output["status"]) + } + }) + } +} diff --git a/schema/module_schema.go b/schema/module_schema.go index 15c9b104..04b7e9f8 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -1105,6 +1105,7 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { Outputs: []ServiceIODef{{Name: "result", Type: "StepResult", Description: "Response status (always sets Stop: true)"}}, ConfigFields: []ConfigFieldDef{ {Key: "status", Label: "Status Code", Type: FieldTypeNumber, DefaultValue: "200", Description: "HTTP status code for the response"}, + {Key: "status_from", Label: "Status From", Type: FieldTypeString, Description: "Dotted path to resolve HTTP status code dynamically (e.g., steps.call_upstream.status_code). Takes precedence over 'status' when resolved to a valid HTTP status code (100-599).", Placeholder: "steps.call_upstream.status_code"}, {Key: "headers", Label: "Headers", Type: FieldTypeMap, MapValueType: "string", Description: "Additional response headers"}, {Key: "body", Label: "Body", Type: FieldTypeJSON, Description: "Response body as JSON (supports template expressions)"}, {Key: "body_from", Label: "Body From", Type: FieldTypeString, Description: "Dotted path to resolve body from step outputs (e.g., steps.get-company.row)", Placeholder: "steps.get-company.row"}, diff --git a/schema/step_schema_builtins.go b/schema/step_schema_builtins.go index 41f1062c..b189db21 100644 --- a/schema/step_schema_builtins.go +++ b/schema/step_schema_builtins.go @@ -95,8 +95,8 @@ func (r *StepSchemaRegistry) registerBuiltins() { Plugin: "pipelinesteps", Description: "Sends a JSON HTTP response and terminates pipeline execution.", ConfigFields: []ConfigFieldDef{ - {Key: "status", Type: FieldTypeNumber, Description: "HTTP status code", Required: true}, - {Key: "status_from", Type: FieldTypeString, Description: "Template path to resolve HTTP status code dynamically (e.g. 'steps.call_upstream.status_code'). Takes precedence over 'status' when resolved to a valid integer."}, + {Key: "status", Type: FieldTypeNumber, Description: "HTTP status code (default 200)", DefaultValue: "200"}, + {Key: "status_from", Type: FieldTypeString, Description: "Dotted path to resolve HTTP status code dynamically (e.g. 'steps.call_upstream.status_code'). Takes precedence over 'status' when resolved to a valid HTTP status code (100-599)."}, {Key: "body", Type: FieldTypeJSON, Description: "Response body (static JSON object or template expression)"}, {Key: "body_from", Type: FieldTypeString, Description: "Template expression to build body from step outputs (e.g. 'steps.query.rows')"}, {Key: "headers", Type: FieldTypeMap, Description: "Additional response headers"}, diff --git a/schema/testdata/editor-schemas.golden.json b/schema/testdata/editor-schemas.golden.json index 7893c3ae..6e2a4810 100644 --- a/schema/testdata/editor-schemas.golden.json +++ b/schema/testdata/editor-schemas.golden.json @@ -6615,6 +6615,13 @@ "description": "HTTP status code for the response", "defaultValue": "200" }, + { + "key": "status_from", + "label": "Status From", + "type": "string", + "description": "Dotted path to resolve HTTP status code dynamically (e.g., steps.call_upstream.status_code). Takes precedence over 'status' when resolved to a valid HTTP status code (100-599).", + "placeholder": "steps.call_upstream.status_code" + }, { "key": "headers", "label": "Headers",