Skip to content

Commit

Permalink
feat(placeholders): support custom unescape functionality
Browse files Browse the repository at this point in the history
Signed-off-by: Ben Meier <ben.meier@humanitec.com>
  • Loading branch information
astromechza committed Jul 9, 2024
1 parent 00d7027 commit 81afe55
Show file tree
Hide file tree
Showing 2 changed files with 67 additions and 12 deletions.
57 changes: 45 additions & 12 deletions framework/substitution.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import (

var (
// placeholderRegEx will search for ${...} with any sequence of characters between them.
placeholderRegEx = regexp.MustCompile(`\$(\$|{([^}]*)})`)
placeholderRegEx = regexp.MustCompile(`\$((?:\$?{([^}]*)})|\$)`)
)

func SplitRefParts(ref string) []string {
Expand All @@ -36,9 +36,24 @@ func SplitRefParts(ref string) []string {
return parts
}

// SubstituteString replaces all matching '${...}' templates in a source string with whatever is returned
// from the inner function. Double $'s are unescaped.
func SubstituteString(src string, inner func(string) (string, error)) (string, error) {
// A Substituter is a type that supports substitutions of $-sign placeholders in strings. This detects and replaces
// patterns like: fizz ${var} buzz while supporting custom un-escaping of patterns like $$ and $${var}. The Replacer
// function is _required_ and the substituter will not function without it, but the UnEscaper is optional and will
// default to simply replacing sequences of $$ with a $.
// Overriding the UnEscaper may be necessary if non default behavior is required.
type Substituter struct {
Replacer func(string) (string, error)
UnEscaper func(string) (string, error)
}

func DefaultUnEscaper(original string) (string, error) {
return original[1:], nil
}

func (s *Substituter) SubstituteString(src string) (string, error) {
if s.Replacer == nil {
return "", errors.New("replacer function is nil")
}
var err error
result := placeholderRegEx.ReplaceAllStringFunc(src, func(str string) string {
// WORKAROUND: ReplaceAllStringFunc(..) does not provide match details
Expand All @@ -52,29 +67,36 @@ func SubstituteString(src string, inner func(string) (string, error)) (string, e
}

// support escaped dollars
if matches[1] == "$" {
return matches[1]
if strings.HasPrefix(matches[1], "$") {
ue := DefaultUnEscaper
if s.UnEscaper != nil {
ue = s.UnEscaper
}
res, subErr := ue(matches[0])
if subErr != nil {
err = errors.Join(err, fmt.Errorf("failed to unescape '%s': %w", matches[0], subErr))
}
return res
}

result, subErr := inner(matches[2])
result, subErr := s.Replacer(matches[2])
err = errors.Join(err, subErr)
return result
})
return result, err
}

// Substitute does the same thing as SubstituteString but recursively through a map. It returns a copy of the original map.
func Substitute(source interface{}, inner func(string) (string, error)) (interface{}, error) {
func (s *Substituter) Substitute(source interface{}) (interface{}, error) {
if source == nil {
return nil, nil
}
switch v := source.(type) {
case string:
return SubstituteString(v, inner)
return s.SubstituteString(v)
case map[string]interface{}:
out := make(map[string]interface{}, len(v))
for k, v := range v {
v2, err := Substitute(v, inner)
v2, err := s.Substitute(v)
if err != nil {
return nil, fmt.Errorf("%s: %w", k, err)
}
Expand All @@ -84,7 +106,7 @@ func Substitute(source interface{}, inner func(string) (string, error)) (interfa
case []interface{}:
out := make([]interface{}, len(v))
for i, i2 := range v {
i3, err := Substitute(i2, inner)
i3, err := s.Substitute(i2)
if err != nil {
return nil, fmt.Errorf("%d: %w", i, err)
}
Expand All @@ -96,6 +118,17 @@ func Substitute(source interface{}, inner func(string) (string, error)) (interfa
}
}

// SubstituteString replaces all matching '${...}' templates in a source string with whatever is returned
// from the inner function. Double $'s are unescaped using DefaultUnEscaper.
func SubstituteString(src string, inner func(string) (string, error)) (string, error) {
return (&Substituter{Replacer: inner, UnEscaper: DefaultUnEscaper}).SubstituteString(src)
}

// Substitute does the same thing as SubstituteString but recursively through a map. It returns a copy of the original map.
func Substitute(source interface{}, inner func(string) (string, error)) (interface{}, error) {
return (&Substituter{Replacer: inner, UnEscaper: DefaultUnEscaper}).Substitute(source)
}

func mapLookupOutput(ctx map[string]interface{}) func(keys ...string) (interface{}, error) {
return func(keys ...string) (interface{}, error) {
var resolvedValue interface{}
Expand Down
22 changes: 22 additions & 0 deletions framework/substitution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ func TestSubstituteString(t *testing.T) {
{Input: "$abc", Expected: "$abc"},
{Input: "abc $$ abc", Expected: "abc $ abc"},
{Input: "$${abc}", Expected: "${abc}"},
{Input: "$$${abc}", ExpectedError: "invalid ref 'abc': unknown reference root, use $$ to escape the substitution"},
{Input: "$$$${abc}", Expected: "$${abc}"},
{Input: "$$$$${abc}", ExpectedError: "invalid ref 'abc': unknown reference root, use $$ to escape the substitution"},
{Input: "$${abc .4t3298y *(^&(*}", Expected: "${abc .4t3298y *(^&(*}"},
{Input: "my name is ${metadata.name}", Expected: "my name is test-name"},
{Input: "my name is ${metadata.thing\\.two}", ExpectedError: "invalid ref 'metadata.thing\\.two': key 'thing.two' not found"},
Expand Down Expand Up @@ -161,3 +164,22 @@ func TestSubstituteMap_fail(t *testing.T) {
}, substitutionFunction)
assert.EqualError(t, err, "a: 0: b: invalid ref 'metadata.unknown': key 'unknown' not found")
}

func TestCustomSubstituter_nil(t *testing.T) {
s := new(Substituter)
_, err := s.SubstituteString("${fizz}")
assert.EqualError(t, err, "replacer function is nil")
}

func TestCustomerUnescaper(t *testing.T) {
s := new(Substituter)
s.Replacer = func(s string) (string, error) {
return strings.ToUpper(s), nil
}
s.UnEscaper = func(s string) (string, error) {
return strings.Repeat(s, 2), nil
}
x, err := s.SubstituteString("$$ $${thing}")
assert.NoError(t, err)
assert.Equal(t, "$$$$ $${thing}$${thing}", x)
}

0 comments on commit 81afe55

Please sign in to comment.