diff --git a/engine.go b/engine.go index ef74a20b..506d5b5d 100644 --- a/engine.go +++ b/engine.go @@ -711,6 +711,14 @@ func (e *StdEngine) configurePipelines(pipelineCfg map[string]any) error { Compensation: compSteps, } + // Set RoutePattern from inline HTTP trigger path so that step.request_parse + // can extract path parameters via _route_pattern in the pipeline context. + if pipeCfg.Trigger.Type == "http" { + if path, _ := pipeCfg.Trigger.Config["path"].(string); path != "" { + pipeline.RoutePattern = path + } + } + adder.AddPipeline(pipelineName, pipeline) // Register in the engine's pipeline registry so step.workflow_call can // look up this pipeline at execution time. diff --git a/engine_pipeline_test.go b/engine_pipeline_test.go index 16e457e9..565ff05c 100644 --- a/engine_pipeline_test.go +++ b/engine_pipeline_test.go @@ -189,6 +189,96 @@ func TestPipeline_ConfigurePipelines_InlineHTTPTrigger(t *testing.T) { } } +func TestPipeline_ConfigurePipelines_InlineHTTPTrigger_SetsRoutePattern(t *testing.T) { + // Verify that RoutePattern is populated from the inline HTTP trigger path + // so that step.request_parse can extract path parameters. + const wantPattern = "/api/resources/{id}" + + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + engine.AddStepType("step.set", module.NewSetStepFactory()) + pipelineHandler := handlers.NewPipelineWorkflowHandler() + engine.RegisterWorkflowHandler(pipelineHandler) + + mt := &mockTrigger{name: module.HTTPTriggerName, configType: "http"} + engine.RegisterTrigger(mt) + + pipelineCfg := map[string]any{ + "resource-pipeline": map[string]any{ + "trigger": map[string]any{ + "type": "http", + "config": map[string]any{ + "method": "GET", + "path": wantPattern, + }, + }, + "steps": []any{ + map[string]any{ + "name": "set-ok", + "type": "step.set", + "config": map[string]any{ + "values": map[string]any{"status": "ok"}, + }, + }, + }, + }, + } + + if err := engine.configurePipelines(pipelineCfg); err != nil { + t.Fatalf("configurePipelines failed: %v", err) + } + + pipeline, ok := engine.pipelineRegistry["resource-pipeline"] + if !ok { + t.Fatal("expected pipeline to be registered in pipelineRegistry") + } + if pipeline.RoutePattern != wantPattern { + t.Errorf("expected RoutePattern %q, got %q", wantPattern, pipeline.RoutePattern) + } +} + +func TestPipeline_ConfigurePipelines_InlineHTTPTrigger_NoPathNoRoutePattern(t *testing.T) { + // When no path is provided in the trigger config, RoutePattern should remain empty. + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + engine.AddStepType("step.set", module.NewSetStepFactory()) + pipelineHandler := handlers.NewPipelineWorkflowHandler() + engine.RegisterWorkflowHandler(pipelineHandler) + + mt := &mockTrigger{name: module.HTTPTriggerName, configType: "http"} + engine.RegisterTrigger(mt) + + pipelineCfg := map[string]any{ + "no-path-pipeline": map[string]any{ + "trigger": map[string]any{ + "type": "http", + "config": map[string]any{}, + }, + "steps": []any{ + map[string]any{ + "name": "set-ok", + "type": "step.set", + "config": map[string]any{ + "values": map[string]any{"status": "ok"}, + }, + }, + }, + }, + } + + if err := engine.configurePipelines(pipelineCfg); err != nil { + t.Fatalf("configurePipelines failed: %v", err) + } + + pipeline, ok := engine.pipelineRegistry["no-path-pipeline"] + if !ok { + t.Fatal("expected pipeline to be registered in pipelineRegistry") + } + if pipeline.RoutePattern != "" { + t.Errorf("expected empty RoutePattern, got %q", pipeline.RoutePattern) + } +} + func TestPipeline_ConfigurePipelines_InlineEventTrigger(t *testing.T) { // Use a minimal engine without plugins to avoid trigger collisions. app := newMockApplication() diff --git a/module/pipeline_executor.go b/module/pipeline_executor.go index d6124f54..7e1e4273 100644 --- a/module/pipeline_executor.go +++ b/module/pipeline_executor.go @@ -112,6 +112,14 @@ func (p *Pipeline) Execute(ctx context.Context, triggerData map[string]any) (*Pi if req := ctx.Value(HTTPRequestContextKey); req != nil { md["_http_request"] = req } + // Seed _route_pattern from RoutePattern if not already provided via Metadata + // (e.g. by CQRS handlers). This ensures step.request_parse can extract path + // parameters when a pipeline is executed via an inline HTTP trigger. + if p.RoutePattern != "" { + if _, exists := md["_route_pattern"]; !exists { + md["_route_pattern"] = p.RoutePattern + } + } pc := NewPipelineContext(triggerData, md) logger := p.Logger diff --git a/module/pipeline_executor_test.go b/module/pipeline_executor_test.go index a394544e..4ff4a4f8 100644 --- a/module/pipeline_executor_test.go +++ b/module/pipeline_executor_test.go @@ -499,3 +499,87 @@ func TestPipeline_CompensateWithNoCompensationSteps(t *testing.T) { t.Errorf("expected 'compensation executed' in error, got: %v", err) } } + +func TestPipeline_Execute_SeedsRoutePatternFromField(t *testing.T) { + // RoutePattern should be seeded into _route_pattern in pipeline metadata + // when Execute is called, enabling step.request_parse path param extraction + // for pipelines executed via inline HTTP triggers. + var capturedRoutePattern any + step1 := &mockStep{ + name: "capture", + execFn: func(_ context.Context, pc *PipelineContext) (*StepResult, error) { + capturedRoutePattern = pc.Metadata["_route_pattern"] + return &StepResult{}, nil + }, + } + + p := &Pipeline{ + Name: "route-pipeline", + Steps: []PipelineStep{step1}, + RoutePattern: "/api/items/{id}", + } + + _, err := p.Execute(context.Background(), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if capturedRoutePattern != "/api/items/{id}" { + t.Errorf("expected _route_pattern %q in metadata, got %v", "/api/items/{id}", capturedRoutePattern) + } +} + +func TestPipeline_Execute_MetadataRoutePatternTakesPrecedence(t *testing.T) { + // When _route_pattern is already set in p.Metadata (e.g. by a CQRS handler), + // it should not be overwritten by p.RoutePattern. + var capturedRoutePattern any + step1 := &mockStep{ + name: "capture", + execFn: func(_ context.Context, pc *PipelineContext) (*StepResult, error) { + capturedRoutePattern = pc.Metadata["_route_pattern"] + return &StepResult{}, nil + }, + } + + p := &Pipeline{ + Name: "route-pipeline", + Steps: []PipelineStep{step1}, + RoutePattern: "/api/items/{id}", + Metadata: map[string]any{ + "_route_pattern": "/overridden/pattern/{id}", + }, + } + + _, err := p.Execute(context.Background(), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if capturedRoutePattern != "/overridden/pattern/{id}" { + t.Errorf("expected _route_pattern %q from Metadata, got %v", "/overridden/pattern/{id}", capturedRoutePattern) + } +} + +func TestPipeline_Execute_NoRoutePatternNoMetadata(t *testing.T) { + // When RoutePattern is empty and Metadata has no _route_pattern, + // _route_pattern should not appear in the pipeline context. + var routePatternPresent bool + step1 := &mockStep{ + name: "capture", + execFn: func(_ context.Context, pc *PipelineContext) (*StepResult, error) { + _, routePatternPresent = pc.Metadata["_route_pattern"] + return &StepResult{}, nil + }, + } + + p := &Pipeline{ + Name: "plain-pipeline", + Steps: []PipelineStep{step1}, + } + + _, err := p.Execute(context.Background(), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if routePatternPresent { + t.Error("expected _route_pattern key to be absent from metadata") + } +} diff --git a/module/pipeline_step_base64_decode.go b/module/pipeline_step_base64_decode.go index 1104dce4..29fc2b9a 100644 --- a/module/pipeline_step_base64_decode.go +++ b/module/pipeline_step_base64_decode.go @@ -18,32 +18,32 @@ const ( // mimeToExtension maps common MIME types to their canonical file extensions. var mimeToExtension = map[string]string{ - "image/jpeg": ".jpg", - "image/png": ".png", - "image/gif": ".gif", - "image/webp": ".webp", - "image/bmp": ".bmp", - "image/tiff": ".tiff", - "image/svg+xml": ".svg", - "image/x-icon": ".ico", - "application/pdf": ".pdf", - "application/zip": ".zip", - "text/plain": ".txt", - "text/html": ".html", - "text/css": ".css", - "text/javascript": ".js", - "application/json": ".json", - "application/xml": ".xml", - "audio/mpeg": ".mp3", - "audio/ogg": ".ogg", - "audio/wav": ".wav", - "video/mp4": ".mp4", - "video/webm": ".webm", - "video/ogg": ".ogv", - "application/octet-stream": ".bin", - "application/gzip": ".gz", - "application/x-tar": ".tar", - "application/vnd.ms-excel": ".xls", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/bmp": ".bmp", + "image/tiff": ".tiff", + "image/svg+xml": ".svg", + "image/x-icon": ".ico", + "application/pdf": ".pdf", + "application/zip": ".zip", + "text/plain": ".txt", + "text/html": ".html", + "text/css": ".css", + "text/javascript": ".js", + "application/json": ".json", + "application/xml": ".xml", + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "audio/wav": ".wav", + "video/mp4": ".mp4", + "video/webm": ".webm", + "video/ogg": ".ogv", + "application/octet-stream": ".bin", + "application/gzip": ".gz", + "application/x-tar": ".tar", + "application/vnd.ms-excel": ".xls", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", "application/msword": ".doc", "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", @@ -52,12 +52,12 @@ var mimeToExtension = map[string]string{ // Base64DecodeStep decodes base64-encoded content (raw or data-URI), optionally // validating the MIME type and decoded size. type Base64DecodeStep struct { - name string - inputFrom string - format string - allowedTypes []string - maxSizeBytes int - validateMagic bool + name string + inputFrom string + format string + allowedTypes []string + maxSizeBytes int + validateMagic bool } // NewBase64DecodeStepFactory returns a StepFactory that creates Base64DecodeStep instances. diff --git a/module/pipeline_step_webhook_verify.go b/module/pipeline_step_webhook_verify.go index 8f735ec3..3b8c2d5d 100644 --- a/module/pipeline_step_webhook_verify.go +++ b/module/pipeline_step_webhook_verify.go @@ -3,7 +3,7 @@ package module import ( "context" "crypto/hmac" - "crypto/sha1" //nolint:gosec // Required for Twilio HMAC-SHA1 webhook signature verification + "crypto/sha1" //nolint:gosec // Required for Twilio HMAC-SHA1 webhook signature verification "crypto/sha256" "crypto/subtle" "encoding/base64"