From 988d83cae501a0b60b83cca7e8b359f5c6bf58a0 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Tue, 6 Feb 2024 22:31:25 +0300 Subject: [PATCH] feat(config): process expressions in config data before unmarshalling This commit introduces the ability to preprocess configuration data to evaluate expressions contained within it before the data is unmarshalled into Go structures. It leverages a new function, `ProcessConfig`, from the `expr` package, which searches for and evaluates expressions denoted by `${{...}}` syntax. Additionally, this commit includes comprehensive tests for both the `LoadConfig` function and the newly added `processConfig` logic to ensure that expressions are correctly evaluated and that the configuration loading behaves as expected, even when faced with invalid configurations or expressions. The ability to preprocess and evaluate expressions within the configuration could simplify dynamic configuration scenarios and reduce the need for external templating tools. --- internal/app/app.go | 9 ++- internal/app/app_test.go | 130 +++++++++++++++++++++++++++++++++++++++ pkg/expr/expr.go | 36 +++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 internal/app/app_test.go diff --git a/internal/app/app.go b/internal/app/app.go index b27437db..208aad77 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,6 +9,7 @@ import ( "runtime" "strings" + "github.com/AlexxIT/go2rtc/pkg/expr" "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/AlexxIT/go2rtc/pkg/yaml" "github.com/rs/zerolog/log" @@ -85,7 +86,13 @@ func Init() { func LoadConfig(v any) { for _, data := range configs { - if err := yaml.Unmarshal(data, v); err != nil { + processedData, err := expr.ProcessConfig(data) + if err != nil { + log.Warn().Err(err).Msg("[app] process config") + continue + } + + if err := yaml.Unmarshal(processedData, v); err != nil { log.Warn().Err(err).Msg("[app] read config") } } diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 00000000..86822e93 --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,130 @@ +package app + +import ( + "bytes" + "github.com/AlexxIT/go2rtc/pkg/expr" + "log" + "os" + "reflect" + "testing" +) + +// TestLoadConfig tests the LoadConfig function. +func TestLoadConfig(t *testing.T) { + // Redirect log output to buffer for testing. + var buf bytes.Buffer + log.SetOutput(&buf) + defer func() { + log.SetOutput(os.Stderr) + }() + + type Config struct { + Key1 string `yaml:"key1"` + Key2 string `yaml:"key2"` + Key3 string `yaml:"key3"` + } + + tests := []struct { + name string + configs [][]byte + want Config + expectError bool + }{ + { + name: "Valid configs", + configs: [][]byte{ + []byte("key1: value1\nkey2: value2"), + []byte("key3: value3"), + }, + want: Config{ + Key1: "value1", + Key2: "value2", + Key3: "value3", + }, + expectError: false, + }, + { + name: "Invalid config", + configs: [][]byte{ + []byte("key1: value1"), + []byte("invalid_yaml"), + }, + want: Config{ + Key1: "value1", + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock the configs with the test case's configs. + configs = tt.configs + + var got Config + LoadConfig(&got) + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%s: LoadConfig() got = %v, want %v", tt.name, got, tt.want) + } + + containsError := bytes.Contains(buf.Bytes(), []byte("read config")) + if containsError != tt.expectError { + t.Errorf("%s: LoadConfig() expected error = %v, but got = %v", tt.name, tt.expectError, containsError) + } + + // Clear buffer after each test case. + buf.Reset() + }) + } +} + +// Test_processConfig tests the processConfig function. +func Test_processConfig(t *testing.T) { + type args struct { + data []byte + } + tests := []struct { + name string + args args + want []byte + wantErr bool + }{ + { + name: "No expressions", + args: args{data: []byte("config: value")}, + want: []byte("config: value"), + wantErr: false, + }, + { + name: "Simple expression", + args: args{data: []byte(`config: ${{ "value" }}`)}, + want: []byte("config: value"), + wantErr: false, + }, + { + name: "Math expression", + args: args{data: []byte(`config: ${{ 2+2 }}`)}, + want: []byte("config: 4"), + wantErr: false, + }, + { + name: "Invalid expression", + args: args{data: []byte(`config: ${{ invalid }}`)}, + want: []byte(`config: ${{ invalid }}`), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := expr.ProcessConfig(tt.args.data) + if (err != nil) != tt.wantErr { + t.Errorf("%s: processConfig() error = %v, wantErr %v", tt.name, err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%s: processConfig() got = %v, want %v", tt.name, got, tt.want) + } + }) + } +} diff --git a/pkg/expr/expr.go b/pkg/expr/expr.go index 36719100..583082d7 100644 --- a/pkg/expr/expr.go +++ b/pkg/expr/expr.go @@ -1,8 +1,10 @@ package expr import ( + "bytes" "encoding/json" "fmt" + "html/template" "io" "net/http" "regexp" @@ -113,3 +115,37 @@ func Run(input string) (any, error) { return expr.Run(program, nil) } + +func ProcessConfig(data []byte) ([]byte, error) { + r := regexp.MustCompile(`\${{(.+?)}}`) + return r.ReplaceAllFunc(data, func(match []byte) []byte { + exprStr := match[3 : len(match)-2] // Extract the expression without `${{` and `}}`. + result, err := evalExpr(string(exprStr)) + if err != nil { + // log.Warn().Err(err).Msg("[app] eval expression") + return match + } + + return []byte(result) + }), nil +} + +func evalExpr(expression string) (string, error) { + result, err := expr.Eval(expression, nil) + if err != nil { + return "", err + } + + // Use a template to ensure proper conversion to string. + var tpl bytes.Buffer + tmpl, err := template.New("expr").Parse("{{ . }}") + if err != nil { + return "", err + } + err = tmpl.Execute(&tpl, result) + if err != nil { + return "", err + } + + return tpl.String(), nil +}