From a978cfa2b8e69ef9b4be4b83d5a7c393f7521815 Mon Sep 17 00:00:00 2001
From: Pavel Antonov <antonov@perx.ru>
Date: Tue, 13 Aug 2024 10:13:19 +0000
Subject: [PATCH] =?UTF-8?q?feat:=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?=
 =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83?=
 =?UTF-8?q?=D0=B7=D0=BA=D0=B0=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20?=
 =?UTF-8?q?=D1=81=D1=85=D0=B5=D0=BC=20=D0=B8=20=D0=B7=D0=B0=D0=BF=D0=B8?=
 =?UTF-8?q?=D1=81=D0=B5=D0=B9=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D1=84?=
 =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D1=81?=
 =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?=
 =?UTF-8?q?=D1=8F=20=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD=D0=B8?=
 =?UTF-8?q?=D1=8F=D1=85?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 assets.go                          | 168 ++++++++++++++++
 assets/tests/invalid.txt           |   0
 assets/tests/item.json             |  16 ++
 assets/tests/items.yaml            |  23 +++
 assets_test.go                     |  86 ++++++++
 pkg/collections/collection.go      |  11 ++
 pkg/extension/extension.go         |  24 +--
 pkg/extension/service/extension.go |   5 +
 pkg/files/field.go                 |  12 +-
 pkg/files/file.go                  |  16 +-
 pkg/schema/schema.go               |  54 ++---
 pkg/schema/schema_yaml.go          |  55 ------
 pkg/schema/test/convert_test.go    |  43 +---
 pkg/schema/test/object_test.go     |  77 --------
 pkg/schema/validate/enum.go        |  17 ++
 pkg/setup/collection.go            |   6 +
 pkg/setup/config.go                | 305 +++++++++++++++++++++++++++++
 pkg/setup/item.go                  |  48 ++++-
 pkg/setup/setup.go                 | 142 ++++++++------
 19 files changed, 799 insertions(+), 309 deletions(-)
 create mode 100644 assets.go
 create mode 100644 assets/tests/invalid.txt
 create mode 100644 assets/tests/item.json
 create mode 100644 assets/tests/items.yaml
 create mode 100644 assets_test.go
 delete mode 100644 pkg/schema/schema_yaml.go
 create mode 100644 pkg/setup/config.go

