Skip to content
Merged
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
5 changes: 5 additions & 0 deletions cmd/wfctl/type_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
90 changes: 90 additions & 0 deletions module/pipeline_step_static_file.go
Original file line number Diff line number Diff line change
@@ -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
}
171 changes: 171 additions & 0 deletions module/pipeline_step_static_file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
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)
Comment on lines +21 to +28
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

The tests don’t cover the advertised config-relative path resolution via _config_dir/config.ResolvePathInConfig (i.e., passing a relative file path and setting _config_dir in the step config). Adding a test for this would guard the primary feature this step is introducing.

Copilot uses AI. Check for mistakes.
}

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, 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))
}

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_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{
"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")
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{
"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")
}
}
4 changes: 3 additions & 1 deletion plugins/pipelinesteps/plugin.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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()),
Expand Down
1 change: 1 addition & 0 deletions plugins/pipelinesteps/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions schema/module_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -1989,4 +1989,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: "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)"},
{Key: "cache_control", Label: "Cache-Control", Type: FieldTypeString, Description: "Optional Cache-Control header value (e.g. public, max-age=3600)"},
},
})
}
1 change: 1 addition & 0 deletions schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading