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
52 changes: 48 additions & 4 deletions module/pipeline_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,52 @@ func (te *TemplateEngine) resolveValue(v any, pc *PipelineContext) (any, error)
}
}

// timeLayouts maps common Go time constant names to their layout strings.
var timeLayouts = map[string]string{
"ANSIC": time.ANSIC,
"UnixDate": time.UnixDate,
"RubyDate": time.RubyDate,
"RFC822": time.RFC822,
"RFC822Z": time.RFC822Z,
"RFC850": time.RFC850,
"RFC1123": time.RFC1123,
"RFC1123Z": time.RFC1123Z,
"RFC3339": time.RFC3339,
"RFC3339Nano": time.RFC3339Nano,
"Kitchen": time.Kitchen,
"Stamp": time.Stamp,
"StampMilli": time.StampMilli,
"StampMicro": time.StampMicro,
"StampNano": time.StampNano,
"DateTime": time.DateTime,
"DateOnly": time.DateOnly,
"TimeOnly": time.TimeOnly,
}

// templateFuncMap returns the function map available in pipeline templates.
func templateFuncMap() template.FuncMap {
return template.FuncMap{
// uuidv4 generates a new UUID v4 string.
// uuid generates a new UUID v4 string.
"uuid": func() string {
return uuid.New().String()
},
// uuidv4 generates a new UUID v4 string (alias for uuid).
"uuidv4": func() string {
return uuid.New().String()
},
// now returns the current time in RFC3339 format (UTC).
"now": func() string {
return time.Now().UTC().Format(time.RFC3339)
// now returns the current UTC time formatted with the given Go time layout
// string or named constant (e.g. "RFC3339", "2006-01-02").
// When called with no argument it defaults to RFC3339.
"now": func(args ...string) string {
layout := time.RFC3339
if len(args) > 0 && args[0] != "" {
if l, ok := timeLayouts[args[0]]; ok {
layout = l
} else {
layout = args[0]
}
}
return time.Now().UTC().Format(layout)
},
// lower converts a string to lowercase.
"lower": strings.ToLower,
Expand All @@ -117,6 +153,14 @@ func templateFuncMap() template.FuncMap {
}
return val
},
// trimPrefix removes the given prefix from a string if present.
"trimPrefix": func(prefix, s string) string {
return strings.TrimPrefix(s, prefix)
},
// trimSuffix removes the given suffix from a string if present.
"trimSuffix": func(suffix, s string) string {
return strings.TrimSuffix(s, suffix)
},
// json marshals a value to a JSON string.
"json": func(v any) string {
b, err := json.Marshal(v)
Expand Down
127 changes: 127 additions & 0 deletions module/pipeline_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,133 @@ func TestTemplateEngine_ResolveMap_ErrorPropagation(t *testing.T) {
}
}

func TestTemplateEngine_FuncUUID(t *testing.T) {
te := NewTemplateEngine()
pc := NewPipelineContext(nil, nil)

result, err := te.Resolve("{{ uuid }}", pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// UUID v4 has the form xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
if len(result) != 36 {
t.Errorf("expected UUID of length 36, got %q (len=%d)", result, len(result))
}
if result[14] != '4' {
t.Errorf("expected UUID version 4, got %q", result)
}
}

func TestTemplateEngine_FuncNowNoArgs(t *testing.T) {
te := NewTemplateEngine()
pc := NewPipelineContext(nil, nil)

result, err := te.Resolve(`{{ now }}`, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "T") {
t.Errorf("expected RFC3339 timestamp from no-arg now, got %q", result)
}
}

func TestTemplateEngine_FuncNowRFC3339(t *testing.T) {
te := NewTemplateEngine()
pc := NewPipelineContext(nil, nil)

result, err := te.Resolve(`{{ now "RFC3339" }}`, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) == 0 {
t.Error("expected non-empty timestamp")
}
// RFC3339 strings contain 'T' and 'Z' or offset
if !strings.Contains(result, "T") {
t.Errorf("expected RFC3339 timestamp, got %q", result)
}
}

func TestTemplateEngine_FuncNowRawLayout(t *testing.T) {
te := NewTemplateEngine()
pc := NewPipelineContext(nil, nil)

result, err := te.Resolve(`{{ now "2006-01-02" }}`, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should be a date of the form YYYY-MM-DD
if len(result) != 10 {
t.Errorf("expected date of length 10, got %q", result)
}
}

func TestTemplateEngine_FuncTrimPrefix(t *testing.T) {
te := NewTemplateEngine()
pc := NewPipelineContext(map[string]any{"phone": "+15551234567"}, nil)

result, err := te.Resolve(`{{ .phone | trimPrefix "+" }}`, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "15551234567" {
t.Errorf("expected '15551234567', got %q", result)
}
}

func TestTemplateEngine_FuncTrimPrefixNotPresent(t *testing.T) {
te := NewTemplateEngine()
pc := NewPipelineContext(map[string]any{"val": "hello"}, nil)

result, err := te.Resolve(`{{ .val | trimPrefix "world" }}`, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "hello" {
t.Errorf("expected 'hello', got %q", result)
}
}

func TestTemplateEngine_FuncTrimSuffix(t *testing.T) {
te := NewTemplateEngine()
pc := NewPipelineContext(map[string]any{"val": "hello.txt"}, nil)

result, err := te.Resolve(`{{ .val | trimSuffix ".txt" }}`, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "hello" {
t.Errorf("expected 'hello', got %q", result)
}
}

func TestTemplateEngine_FuncTrimSuffixNotPresent(t *testing.T) {
te := NewTemplateEngine()
pc := NewPipelineContext(map[string]any{"val": "hello"}, nil)

result, err := te.Resolve(`{{ .val | trimSuffix ".txt" }}`, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "hello" {
t.Errorf("expected 'hello', got %q", result)
}
}

func TestTemplateEngine_FuncDefault(t *testing.T) {
te := NewTemplateEngine()
pc := NewPipelineContext(nil, nil)
pc.MergeStepOutput("parse", map[string]any{"query_params": map[string]any{}})

result, err := te.Resolve(`{{ .steps.parse.query_params.page_size | default "25" }}`, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "25" {
t.Errorf("expected '25', got %q", result)
}
}

func TestTemplateEngine_ResolveMap_DoesNotMutateInput(t *testing.T) {
te := NewTemplateEngine()
pc := NewPipelineContext(map[string]any{"x": "resolved"}, nil)
Expand Down
Loading