diff --git a/template/builder.go b/template/builder.go index 9944aab4f93631d849539fa74b1424f30a5aaa46..5a2e08cb430dc6ed7ea36f004e6d57125d7066eb 100644 --- a/template/builder.go +++ b/template/builder.go @@ -1,12 +1,7 @@ package template import ( - "bytes" "context" - html "html/template" - "io" - "maps" - text "text/template" "git.perx.ru/perxis/perxis-go/pkg/collections" "git.perx.ru/perxis/perxis-go/pkg/content" @@ -15,198 +10,138 @@ import ( ) type Template interface { - Execute(w io.Writer, data any) error + // Execute выполняет заданный шаблон pattern с предоставленными данными data и возвращает результат в виде строки. + Execute(pattern string, data ...any) (string, error) + + // ExecuteList выполняет каждый шаблон из списка patterns с предоставленными данными data и возвращает результаты в виде среза строк. + // Каждый шаблон обрабатывается индивидуально. + ExecuteList(patterns []string, data ...any) ([]string, error) + + // ExecuteMap выполняет шаблоны из мапы patternMap с предоставленными данными data и возвращает результаты в виде мапы. + // Шаблоны применяются к значениям мапы, если они являются строками или срезами строк; в противном случае, значения остаются без изменений. + ExecuteMap(patternMap map[string]any, data ...any) (map[string]any, error) } type Builder struct { + conf *BuilderConfig + ctx context.Context - cnt *content.Content - SpaceID string - EnvID string - CollID string data map[string]any + funcMap map[string]any // Для кеширования запросов space *spaces.Space environment *environments.Environment collection *collections.Collection - - template func(templ string) (Template, error) } -func NewBuilder(cnt *content.Content, space, env, coll string) *Builder { - b := &Builder{ - ctx: context.Background(), - cnt: cnt, - SpaceID: space, - EnvID: env, - CollID: coll, - } - b.template = func(templ string) (Template, error) { - t, err := text.New("main"). - Funcs(b.getFuncs()). - Parse(templ) - if err != nil { - return nil, err - } - return t, nil - } - return b -} +type BuilderConfig struct { + // DefaultHTML если true, по умолчанию используется шаблонизатор для HTML данных, + // иначе для текстовых. + DefaultHTML bool -func NewHTMLBuilder(cnt *content.Content, space, env, coll string) *Builder { - b := &Builder{ - ctx: context.Background(), - cnt: cnt, - SpaceID: space, - EnvID: env, - CollID: coll, - } - b.template = func(templ string) (Template, error) { - t, err := html.New("main"). - Funcs(b.getFuncs()). - Parse(templ) - if err != nil { - return nil, err - } - return t, nil - } - return b + Content *content.Content + SpaceID string + EnvironmentID string + CollectionID string } -func (b *Builder) getFuncs() map[string]any { - return map[string]any{ - "lookup": getLookup(b), - "system": getSystem(b), +func NewBuilder(conf *BuilderConfig) *Builder { + if conf == nil { + conf = &BuilderConfig{} } -} -func (b *Builder) WithData(data map[string]any) *Builder { - bld := *b - bld.data = data - return &bld -} - -func (b *Builder) WithKV(kv ...any) *Builder { - bld := *b - if bld.data == nil { - //nolint:mnd // Количество аргументов делится на 2, так как они представлены в виде пар ключ-значение. - bld.data = make(map[string]any, len(kv)/2) + builder := &Builder{ + conf: conf, + ctx: context.Background(), + data: make(map[string]any), } - for i := 0; i < len(kv)-1; i += 2 { - k, _ := kv[i].(string) - v := kv[i+1] - if k != "" && v != nil { - bld.data[k] = v - } + builder.funcMap = map[string]any{ + "lookup": getLookup(builder), + "system": getSystem(builder), } - return &bld -} -func (b *Builder) GetData() map[string]any { - return b.data + return builder } -func (b *Builder) WithSpace(space, env string) *Builder { - bld := *b - bld.SpaceID = space - bld.EnvID = env - return &bld +// TextTemplate возвращает новый шаблонизатор для работы с текстовыми данными. +// При выполнении шаблонов доступны данные и функции из Builder. +func (b *Builder) TextTemplate() Template { + return NewCommonTemplate(false, b.data, b.funcMap) } -func (b *Builder) WithContext(ctx context.Context) *Builder { - bld := *b - bld.ctx = ctx - return &bld +// HTMLTemplate возвращает новый шаблонизатор для работы с HTML данными. +// При выполнении шаблонов доступны данные и функции из Builder. +func (b *Builder) HTMLTemplate() Template { + return NewCommonTemplate(true, b.data, b.funcMap) } func (b *Builder) Context() context.Context { return b.ctx } -func (b *Builder) Template(text string) (Template, error) { - return b.template(text) +func (b *Builder) WithContext(ctx context.Context) *Builder { + clone := *b + clone.ctx = ctx + return &clone } -func (b *Builder) Execute(templ string, data ...any) (string, error) { - t, err := b.Template(templ) - if err != nil { - return "", err - } - buf := new(bytes.Buffer) - if err = t.Execute(buf, b.getData(data...)); err != nil { - return "", err - } - return buf.String(), nil +func (b *Builder) Content() *content.Content { + return b.conf.Content } -func (b *Builder) ExecuteList(templs []string, data ...any) ([]string, error) { - result := make([]string, len(templs)) - buf := new(bytes.Buffer) - d := b.getData(data...) +func (b *Builder) SpaceID() string { + return b.conf.SpaceID +} - for i, templ := range templs { - t, err := b.Template(templ) - if err != nil { - return nil, err - } +func (b *Builder) EnvironmentID() string { + return b.conf.EnvironmentID +} - buf.Reset() - err = t.Execute(buf, d) - if err != nil { - return nil, err - } +func (b *Builder) CollectionID() string { + return b.conf.CollectionID +} - result[i] = buf.String() - } +// WithData возвращает копию Builder с новыми переданными данными. +func (b *Builder) WithData(data map[string]any) *Builder { + clone := *b + clone.data = data + return &clone +} - return result, nil -} - -func (b *Builder) ExecuteMap(templMap map[string]any, data ...any) (map[string]any, error) { - result := make(map[string]any, len(templMap)) - d := b.getData(data...) - - for k, v := range templMap { - switch t := v.(type) { - case string: - value, err := b.Execute(t, d) - if err != nil { - return nil, err - } - result[k] = value - case []string: - var err error - result[k], err = b.ExecuteList(t, d) - if err != nil { - return nil, err - } - default: - result[k] = v +func (b *Builder) WithKV(kv ...any) *Builder { + clone := *b + if clone.data == nil { + //nolint:mnd // Количество аргументов делится на 2, так как они представлены в виде пар ключ-значение. + clone.data = make(map[string]any, len(kv)/2) + } + for i := 0; i < len(kv)-1; i += 2 { + k, _ := kv[i].(string) + v := kv[i+1] + if k != "" && v != nil { + clone.data[k] = v } } - - return result, nil + return &clone } -func (b *Builder) getData(data ...any) any { - if len(data) == 0 { - return b.data +func (b *Builder) Execute(pattern string, data ...any) (string, error) { + if b.conf.DefaultHTML { + return b.HTMLTemplate().Execute(pattern, data...) } + return b.TextTemplate().Execute(pattern, data...) +} - res := maps.Clone(b.data) - if res == nil { - res = make(map[string]any) - } - for _, v := range data { - if m, ok := v.(map[string]any); ok { - maps.Copy(res, m) - } +func (b *Builder) ExecuteList(patterns []string, data ...any) ([]string, error) { + if b.conf.DefaultHTML { + return b.HTMLTemplate().ExecuteList(patterns, data...) } + return b.TextTemplate().ExecuteList(patterns, data...) +} - if len(res) == 0 { - return data[0] +func (b *Builder) ExecuteMap(patternMap map[string]any, data ...any) (map[string]any, error) { + if b.conf.DefaultHTML { + return b.HTMLTemplate().ExecuteMap(patternMap, data...) } - - return res + return b.TextTemplate().ExecuteMap(patternMap, data...) } diff --git a/template/builder_test.go b/template/builder_test.go index 48fdb2cb0826c6d392eaed0cb725f7ee5a6651df..2cd640cd8525671126e84ccfd262a5b19a918364 100644 --- a/template/builder_test.go +++ b/template/builder_test.go @@ -1,582 +1,196 @@ package template import ( - "context" "testing" + "git.perx.ru/perxis/perxis-go/pkg/collections" + collectionsmocks "git.perx.ru/perxis/perxis-go/pkg/collections/mocks" "git.perx.ru/perxis/perxis-go/pkg/content" - "git.perx.ru/perxis/perxis-go/pkg/errors" + "git.perx.ru/perxis/perxis-go/pkg/environments" + environmentsmocks "git.perx.ru/perxis/perxis-go/pkg/environments/mocks" "git.perx.ru/perxis/perxis-go/pkg/items" - mocksitems "git.perx.ru/perxis/perxis-go/pkg/items/mocks" + itemsmocks "git.perx.ru/perxis/perxis-go/pkg/items/mocks" + "git.perx.ru/perxis/perxis-go/pkg/spaces" + spacesmocks "git.perx.ru/perxis/perxis-go/pkg/spaces/mocks" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) -func TestBuilder_Execute(t *testing.T) { - tests := []struct { - name string - SpaceID string - EnvID string - CollID string - template string - data any - want any - htmlBuilder bool - wantErr bool +func TestBuilder(t *testing.T) { + t.Run("using default HTML template", func(t *testing.T) { + builder := NewBuilder(&BuilderConfig{ + DefaultHTML: true, + }).WithData(map[string]any{ + "foo": "<b>bar</b>", + }) - getCnt func(t *testing.T) *content.Content + got, err := builder.Execute("{{ .foo }}") + require.NoError(t, err) + assert.Equal(t, "<b>bar</b>", got) + }) +} + +func TestBuilder_Funcs(t *testing.T) { + for _, tc := range []struct { + name string + pattern string + setupContent func(t *testing.T) *content.Content + spaceID string + environmentID string + collectionID string + want string + assertError assert.ErrorAssertionFunc }{ { - name: "invalid data", - template: "hello {{ .a }}", - data: "world", - want: "", - wantErr: true, - }, - { - name: "invalid template", - template: "hello {{ . ", - data: "world", - want: "", - wantErr: true, - }, - { - name: "empty template", - template: "", - data: "world", - want: "", - wantErr: false, - }, - { - name: "empty data", - template: "{{ . }}", - data: "", - want: "", - wantErr: false, - }, - { - name: "success", - template: "hello {{ . }}", - data: "world", - want: "hello world", - wantErr: false, - }, - { - name: "success with html builder", - template: "<span class=\"comment\">{{ . }}</span>", - data: "<script>alert(localStorage.getItem('secret'))</script>", - want: "<span class=\"comment\">" + - "<script>alert(localStorage.getItem('secret'))</script>" + - "</span>", - htmlBuilder: true, - wantErr: false, - }, - { - name: "template as plain text", - template: "hello", - data: "world", - want: "hello", - wantErr: false, - }, - { - name: "lookup function", - SpaceID: "space", - EnvID: "env", - template: "hello, {{ lookup \"secrets.dev.key\" }}", - data: "", - want: "hello, Luk", - wantErr: false, - getCnt: func(t *testing.T) *content.Content { - itemsSvc := mocksitems.NewItems(t) - itemsSvc.On("Get", context.Background(), "space", "env", "secrets", "dev"). + name: "basic lookup", + pattern: "{{ lookup \"coll_id.item_id.field\" }}", + setupContent: func(t *testing.T) *content.Content { + itemsService := itemsmocks.NewItems(t) + itemsService.On("Get", mock.Anything, "space_id", "env_id", "coll_id", "item_id"). Return(&items.Item{ - ID: "dev", - SpaceID: "space", - EnvID: "env", - CollectionID: "secrets", Data: map[string]any{ - "id": "dev", - "key": "Luk", + "field": "value", }, - }, nil).Once() - return &content.Content{Items: itemsSvc} - }, - }, - { - name: "lookup function with slice", - SpaceID: "space", - EnvID: "env", - template: "numbers {{ lookup \"secrets.dev.slice\" }}", - data: "", - want: "numbers [1 2 3]", - wantErr: false, - getCnt: func(t *testing.T) *content.Content { - itemsSvc := mocksitems.NewItems(t) - itemsSvc.On("Get", context.Background(), "space", "env", "secrets", "dev"). + }, nil). + Once() + return &content.Content{ + Items: itemsService, + } + }, + spaceID: "space_id", + environmentID: "env_id", + want: "value", + assertError: assert.NoError, + }, + { + name: "lookup with unknown field", + pattern: "{{ lookup \"coll_id.item_id.unknown\" }}", + setupContent: func(t *testing.T) *content.Content { + itemsService := itemsmocks.NewItems(t) + itemsService.On("Get", mock.Anything, "space_id", "env_id", "coll_id", "item_id"). Return(&items.Item{ - ID: "dev", - SpaceID: "space", - EnvID: "env", - CollectionID: "secrets", Data: map[string]any{ - "id": "dev", - "slice": []int{1, 2, 3}, + "field": "value", }, - }, nil).Once() - return &content.Content{Items: itemsSvc} - }, - }, - { - name: "lookup function with unknown field", - SpaceID: "space", - EnvID: "env", - template: "numbers {{ lookup \"secrets.dev.slice\" }}", - data: "", - want: "numbers <no value>", - wantErr: false, - getCnt: func(t *testing.T) *content.Content { - itemsSvc := mocksitems.NewItems(t) - itemsSvc.On("Get", context.Background(), "space", "env", "secrets", "dev"). - Return(&items.Item{ - ID: "dev", - SpaceID: "space", - EnvID: "env", - CollectionID: "secrets", - Data: map[string]any{}, - }, nil).Once() - return &content.Content{Items: itemsSvc} - }, - }, - { - name: "lookup function not found item", - SpaceID: "space", - EnvID: "env", - template: "hello {{ lookup \"secrets.prod.pass\" }}", - data: "", - want: "", - wantErr: true, - getCnt: func(t *testing.T) *content.Content { - itemsSvc := mocksitems.NewItems(t) - itemsSvc.On("Get", context.Background(), "space", "env", "secrets", "prod"). - Return(nil, errors.New("not found")).Once() - return &content.Content{Items: itemsSvc} - }, - }, - { - name: "system function", - SpaceID: "space", - template: "hello {{ system.SpaceID }}", - data: "", - want: "hello space", - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var cnt *content.Content - if tt.getCnt != nil { - cnt = tt.getCnt(t) - } - - var b *Builder - if tt.htmlBuilder { - b = NewHTMLBuilder(cnt, tt.SpaceID, tt.EnvID, tt.CollID) - } else { - b = NewBuilder(cnt, tt.SpaceID, tt.EnvID, tt.CollID) - } - got, err := b.Execute(tt.template, tt.data) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tt.want, got) - }) - } -} - -func TestBuilder_ExecuteList(t *testing.T) { - tests := []struct { - name string - templs []string - data any - want []string - htmlBuilder bool - assertError assert.ErrorAssertionFunc - - getCnt func(t *testing.T) *content.Content - }{ - { - name: "invalid data", - templs: []string{ - "hello {{ .a }}", - }, - data: "world", - want: nil, - assertError: assert.Error, - }, - { - name: "invalid template", - templs: []string{ - "hello {{ . ", - }, - data: "world", - want: nil, - assertError: assert.Error, - }, - { - name: "empty template", - templs: []string{ - "", - }, - data: "world", - want: []string{ - "", - }, - assertError: assert.NoError, - }, - { - name: "empty data", - templs: []string{ - "{{ . }}", - }, - data: "", - want: []string{ - "", - }, - assertError: assert.NoError, - }, - { - name: "success", - templs: []string{ - "hello {{ . }}", - "world {{ . }}", - }, - data: "world", - want: []string{ - "hello world", - "world world", - }, - assertError: assert.NoError, - }, - { - name: "success with html builder", - templs: []string{ - "<span class=\"comment\">{{ . }}</span>", - }, - data: "<script>alert(localStorage.getItem('secret'))</script>", - want: []string{ - "<span class=\"comment\">" + - "<script>alert(localStorage.getItem('secret'))</script>" + - "</span>", - }, - htmlBuilder: true, - assertError: assert.NoError, - }, - { - name: "template as plain text", - templs: []string{ - "hello", - }, - data: "world", - want: []string{ - "hello", - }, + }, nil). + Once() + return &content.Content{ + Items: itemsService, + } + }, + spaceID: "space_id", + environmentID: "env_id", + want: "<no value>", + assertError: assert.NoError, + }, + { + name: "invalid lookup", + pattern: "{{ lookup \"coll_id.item_id\" }}", + setupContent: func(t *testing.T) *content.Content { + itemsService := itemsmocks.NewItems(t) + return &content.Content{ + Items: itemsService, + } + }, + spaceID: "space_id", + environmentID: "env_id", + want: "", + assertError: assert.Error, + }, + { + name: "system space id", + pattern: "{{ system.SpaceID }}", + spaceID: "space_id", + want: "space_id", assertError: assert.NoError, }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var cnt *content.Content - if tt.getCnt != nil { - cnt = tt.getCnt(t) - } - - var b *Builder - if tt.htmlBuilder { - b = NewHTMLBuilder(cnt, "space_id", "env_id", "coll_id") - } else { - b = NewBuilder(cnt, "space_id", "env_id", "coll_id") - } - - got, err := b.ExecuteList(tt.templs, tt.data) - tt.assertError(t, err) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestBuilder_ExecuteMap(t *testing.T) { - tests := []struct { - name string - SpaceID string - EnvID string - templateMap map[string]any - data any - want map[string]any - wantErr bool - - itemsCall func(itemsSvc *mocksitems.Items) - }{ - { - name: "invalid template", - SpaceID: "space", - EnvID: "env", - templateMap: map[string]any{"hello": "{{ . }"}, - data: "world", - want: nil, - wantErr: true, - }, - { - name: "empty data", - SpaceID: "space", - EnvID: "env", - templateMap: map[string]any{}, - data: "", - want: map[string]any{}, - wantErr: false, - }, - { - name: "one template", - SpaceID: "space", - EnvID: "env", - templateMap: map[string]any{ - "hello": "{{ . }}", - }, - data: "world", - want: map[string]any{ - "hello": "world", - }, - wantErr: false, - }, - { - name: "one template with many value templates", - SpaceID: "space", - EnvID: "env", - templateMap: map[string]any{ - "hello": []string{ - "{{ .foo }}", - "{{ .bar }}", - }, - }, - data: map[string]any{ - "foo": "hello", - "bar": "world", - }, - want: map[string]any{ - "hello": []string{ - "hello", - "world", - }, - }, - wantErr: false, - }, - { - name: "many template", - SpaceID: "space", - EnvID: "env", - templateMap: map[string]any{ - "hello": "{{ . }}", - "go": "{{ . }}", - }, - data: "world", - want: map[string]any{ - "hello": "world", - "go": "world", - }, - wantErr: false, - }, - { - name: "template in key with one value", - SpaceID: "space", - EnvID: "env", - templateMap: map[string]any{ - "{{ .key }}": "value", - }, - data: map[string]any{ - "key": "foo", - }, - want: map[string]any{ - "{{ .key }}": "value", - }, - wantErr: false, - }, { - name: "template in key with many values", - SpaceID: "space", - EnvID: "env", - templateMap: map[string]any{ - "{{ .key }}": []string{ - "value", - }, - }, - data: map[string]any{ - "key": "foo", - }, - want: map[string]any{ - "{{ .key }}": []string{ - "value", - }, - }, - wantErr: false, - }, - { - name: "template as plain text", - SpaceID: "space", - EnvID: "env", - templateMap: map[string]any{ - "a": "b", - }, - data: "world", - want: map[string]any{ - "a": "b", - }, - wantErr: false, - }, - { - name: "template is not template", - SpaceID: "space", - EnvID: "env", - templateMap: map[string]any{ - "a": "b", - "b": []int{1, 2, 3}, - }, - data: "world", - want: map[string]any{ - "a": "b", - "b": []int{1, 2, 3}, - }, - wantErr: false, - }, - { - name: "lookup function", - SpaceID: "space", - EnvID: "env", - templateMap: map[string]any{ - "hello": "{{ lookup \"secrets.dev.key\" }}", - }, - want: map[string]any{ - "hello": "1234", - }, - wantErr: false, - itemsCall: func(itemsSvc *mocksitems.Items) { - itemsSvc.On("Get", context.Background(), "space", "env", "secrets", "dev").Return(&items.Item{ - ID: "dev", - SpaceID: "space", - EnvID: "env", - CollectionID: "secrets", - Data: map[string]any{ - "id": "dev", - "key": "1234", - }, - }, nil).Once() - }, - }, - { - name: "lookup function with unknown field", - SpaceID: "space", - EnvID: "env", - templateMap: map[string]any{"hello": "{{ lookup \"secrets.dev.incorrect\" }}"}, - want: map[string]any{"hello": "<no value>"}, - wantErr: false, - itemsCall: func(itemsSvc *mocksitems.Items) { - itemsSvc.On("Get", context.Background(), "space", "env", "secrets", "dev").Return(&items.Item{ - ID: "dev", - SpaceID: "space", - EnvID: "env", - CollectionID: "secrets", - Data: map[string]any{ - "id": "dev", - "key": "1234", - }, - }, nil).Once() - }, - }, - { - name: "system function", - SpaceID: "space", - EnvID: "env", - templateMap: map[string]any{ - "hello": "{{ system.SpaceID }}", - }, - want: map[string]any{ - "hello": "space", - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - itemsSvc := &mocksitems.Items{} - if tt.itemsCall != nil { - tt.itemsCall(itemsSvc) + name: "field from system space", + pattern: "{{ system.Space.Name }}", + setupContent: func(t *testing.T) *content.Content { + spacesService := spacesmocks.NewSpaces(t) + spacesService.On("Get", mock.Anything, "space_id"). + Return(&spaces.Space{ + Name: "name", + }, nil). + Once() + return &content.Content{ + Spaces: spacesService, + } + }, + spaceID: "space_id", + environmentID: "env_id", + want: "name", + assertError: assert.NoError, + }, + { + name: "system environment id", + pattern: "{{ system.EnvID }}", + environmentID: "env_id", + want: "env_id", + assertError: assert.NoError, + }, + { + name: "field from system environment", + pattern: "{{ system.Environment.ID }}", + setupContent: func(t *testing.T) *content.Content { + environmentsService := environmentsmocks.NewEnvironments(t) + environmentsService.On("Get", mock.Anything, "space_id", "env_id"). + Return(&environments.Environment{ + ID: "env_id", + }, nil). + Once() + return &content.Content{ + Environments: environmentsService, + } + }, + spaceID: "space_id", + environmentID: "env_id", + want: "env_id", + assertError: assert.NoError, + }, + { + name: "system collection id", + pattern: "{{ system.CollectionID }}", + collectionID: "coll_id", + want: "coll_id", + assertError: assert.NoError, + }, + { + name: "field from system collection", + pattern: "{{ system.Collection.ID }}", + setupContent: func(t *testing.T) *content.Content { + collectionsService := collectionsmocks.NewCollections(t) + collectionsService.On("Get", mock.Anything, "space_id", "env_id", "coll_id"). + Return(&collections.Collection{ + ID: "coll_id", + }, nil). + Once() + return &content.Content{ + Collections: collectionsService, + } + }, + spaceID: "space_id", + environmentID: "env_id", + collectionID: "coll_id", + want: "coll_id", + assertError: assert.NoError, + }, + } { + t.Run(tc.name, func(t *testing.T) { + conf := &BuilderConfig{ + SpaceID: tc.spaceID, + EnvironmentID: tc.environmentID, + CollectionID: tc.collectionID, } - b := NewBuilder(&content.Content{Items: itemsSvc}, tt.SpaceID, tt.EnvID, "") - - got, err := b.ExecuteMap(tt.templateMap, tt.data) - if tt.wantErr == true { - assert.Error(t, err) - } else { - assert.NoError(t, err) + if tc.setupContent != nil { + conf.Content = tc.setupContent(t) } - assert.Equal(t, tt.want, got) - itemsSvc.AssertExpectations(t) + builder := NewBuilder(conf) + got, err := builder.TextTemplate().Execute(tc.pattern) + tc.assertError(t, err) + assert.Equal(t, tc.want, got) }) } } - -func TestBuilder_getData(t *testing.T) { - t.Run("empty data", func(t *testing.T) { - builder := NewBuilder(nil, "space_id", "env_id", "coll_id") - assert.Equal(t, - map[string]any(nil), - builder.getData(), - ) - }) - t.Run("with data", func(t *testing.T) { - builder := NewBuilder(nil, "space_id", "env_id", "coll_id"). - WithData(map[string]any{ - "foo": "bar", - }) - assert.Equal(t, - map[string]any{ - "foo": "bar", - }, - builder.getData(), - ) - }) - t.Run("merge data", func(t *testing.T) { - builder := NewBuilder(nil, "space_id", "env_id", "coll_id") - assert.Equal(t, - map[string]any{ - "a": 1, - "b": 2, - }, - builder.getData( - map[string]any{}, - map[string]any{"a": 1}, - map[string]any{"b": 2}, - []string{"a", "b"}, - "string", - ), - ) - }) - t.Run("merge with original data", func(t *testing.T) { - builder := NewBuilder(nil, "space_id", "env_id", "coll_id"). - WithData(map[string]any{ - "foo": "bar", - "baz": "qux", - }) - assert.Equal(t, - map[string]any{ - "a": 1, - "b": 2, - "foo": "bar", - "baz": "qux", - }, - builder.getData( - map[string]any{"a": 1}, - map[string]any{"b": 2}, - ), - ) - }) -} diff --git a/template/funcs.go b/template/funcs.go index 55eab67682f799ca671e1650ebc6a45546722c20..659b9c9dde6d2c5e3789dd71d7466614035c5f23 100644 --- a/template/funcs.go +++ b/template/funcs.go @@ -19,7 +19,7 @@ func getLookup(b *Builder) func(string) (any, error) { collectionID := parsedName[0] itemID := parsedName[1] field := parsedName[2] - item, err := b.cnt.Items.Get(b.Context(), b.SpaceID, b.EnvID, collectionID, itemID) + item, err := b.Content().Items.Get(b.Context(), b.SpaceID(), b.EnvironmentID(), collectionID, itemID) if err != nil { return nil, errors.Wrapf(err, "failed to get \"%s\"") } diff --git a/template/funcs_test.go b/template/funcs_test.go index af4a7717328d8d8de3f3426136dfb1d91f0ba135..931d1fbd4d80f2022f2bbec3085f83c2911c4f32 100644 --- a/template/funcs_test.go +++ b/template/funcs_test.go @@ -85,7 +85,12 @@ func Test_getLookup(t *testing.T) { if tc.setup != nil { tc.setup(svc) } - b := NewBuilder(&content.Content{Items: svc}, "space_id", "env_id", "coll_id") + b := NewBuilder(&BuilderConfig{ + Content: &content.Content{Items: svc}, + SpaceID: "space_id", + EnvironmentID: "env_id", + CollectionID: "coll_id", + }) lookup := getLookup(b) got, err := lookup(tc.input) @@ -100,11 +105,16 @@ func Test_getSystem(t *testing.T) { spacesSvc := mocksspaces.NewSpaces(t) envsSvc := mocksenvs.NewEnvironments(t) collsSvc := mockscolls.NewCollections(t) - b := NewBuilder(&content.Content{ - Spaces: spacesSvc, - Environments: envsSvc, - Collections: collsSvc, - }, "space_id", "env_id", "coll_id") + b := NewBuilder(&BuilderConfig{ + Content: &content.Content{ + Spaces: spacesSvc, + Environments: envsSvc, + Collections: collsSvc, + }, + SpaceID: "space_id", + EnvironmentID: "env_id", + CollectionID: "coll_id", + }) system := getSystem(b) assert.Equal(t, "space_id", system().SpaceID()) }) @@ -112,11 +122,16 @@ func Test_getSystem(t *testing.T) { spacesSvc := mocksspaces.NewSpaces(t) envsSvc := mocksenvs.NewEnvironments(t) collsSvc := mockscolls.NewCollections(t) - b := NewBuilder(&content.Content{ - Spaces: spacesSvc, - Environments: envsSvc, - Collections: collsSvc, - }, "space_id", "env_id", "coll_id") + b := NewBuilder(&BuilderConfig{ + Content: &content.Content{ + Spaces: spacesSvc, + Environments: envsSvc, + Collections: collsSvc, + }, + SpaceID: "space_id", + EnvironmentID: "env_id", + CollectionID: "coll_id", + }) system := getSystem(b) assert.Equal(t, "env_id", system().EnvID()) }) @@ -124,11 +139,16 @@ func Test_getSystem(t *testing.T) { spacesSvc := mocksspaces.NewSpaces(t) envsSvc := mocksenvs.NewEnvironments(t) collsSvc := mockscolls.NewCollections(t) - b := NewBuilder(&content.Content{ - Spaces: spacesSvc, - Environments: envsSvc, - Collections: collsSvc, - }, "space_id", "env_id", "coll_id") + b := NewBuilder(&BuilderConfig{ + Content: &content.Content{ + Spaces: spacesSvc, + Environments: envsSvc, + Collections: collsSvc, + }, + SpaceID: "space_id", + EnvironmentID: "env_id", + CollectionID: "coll_id", + }) system := getSystem(b) assert.Equal(t, "coll_id", system().CollectionID()) }) @@ -144,11 +164,16 @@ func Test_getSystem(t *testing.T) { Once() envsSvc := mocksenvs.NewEnvironments(t) collsSvc := mockscolls.NewCollections(t) - b := NewBuilder(&content.Content{ - Spaces: spacesSvc, - Environments: envsSvc, - Collections: collsSvc, - }, "space_id", "env_id", "coll_id") + b := NewBuilder(&BuilderConfig{ + Content: &content.Content{ + Spaces: spacesSvc, + Environments: envsSvc, + Collections: collsSvc, + }, + SpaceID: "space_id", + EnvironmentID: "env_id", + CollectionID: "coll_id", + }) system := getSystem(b) got, err := system().Space() require.NoError(t, err) @@ -165,11 +190,16 @@ func Test_getSystem(t *testing.T) { Once() envsSvc := mocksenvs.NewEnvironments(t) collsSvc := mockscolls.NewCollections(t) - b := NewBuilder(&content.Content{ - Spaces: spacesSvc, - Environments: envsSvc, - Collections: collsSvc, - }, "space_id", "env_id", "coll_id") + b := NewBuilder(&BuilderConfig{ + Content: &content.Content{ + Spaces: spacesSvc, + Environments: envsSvc, + Collections: collsSvc, + }, + SpaceID: "space_id", + EnvironmentID: "env_id", + CollectionID: "coll_id", + }) system := getSystem(b) got, err := system().Space() require.Error(t, err) @@ -187,11 +217,16 @@ func Test_getSystem(t *testing.T) { }, nil). Once() collsSvc := mockscolls.NewCollections(t) - b := NewBuilder(&content.Content{ - Spaces: spacesSvc, - Environments: envsSvc, - Collections: collsSvc, - }, "space_id", "env_id", "coll_id") + b := NewBuilder(&BuilderConfig{ + Content: &content.Content{ + Spaces: spacesSvc, + Environments: envsSvc, + Collections: collsSvc, + }, + SpaceID: "space_id", + EnvironmentID: "env_id", + CollectionID: "coll_id", + }) system := getSystem(b) got, err := system().Environment() require.NoError(t, err) @@ -207,11 +242,16 @@ func Test_getSystem(t *testing.T) { Return(nil, errors.New("some error")). Once() collsSvc := mockscolls.NewCollections(t) - b := NewBuilder(&content.Content{ - Spaces: spacesSvc, - Environments: envsSvc, - Collections: collsSvc, - }, "space_id", "env_id", "coll_id") + b := NewBuilder(&BuilderConfig{ + Content: &content.Content{ + Spaces: spacesSvc, + Environments: envsSvc, + Collections: collsSvc, + }, + SpaceID: "space_id", + EnvironmentID: "env_id", + CollectionID: "coll_id", + }) system := getSystem(b) got, err := system().Environment() require.Error(t, err) @@ -231,11 +271,16 @@ func Test_getSystem(t *testing.T) { Name: "Collection", }, nil). Once() - b := NewBuilder(&content.Content{ - Spaces: spacesSvc, - Environments: envsSvc, - Collections: collsSvc, - }, "space_id", "env_id", "coll_id") + b := NewBuilder(&BuilderConfig{ + Content: &content.Content{ + Spaces: spacesSvc, + Environments: envsSvc, + Collections: collsSvc, + }, + SpaceID: "space_id", + EnvironmentID: "env_id", + CollectionID: "coll_id", + }) system := getSystem(b) got, err := system().Collection() require.NoError(t, err) @@ -253,11 +298,16 @@ func Test_getSystem(t *testing.T) { collsSvc.On("Get", mock.Anything, "space_id", "env_id", "coll_id"). Return(nil, errors.New("some error")). Once() - b := NewBuilder(&content.Content{ - Spaces: spacesSvc, - Environments: envsSvc, - Collections: collsSvc, - }, "space_id", "env_id", "coll_id") + b := NewBuilder(&BuilderConfig{ + Content: &content.Content{ + Spaces: spacesSvc, + Environments: envsSvc, + Collections: collsSvc, + }, + SpaceID: "space_id", + EnvironmentID: "env_id", + CollectionID: "coll_id", + }) system := getSystem(b) got, err := system().Collection() require.Error(t, err) diff --git a/template/system.go b/template/system.go index c7dda43f08c852f590cd7cfa16ec00709423c7a3..38ad1a693f758259e516ea749476f382d7eb1f22 100644 --- a/template/system.go +++ b/template/system.go @@ -10,44 +10,45 @@ type System struct { builder *Builder } -func (s *System) SpaceID() string { - return s.builder.SpaceID -} - -func (s *System) EnvID() string { - return s.builder.EnvID -} - -func (s *System) CollectionID() string { - return s.builder.CollID -} - func (s *System) Space() (*spaces.Space, error) { if s.builder.space != nil { return s.builder.space, nil } - space, err := s.builder.cnt.Spaces.Get(s.builder.ctx, s.builder.SpaceID) + space, err := s.builder.Content().Spaces.Get(s.builder.Context(), s.builder.SpaceID()) s.builder.space = space return s.builder.space, err } +func (s *System) SpaceID() string { + return s.builder.SpaceID() +} + func (s *System) Environment() (*environments.Environment, error) { if s.builder.environment != nil { return s.builder.environment, nil } - env, err := s.builder.cnt.Environments.Get(s.builder.ctx, s.builder.SpaceID, s.builder.EnvID) + env, err := s.builder.Content().Environments.Get(s.builder.ctx, s.builder.SpaceID(), s.builder.EnvironmentID()) s.builder.environment = env return s.builder.environment, err } +func (s *System) EnvID() string { + return s.builder.EnvironmentID() +} + func (s *System) Collection() (*collections.Collection, error) { if s.builder.collection != nil { return s.builder.collection, nil } - coll, err := s.builder.cnt.Collections.Get(s.builder.ctx, s.builder.SpaceID, s.builder.EnvID, s.builder.CollID) + coll, err := s.builder.Content().Collections.Get(s.builder.ctx, s.builder.SpaceID(), + s.builder.EnvironmentID(), s.builder.CollectionID()) s.builder.collection = coll return s.builder.collection, err } + +func (s *System) CollectionID() string { + return s.builder.CollectionID() +} diff --git a/template/template.go b/template/template.go new file mode 100644 index 0000000000000000000000000000000000000000..74e6c7d6dc89acdadc4f46e52aff091fa78ff839 --- /dev/null +++ b/template/template.go @@ -0,0 +1,114 @@ +package template + +import ( + "bytes" + html "html/template" + "io" + "maps" + text "text/template" +) + +func NewCommonTemplate(html bool, data map[string]any, funcMap map[string]any) *CommonTemplate { + return &CommonTemplate{ + html: html, + data: data, + funcMap: funcMap, + } +} + +type CommonTemplate struct { + html bool + data map[string]any + funcMap map[string]any +} + +func (t *CommonTemplate) Execute(pattern string, data ...any) (string, error) { + return t.execute(pattern, t.getData(data...)) +} + +func (t *CommonTemplate) ExecuteList(patterns []string, data ...any) ([]string, error) { + return t.executeList(patterns, t.getData(data...)) +} + +func (t *CommonTemplate) ExecuteMap(patternMap map[string]any, data ...any) (map[string]any, error) { + result := make(map[string]any, len(patternMap)) + d := t.getData(data...) + + for key, value := range patternMap { + var err error + switch v := value.(type) { + case string: + result[key], err = t.execute(v, d) + if err != nil { + return nil, err + } + case []string: + result[key], err = t.executeList(v, d) + if err != nil { + return nil, err + } + default: + result[key] = value + } + } + + return result, nil +} + +func (t *CommonTemplate) execute(pattern string, data any) (string, error) { + var ( + exec interface { + Execute(w io.Writer, data any) error + } + err error + ) + if t.html { + exec, err = html.New("main").Funcs(t.funcMap).Parse(pattern) + } else { + exec, err = text.New("main").Funcs(t.funcMap).Parse(pattern) + } + if err != nil { + return "", err + } + + var buf bytes.Buffer + err = exec.Execute(&buf, data) + if err != nil { + return "", err + } + + return buf.String(), nil +} + +func (t *CommonTemplate) executeList(patterns []string, data any) ([]string, error) { + result := make([]string, len(patterns)) + + var err error + for i, pattern := range patterns { + result[i], err = t.execute(pattern, data) + if err != nil { + return nil, err + } + } + + return result, nil +} + +func (t *CommonTemplate) getData(data ...any) any { + if len(data) == 0 { + return t.data + } + + m, ok := data[0].(map[string]any) + if !ok { + return data[0] + } + + result := maps.Clone(t.data) + if result == nil { + result = make(map[string]any) + } + maps.Copy(result, m) + + return result +} diff --git a/template/template_test.go b/template/template_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e54720369b20996b750a76c4205a6067ba21f258 --- /dev/null +++ b/template/template_test.go @@ -0,0 +1,452 @@ +package template + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCommonTemplate_Execute(t *testing.T) { + for _, tc := range []struct { + name string + pattern string + data map[string]any + additionalData []any + funcMap map[string]any + wantText string + wantHTML string + assertError assert.ErrorAssertionFunc + }{ + { + name: "basic", + pattern: "{{ .foo }}", + data: map[string]any{ + "foo": "bar", + }, + wantText: "bar", + wantHTML: "bar", + assertError: assert.NoError, + }, + { + name: "additional data", + pattern: "{{ . }}", + additionalData: []any{ + "bar", + }, + wantText: "bar", + wantHTML: "bar", + assertError: assert.NoError, + }, + { + name: "map in additional data", + pattern: "{{ .foo }}", + additionalData: []any{ + map[string]any{ + "foo": "bar", + }, + }, + wantText: "bar", + wantHTML: "bar", + assertError: assert.NoError, + }, + { + name: "html in additional data", + pattern: "{{ . }}", + additionalData: []any{ + "<a href=\"https://example.com/\">Link</a>", + }, + wantText: "<a href=\"https://example.com/\">Link</a>", + wantHTML: "<a href="https://example.com/">Link</a>", + assertError: assert.NoError, + }, + { + name: "empty pattern", + pattern: "", + wantText: "", + wantHTML: "", + assertError: assert.NoError, + }, + { + name: "plain text", + pattern: "text", + wantText: "text", + wantHTML: "text", + assertError: assert.NoError, + }, + { + name: "func map", + pattern: "{{ foo }}", + funcMap: map[string]any{ + "foo": func() string { + return "bar" + }, + }, + wantText: "bar", + wantHTML: "bar", + assertError: assert.NoError, + }, + { + name: "data and additional data", + pattern: "{{ .foo }},{{ .bar }}", + data: map[string]any{ + "foo": "foo", + }, + additionalData: []any{ + map[string]any{ + "bar": "bar", + }, + }, + wantText: "foo,bar", + wantHTML: "foo,bar", + assertError: assert.NoError, + }, + { + name: "invalid pattern", + pattern: "{{ .", + wantText: "", + wantHTML: "", + assertError: assert.Error, + }, + { + name: "no value", + pattern: "{{ .foo }}", + wantText: "<no value>", + wantHTML: "", + assertError: assert.NoError, + }, + } { + t.Run(tc.name, func(t *testing.T) { + for _, enc := range []struct { + name string + template *CommonTemplate + want string + }{ + { + name: "text", + template: NewCommonTemplate(false, tc.data, tc.funcMap), + want: tc.wantText, + }, + { + name: "html", + template: NewCommonTemplate(true, tc.data, tc.funcMap), + want: tc.wantHTML, + }, + } { + t.Run(enc.name, func(t *testing.T) { + got, err := enc.template.Execute(tc.pattern, tc.additionalData...) + tc.assertError(t, err) + assert.Equal(t, enc.want, got) + }) + } + }) + } +} + +func TestCommonTemplate_ExecuteList(t *testing.T) { + for _, tc := range []struct { + name string + patterns []string + data map[string]any + additionalData []any + funcMap map[string]any + wantText []string + wantHTML []string + assertError assert.ErrorAssertionFunc + }{ + { + name: "basic", + patterns: []string{"{{ .foo }}", "{{ .bar }}"}, + data: map[string]any{ + "foo": "hello", + "bar": "world", + }, + wantText: []string{"hello", "world"}, + wantHTML: []string{"hello", "world"}, + assertError: assert.NoError, + }, + { + name: "additional data", + patterns: []string{"{{ . }}"}, + additionalData: []any{ + "test", + }, + wantText: []string{"test"}, + wantHTML: []string{"test"}, + assertError: assert.NoError, + }, + { + name: "map in additional data", + patterns: []string{"{{ .foo }}", "{{ .bar }}"}, + additionalData: []any{ + map[string]any{ + "foo": "value1", + "bar": "value2", + }, + }, + wantText: []string{"value1", "value2"}, + wantHTML: []string{"value1", "value2"}, + assertError: assert.NoError, + }, + { + name: "html escaping", + patterns: []string{"{{ . }}"}, + additionalData: []any{ + "<script>alert('secret')</script>", + }, + wantText: []string{"<script>alert('secret')</script>"}, + wantHTML: []string{"<script>alert('secret')</script>"}, + assertError: assert.NoError, + }, + { + name: "empty list", + patterns: []string{}, + wantText: []string{}, + wantHTML: []string{}, + assertError: assert.NoError, + }, + { + name: "single plain text", + patterns: []string{"just text"}, + wantText: []string{"just text"}, + wantHTML: []string{"just text"}, + assertError: assert.NoError, + }, + { + name: "func map", + patterns: []string{"{{ greet }}", "{{ 42 | add }}"}, + funcMap: map[string]any{ + "greet": func() string { return "hello" }, + "add": func(i int) int { return i + 1 }, + }, + wantText: []string{"hello", "43"}, + wantHTML: []string{"hello", "43"}, + assertError: assert.NoError, + }, + { + name: "data merging", + patterns: []string{"{{ .foo }}", "{{ .bar }}"}, + data: map[string]any{ + "foo": "base", + }, + additionalData: []any{ + map[string]any{ + "bar": "extra", + }, + }, + wantText: []string{"base", "extra"}, + wantHTML: []string{"base", "extra"}, + assertError: assert.NoError, + }, + { + name: "invalid pattern", + patterns: []string{"valid", "{{ .invalid "}, + wantText: []string(nil), + wantHTML: []string(nil), + assertError: assert.Error, + }, + { + name: "unknown value", + patterns: []string{"{{ .unknown }}"}, + wantText: []string{"<no value>"}, + wantHTML: []string{""}, + assertError: assert.NoError, + }, + } { + t.Run(tc.name, func(t *testing.T) { + for _, enc := range []struct { + name string + template *CommonTemplate + want []string + }{ + { + name: "text", + template: NewCommonTemplate(false, tc.data, tc.funcMap), + want: tc.wantText, + }, + { + name: "html", + template: NewCommonTemplate(true, tc.data, tc.funcMap), + want: tc.wantHTML, + }, + } { + t.Run(enc.name, func(t *testing.T) { + got, err := enc.template.ExecuteList(tc.patterns, tc.additionalData...) + tc.assertError(t, err) + assert.Equal(t, enc.want, got) + }) + } + }) + } +} + +func TestCommonTemplate_ExecuteMap(t *testing.T) { + for _, tc := range []struct { + name string + patternMap map[string]any + data map[string]any + additionalData []any + funcMap map[string]any + wantText map[string]any + wantHTML map[string]any + assertError assert.ErrorAssertionFunc + }{ + { + name: "mixed types", + patternMap: map[string]any{ + "key1": "{{ .foo }}", + "key2": []string{"{{ .bar }}", "static"}, + "key3": 42, + }, + data: map[string]any{ + "foo": "value1", + "bar": "value2", + }, + wantText: map[string]any{ + "key1": "value1", + "key2": []string{"value2", "static"}, + "key3": 42, + }, + wantHTML: map[string]any{ + "key1": "value1", + "key2": []string{"value2", "static"}, + "key3": 42, + }, + assertError: assert.NoError, + }, + { + name: "html escaping", + patternMap: map[string]any{ + "html": "{{ .text }}", + }, + data: map[string]any{ + "text": "<b>content</b>", + }, + wantText: map[string]any{ + "html": "<b>content</b>", + }, + wantHTML: map[string]any{ + "html": "<b>content</b>", + }, + assertError: assert.NoError, + }, + { + name: "func map", + patternMap: map[string]any{ + "func": "{{ add 1 2 }}", + "slice": []string{"{{ greet }}"}, + }, + funcMap: map[string]any{ + "add": func(a, b int) int { return a + b }, + "greet": func() string { return "hello" }, + }, + wantText: map[string]any{ + "func": "3", + "slice": []string{"hello"}, + }, + wantHTML: map[string]any{ + "func": "3", + "slice": []string{"hello"}, + }, + assertError: assert.NoError, + }, + { + name: "data merging", + patternMap: map[string]any{ + "merged": "{{ .base }}, {{ .extra }}", + }, + data: map[string]any{ + "base": "foo", + }, + additionalData: []any{ + map[string]any{ + "extra": "bar", + }, + }, + wantText: map[string]any{ + "merged": "foo, bar", + }, + wantHTML: map[string]any{ + "merged": "foo, bar", + }, + assertError: assert.NoError, + }, + { + name: "invalid pattern", + patternMap: map[string]any{ + "ok": "valid", + "error": "{{ .invalid", + }, + wantText: map[string]any(nil), + wantHTML: map[string]any(nil), + assertError: assert.Error, + }, + { + name: "missing value", + patternMap: map[string]any{ + "missing": "{{ .unknown }}", + }, + wantText: map[string]any{ + "missing": "<no value>", + }, + wantHTML: map[string]any{ + "missing": "", + }, + assertError: assert.NoError, + }, + { + name: "nested slices", + patternMap: map[string]any{ + "slice": []string{ + "{{ .item1 }}", + "{{ .item2 }}", + "plain text", + }, + }, + additionalData: []any{ + map[string]any{ + "item1": "value1", + "item2": "value2", + }, + }, + wantText: map[string]any{ + "slice": []string{"value1", "value2", "plain text"}, + }, + wantHTML: map[string]any{ + "slice": []string{"value1", "value2", "plain text"}, + }, + assertError: assert.NoError, + }, + { + name: "empty map", + patternMap: map[string]any{}, + wantText: map[string]any{}, + wantHTML: map[string]any{}, + assertError: assert.NoError, + }, + } { + t.Run(tc.name, func(t *testing.T) { + for _, enc := range []struct { + name string + template *CommonTemplate + want map[string]any + }{ + { + name: "text", + template: NewCommonTemplate(false, tc.data, tc.funcMap), + want: tc.wantText, + }, + { + name: "html", + template: NewCommonTemplate(true, tc.data, tc.funcMap), + want: tc.wantHTML, + }, + } { + t.Run(enc.name, func(t *testing.T) { + got, err := enc.template.ExecuteMap(tc.patternMap, tc.additionalData...) + tc.assertError(t, err) + assert.Equal(t, enc.want, got) + }) + } + }) + } +}