From 4c80b675b8c2648944fd86d0632e79c1dedda5fa Mon Sep 17 00:00:00 2001 From: Yaroslav Kirillov Date: Thu, 22 Oct 2026 16:57:18 +0500 Subject: [PATCH 1/3] feat(config): add env() function --- internal/app/config/config.go | 37 +++++++++ internal/app/config/fnc/env.go | 28 +++++++ internal/app/config/fnc/env_test.go | 92 ++++++++++++++++++++++ internal/app/config/fnc/func.go | 110 +++++++++++++++++++++++++++ internal/app/config/fnc/func_test.go | 110 +++++++++++++++++++++++++++ internal/app/config/fnc/test.go | 26 +++++++ 6 files changed, 403 insertions(+) create mode 100644 internal/app/config/fnc/env.go create mode 100644 internal/app/config/fnc/env_test.go create mode 100644 internal/app/config/fnc/func.go create mode 100644 internal/app/config/fnc/func_test.go create mode 100644 internal/app/config/fnc/test.go diff --git a/internal/app/config/config.go b/internal/app/config/config.go index cf926f9..82feb51 100644 --- a/internal/app/config/config.go +++ b/internal/app/config/config.go @@ -4,9 +4,12 @@ import ( "bytes" "fmt" "os" + "reflect" "time" "gopkg.in/yaml.v3" + + "github.com/ozontech/seq-ui/internal/app/config/fnc" ) const ( @@ -340,6 +343,8 @@ func FromFile(cfgPath string) (Config, error) { return Config{}, fmt.Errorf("error parsing file: %s", err) } + applyFuncs(reflect.ValueOf(&cfg)) + proxyClientMode := cfg.Clients.ProxyClientMode if proxyClientMode == "" { cfg.Clients.ProxyClientMode = ProxyClientModeGRPC @@ -488,3 +493,35 @@ func parse(cfg []byte) (Config, error) { return result, nil } + +func applyFuncs(rv reflect.Value) { + if rv.Kind() == reflect.Pointer || rv.Kind() == reflect.Interface { + if rv.IsNil() { + return + } + rv = rv.Elem() + } + + if rv.Kind() == reflect.String && rv.CanSet() { + if newVal, ok := fnc.TryApply(rv.String()); ok { + rv.SetString(newVal) + } + return + } + + switch rv.Kind() { + case reflect.Struct: + for i := 0; i < rv.NumField(); i++ { + applyFuncs(rv.Field(i)) + } + case reflect.Slice, reflect.Array: + for i := 0; i < rv.Len(); i++ { + applyFuncs(rv.Index(i)) + } + case reflect.Map: + iter := rv.MapRange() + for iter.Next() { + applyFuncs(iter.Value()) + } + } +} diff --git a/internal/app/config/fnc/env.go b/internal/app/config/fnc/env.go new file mode 100644 index 0000000..d60474c --- /dev/null +++ b/internal/app/config/fnc/env.go @@ -0,0 +1,28 @@ +package fnc + +import ( + "errors" + "os" +) + +type env struct { + args []string +} + +func parseEnv(args []string) (*env, error) { + if len(args) != 1 { + return nil, errors.New("invalid args count") + } + if args[0] == "" { + return nil, errors.New("empty arg") + } + return &env{args: args}, nil +} + +func (e *env) run() (string, error) { + env, ok := os.LookupEnv(e.args[0]) + if !ok { + return "", errors.New("failed to lookup env") + } + return env, nil +} diff --git a/internal/app/config/fnc/env_test.go b/internal/app/config/fnc/env_test.go new file mode 100644 index 0000000..28c88b9 --- /dev/null +++ b/internal/app/config/fnc/env_test.go @@ -0,0 +1,92 @@ +package fnc + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseEnv(t *testing.T) { + cases := []struct { + name string + + args []string + wantErr bool + }{ + { + name: "ok", + args: []string{"TEST_ENV"}, + }, + { + name: "err_invalid_args_count_1", + args: []string{}, + wantErr: true, + }, + { + name: "err_invalid_args_count_2", + args: []string{"1", "2"}, + wantErr: true, + }, + { + name: "err_empty_arg", + args: []string{""}, + wantErr: true, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + _, gotErr := parseEnv(c.args) + require.Equal(t, c.wantErr, gotErr != nil) + }) + } +} + +func TestEnvRun(t *testing.T) { + cases := []struct { + name string + + args []string + envs [][2]string + want string + wantErr bool + }{ + { + name: "ok", + args: []string{"TEST_ENV"}, + envs: [][2]string{ + {"TEST_ENV", "val"}, + }, + want: "val", + }, + { + name: "err_lookup", + args: []string{"TEST_ENV2"}, + wantErr: true, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + for _, e := range c.envs { + os.Setenv(e[0], e[1]) + } + + env, err := parseEnv(c.args) + require.NoError(t, err) + + got, gotErr := env.run() + require.Equal(t, c.wantErr, gotErr != nil) + if c.wantErr { + return + } + + require.Equal(t, c.want, got) + }) + } +} diff --git a/internal/app/config/fnc/func.go b/internal/app/config/fnc/func.go new file mode 100644 index 0000000..adf4a72 --- /dev/null +++ b/internal/app/config/fnc/func.go @@ -0,0 +1,110 @@ +package fnc + +import ( + "errors" + "fmt" + "slices" + "strings" + + "go.uber.org/zap" + + "github.com/ozontech/seq-ui/logger" +) + +// TryApply tries to parse and apply the func. +// +// Returns the result of the func execution and indicates whether it should be applied. +func TryApply(fn string) (string, bool) { + f, err := parse(fn) + if errors.Is(err, errEscaped) { + return fn[1:], true + } + if err != nil { + logger.Error("failed to parse config func", + zap.String("func", fn), + zap.Error(err), + ) + return "", false + } + if f == nil { + return "", false + } + + res, err := f.run() + if err != nil { + logger.Error("failed to apply config func", + zap.String("func", fn), + zap.Error(err), + ) + return "", false + } + return res, true +} + +const ( + fnTest = "__test__" // only for testing + + fnEnv = "env" +) + +var knownFuncs = []string{fnTest, fnEnv} + +func isKnownFn(fn string) bool { + return slices.Contains(knownFuncs, fn) +} + +type fnc interface { + run() (string, error) +} + +var errEscaped = errors.New("escaped") + +func parse(fn string) (fnc, error) { + fnName := "" + if idx := strings.Index(fn, "("); idx != -1 { + fnName = fn[:idx] + fn = fn[idx:] + } else { + return nil, nil + } + + // escaped + if strings.HasPrefix(fnName, `\`) && isKnownFn(fnName[1:]) { + return nil, errEscaped + } + if !isKnownFn(fnName) { + return nil, nil + } + + if fn[len(fn)-1] != ')' { + return nil, errors.New("missing ')'") + } + + argsRaw := fn[1 : len(fn)-1] + + var args []string + if argsRaw != "" { + args = strings.Split(argsRaw, ",") + for i := range args { + args[i] = strings.TrimSpace(args[i]) + } + } + + f, err := newFn(fnName, args) + if err != nil { + return nil, fmt.Errorf("failed to parse %q func: %w", fnName, err) + } + + return f, nil +} + +func newFn(name string, args []string) (fnc, error) { + switch name { + case fnTest: + return parseTest(args) + case fnEnv: + return parseEnv(args) + default: + panic("unreachable") + } +} diff --git a/internal/app/config/fnc/func_test.go b/internal/app/config/fnc/func_test.go new file mode 100644 index 0000000..d319728 --- /dev/null +++ b/internal/app/config/fnc/func_test.go @@ -0,0 +1,110 @@ +package fnc + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTryApply(t *testing.T) { + cases := []struct { + name string + + fn string + want string + wantStatus bool + }{ + { + name: "ok", + fn: "__test__(tst, false)", + want: "tst", + wantStatus: true, + }, + { + name: "ok_escaped", + fn: `\__test__()`, + want: "__test__()", + wantStatus: true, + }, + { + name: "ok_unknown", + fn: `unknownfunc()`, + wantStatus: false, + }, + { + name: "err_parse", + fn: "__test__(", + wantStatus: false, + }, + { + name: "err_run", + fn: "__test__(tst, true)", + wantStatus: false, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + got, gotStatus := TryApply(c.fn) + require.Equal(t, c.want, got) + require.Equal(t, c.wantStatus, gotStatus) + }) + } +} + +func TestParse(t *testing.T) { + cases := []struct { + name string + + fn string + want fnc + wantErr bool + }{ + { + name: "ok_not_fn", + fn: "test", + want: nil, + }, + { + name: "ok_escaped", + fn: `\__test__()`, + wantErr: true, // custom errEscaped, but valid case + }, + { + name: "ok_valid_fn", + fn: "__test__(1,true)", + want: &test{want: "1", wantErr: true}, + }, + { + name: "ok_unknown_fn", + fn: `unknownfn()`, + want: nil, + }, + { + name: "err_missing_parenthesis", + fn: `__test__(`, + wantErr: true, + }, + { + name: "err_new_fn", + fn: `__test__()`, + wantErr: true, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + got, gotErr := parse(c.fn) + require.Equal(t, c.wantErr, gotErr != nil) + if c.wantErr { + return + } + + require.Equal(t, c.want, got) + }) + } +} diff --git a/internal/app/config/fnc/test.go b/internal/app/config/fnc/test.go new file mode 100644 index 0000000..b71335b --- /dev/null +++ b/internal/app/config/fnc/test.go @@ -0,0 +1,26 @@ +package fnc + +import "errors" + +// func for testing +type test struct { + want string + wantErr bool +} + +func parseTest(args []string) (*test, error) { + if len(args) != 2 { + return nil, errors.New("invalid args count") + } + return &test{ + want: args[0], + wantErr: args[1] == "true", + }, nil +} + +func (t *test) run() (string, error) { + if t.wantErr { + return t.want, errors.New("some err") + } + return t.want, nil +} From fc42f5bba08bd247b91db7f9741e1f652bf30089 Mon Sep 17 00:00:00 2001 From: Yaroslav Kirillov Date: Thu, 22 Oct 2026 17:15:22 +0500 Subject: [PATCH 2/3] add docs --- docs/en/02-configuration.md | 29 +++++++++++++++++++++++++++++ docs/ru/02-configuration.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/docs/en/02-configuration.md b/docs/en/02-configuration.md index eefd5b5..7c19a10 100644 --- a/docs/en/02-configuration.md +++ b/docs/en/02-configuration.md @@ -882,3 +882,32 @@ export TRACING_SERVICE_NAME=seq-ui + **`TRACING_SAMPLER_PARAM`** *`float64`* *`required`* Sampling rate parameter. Determines the fraction of requests that will be traced. Must be value between 0.0 and 1.0. For instance, use 0.1 to sample 10% of requests. + +# Functions + +The configuration supports a set of functions that are performed at the parsing stage. + +> If you need to pass a literal string that begins with `(`, you should escape the value with a +backslash: `\(`. + +## ENV +The `env(ENV_NAME)` function allows you to get environment variables. + +Params: ++ **ENV_NAME** - name of the environment variable + +Suppose you have environment variable `DB_PASS=my-pass`. + +Raw config: +```yaml +server: + db: + pass: env(DB_PASS) +``` + +Final config: +```yaml +server: + db: + pass: my-pass +``` diff --git a/docs/ru/02-configuration.md b/docs/ru/02-configuration.md index 2a8b96f..c805436 100644 --- a/docs/ru/02-configuration.md +++ b/docs/ru/02-configuration.md @@ -883,3 +883,31 @@ export TRACING_SERVICE_NAME=seq-ui Параметр сэмплера, определяющий долю запросов, которые будут отслеживаться. Допустимые значения - от 0.0 до 1.0 включительно. Например, 0.1 соответствует 10% запросов. + +# Функции + +Конфигурация поддерживает набор функций, которые выполняются на этапе парсинга. + +> Если вам нужно использовать строку, которая начинается с `(`, то вы должны экранировать ее с помощью обратной косой черты: `\(`. + +## ENV +Функция `env(ENV_NAME)` позволяет получать переменные окружения. + +Параметры: ++ **ENV_NAME** - имя переменной окружения + +Предположим, у вас есть переменная окружения `DB_PASS=my-pass`. + +Исходный конфиг: +```yaml +server: + db: + pass: env(DB_PASS) +``` + +Итоговый конфиг: +```yaml +server: + db: + pass: my-pass +``` From f4a1b68a350b24b710f1b3901a5756307390ed23 Mon Sep 17 00:00:00 2001 From: Yaroslav Kirillov Date: Fri, 23 Oct 2026 14:31:41 +0500 Subject: [PATCH 3/3] fix map --- internal/app/config/config.go | 35 +------- internal/app/config/fnc/func.go | 93 +++++++++++++++++++- internal/app/config/fnc/func_test.go | 123 ++++++++++++++++++++++++++- 3 files changed, 214 insertions(+), 37 deletions(-) diff --git a/internal/app/config/config.go b/internal/app/config/config.go index 82feb51..8f6eff9 100644 --- a/internal/app/config/config.go +++ b/internal/app/config/config.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "os" - "reflect" "time" "gopkg.in/yaml.v3" @@ -343,7 +342,7 @@ func FromFile(cfgPath string) (Config, error) { return Config{}, fmt.Errorf("error parsing file: %s", err) } - applyFuncs(reflect.ValueOf(&cfg)) + fnc.ApplyFuncs(&cfg) proxyClientMode := cfg.Clients.ProxyClientMode if proxyClientMode == "" { @@ -493,35 +492,3 @@ func parse(cfg []byte) (Config, error) { return result, nil } - -func applyFuncs(rv reflect.Value) { - if rv.Kind() == reflect.Pointer || rv.Kind() == reflect.Interface { - if rv.IsNil() { - return - } - rv = rv.Elem() - } - - if rv.Kind() == reflect.String && rv.CanSet() { - if newVal, ok := fnc.TryApply(rv.String()); ok { - rv.SetString(newVal) - } - return - } - - switch rv.Kind() { - case reflect.Struct: - for i := 0; i < rv.NumField(); i++ { - applyFuncs(rv.Field(i)) - } - case reflect.Slice, reflect.Array: - for i := 0; i < rv.Len(); i++ { - applyFuncs(rv.Index(i)) - } - case reflect.Map: - iter := rv.MapRange() - for iter.Next() { - applyFuncs(iter.Value()) - } - } -} diff --git a/internal/app/config/fnc/func.go b/internal/app/config/fnc/func.go index adf4a72..4fbf329 100644 --- a/internal/app/config/fnc/func.go +++ b/internal/app/config/fnc/func.go @@ -3,6 +3,7 @@ package fnc import ( "errors" "fmt" + "reflect" "slices" "strings" @@ -11,10 +12,98 @@ import ( "github.com/ozontech/seq-ui/logger" ) -// TryApply tries to parse and apply the func. +// ApplyFuncs applies funcs to config +func ApplyFuncs(v any) { + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Pointer || rv.IsNil() { + return + } + applyFuncsReflect(rv) +} + +func applyFuncsReflect(rv reflect.Value) { + if rv.Kind() == reflect.Interface { + if !rv.IsNil() { + applyFuncsReflect(rv.Elem()) + } + return + } + + if rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return + } + rv = rv.Elem() + } + + if rv.Kind() == reflect.String && rv.CanSet() { + if newVal, ok := tryApply(rv.String()); ok { + rv.SetString(newVal) + } + return + } + + switch rv.Kind() { + case reflect.Struct: + for i := range rv.NumField() { + applyFuncsReflect(rv.Field(i)) + } + case reflect.Slice, reflect.Array: + for i := range rv.Len() { + applyFuncsReflect(rv.Index(i)) + } + case reflect.Map: + out := reflect.MakeMapWithSize(rv.Type(), rv.Len()) + iter := rv.MapRange() + for iter.Next() { + out.SetMapIndex(iter.Key(), applyFuncsReflectCopy(iter.Value())) + } + rv.Set(out) + } +} + +// map needs to be completely recreated from since its values are not addressable (CanSet() == false) +func applyFuncsReflectCopy(rv reflect.Value) reflect.Value { + switch rv.Kind() { + case reflect.String: + if newVal, ok := tryApply(rv.String()); ok { + return reflect.ValueOf(newVal) + } + return rv + case reflect.Pointer, reflect.Interface: + if rv.IsNil() { + return rv + } + applyFuncsReflect(rv) + return rv + case reflect.Struct: + out := reflect.New(rv.Type()).Elem() + for i := range rv.NumField() { + out.Field(i).Set(applyFuncsReflectCopy(rv.Field(i))) + } + return out + case reflect.Slice, reflect.Array: + out := reflect.MakeSlice(rv.Type(), rv.Len(), rv.Len()) + for i := range rv.Len() { + out.Index(i).Set(applyFuncsReflectCopy(rv.Index(i))) + } + return out + case reflect.Map: + out := reflect.MakeMapWithSize(rv.Type(), rv.Len()) + iter := rv.MapRange() + for iter.Next() { + out.SetMapIndex(iter.Key(), applyFuncsReflectCopy(iter.Value())) + } + return out + } + + return rv +} + +// tryApply tries to parse and apply the func. // // Returns the result of the func execution and indicates whether it should be applied. -func TryApply(fn string) (string, bool) { +func tryApply(fn string) (string, bool) { f, err := parse(fn) if errors.Is(err, errEscaped) { return fn[1:], true diff --git a/internal/app/config/fnc/func_test.go b/internal/app/config/fnc/func_test.go index d319728..0ae1e96 100644 --- a/internal/app/config/fnc/func_test.go +++ b/internal/app/config/fnc/func_test.go @@ -1,3 +1,4 @@ +// nolint:gofmt package fnc import ( @@ -47,7 +48,7 @@ func TestTryApply(t *testing.T) { t.Run(c.name, func(t *testing.T) { t.Parallel() - got, gotStatus := TryApply(c.fn) + got, gotStatus := tryApply(c.fn) require.Equal(t, c.want, got) require.Equal(t, c.wantStatus, gotStatus) }) @@ -108,3 +109,123 @@ func TestParse(t *testing.T) { }) } } + +type testIntf interface { + test() +} + +type testImpl struct { + V string +} + +func (i *testImpl) test() {} + +func TestApplyFuncs(t *testing.T) { + t.Parallel() + + type ( + obj struct { + A int + B string + C *obj + } + + cfg struct { + NonFuncStr string + unsettableStr string + + Str string + PtrStr *string + + Strct obj + Intf testIntf + + Slc []string + SlcPtrs []*string + SlcObjs []obj + + Arr [2]string + ArrPtrs [2]*string + ArrObjs [1]obj + + M map[int]string + MPtrs map[int]*string + MObjs map[int]obj + } + ) + + in := cfg{ + NonFuncStr: "test", + unsettableStr: "__test__(unset, false)", + + Str: "__test__(str, false)", + PtrStr: new(string("__test__(ptrstr, false)")), + + Strct: obj{ + A: 1, + B: "__test__(strct.b, false)", + C: &obj{B: "__test__(strct.c.b, false)"}, + }, + Intf: &testImpl{V: "__test__(intf, false)"}, + + Slc: []string{"test", "__test__(slc[1], false)"}, + SlcPtrs: []*string{new(string("__test__(slcptrs[0], false)")), new(string("test"))}, + SlcObjs: []obj{{B: "__test__(slcobjs[0].b, false)"}}, + + Arr: [2]string{"test", "__test__(arr[1], false)"}, + ArrPtrs: [2]*string{new(string("__test__(arrptrs[0], false)")), new(string("test"))}, + ArrObjs: [1]obj{{B: "__test__(arrobjs[0].b, false)"}}, + + M: map[int]string{ + 1: "__test__(m[1], false)", + 2: "test", + }, + MPtrs: map[int]*string{ + 1: new(string("test")), + 2: new(string("__test__(mptrs[2], false)")), + }, + MObjs: map[int]obj{ + 1: obj{B: "__test__(mobjs[1].b, false)"}, + 2: obj{B: "test"}, + }, + } + + want := cfg{ + NonFuncStr: "test", + unsettableStr: "__test__(unset, false)", + + Str: "str", + PtrStr: new(string("ptrstr")), + + Strct: obj{ + A: 1, + B: "strct.b", + C: &obj{B: "strct.c.b"}, + }, + Intf: &testImpl{V: "intf"}, + + Slc: []string{"test", "slc[1]"}, + SlcPtrs: []*string{new(string("slcptrs[0]")), new(string("test"))}, + SlcObjs: []obj{{B: "slcobjs[0].b"}}, + + Arr: [2]string{"test", "arr[1]"}, + ArrPtrs: [2]*string{new(string("arrptrs[0]")), new(string("test"))}, + ArrObjs: [1]obj{{B: "arrobjs[0].b"}}, + + M: map[int]string{ + 1: "m[1]", + 2: "test", + }, + MPtrs: map[int]*string{ + 1: new(string("test")), + 2: new(string("mptrs[2]")), + }, + MObjs: map[int]obj{ + 1: obj{B: "mobjs[1].b"}, + 2: obj{B: "test"}, + }, + } + + ApplyFuncs(&in) + require.Equal(t, want, in) +}