From 0b1dcf1fdfa90804d4c7ff0eb02919953ca48754 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:22:55 +0000 Subject: [PATCH 1/3] Initial plan From 97951e587d409a0105ea28aa17678e7aaab50731 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:48:44 +0000 Subject: [PATCH 2/3] feat: add step.static_file pipeline step type for serving files from disk Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/type_registry.go | 5 + module/pipeline_step_static_file.go | 90 +++++++++++++++ module/pipeline_step_static_file_test.go | 134 +++++++++++++++++++++++ plugins/pipelinesteps/plugin.go | 4 +- plugins/pipelinesteps/plugin_test.go | 1 + schema/module_schema.go | 16 +++ schema/schema.go | 1 + 7 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 module/pipeline_step_static_file.go create mode 100644 module/pipeline_step_static_file_test.go diff --git a/cmd/wfctl/type_registry.go b/cmd/wfctl/type_registry.go index ec59006d..cb1e1114 100644 --- a/cmd/wfctl/type_registry.go +++ b/cmd/wfctl/type_registry.go @@ -617,6 +617,11 @@ func KnownStepTypes() map[string]StepTypeInfo { Plugin: "pipelinesteps", ConfigKeys: []string{"status", "body", "headers"}, }, + "step.static_file": { + Type: "step.static_file", + Plugin: "pipelinesteps", + ConfigKeys: []string{"file", "content_type", "cache_control"}, + }, "step.workflow_call": { Type: "step.workflow_call", Plugin: "pipelinesteps", diff --git a/module/pipeline_step_static_file.go b/module/pipeline_step_static_file.go new file mode 100644 index 00000000..2d85d1ef --- /dev/null +++ b/module/pipeline_step_static_file.go @@ -0,0 +1,90 @@ +package module + +import ( + "context" + "fmt" + "net/http" + "os" + + "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/workflow/config" +) + +// StaticFileStep serves a pre-loaded file from disk as an HTTP response. +// The file is read at init time (factory creation) for performance. +type StaticFileStep struct { + name string + content []byte + contentType string + cacheControl string +} + +// NewStaticFileStepFactory returns a StepFactory that creates StaticFileStep instances. +// The file is read from disk when the factory is invoked (at config load time). +func NewStaticFileStepFactory() StepFactory { + return func(name string, cfg map[string]any, _ modular.Application) (PipelineStep, error) { + filePath, _ := cfg["file"].(string) + if filePath == "" { + return nil, fmt.Errorf("static_file step %q: 'file' is required", name) + } + + contentType, _ := cfg["content_type"].(string) + if contentType == "" { + return nil, fmt.Errorf("static_file step %q: 'content_type' is required", name) + } + + // Resolve file path relative to the config file directory. + resolved := config.ResolvePathInConfig(cfg, filePath) + + content, err := os.ReadFile(resolved) + if err != nil { + return nil, fmt.Errorf("static_file step %q: failed to read file %q: %w", name, resolved, err) + } + + cacheControl, _ := cfg["cache_control"].(string) + + return &StaticFileStep{ + name: name, + content: content, + contentType: contentType, + cacheControl: cacheControl, + }, nil + } +} + +func (s *StaticFileStep) Name() string { return s.name } + +func (s *StaticFileStep) Execute(_ context.Context, pc *PipelineContext) (*StepResult, error) { + w, ok := pc.Metadata["_http_response_writer"].(http.ResponseWriter) + if !ok { + // No HTTP response writer — return content as output without writing HTTP. + output := map[string]any{ + "content_type": s.contentType, + "body": string(s.content), + } + if s.cacheControl != "" { + output["cache_control"] = s.cacheControl + } + return &StepResult{Output: output, Stop: true}, nil + } + + w.Header().Set("Content-Type", s.contentType) + if s.cacheControl != "" { + w.Header().Set("Cache-Control", s.cacheControl) + } + + w.WriteHeader(http.StatusOK) + + if _, err := w.Write(s.content); err != nil { + return nil, fmt.Errorf("static_file step %q: failed to write response: %w", s.name, err) + } + + pc.Metadata["_response_handled"] = true + + return &StepResult{ + Output: map[string]any{ + "content_type": s.contentType, + }, + Stop: true, + }, nil +} diff --git a/module/pipeline_step_static_file_test.go b/module/pipeline_step_static_file_test.go new file mode 100644 index 00000000..b8ac133b --- /dev/null +++ b/module/pipeline_step_static_file_test.go @@ -0,0 +1,134 @@ +package module + +import ( + "context" + "io" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestStaticFileStep_ServesFile(t *testing.T) { + // Write a temporary file to serve. + dir := t.TempDir() + filePath := filepath.Join(dir, "spec.yaml") + content := "openapi: 3.0.0\ninfo:\n title: Test\n" + if err := os.WriteFile(filePath, []byte(content), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + factory := NewStaticFileStepFactory() + step, err := factory("serve_spec", map[string]any{ + "file": filePath, + "content_type": "application/yaml", + "cache_control": "public, max-age=3600", + }, 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) + } + + if !result.Stop { + t.Error("expected Stop=true") + } + + resp := recorder.Result() + if resp.StatusCode != 200 { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); ct != "application/yaml" { + t.Errorf("expected Content-Type application/yaml, got %q", ct) + } + if cc := resp.Header.Get("Cache-Control"); cc != "public, max-age=3600" { + t.Errorf("expected Cache-Control header, got %q", cc) + } + + body, _ := io.ReadAll(resp.Body) + if string(body) != content { + t.Errorf("expected body %q, got %q", content, string(body)) + } + + if pc.Metadata["_response_handled"] != true { + t.Error("expected _response_handled=true") + } +} + +func TestStaticFileStep_NoHTTPWriter(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "data.json") + content := `{"key":"value"}` + if err := os.WriteFile(filePath, []byte(content), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + factory := NewStaticFileStepFactory() + step, err := factory("serve_json", map[string]any{ + "file": filePath, + "content_type": "application/json", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, map[string]any{}) + 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["body"] != content { + t.Errorf("expected body %q, got %q", content, result.Output["body"]) + } + if result.Output["content_type"] != "application/json" { + t.Errorf("unexpected content_type: %v", result.Output["content_type"]) + } +} + +func TestStaticFileStep_MissingFile(t *testing.T) { + factory := NewStaticFileStepFactory() + _, err := factory("bad_step", map[string]any{ + "file": "", + "content_type": "text/plain", + }, nil) + if err == nil { + t.Error("expected error for missing 'file' config") + } +} + +func TestStaticFileStep_MissingContentType(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "data.txt") + _ = os.WriteFile(filePath, []byte("hello"), 0o600) + + factory := NewStaticFileStepFactory() + _, err := factory("bad_step", map[string]any{ + "file": filePath, + }, nil) + if err == nil { + t.Error("expected error for missing 'content_type' config") + } +} + +func TestStaticFileStep_FileNotFound(t *testing.T) { + factory := NewStaticFileStepFactory() + _, err := factory("bad_step", map[string]any{ + "file": "/nonexistent/path/file.yaml", + "content_type": "application/yaml", + }, nil) + if err == nil { + t.Error("expected error for non-existent file") + } +} diff --git a/plugins/pipelinesteps/plugin.go b/plugins/pipelinesteps/plugin.go index af7c0e85..1eec412c 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, http_proxy, request_parse, db_query, db_exec, db_query_cached, json_response, -// raw_response, validate_path_param, validate_pagination, validate_request_body, +// raw_response, static_file, 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. @@ -70,6 +70,7 @@ func New() *Plugin { "step.db_sync_partitions", "step.json_response", "step.raw_response", + "step.static_file", "step.workflow_call", "step.validate_path_param", "step.validate_pagination", @@ -135,6 +136,7 @@ func (p *Plugin) StepFactories() map[string]plugin.StepFactory { "step.db_sync_partitions": wrapStepFactory(module.NewDBSyncPartitionsStepFactory()), "step.json_response": wrapStepFactory(module.NewJSONResponseStepFactory()), "step.raw_response": wrapStepFactory(module.NewRawResponseStepFactory()), + "step.static_file": wrapStepFactory(module.NewStaticFileStepFactory()), "step.validate_path_param": wrapStepFactory(module.NewValidatePathParamStepFactory()), "step.validate_pagination": wrapStepFactory(module.NewValidatePaginationStepFactory()), "step.validate_request_body": wrapStepFactory(module.NewValidateRequestBodyStepFactory()), diff --git a/plugins/pipelinesteps/plugin_test.go b/plugins/pipelinesteps/plugin_test.go index 48a484db..30693340 100644 --- a/plugins/pipelinesteps/plugin_test.go +++ b/plugins/pipelinesteps/plugin_test.go @@ -49,6 +49,7 @@ func TestStepFactories(t *testing.T) { "step.db_sync_partitions", "step.json_response", "step.raw_response", + "step.static_file", "step.validate_path_param", "step.validate_pagination", "step.validate_request_body", diff --git a/schema/module_schema.go b/schema/module_schema.go index 8846e578..4782f077 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -1981,4 +1981,20 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { {Key: "input", Label: "Input", Type: FieldTypeString, Required: true, Description: "Input string to match against (template expressions supported)"}, }, }) + + // ---- Static File ---- + + r.Register(&ModuleSchema{ + Type: "step.static_file", + Label: "Static File", + Category: "pipeline", + Description: "Serves a static file from disk as an HTTP response; file is read at init time for performance", + Inputs: []ServiceIODef{{Name: "context", Type: "PipelineContext", Description: "Pipeline context (HTTP response writer)"}}, + Outputs: []ServiceIODef{{Name: "result", Type: "StepResult", Description: "HTTP response with file content"}}, + ConfigFields: []ConfigFieldDef{ + {Key: "file", Label: "File Path", Type: FieldTypeString, Required: true, Description: "Path to the file to serve; resolved relative to the config file directory"}, + {Key: "content_type", Label: "Content-Type", Type: FieldTypeString, Required: true, Description: "MIME type of the file (e.g. application/yaml, text/html)"}, + {Key: "cache_control", Label: "Cache-Control", Type: FieldTypeString, Description: "Optional Cache-Control header value (e.g. public, max-age=3600)"}, + }, + }) } diff --git a/schema/schema.go b/schema/schema.go index 90f97f77..81440797 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -250,6 +250,7 @@ var coreModuleTypes = []string{ "step.scan_sast", "step.set", "step.shell_exec", + "step.static_file", "step.sub_workflow", "step.transform", "step.ui_scaffold", From c159f2bc2d80c4aba09c89d60800cb4f1e6ff4dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:18:31 +0000 Subject: [PATCH 3/3] fix: address PR review comments on step.static_file tests and schema Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- module/pipeline_step_static_file_test.go | 41 ++++++++++++++++++++++-- schema/module_schema.go | 2 +- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/module/pipeline_step_static_file_test.go b/module/pipeline_step_static_file_test.go index b8ac133b..7c3c2c5b 100644 --- a/module/pipeline_step_static_file_test.go +++ b/module/pipeline_step_static_file_test.go @@ -53,7 +53,11 @@ func TestStaticFileStep_ServesFile(t *testing.T) { t.Errorf("expected Cache-Control header, got %q", cc) } - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + t.Fatalf("read response body: %v", err) + } if string(body) != content { t.Errorf("expected body %q, got %q", content, string(body)) } @@ -97,6 +101,37 @@ func TestStaticFileStep_NoHTTPWriter(t *testing.T) { } } +func TestStaticFileStep_ConfigRelativePath(t *testing.T) { + // Write a temporary file to serve via a relative path resolved from _config_dir. + dir := t.TempDir() + filePath := filepath.Join(dir, "spec.yaml") + content := "openapi: 3.0.0\n" + if err := os.WriteFile(filePath, []byte(content), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + factory := NewStaticFileStepFactory() + // Pass relative file name + _config_dir so ResolvePathInConfig joins them. + step, err := factory("serve_spec", map[string]any{ + "file": "spec.yaml", + "content_type": "application/yaml", + "_config_dir": dir, + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + pc := NewPipelineContext(nil, map[string]any{}) + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + if result.Output["body"] != content { + t.Errorf("expected body %q, got %q", content, result.Output["body"]) + } +} + func TestStaticFileStep_MissingFile(t *testing.T) { factory := NewStaticFileStepFactory() _, err := factory("bad_step", map[string]any{ @@ -111,7 +146,9 @@ func TestStaticFileStep_MissingFile(t *testing.T) { func TestStaticFileStep_MissingContentType(t *testing.T) { dir := t.TempDir() filePath := filepath.Join(dir, "data.txt") - _ = os.WriteFile(filePath, []byte("hello"), 0o600) + if err := os.WriteFile(filePath, []byte("hello"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } factory := NewStaticFileStepFactory() _, err := factory("bad_step", map[string]any{ diff --git a/schema/module_schema.go b/schema/module_schema.go index 7b2fcfdd..d237a227 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -1993,7 +1993,7 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { Category: "pipeline", Description: "Serves a static file from disk as an HTTP response; file is read at init time for performance", Inputs: []ServiceIODef{{Name: "context", Type: "PipelineContext", Description: "Pipeline context (HTTP response writer)"}}, - Outputs: []ServiceIODef{{Name: "result", Type: "StepResult", Description: "HTTP response with file content"}}, + Outputs: []ServiceIODef{{Name: "result", Type: "StepResult", Description: "Writes an HTTP response with file content and stops the pipeline; if no HTTP writer is available, returns the file content in StepResult.Output as body"}}, ConfigFields: []ConfigFieldDef{ {Key: "file", Label: "File Path", Type: FieldTypeString, Required: true, Description: "Path to the file to serve; resolved relative to the config file directory"}, {Key: "content_type", Label: "Content-Type", Type: FieldTypeString, Required: true, Description: "MIME type of the file (e.g. application/yaml, text/html)"},