diff --git a/assets.go b/assets.go
new file mode 100644
index 00000000..b3ed90c1
--- /dev/null
+++ b/assets.go
@@ -0,0 +1,168 @@
+package perxis
+
+import (
+	"io"
+	"io/fs"
+	"path/filepath"
+
+	"git.perx.ru/perxis/perxis-go/pkg/errors"
+	jsoniter "github.com/json-iterator/go"
+	"gopkg.in/yaml.v3"
+)
+
+// Assets предоставляет методы для загрузки данных из файловой системы
+type Assets[T any] struct {
+	Constructor func() T
+}
+
+// NewAssets возвращает новый экземпляр загрузчика
+func NewAssets[T any]() *Assets[T] {
+	return &Assets[T]{
+		Constructor: func() (t T) { return t },
+	}
+}
+
+type FromFSFunc[T any] func(fsys fs.FS) ([]T, error)
+type FromFileFunc[T any] func(file fs.File) ([]T, error)
+
+func (a *Assets[T]) Funcs() (FromFSFunc[T], FromFileFunc[T]) {
+	return a.FromFS, a.FromFile
+}
+
+// WithConstructor устанавливает конструктор для создания новых экземпляров
+func (a *Assets[T]) WithConstructor(t func() T) *Assets[T] {
+	a.Constructor = t
+	return a
+}
+
+// MustFrom возвращает все записи в переданной файловой системе
+func (a *Assets[T]) MustFrom(fsys fs.FS, path string) []T {
+	res, err := a.From(fsys, path)
+	if err != nil {
+		panic(err)
+	}
+	return res
+}
+
+// MustOneFrom возвращает одну запись из переданного файла
+func (a *Assets[T]) MustOneFrom(fsys fs.FS, path string) T {
+	res, err := a.From(fsys, path)
+	if err != nil {
+		panic(err)
+	}
+	if len(res) == 0 {
+		panic(errors.Errorf("no entries found"))
+	}
+	if len(res) > 1 {
+		panic(errors.Errorf("multiple entries found"))
+	}
+	return res[0]
+}
+
+// From возвращает записи из переданного файла
+func (a *Assets[T]) From(fsys fs.FS, path string) ([]T, error) {
+	f, err := fsys.Open(path)
+	if err != nil {
+		return nil, err
+	}
+
+	defer f.Close()
+
+	stat, err := f.Stat()
+	if err != nil {
+		return nil, err
+	}
+
+	if stat.IsDir() {
+		sub, err := fs.Sub(fsys, path)
+		if err != nil {
+			return nil, err
+		}
+		return a.FromFS(sub)
+	}
+
+	return a.FromFile(f)
+}
+
+// FromFile возвращает записи в переданном файле
+func (a *Assets[T]) FromFile(file fs.File) ([]T, error) {
+	stat, err := file.Stat()
+	if err != nil {
+		return nil, err
+	}
+
+	switch filepath.Ext(stat.Name()) {
+	case ".json":
+		entry, err := a.FromJSON(file)
+		if err != nil {
+			return nil, errors.Wrapf(err, "file '%s'", stat.Name())
+		}
+		return []T{entry}, nil
+
+	case ".yaml", ".yml":
+		entries, err := a.FromYAML(file)
+		return entries, errors.Wrapf(err, "file '%s'", stat.Name())
+	}
+
+	return nil, errors.Errorf("file '%s' must be in JSON or YAML format", stat.Name())
+}
+
+// FromFS возвращает все записи в переданной файловой системе
+func (a *Assets[T]) FromFS(fsys fs.FS) (result []T, err error) {
+	if err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, _ error) error {
+		file, err := fsys.Open(path)
+		if err != nil {
+			return err
+		}
+		defer file.Close()
+
+		if entries, err := a.FromFile(file); err == nil {
+			result = append(result, entries...)
+		}
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+
+	return result, nil
+}
+
+// FromJSON возвращает запись из JSON
+func (c *Assets[T]) FromJSON(r io.Reader) (T, error) {
+	entry := c.Constructor()
+	data, err := io.ReadAll(r)
+	if err != nil {
+		return entry, err
+	}
+
+	err = jsoniter.Unmarshal(data, &entry)
+	return entry, err
+}
+
+// FromYAML возвращает записи из YAML
+func (c *Assets[T]) FromYAML(r io.Reader) (result []T, err error) {
+	decoder := yaml.NewDecoder(r)
+	for {
+		var data interface{}
+		err = decoder.Decode(&data)
+		if errors.Is(err, io.EOF) {
+			break
+		}
+		if err != nil {
+			return nil, err
+		}
+
+		json, err := jsoniter.Marshal(data)
+		if err != nil {
+			return nil, err
+		}
+
+		entry := c.Constructor()
+		if err = jsoniter.Unmarshal(json, &entry); err != nil {
+			return nil, err
+		}
+		result = append(result, entry)
+	}
+
+	return result, nil
+}
diff --git a/assets/tests/invalid.txt b/assets/tests/invalid.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/assets/tests/item.json b/assets/tests/item.json
new file mode 100644
index 00000000..1240b61e
--- /dev/null
+++ b/assets/tests/item.json
@@ -0,0 +1,16 @@
+{
+  "id": "item3",
+  "enum": 1,
+  "data": {
+    "obj": {
+      "str": "value"
+    },
+    "arr": [
+      "str1",
+      "str2"
+    ]
+  },
+  "struct": {
+    "option": true
+  }
+}
\ No newline at end of file
diff --git a/assets/tests/items.yaml b/assets/tests/items.yaml
new file mode 100644
index 00000000..eacf244f
--- /dev/null
+++ b/assets/tests/items.yaml
@@ -0,0 +1,23 @@
+---
+id: item1
+enum: 1
+data:
+  obj:
+    str: value
+  arr:
+    - str1
+    - str2
+struct:
+  option: true
+
+---
+id: item2
+enum: 1
+data:
+  obj:
+    str: value
+  arr:
+    - str1
+    - str2
+struct:
+  option: true
diff --git a/assets_test.go b/assets_test.go
new file mode 100644
index 00000000..25f33bb5
--- /dev/null
+++ b/assets_test.go
@@ -0,0 +1,86 @@
+package perxis
+
+import (
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+type State int
+
+const (
+	State1 State = iota
+	State2
+)
+
+type testEntry struct {
+	ID     string
+	Enum   State
+	Data   map[string]interface{}
+	Struct *nested
+}
+
+type nested struct {
+	Option *bool
+}
+
+func TestFromFS(t *testing.T) {
+	tr := true
+	i1 := &testEntry{
+		ID:   "item1",
+		Enum: State2,
+		Data: map[string]interface{}{
+			"obj": map[string]interface{}{"str": "value"},
+			"arr": []interface{}{"str1", "str2"},
+		},
+		Struct: &nested{
+			Option: &tr,
+		},
+	}
+
+	i2 := *i1
+	i2.ID = "item2"
+
+	i3 := *i1
+	i3.ID = "item3"
+
+	assets := NewAssets[*testEntry]()
+	r, err := assets.FromFS(os.DirFS("assets/tests"))
+	require.NoError(t, err)
+	require.Len(t, r, 3)
+	assert.ElementsMatch(t, []*testEntry{i1, &i2, &i3}, r)
+}
+
+func TestFrom(t *testing.T) {
+	tr := true
+	i1 := &testEntry{
+		ID:   "item1",
+		Enum: State2,
+		Data: map[string]interface{}{
+			"obj": map[string]interface{}{"str": "value"},
+			"arr": []interface{}{"str1", "str2"},
+		},
+		Struct: &nested{
+			Option: &tr,
+		},
+	}
+
+	i2 := *i1
+	i2.ID = "item2"
+
+	i3 := *i1
+	i3.ID = "item3"
+
+	assets := NewAssets[*testEntry]()
+	r, err := assets.From(os.DirFS("assets"), "tests")
+	require.NoError(t, err)
+	require.Len(t, r, 3)
+	assert.ElementsMatch(t, []*testEntry{i1, &i2, &i3}, r)
+
+	r, err = assets.From(os.DirFS("assets"), "tests/items.yaml")
+	require.NoError(t, err)
+	require.Len(t, r, 2)
+	assert.Equal(t, []*testEntry{i1, &i2}, r)
+}
diff --git a/pkg/collections/collection.go b/pkg/collections/collection.go
index 6583b423..51cc3072 100644
--- a/pkg/collections/collection.go
+++ b/pkg/collections/collection.go
@@ -1,6 +1,7 @@
 package collections
 
 import (
+	"io/fs"
 	"time"
 
 	"git.perx.ru/perxis/perxis-go/pkg/optional"
@@ -236,3 +237,13 @@ func FromSchemaMetadata(schemas ...*schema.Schema) []*Collection {
 
 	return result
 }
+
+// TODO: использовать загрузку из файлов коллекций вместо файлов схем
+func FromFS(filesystem fs.FS) ([]*Collection, error) {
+	schemas, err := schema.FromFS(filesystem)
+	if err != nil {
+		return nil, err
+	}
+
+	return FromSchemaMetadata(schemas...), nil
+}
diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go
index c5c1dd4c..ee87e981 100644
--- a/pkg/extension/extension.go
+++ b/pkg/extension/extension.go
@@ -2,8 +2,8 @@ package extension
 
 import (
 	"context"
-	"io/fs"
 
+	"git.perx.ru/perxis/perxis-go"
 	"git.perx.ru/perxis/perxis-go/pkg/collections"
 	"git.perx.ru/perxis/perxis-go/pkg/content"
 	"git.perx.ru/perxis/perxis-go/pkg/errors"
@@ -42,6 +42,9 @@ var (
 
 	ErrNotInstalled     = errors.New("not installed")
 	ErrUnknownExtension = errors.New("unknown extension")
+
+	ManifestAssets   = perxis.NewAssets[*ExtensionDescriptor]()
+	ManifestFromFile = ManifestAssets.MustOneFrom
 )
 
 // Runnable описывает интерфейс сервиса с запуском и остановкой. Вызывается сервером расширений
@@ -96,22 +99,3 @@ func UpdateCollectionStrategy(s *setup.Setup, exist, collection *collections.Col
 	}
 	return setup.DefaultUpdateCollectionStrategyFn(s, exist, collection)
 }
-
-// CollectionsFromAssets инициализирует коллекции из файловой системы
-// В качестве файловой системы можно использовать файлы ОС `filesystem := os.DirFS("assets/schemas")`
-// Или файловую систему, встроенную в исполняемый файл при компиляции:
-//
-//	//go:embed assets/schemas/*
-//	var filesystem embed.FS
-func CollectionsFromAssets(filesystem fs.FS, extension string) []*collections.Collection {
-	schemas, err := schema.FromFS(filesystem)
-	if err != nil {
-		panic(err)
-	}
-
-	for _, schema := range schemas {
-		schema.WithMetadata(MetadataKey, extension)
-	}
-
-	return collections.FromSchemaMetadata(schemas...)
-}
diff --git a/pkg/extension/service/extension.go b/pkg/extension/service/extension.go
index d64590e3..338dadc2 100644
--- a/pkg/extension/service/extension.go
+++ b/pkg/extension/service/extension.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 
 	"git.perx.ru/perxis/perxis-go/pkg/clients"
+	"git.perx.ru/perxis/perxis-go/pkg/collections"
 	"git.perx.ru/perxis/perxis-go/pkg/content"
 	"git.perx.ru/perxis/perxis-go/pkg/extension"
 	"git.perx.ru/perxis/perxis-go/pkg/roles"
@@ -134,6 +135,10 @@ func (s *Extension) setupExtensionClient(set *setup.Setup, spaceID string) {
 
 func (s *Extension) GetSetup(spaceID, envID string) (*setup.Setup, error) {
 	set := s.setupFunc(spaceID, envID)
+	set.WithCollectionOptions(
+		func(c *collections.Collection) bool { return c.Schema != nil },
+		setup.AddMetadata(extension.MetadataKey, s.GetName()),
+	)
 	if set.HasErrors() {
 		s.Logger.Error("Invalid setup config", zap.Errors("Errors", set.Errors()))
 		return nil, set.Error()
diff --git a/pkg/files/field.go b/pkg/files/field.go
index 5ce4dc4c..00659012 100644
--- a/pkg/files/field.go
+++ b/pkg/files/field.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"io"
 	"net/url"
 	"reflect"
 
@@ -65,14 +66,21 @@ func (t FileType) Encode(ctx context.Context, fld *field.Field, v interface{}) (
 		return nil, fmt.Errorf("FileField encode error: incorrect type: \"%s\", expected \"file\"", reflect.ValueOf(v).Kind())
 	}
 
-	if f.File != nil { // upload from file system
+	if f.Content != nil {
+		// По возможности устанавливаем указатель в начало,
+		// для избегания ошибки при повторном чтении
+		if seeker, ok := f.Content.(io.Seeker); ok {
+			_, _ = seeker.Seek(0, io.SeekStart)
+		}
+
 		upload, err := t.fs.Upload(ctx, f)
 		if err != nil {
 			return nil, err
 		}
-		if err = t.uploader.Upload(f.File, upload); err != nil {
+		if err = t.uploader.Upload(f.Content, upload); err != nil {
 			return nil, err
 		}
+
 		f = &upload.File
 	}
 
diff --git a/pkg/files/file.go b/pkg/files/file.go
index 149d88a4..ce49d8e2 100644
--- a/pkg/files/file.go
+++ b/pkg/files/file.go
@@ -3,7 +3,7 @@ package files
 import (
 	"bytes"
 	"fmt"
-	"io/fs"
+	"io"
 	"strings"
 	"text/template"
 
@@ -16,13 +16,13 @@ const (
 
 // File - описание файла в системе хранения perxis
 type File struct {
-	ID       string  `mapstructure:"id,omitempty" json:"id" expr:"id"`                                              // Уникальный идентификатор файла в хранилище
-	Name     string  `mapstructure:"name,omitempty" json:"name" bson:"name,omitempty" expr:"name"`                  // Имя файла
-	Size     uint64  `mapstructure:"size,omitempty" json:"size" bson:"size,omitempty" expr:"size"`                  // Размер файла
-	MimeType string  `mapstructure:"mimeType,omitempty" json:"mimeType" bson:"mimeType,omitempty" expr:"mime_type"` // Mime-type файла
-	URL      string  `mapstructure:"url,omitempty" json:"url" bson:"url,omitempty" expr:"url"`                      // Адрес для загрузки файла
-	Key      string  `mapstructure:"key,omitempty" json:"key" bson:"key,omitempty" expr:"key"`                      // Ключ для хранения файла в хранилище
-	File     fs.File `mapstructure:"-" json:"-" bson:"-"`                                                           // Файл для загрузки(из файловой системы)
+	ID       string    `mapstructure:"id,omitempty" json:"id" expr:"id"`                                              // Уникальный идентификатор файла в хранилище
+	Name     string    `mapstructure:"name,omitempty" json:"name" bson:"name,omitempty" expr:"name"`                  // Имя файла
+	Size     uint64    `mapstructure:"size,omitempty" json:"size" bson:"size,omitempty" expr:"size"`                  // Размер файла
+	MimeType string    `mapstructure:"mimeType,omitempty" json:"mimeType" bson:"mimeType,omitempty" expr:"mime_type"` // Mime-type файла
+	URL      string    `mapstructure:"url,omitempty" json:"url" bson:"url,omitempty" expr:"url"`                      // Адрес для загрузки файла
+	Key      string    `mapstructure:"key,omitempty" json:"key" bson:"key,omitempty" expr:"key"`                      // Ключ для хранения файла в хранилище
+	Content  io.Reader `mapstructure:"-" json:"-" bson:"-"`                                                           // Альтернативный способ задать содержимое файла
 }
 
 func (f File) Clone() *File {
diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go
index bb91b420..71d87276 100644
--- a/pkg/schema/schema.go
+++ b/pkg/schema/schema.go
@@ -2,10 +2,9 @@ package schema
 
 import (
 	"context"
-	"io/fs"
-	"path/filepath"
 	"reflect"
 
+	"git.perx.ru/perxis/perxis-go"
 	"git.perx.ru/perxis/perxis-go/pkg/errors"
 	"git.perx.ru/perxis/perxis-go/pkg/expr"
 	"git.perx.ru/perxis/perxis-go/pkg/schema/field"
@@ -27,50 +26,16 @@ func NewFromField(f *field.Field) *Schema {
 	return &Schema{Field: *f}
 }
 
-// FromFile инициализирует и возвращает объект схемы из файла
-// Поддерживаются форматы JSON и YAML
-func FromFile(file fs.File) (*Schema, error) {
-	stat, err := file.Stat()
-	if err != nil {
-		return nil, err
-	}
-
-	switch filepath.Ext(stat.Name()) {
-	case ".json":
-		return FromJSON(file)
-	case ".yaml", ".yml":
-		return FromYAML(file)
-	}
-
-	return nil, errors.New("schema must be in JSON or YAML format")
-}
-
-// FromFS возвращает все валидные схемы в переданной файловой системе
-func FromFS(fsys fs.FS) (result []*Schema, err error) {
-	if err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, _ error) error {
-		file, err := fsys.Open(path)
-		if err != nil {
-			return err
-		}
-		defer file.Close()
-
-		if schema, err := FromFile(file); err == nil {
-			result = append(result, schema)
-		}
-		return nil
-	}); err != nil {
-		return nil, err
-	}
-
-	return result, nil
-}
-
 var (
 	Encode   = field.Encode
 	Decode   = field.Decode
 	Modify   = modify.Modify
 	Validate = validate.Validate
 	Evaluate = field.Evaluate
+
+	Assets   = perxis.NewAssets[*Schema]().WithConstructor(func() *Schema { return New() })
+	FromFS   = Assets.FromFS
+	FromFile = Assets.FromFile
 )
 
 func (s *Schema) Clone(reset bool) *Schema {
@@ -295,3 +260,12 @@ func (s *Schema) Introspect(ctx context.Context, data map[string]interface{}) (m
 
 	return val, mutatedSchema, nil
 }
+
+// GetEnum возвращает список опций перечисления для поля
+func (s *Schema) GetEnum(fieldPath string) []validate.EnumOpt {
+	f := s.Field.GetField(fieldPath)
+	if f == nil {
+		return nil
+	}
+	return validate.GetEnum(f)
+}
diff --git a/pkg/schema/schema_yaml.go b/pkg/schema/schema_yaml.go
deleted file mode 100644
index 7bfe6365..00000000
--- a/pkg/schema/schema_yaml.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package schema
-
-import (
-	"io"
-
-	jsoniter "github.com/json-iterator/go"
-	"gopkg.in/yaml.v3"
-)
-
-func FromYAML(r io.Reader) (s *Schema, err error) {
-	yml, err := io.ReadAll(r)
-	if err != nil {
-		return nil, err
-	}
-
-	s = New()
-	err = s.UnmarshalYAML(yml)
-	return s, err
-}
-
-func (s *Schema) UnmarshalYAML(b []byte) error {
-	jsonData, err := yamlToJson(b)
-	if err != nil {
-		return err
-	}
-
-	return s.UnmarshalJSON(jsonData)
-}
-
-func (s *Schema) MarshalYAML() ([]byte, error) {
-	jsonData, err := s.MarshalJSON()
-	if err != nil {
-		return nil, err
-	}
-
-	return jsonToYaml(jsonData)
-}
-
-func jsonToYaml(b []byte) ([]byte, error) {
-	var data interface{}
-	if err := jsoniter.Unmarshal(b, &data); err != nil {
-		return nil, err
-	}
-
-	return yaml.Marshal(data)
-}
-
-func yamlToJson(b []byte) ([]byte, error) {
-	var data interface{}
-	if err := yaml.Unmarshal(b, &data); err != nil {
-		return nil, err
-	}
-
-	return jsoniter.Marshal(data)
-}
diff --git a/pkg/schema/test/convert_test.go b/pkg/schema/test/convert_test.go
index 1f6149ce..170bee83 100644
--- a/pkg/schema/test/convert_test.go
+++ b/pkg/schema/test/convert_test.go
@@ -10,50 +10,9 @@ import (
 	"git.perx.ru/perxis/perxis-go/pkg/schema/field"
 	"git.perx.ru/perxis/perxis-go/pkg/schema/modify"
 	"git.perx.ru/perxis/perxis-go/pkg/schema/validate"
-	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
-func TestFromFile(t *testing.T) {
-	for _, tt := range []struct {
-		path       string
-		wantSchema *schema.Schema
-		wantErr    string
-	}{
-		{
-			path:    "assets/not_schema.txt",
-			wantErr: "schema must be in JSON or YAML format",
-		},
-		{
-			path:    "assets/invalid.json",
-			wantErr: "error unmarshal json into field",
-		},
-		{
-			path:       "assets/web_pages.json",
-			wantSchema: getPagesSchema(),
-		},
-		{
-			path:       "assets/web_pages.yml",
-			wantSchema: getPagesSchema(),
-		},
-	} {
-		t.Run(tt.path, func(t *testing.T) {
-			file, err := os.Open(tt.path)
-			require.NoError(t, err)
-
-			result, err := schema.FromFile(file)
-			if tt.wantErr != "" {
-				require.Error(t, err)
-				assert.ErrorContains(t, err, tt.wantErr)
-				return
-			}
-
-			require.NoError(t, err)
-			require.Equal(t, tt.wantSchema, result)
-		})
-	}
-}
-
 func TestFromFiles(t *testing.T) {
 	t.Run("Non-existen path", func(t *testing.T) {
 		schemas, err := schema.FromFS(os.DirFS("non-existen"))
@@ -66,7 +25,7 @@ func TestFromFiles(t *testing.T) {
 		schemas, err := schema.FromFS(os.DirFS("assets"))
 		require.NoError(t, err)
 		require.Len(t, schemas, 2, "В директории хранятся две корректные схемы")
-		require.Equal(t, schemas[0], schemas[1], "Они одинаковые, но в разных форматах")
+		require.ElementsMatch(t, []*schema.Schema{getPagesSchema(), getPagesSchema()}, schemas, "Cхемы должны соответствовать объекту из функции")
 	})
 }
 
diff --git a/pkg/schema/test/object_test.go b/pkg/schema/test/object_test.go
index 6b53b0ad..f17b6451 100644
--- a/pkg/schema/test/object_test.go
+++ b/pkg/schema/test/object_test.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
-	"strings"
 	"testing"
 	"time"
 
@@ -293,82 +292,6 @@ func TestSchemaUI_UnmarshalJSON(t *testing.T) {
 	assert.Equal(t, sch, schm)
 }
 
-func TestSchemaUI_UnmarshalYAML(t *testing.T) {
-	vw := &field.View{
-		Widget:  "Widget",
-		Options: map[string]interface{}{"title": "name", "key": "name"},
-	}
-	ui := &field.UI{
-		Widget:      "Widget",
-		Placeholder: "Placeholder",
-		Options:     map[string]interface{}{"title": "name", "key": "name"},
-		ListView:    vw,
-		ReadView:    vw,
-		EditView:    vw,
-	}
-	schm := schema.New(
-		"name", field.String().WithUI(ui),
-	)
-	schm.UI = ui
-
-	j := `
----
-ui:
-  widget: Widget
-  placeholder: Placeholder
-  options:
-    title: name
-    key: name
-  read_view:
-    widget: Widget
-    options:
-      title: name
-      key: name
-  edit_view:
-    widget: Widget
-    options:
-      title: name
-      key: name
-  list_view:
-    widget: Widget
-    options:
-      title: name
-      key: name
-type: object
-params:
-  inline: false
-  fields:
-    name:
-      ui:
-        widget: Widget
-        placeholder: Placeholder
-        options:
-          title: name
-          key: name
-        read_view:
-          widget: Widget
-          options:
-            title: name
-            key: name
-        edit_view:
-          widget: Widget
-          options:
-            title: name
-            key: name
-        list_view:
-          widget: Widget
-          options:
-            title: name
-            key: name
-      type: string
-      params: {}
-loaded: false
-`
-	sch, err := schema.FromYAML(strings.NewReader(j))
-	require.NoError(t, err)
-	assert.Equal(t, sch, schm)
-}
-
 func TestSchema_GetField(t *testing.T) {
 
 	sch := schema.New(
diff --git a/pkg/schema/validate/enum.go b/pkg/schema/validate/enum.go
index 76d2ecc6..8df38c6d 100644
--- a/pkg/schema/validate/enum.go
+++ b/pkg/schema/validate/enum.go
@@ -25,6 +25,14 @@ func (o EnumOpt) String() string {
 	return fmt.Sprintf("%s", o.Value)
 }
 
+func (o EnumOpt) ValueInt() int {
+	return int(o.Value.(float64))
+}
+
+func (o EnumOpt) ValueString() string {
+	return o.Value.(string)
+}
+
 type enum []EnumOpt
 
 func Enum(opts ...EnumOpt) Validator {
@@ -66,3 +74,12 @@ func (t enum) ValidateOption() error {
 	}
 	return nil
 }
+
+func GetEnum(f *field.Field) []EnumOpt {
+	if v, ok := f.Options["enum"]; ok {
+		if e, ok := v.(*enum); ok {
+			return *e
+		}
+	}
+	return nil
+}
diff --git a/pkg/setup/collection.go b/pkg/setup/collection.go
index 9f5f6a46..2d3656ea 100644
--- a/pkg/setup/collection.go
+++ b/pkg/setup/collection.go
@@ -51,6 +51,12 @@ func NewCollectionConfig(collection *collections.Collection, opt ...CollectionsO
 	return c, nil
 }
 
+func AddMetadata(key, value string) CollectionsOption {
+	return func(c *CollectionConfig) {
+		c.collection.Schema.WithMetadata(key, value)
+	}
+}
+
 func SkipMigration() CollectionsOption {
 	return func(c *CollectionConfig) {
 		c.SkipMigration = true
diff --git a/pkg/setup/config.go b/pkg/setup/config.go
new file mode 100644
index 00000000..08e1c740
--- /dev/null
+++ b/pkg/setup/config.go
@@ -0,0 +1,305 @@
+package setup
+
+import (
+	"errors"
+	"io/fs"
+
+	"git.perx.ru/perxis/perxis-go"
+
+	"git.perx.ru/perxis/perxis-go/pkg/clients"
+	"git.perx.ru/perxis/perxis-go/pkg/collections"
+	"git.perx.ru/perxis/perxis-go/pkg/items"
+	"git.perx.ru/perxis/perxis-go/pkg/roles"
+	"github.com/hashicorp/go-multierror"
+)
+
+type Config struct {
+	Roles       []RoleConfig
+	Clients     []ClientConfig
+	Collections []CollectionConfig
+	Items       []ItemConfig
+}
+
+func NewConfig() *Config {
+	return &Config{}
+}
+
+// Load загружает Config из файловой системы
+// Файлы должны быть расположены в директории со следующей структурой:
+//   - collections/ - директория с файлами конфигурации коллекций
+//   - clients/ - директория с файлами конфигурации клиентов
+//   - items/ - директория с файлами конфигурации элементов
+//   - roles/ - директория с файлами конфигурации ролей
+func (cfg *Config) Load(fsys fs.FS) (*Config, error) {
+	if subFS, err := fs.Sub(fsys, "collections"); err == nil {
+		if _, err = cfg.LoadCollections(subFS); err != nil && !errors.Is(err, fs.ErrNotExist) {
+			return nil, err
+		}
+	}
+
+	if subFS, err := fs.Sub(fsys, "items"); err == nil {
+		if _, err = cfg.LoadItems(subFS); err != nil && !errors.Is(err, fs.ErrNotExist) {
+			return nil, err
+		}
+	}
+
+	if subFS, err := fs.Sub(fsys, "roles"); err == nil {
+		if _, err = cfg.LoadRoles(subFS); err != nil && !errors.Is(err, fs.ErrNotExist) {
+			return nil, err
+		}
+	}
+
+	if subFS, err := fs.Sub(fsys, "clients"); err == nil {
+		if _, err = cfg.LoadClients(subFS); err != nil && !errors.Is(err, fs.ErrNotExist) {
+			return nil, err
+		}
+	}
+
+	return cfg, nil
+}
+
+func (cfg *Config) MustLoad(fsys fs.FS) *Config {
+	c, err := cfg.Load(fsys)
+	if err != nil {
+		panic(err)
+	}
+	return c
+}
+
+func (cfg *Config) WithCollectionOptions(filter func(c *collections.Collection) bool, opts ...CollectionsOption) *Config {
+	for _, c := range cfg.Collections {
+		if filter(c.collection) {
+			for _, o := range opts {
+				o(&c)
+			}
+		}
+	}
+	return cfg
+}
+
+func (cfg *Config) WithItemsOptions(filter func(c *items.Item) bool, opts ...ItemsOption) *Config {
+	for _, i := range cfg.Items {
+		if filter(i.item) {
+			for _, o := range opts {
+				o(&i)
+			}
+		}
+	}
+	return cfg
+}
+
+func (cfg *Config) WithRolesOptions(filter func(c *roles.Role) bool, opts ...RolesOption) *Config {
+	for _, r := range cfg.Roles {
+		if filter(r.role) {
+			for _, o := range opts {
+				o(&r)
+			}
+		}
+	}
+	return cfg
+}
+
+func (cfg *Config) WithClientsOptions(filter func(c *clients.Client) bool, opts ...ClientsOption) *Config {
+	for _, c := range cfg.Clients {
+		if filter(c.client) {
+			for _, o := range opts {
+				o(&c)
+			}
+		}
+	}
+	return cfg
+}
+
+// LoadItems загружает элементы из указанной файловой системы
+func (cfg *Config) LoadClients(fsys fs.FS, opt ...ClientsOption) (*Config, error) {
+	assets := perxis.NewAssets[*clients.Client]()
+	cls, err := assets.FromFS(fsys)
+	if err != nil {
+		return nil, err
+	}
+	return cfg.AddClients(cls, opt...), nil
+}
+
+func (cfg *Config) MustLoadClients(fsys fs.FS, opt ...ClientsOption) *Config {
+	c, err := cfg.LoadClients(fsys, opt...)
+	if err != nil {
+		panic(err)
+	}
+	return c
+}
+
+// AddClients добавляет требования к настройке приложений в пространстве
+func (cfg *Config) AddClients(clients []*clients.Client, opt ...ClientsOption) *Config {
+	for _, client := range clients {
+		cfg.AddClient(client, opt...)
+	}
+	return cfg
+}
+
+// AddClient добавляет требования к настройке приложений в пространстве
+func (c *Config) AddClient(client *clients.Client, opt ...ClientsOption) *Config {
+	c.Clients = append(c.Clients, NewClientConfig(client, opt...))
+	return c
+}
+
+// LoadItems загружает элементы из указанной файловой системы
+func (cfg *Config) LoadRoles(fsys fs.FS, opt ...RolesOption) (*Config, error) {
+	assets := perxis.NewAssets[*roles.Role]()
+	rls, err := assets.FromFS(fsys)
+	if err != nil {
+		return nil, err
+	}
+	return cfg.AddRoles(rls, opt...), nil
+}
+
+func (cfg *Config) MustLoadRoles(fsys fs.FS, opt ...RolesOption) *Config {
+	c, err := cfg.LoadRoles(fsys, opt...)
+	if err != nil {
+		panic(err)
+	}
+	return c
+}
+
+// AddRoles добавляет требования к настройке ролей в пространстве
+func (cfg *Config) AddRoles(roles []*roles.Role, opt ...RolesOption) *Config {
+	for _, role := range roles {
+		cfg.AddRole(role, opt...)
+	}
+	return cfg
+}
+
+// AddRole добавляет требования к настройке ролей в пространстве
+func (cfg *Config) AddRole(role *roles.Role, opt ...RolesOption) *Config {
+	cfg.Roles = append(cfg.Roles, NewRoleConfig(role, opt...))
+	return cfg
+}
+
+// AddCollections добавляет требования к настройке коллекций в пространстве
+func (cfg *Config) AddCollections(collections []*collections.Collection, opt ...CollectionsOption) (*Config, error) {
+	var errs *multierror.Error
+	for _, col := range collections {
+		if _, err := cfg.AddCollection(col, opt...); err != nil {
+			errs = multierror.Append(errs, err)
+		}
+	}
+	return cfg, errs.ErrorOrNil()
+}
+
+// AddCollection добавляет требование к настройке коллекции в пространстве
+func (cfg *Config) AddCollection(collection *collections.Collection, opt ...CollectionsOption) (*Config, error) {
+	config, err := NewCollectionConfig(collection, opt...)
+	if err != nil {
+		return nil, err
+	}
+	cfg.Collections = append(cfg.Collections, config)
+	return cfg, nil
+}
+
+// MustAddCollection добавляет требование к настройке коллекции в пространстве
+func (cfg *Config) MustAddCollection(collection *collections.Collection, opt ...CollectionsOption) *Config {
+	config, err := NewCollectionConfig(collection, opt...)
+	if err != nil {
+		panic(err)
+	}
+	cfg.Collections = append(cfg.Collections, config)
+	return cfg
+}
+
+// LoadCollections загружает коллекции из указанной файловой системы
+func (cfg *Config) LoadCollections(fsys fs.FS, opt ...CollectionsOption) (*Config, error) {
+	colls, err := collections.FromFS(fsys)
+	if err != nil {
+		return nil, err
+	}
+	return cfg.AddCollections(colls, opt...)
+}
+
+func (cfg *Config) MustLoadCollections(fsys fs.FS, opt ...CollectionsOption) *Config {
+	c, err := cfg.LoadCollections(fsys, opt...)
+	if err != nil {
+		panic(err)
+	}
+	return c
+}
+
+// GetCollection возвращает коллекцию по идентификатору
+func (cfg *Config) GetCollection(id string) *collections.Collection {
+	for _, c := range cfg.Collections {
+		if c.collection.ID == id {
+			return c.collection
+		}
+	}
+	return nil
+}
+
+// GetCollection возвращает коллекцию по идентификатору
+func (cfg *Config) GetAllCollections() []*collections.Collection {
+	res := make([]*collections.Collection, 0, len(cfg.Collections))
+	for _, c := range cfg.Collections {
+		res = append(res, c.collection)
+	}
+	return res
+}
+
+// GetCollectionConfig возвращает конфигурацию коллекции по идентификатору
+func (cfg *Config) GetCollectionConfig(id string) *CollectionConfig {
+	for _, c := range cfg.Collections {
+		if c.collection.ID == id {
+			return &c
+		}
+	}
+	return nil
+}
+
+// LoadItems загружает элементы из указанной файловой системы
+func (cfg *Config) LoadItems(fsys fs.FS, opt ...ItemsOption) (*Config, error) {
+	assets := perxis.NewAssets[*items.Item]()
+	itms, err := assets.FromFS(fsys)
+	if err != nil {
+		return nil, err
+	}
+	return cfg.AddItems(itms, append(opt, DecodeItem())...), nil
+}
+
+func (cfg *Config) MustLoadItems(fsys fs.FS, opt ...ItemsOption) *Config {
+	cfg, err := cfg.LoadItems(fsys, opt...)
+	if err != nil {
+		panic(err)
+	}
+	return cfg
+}
+
+// AddItems добавляет требования к настройке элементов в пространстве
+func (cfg *Config) AddItems(items []*items.Item, opt ...ItemsOption) *Config {
+	for _, item := range items {
+		cfg.AddItem(item, opt...)
+	}
+	return cfg
+}
+
+// AddItem добавляет требования к настройке элементов в пространстве
+func (cfg *Config) AddItem(item *items.Item, opt ...ItemsOption) *Config {
+	cfg.Items = append(cfg.Items, NewItemConfig(item, opt...))
+	return cfg
+}
+
+// GetItems возвращает элементы для указанной коллекции
+func (cfg *Config) GetItems(collectionId string) []*items.Item {
+	var items []*items.Item
+	for _, i := range cfg.Items {
+		if i.item.CollectionID == collectionId {
+			items = append(items, i.item)
+		}
+	}
+	return items
+}
+
+// GetItem возвращает элемент для указанной коллекции и идентификатора
+func (cfg *Config) GetItem(collectionId, itemId string) *items.Item {
+	for _, i := range cfg.Items {
+		if i.item.CollectionID == collectionId && i.item.ID == itemId {
+			return i.item
+		}
+	}
+	return nil
+}
diff --git a/pkg/setup/item.go b/pkg/setup/item.go
index a17512d0..af08ea95 100644
--- a/pkg/setup/item.go
+++ b/pkg/setup/item.go
@@ -5,6 +5,7 @@ import (
 	"reflect"
 	"strings"
 
+	"git.perx.ru/perxis/perxis-go/pkg/collections"
 	"git.perx.ru/perxis/perxis-go/pkg/errors"
 	"git.perx.ru/perxis/perxis-go/pkg/items"
 	"go.uber.org/zap"
@@ -27,6 +28,9 @@ type ItemConfig struct {
 	PublishFn PublishItemFn
 	UpdateFn  UpdateItemFn
 	DeleteFn  DeleteItemFn
+
+	// Если запись загружена из файла, необходимо выполнить Decode перед установкой
+	encoded bool
 }
 
 func NewItemConfig(item *items.Item, opt ...ItemsOption) ItemConfig {
@@ -43,12 +47,24 @@ func NewItemConfig(item *items.Item, opt ...ItemsOption) ItemConfig {
 	return c
 }
 
+func PrepareItems(handler func(*items.Item)) ItemsOption {
+	return func(c *ItemConfig) {
+		handler(c.item)
+	}
+}
+
 func OverwriteItem() ItemsOption {
 	return func(c *ItemConfig) {
 		c.UpdateFn = func(s *Setup, old, new *items.Item) (*items.Item, bool) { return new, true }
 	}
 }
 
+func DecodeItem() ItemsOption {
+	return func(c *ItemConfig) {
+		c.encoded = true
+	}
+}
+
 func OverwriteFields(fields ...string) ItemsOption {
 	return func(c *ItemConfig) {
 		c.UpdateFn = func(s *Setup, old, new *items.Item) (*items.Item, bool) {
@@ -135,15 +151,41 @@ func KeepDraft() ItemsOption {
 	}
 }
 
-func (s *Setup) InstallItems(ctx context.Context) error {
+func (s *Setup) InstallItems(ctx context.Context) (err error) {
 	if len(s.Items) == 0 {
 		return nil
 	}
 
 	s.logger.Debug("Install items", zap.Int("Items", len(s.Items)))
 
-	for col, itms := range s.groupByCollection() {
-		exists, err := s.getExisting(ctx, col, itms)
+	for collID, itms := range s.groupByCollection() {
+		var coll *collections.Collection
+		for i, c := range itms {
+			if !c.encoded {
+				continue
+			}
+
+			if coll == nil {
+				coll, err = s.content.Collections.Get(ctx, s.SpaceID, s.EnvironmentID, collID)
+				if err != nil {
+					return err
+				}
+			}
+
+			decoded, err := c.item.Decode(ctx, coll.Schema)
+			if err != nil {
+				return err
+			}
+			itms[i] = ItemConfig{
+				item:      decoded,
+				PublishFn: c.PublishFn,
+				UpdateFn:  c.UpdateFn,
+				DeleteFn:  c.DeleteFn,
+				encoded:   false,
+			}
+		}
+
+		exists, err := s.getExisting(ctx, collID, itms)
 		if err != nil {
 			return err
 		}
diff --git a/pkg/setup/setup.go b/pkg/setup/setup.go
index b087337f..9b6473a5 100644
--- a/pkg/setup/setup.go
+++ b/pkg/setup/setup.go
@@ -2,6 +2,7 @@ package setup
 
 import (
 	"context"
+	"io/fs"
 
 	"git.perx.ru/perxis/perxis-go/pkg/clients"
 	"git.perx.ru/perxis/perxis-go/pkg/collections"
@@ -23,11 +24,6 @@ type Setup struct {
 	SpaceID       string
 	EnvironmentID string
 
-	Roles       []RoleConfig
-	Clients     []ClientConfig
-	Collections []CollectionConfig
-	Items       []ItemConfig
-
 	content *content.Content
 
 	force  bool
@@ -39,6 +35,8 @@ type Setup struct {
 
 	errors []error
 	logger *zap.Logger
+
+	*Config
 }
 
 func NewSetup(content *content.Content, spaceID, environmentID string, logger *zap.Logger) *Setup {
@@ -54,9 +52,16 @@ func NewSetup(content *content.Content, spaceID, environmentID string, logger *z
 		content:            content,
 		logger:             logger,
 		waitSpaceAvailable: true,
+		Config:             NewConfig(),
 	}
 }
 
+func (s *Setup) WithConfig(cfg *Config) *Setup {
+	setup := *s
+	setup.Config = cfg
+	return &setup
+}
+
 func (s *Setup) WithForce(force bool) *Setup {
 	setup := *s
 	setup.force = force
@@ -99,63 +104,6 @@ func (s *Setup) Error() error {
 	return errors.WithErrors(ErrInvalidSetupConfig, s.errors...)
 }
 
-// AddRoles добавляет требования к настройке ролей в пространстве
-func (s *Setup) AddRoles(roles []*roles.Role, opt ...RolesOption) *Setup {
-	for _, role := range roles {
-		s.AddRole(role, opt...)
-	}
-	return s
-}
-
-func (s *Setup) AddRole(role *roles.Role, opt ...RolesOption) *Setup {
-	s.Roles = append(s.Roles, NewRoleConfig(role, opt...))
-	return s
-}
-
-// AddClients добавляет требования к настройке приложений в пространстве
-func (s *Setup) AddClients(clients []*clients.Client, opt ...ClientsOption) *Setup {
-	for _, client := range clients {
-		s.AddClient(client, opt...)
-	}
-	return s
-}
-
-func (s *Setup) AddClient(client *clients.Client, opt ...ClientsOption) *Setup {
-	s.Clients = append(s.Clients, NewClientConfig(client, opt...))
-	return s
-}
-
-// AddCollections добавляет требования к настройке коллекций в пространстве
-func (s *Setup) AddCollections(collections []*collections.Collection, opt ...CollectionsOption) *Setup {
-	for _, col := range collections {
-		s.AddCollection(col, opt...)
-	}
-	return s
-}
-
-func (s *Setup) AddCollection(collection *collections.Collection, opt ...CollectionsOption) *Setup {
-	config, err := NewCollectionConfig(collection, opt...)
-	if err != nil {
-		s.AddError(err)
-		return s
-	}
-	s.Collections = append(s.Collections, config)
-	return s
-}
-
-// AddItems добавляет требования к настройке элементов в пространстве
-func (s *Setup) AddItems(items []*items.Item, opt ...ItemsOption) *Setup {
-	for _, item := range items {
-		s.AddItem(item, opt...)
-	}
-	return s
-}
-
-func (s *Setup) AddItem(item *items.Item, opt ...ItemsOption) *Setup {
-	s.Items = append(s.Items, NewItemConfig(item, opt...))
-	return s
-}
-
 // Install выполняет установку необходимых требований
 func (s *Setup) Install(ctx context.Context) error {
 	if s.waitSpaceAvailable {
@@ -222,3 +170,73 @@ func (s *Setup) Uninstall(ctx context.Context) error {
 	}
 	return nil
 }
+
+// Deprecated: use Config
+func (s *Setup) Load(fsys fs.FS) *Setup {
+	var err error
+	s.Config, err = s.Config.Load(fsys)
+	if err != nil {
+		s.AddError(err)
+	}
+	return s
+}
+
+// Deprecated: use Config instead
+// AddCollections добавляет требования к настройке коллекций в пространстве
+func (s *Setup) AddCollections(collections []*collections.Collection, opt ...CollectionsOption) *Setup {
+	for _, col := range collections {
+		s.AddCollection(col, opt...)
+	}
+	return s
+}
+
+// Deprecated: use Config instead
+// AddCollection добавляет требование к настройке коллекции в пространстве
+func (s *Setup) AddCollection(collection *collections.Collection, opt ...CollectionsOption) *Setup {
+	if _, err := s.Config.AddCollection(collection, opt...); err != nil {
+		s.AddError(err)
+	}
+	return s
+}
+
+// Deprecated: use Config instead
+// AddRoles добавляет требования к настройке ролей в пространстве
+func (s *Setup) AddRoles(roles []*roles.Role, opt ...RolesOption) *Setup {
+	s.Config = s.Config.AddRoles(roles, opt...)
+	return s
+}
+
+// Deprecated: use Config instead
+// AddRole добавляет требования к настройке элементов в пространстве
+func (s *Setup) AddRole(role *roles.Role, opt ...RolesOption) *Setup {
+	s.Config = s.Config.AddRole(role, opt...)
+	return s
+}
+
+// Deprecated: use Config instead
+// AddItem добавляет требования к настройке элементов в пространстве
+func (s *Setup) AddItem(item *items.Item, opt ...ItemsOption) *Setup {
+	s.Config = s.Config.AddItem(item, opt...)
+	return s
+}
+
+// Deprecated: use Config instead
+// AddItems добавляет требования к настройке элементов в пространстве
+func (s *Setup) AddItems(items []*items.Item, opt ...ItemsOption) *Setup {
+	s.Config = s.Config.AddItems(items, opt...)
+	return s
+}
+
+// Deprecated: use Config instead
+// AddClient добавляет требования к настройке элементов в пространстве
+func (s *Setup) AddClient(client *clients.Client, opt ...ClientsOption) *Setup {
+	s.Config = s.Config.AddClient(client, opt...)
+	return s
+}
+
+// Deprecated: use Config instead
+// AddClients добавляет требования к настройке элементов в пространстве
+func (s *Setup) AddClients(clients []*clients.Client, opt ...ClientsOption) *Setup {
+	s.Config = s.Config.AddClients(clients, opt...)
+	return s
+}
-- 
GitLab