diff --git a/yaml/file_resolver.go b/yaml/file_resolver.go new file mode 100644 index 0000000000000000000000000000000000000000..03a795d9871571c1c5b640ed9c91877b8918de18 --- /dev/null +++ b/yaml/file_resolver.go @@ -0,0 +1,31 @@ +package yaml + +import ( + "io" + + "git.perx.ru/perxis/perxis-go/pkg/errors" + "gopkg.in/yaml.v3" +) + +// FileResolver подключает содержимое целевого файла в качестве значения поля. +func FileResolver(tp TagProcessor, node *yaml.Node) (*yaml.Node, error) { + if node.Kind != yaml.ScalarNode { + return nil, errors.New("!include on a non-scalar node") + } + + file, err := tp.FS().Open(node.Value) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + + bytes, err := io.ReadAll(file) + if err != nil { + return nil, err + } + + out := new(yaml.Node) + out.SetString(string(bytes)) + + return out, err +} diff --git a/yaml/include_resolver.go b/yaml/include_resolver.go new file mode 100644 index 0000000000000000000000000000000000000000..0f459c539ab2a244413eab46f2a67181cfce0a0a --- /dev/null +++ b/yaml/include_resolver.go @@ -0,0 +1,33 @@ +package yaml + +import ( + "path/filepath" + + "git.perx.ru/perxis/perxis-go/pkg/errors" + "gopkg.in/yaml.v3" +) + +// IncludeResolver включает содержимое целевого YAML-файла в поле. +func IncludeResolver(tp TagProcessor, node *yaml.Node) (*yaml.Node, error) { + if node.Kind != yaml.ScalarNode { + return nil, errors.New("!include on a non-scalar node") + } + + if ext := filepath.Ext(node.Value); ext != ".yaml" && ext != ".yml" { + return nil, errors.New("!include on file with unknown extension") + } + + file, err := tp.FS().Open(node.Value) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + + out := new(yaml.Node) + err = yaml.NewDecoder(file).Decode(WithTagProcessor(tp.FS())(out)) + if err != nil { + return nil, err + } + + return out, nil +} diff --git a/yaml/tag_processor.go b/yaml/tag_processor.go new file mode 100644 index 0000000000000000000000000000000000000000..89678b93d8242fb15260193208d8445404570cc4 --- /dev/null +++ b/yaml/tag_processor.go @@ -0,0 +1,61 @@ +package yaml + +import ( + "io/fs" + + "gopkg.in/yaml.v3" +) + +type TagProcessor interface { + yaml.Unmarshaler + FS() fs.FS +} + +// tagProcessor обёртка для декодирования целевого значения. +// +// Перед декодированием значения обрабатываются все теги узла. +type tagProcessor struct { + fsys fs.FS + target any +} + +// WithTagProcessor возвращает функцию, которая оборачивает декодируемые значения для поддержки обработки тегов YAML. +// +// Для путей к файлам, используемых в качестве значений тегов, пути должны быть указаны относительно переданной файловой системы. +func WithTagProcessor(fsys fs.FS) func(any) *tagProcessor { + return func(v any) *tagProcessor { + return &tagProcessor{fsys: fsys, target: v} + } +} + +func (tp *tagProcessor) FS() fs.FS { + return tp.fsys +} + +func (tp *tagProcessor) UnmarshalYAML(value *yaml.Node) error { + resolved, err := resolveTags(tp, value) + if err != nil { + return err + } + return resolved.Decode(tp.target) +} + +// resolveTags обрабатывает все теги YAML для переданного узла и возвращает исправленный узел. +// Если узел представляет собой последовательность или словарь, то обрабатываются теги для его дочерних элементов. +func resolveTags(tp TagProcessor, node *yaml.Node) (*yaml.Node, error) { + switch node.Kind { + case yaml.SequenceNode, yaml.MappingNode: + for i := range node.Content { + var err error + node.Content[i], err = resolveTags(tp, node.Content[i]) + if err != nil { + return nil, err + } + } + default: + if resolver, ok := tagResolvers[node.Tag]; ok { + return resolver.Resolve(tp, node) + } + } + return node, nil +} diff --git a/yaml/tag_processor_test.go b/yaml/tag_processor_test.go new file mode 100644 index 0000000000000000000000000000000000000000..79e9f5f80dc452b099176d922a7c4fc5eda008bf --- /dev/null +++ b/yaml/tag_processor_test.go @@ -0,0 +1,44 @@ +package yaml + +import ( + "testing" + + "git.perx.ru/perxis/perxis-go/yaml/testdata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestTagProcessor(t *testing.T) { + t.Run("!file", func(t *testing.T) { + file, err := testdata.FS.Open("file/file_simple.yaml") + require.NoError(t, err) + defer file.Close() + + var result any + decoder := yaml.NewDecoder(file) + err = decoder.Decode(WithTagProcessor(testdata.FS)(&result)) + require.NoError(t, err) + assert.Equal(t, map[string]any{"config": `server { + listen 80; + server_name example.com; + + location / { + root /var/www/example.com/html; + index index.html index.htm; + try_files $uri $uri/ =404; + } +}`}, result) + }) + t.Run("!include", func(t *testing.T) { + file, err := testdata.FS.Open("include/include_simple.yaml") + require.NoError(t, err) + defer file.Close() + + var result any + decoder := yaml.NewDecoder(file) + err = decoder.Decode(WithTagProcessor(testdata.FS)(&result)) + require.NoError(t, err) + assert.Equal(t, map[string]any{"data": map[string]any{"text": "Hello, World!"}}, result) + }) +} diff --git a/yaml/testdata/file/file_simple.yaml b/yaml/testdata/file/file_simple.yaml new file mode 100644 index 0000000000000000000000000000000000000000..6e7181a3eb1ae553aa5a8055c9a95f772cb43e0b --- /dev/null +++ b/yaml/testdata/file/file_simple.yaml @@ -0,0 +1 @@ +config: !file file/nginx.conf \ No newline at end of file diff --git a/yaml/testdata/file/nginx.conf b/yaml/testdata/file/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..a9bbbc506ed333d44456d224cecda27d77d65827 --- /dev/null +++ b/yaml/testdata/file/nginx.conf @@ -0,0 +1,10 @@ +server { + listen 80; + server_name example.com; + + location / { + root /var/www/example.com/html; + index index.html index.htm; + try_files $uri $uri/ =404; + } +} \ No newline at end of file diff --git a/yaml/testdata/include/include_simple.yaml b/yaml/testdata/include/include_simple.yaml new file mode 100644 index 0000000000000000000000000000000000000000..689f5f9f0cd8e2f5acf9f05eb248bf92e3df168a --- /dev/null +++ b/yaml/testdata/include/include_simple.yaml @@ -0,0 +1 @@ +data: !include include/simple_data.yaml \ No newline at end of file diff --git a/yaml/testdata/include/simple_data.yaml b/yaml/testdata/include/simple_data.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a0bfdf8ea14a5dae1dbe66345309cc67df0b2f4e --- /dev/null +++ b/yaml/testdata/include/simple_data.yaml @@ -0,0 +1 @@ +text: Hello, World! \ No newline at end of file diff --git a/yaml/testdata/testdata.go b/yaml/testdata/testdata.go new file mode 100644 index 0000000000000000000000000000000000000000..f8b469980cf76e31cd31f40dfae6f6cc0f92dcb1 --- /dev/null +++ b/yaml/testdata/testdata.go @@ -0,0 +1,6 @@ +package testdata + +import "embed" + +//go:embed * +var FS embed.FS diff --git a/yaml/yaml.go b/yaml/yaml.go new file mode 100644 index 0000000000000000000000000000000000000000..3830b76d2f606f32013236152479a2eaa2c1609f --- /dev/null +++ b/yaml/yaml.go @@ -0,0 +1,34 @@ +package yaml + +import ( + "gopkg.in/yaml.v3" +) + +var ( + NewDecoder = yaml.NewDecoder + NewEncoder = yaml.NewEncoder + Unmarshal = yaml.Unmarshal + Marshal = yaml.Marshal +) + +var tagResolvers = make(map[string]Resolver) + +func RegisterTagResolver(tag string, resolver Resolver) { + tagResolvers[tag] = resolver +} + +// Resolver обрабатывает тег YAML и возвращает его обработанный вариант +type Resolver interface { + Resolve(tp TagProcessor, node *yaml.Node) (*yaml.Node, error) +} + +type ResolverFunc func(TagProcessor, *yaml.Node) (*yaml.Node, error) + +func (fn ResolverFunc) Resolve(tp TagProcessor, node *yaml.Node) (*yaml.Node, error) { + return fn(tp, node) +} + +func init() { + RegisterTagResolver("!include", ResolverFunc(IncludeResolver)) + RegisterTagResolver("!file", ResolverFunc(FileResolver)) +}