From 6315735c56f03017333de1829b5091f4465f2ca3 Mon Sep 17 00:00:00 2001 From: Zsolt Rappi Date: Tue, 15 Aug 2023 16:25:28 +0200 Subject: [PATCH 1/2] feat(config): support multiple config file types Signed-off-by: Zsolt Rappi --- go.mod | 3 +++ go.sum | 1 + internal/config/config.go | 15 ++++++++++- internal/config/config_test.go | 46 ++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 internal/config/config_test.go diff --git a/go.mod b/go.mod index e8204f04..732c5a2a 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.7.0 github.com/spf13/viper v1.16.0 + github.com/stretchr/testify v1.8.4 github.com/trivago/tgo v1.0.7 github.com/uwu-tools/go-jira/v2 v2.0.0-20230801175343-52f822b5cb80 github.com/uwu-tools/magex v0.10.0 @@ -37,6 +38,7 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/cloudflare/circl v1.3.3 // indirect github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/structs v1.1.0 // indirect @@ -62,6 +64,7 @@ require ( github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pierrec/lz4/v4 v4.1.2 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/skeema/knownhosts v1.1.1 // indirect github.com/spf13/afero v1.9.5 // indirect diff --git a/go.sum b/go.sum index 70617589..311122c2 100644 --- a/go.sum +++ b/go.sum @@ -264,6 +264,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM= diff --git a/internal/config/config.go b/internal/config/config.go index a9950b01..eb8ba982 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -337,7 +337,7 @@ func newViper(appName, cfgFile string) *viper.Viper { if cfgFile != "" { v.SetConfigFile(cfgFile) } - v.SetConfigType("json") + v.SetConfigType(getConfigTypeFromName(cfgFile)) if err := v.ReadInConfig(); err == nil { log.WithField("file", v.ConfigFileUsed()).Infof("config file loaded") @@ -539,3 +539,16 @@ var ( func errCustomFieldIDNotFound(field string) error { return fmt.Errorf("could not find ID custom field '%s'; check that it is named correctly", field) //nolint:goerr113 } + +// getConfigTypeFromName extracts the extension from the passed in filename, +// returns `json` if it's empty. +func getConfigTypeFromName(filename string) string { + if filename == "" { + return "json" + } + + // it's enough to rely on the type from the filename because + // viper will parse and validate the the file's content + parts := strings.Split(filename, ".") + return parts[len(parts)-1] +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000..f9c123e7 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,46 @@ +package config + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetConfigTypeFromName(t *testing.T) { + tests := []*struct { + name, filename, expected string + }{ + { + name: "empty name", + filename: "", + expected: "json", + }, + { + name: "json", + filename: "config.json", + expected: "json", + }, + { + name: "toml", + filename: "config.toml", + expected: "toml", + }, + { + name: "yaml", + filename: "config.yaml", + expected: "yaml", + }, + { + name: "any file type", + filename: "config.xyz", + expected: "xyz", + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("Assert config type with %s", test.name), func(t *testing.T) { + assert.Equal(t, test.expected, getConfigTypeFromName(test.filename)) + }) + } +} From 7f951816bbeb2e432218a61441eea273f99f7dae Mon Sep 17 00:00:00 2001 From: Zsolt Rappi Date: Fri, 18 Aug 2023 10:35:44 +0200 Subject: [PATCH 2/2] feat: integrate Viper's internal encoding package Signed-off-by: Zsolt Rappi --- README.md | 6 +- cmd/root.go | 2 +- go.mod | 6 +- internal/config/config.go | 122 ++++++++++++++++++++------- internal/encoding/encoder.go | 66 +++++++++++++++ internal/encoding/error.go | 7 ++ internal/encoding/json/codec.go | 24 ++++++ internal/encoding/json/codec_test.go | 61 ++++++++++++++ internal/encoding/toml/codec.go | 19 +++++ internal/encoding/toml/codec_test.go | 49 +++++++++++ internal/encoding/yaml/codec.go | 19 +++++ internal/encoding/yaml/codec_test.go | 53 ++++++++++++ 12 files changed, 398 insertions(+), 36 deletions(-) create mode 100644 internal/encoding/encoder.go create mode 100644 internal/encoding/error.go create mode 100644 internal/encoding/json/codec.go create mode 100644 internal/encoding/json/codec_test.go create mode 100644 internal/encoding/toml/codec.go create mode 100644 internal/encoding/toml/codec_test.go create mode 100644 internal/encoding/yaml/codec.go create mode 100644 internal/encoding/yaml/codec_test.go diff --git a/README.md b/README.md index ffbdfcd0..0b2a1e94 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ in ### Application Configuration -Arguments to the program may be passed on the command line or in a JSON -configuration file. +Arguments to the program may be passed on the command line or in a configuration file. +Currently supported configuration file types are: JSON, YAML, TOML. For the command line arguments, run: @@ -49,7 +49,7 @@ For the command line arguments, run: gh-jira-issue-sync help ``` -The JSON format is a single, flat object, with the argument long +The configuration is a single, flat object, with the argument long names as keys. Configuration arguments are as follows: diff --git a/cmd/root.go b/cmd/root.go index b5f342cc..377b1fbe 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -72,7 +72,7 @@ var RootCmd = &cobra.Command{ logrus.Error(err) } if !cfg.IsDryRun() { - if err := cfg.SaveConfig(); err != nil { + if err := cfg.UpdateConfig(); err != nil { // TODO(log): Better error message logrus.Error(err) } diff --git a/go.mod b/go.mod index 732c5a2a..e53a56b1 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,8 @@ require ( github.com/fsnotify/fsnotify v1.6.0 github.com/google/go-github/v53 v53.2.0 github.com/magefile/mage v1.15.0 + github.com/mitchellh/mapstructure v1.5.0 + github.com/pelletier/go-toml/v2 v2.0.8 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.7.0 github.com/spf13/viper v1.16.0 @@ -25,6 +27,7 @@ require ( github.com/uwu-tools/magex v0.10.0 golang.org/x/oauth2 v0.11.0 golang.org/x/term v0.11.0 + gopkg.in/yaml.v3 v3.0.1 sigs.k8s.io/release-sdk v0.10.3 sigs.k8s.io/release-utils v0.7.5-0.20230814131120-e16435f5a2de ) @@ -59,9 +62,7 @@ require ( github.com/klauspost/pgzip v1.2.5 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mholt/archiver/v3 v3.5.1 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/nwaples/rardecode v1.1.0 // indirect - github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pierrec/lz4/v4 v4.1.2 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -85,6 +86,5 @@ require ( google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect ) diff --git a/internal/config/config.go b/internal/config/config.go index eb8ba982..4e6945bd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,7 +18,6 @@ package config import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -31,12 +30,17 @@ import ( "github.com/dghubble/oauth1" "github.com/fsnotify/fsnotify" + "github.com/mitchellh/mapstructure" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" jira "github.com/uwu-tools/go-jira/v2/cloud" "golang.org/x/term" + "github.com/uwu-tools/gh-jira-issue-sync/internal/encoding" + "github.com/uwu-tools/gh-jira-issue-sync/internal/encoding/json" + "github.com/uwu-tools/gh-jira-issue-sync/internal/encoding/toml" + "github.com/uwu-tools/gh-jira-issue-sync/internal/encoding/yaml" "github.com/uwu-tools/gh-jira-issue-sync/internal/github" "github.com/uwu-tools/gh-jira-issue-sync/internal/options" ) @@ -59,6 +63,9 @@ const ( CustomFieldNameGitHubStatus = "github-status" CustomFieldNameGitHubReporter = "github-reporter" CustomFieldNameGitHubLastSync = "github-last-sync" + + // Config file related constants. + DefaultConfigFileType = "json" ) // fields represents the custom field IDs of the Jira custom fields we care about. @@ -78,6 +85,9 @@ type Config struct { // cmdFile is the file Viper is using for its configuration. cmdFile string + // cmdFileType is the extension of the config file. + cmdFileType string + // cmdConfig is the Viper configuration object created from the command line and config file. cmdConfig viper.Viper @@ -94,6 +104,9 @@ type Config struct { // project represents the Jira project the user has requested. project *jira.Project + // encoderRegistry is used for encoding the config file to supported Viper types. + encoderRegistry *encoding.EncoderRegistry + // since is the parsed value of the `since` configuration parameter, which is the earliest that // a GitHub issue can have been updated to be retrieved. since time.Time @@ -132,7 +145,8 @@ func New(ctx context.Context, cmd *cobra.Command) (*Config, error) { log.Debugf("using config file: %s", cfgFilePath) cfg.cmdFile = cfgFilePath - cfg.cmdConfig = *newViper(options.AppName, cfg.cmdFile) + cfg.cmdFileType = getConfigTypeFromName(cfgFilePath) + cfg.cmdConfig = *newViper(options.AppName, cfg.cmdFile, cfg.cmdFileType) cfg.cmdConfig.BindPFlags(cmd.Flags()) //nolint:errcheck cfg.cmdFile = cfg.cmdConfig.ConfigFileUsed() @@ -143,6 +157,10 @@ func New(ctx context.Context, cmd *cobra.Command) (*Config, error) { return nil, err } + if err := cfg.resetEncoding(); err != nil { + return nil, err + } + return &cfg, nil } @@ -272,24 +290,24 @@ func (c *Config) SetJiraToken(token *oauth1.Token) { // configFile is a serializable representation of the current Viper configuration. type configFile struct { - LogLevel string `json:"log-level,omitempty" mapstructure:"log-level"` - GithubToken string `json:"github-token,omitempty" mapstructure:"github-token"` - JiraUser string `json:"jira-user,omitempty" mapstructure:"jira-user"` - JiraPass string `json:"jira-pass,omitempty" mapstructure:"jira-pass"` - JiraToken string `json:"jira-token,omitempty" mapstructure:"jira-token"` - JiraSecret string `json:"jira-secret,omitempty" mapstructure:"jira-secret"` - JiraKey string `json:"jira-private-key-path,omitempty" mapstructure:"jira-private-key-path"` - JiraCKey string `json:"jira-consumer-key,omitempty" mapstructure:"jira-consumer-key"` - RepoName string `json:"repo-name,omitempty" mapstructure:"repo-name"` - JiraURI string `json:"jira-uri,omitempty" mapstructure:"jira-uri"` - JiraProject string `json:"jira-project,omitempty" mapstructure:"jira-project"` - Since string `json:"since,omitempty" mapstructure:"since"` - Confirm bool `json:"confirm,omitempty" mapstructure:"confirm"` - Timeout time.Duration `json:"timeout,omitempty" mapstructure:"timeout"` + LogLevel string `mapstructure:"log-level,omitempty"` + GithubToken string `mapstructure:"github-token,omitempty"` + JiraUser string `mapstructure:"jira-user,omitempty"` + JiraPass string `mapstructure:"jira-pass,omitempty"` + JiraToken string `mapstructure:"jira-token,omitempty"` + JiraSecret string `mapstructure:"jira-secret,omitempty"` + JiraKey string `mapstructure:"jira-private-key-path,omitempty"` + JiraCKey string `mapstructure:"jira-consumer-key,omitempty"` + RepoName string `mapstructure:"repo-name,omitempty"` + JiraURI string `mapstructure:"jira-uri,omitempty"` + JiraProject string `mapstructure:"jira-project,omitempty"` + Since string `mapstructure:"since,omitempty"` + Confirm bool `mapstructure:"confirm,omitempty"` + Timeout time.Duration `mapstructure:"timeout,omitempty"` } -// SaveConfig updates the `since` parameter to now, then saves the configuration file. -func (c *Config) SaveConfig() error { +// UpdateConfig updates the `since` parameter to now, then saves the configuration file. +func (c *Config) UpdateConfig() error { c.cmdConfig.Set( options.ConfigKeySince, time.Now().Format(options.DateFormat), @@ -300,9 +318,14 @@ func (c *Config) SaveConfig() error { return fmt.Errorf("unmarshalling config: %w", err) } - b, err := json.MarshalIndent(cf, "", " ") + cfMap := make(map[string]interface{}) + if err := mapstructure.Decode(cf, &cfMap); err != nil { + return fmt.Errorf("decoding configFile struct to map[string]interface{}: %w", err) + } + + b, err := c.encoderRegistry.Encode(c.cmdFileType, cfMap) if err != nil { - return fmt.Errorf("marshalling config: %w", err) + return fmt.Errorf("encoding config to %s format: %w", c.cmdFileType, err) } f, err := os.OpenFile(c.cmdConfig.ConfigFileUsed(), os.O_RDWR|os.O_TRUNC|os.O_CREATE, 0o644) @@ -324,7 +347,7 @@ func (c *Config) SaveConfig() error { // command line options, configuration file options, and // default configuration values. This viper object becomes // the single source of truth for the app configuration. -func newViper(appName, cfgFile string) *viper.Viper { +func newViper(appName, cfgFile, cfgFileType string) *viper.Viper { logger := log.New() v := viper.New() @@ -337,7 +360,7 @@ func newViper(appName, cfgFile string) *viper.Viper { if cfgFile != "" { v.SetConfigFile(cfgFile) } - v.SetConfigType(getConfigTypeFromName(cfgFile)) + v.SetConfigType(cfgFileType) if err := v.ReadInConfig(); err == nil { log.WithField("file", v.ConfigFileUsed()).Infof("config file loaded") @@ -517,6 +540,47 @@ func (c *Config) getFieldIDs(client *jira.Client) (*fields, error) { return &fieldIDs, nil } +// resetEncoding creates a new encoding registry with the currently supported file formats. +func (c *Config) resetEncoding() error { + encoderRegistry := encoding.NewEncoderRegistry() + registerEncoder := func(format string, codec encoding.Encoder) error { + if err := encoderRegistry.RegisterEncoder(format, codec); err != nil { + return fmt.Errorf("registering encoder for %s extension: %w", format, err) + } + + return nil + } + + { + codec := &yaml.Codec{} + if err := registerEncoder("yaml", codec); err != nil { + return err + } + if err := registerEncoder("yml", codec); err != nil { + return err + } + } + + { + codec := &json.Codec{ + Indent: " ", + } + if err := registerEncoder("json", codec); err != nil { + return err + } + } + + { + codec := &toml.Codec{} + if err := registerEncoder("toml", codec); err != nil { + return err + } + } + + c.encoderRegistry = encoderRegistry + return nil +} + // Errors var ( @@ -541,14 +605,14 @@ func errCustomFieldIDNotFound(field string) error { } // getConfigTypeFromName extracts the extension from the passed in filename, -// returns `json` if it's empty. +// returns json if it's empty. func getConfigTypeFromName(filename string) string { - if filename == "" { - return "json" + // it's enough to rely on the type from the filename because + // viper will parse and validate the file's content + ext := filepath.Ext(filename) + if ext == "" { + return DefaultConfigFileType } - // it's enough to rely on the type from the filename because - // viper will parse and validate the the file's content - parts := strings.Split(filename, ".") - return parts[len(parts)-1] + return strings.TrimPrefix(ext, ".") } diff --git a/internal/encoding/encoder.go b/internal/encoding/encoder.go new file mode 100644 index 00000000..a4ba86d0 --- /dev/null +++ b/internal/encoding/encoder.go @@ -0,0 +1,66 @@ +package encoding + +import ( + "fmt" + "sync" +) + +// Encoder encodes the contents of v into a byte representation. +// It's primarily used for encoding a map[string]interface{} into a file format. +type Encoder interface { + Encode(v map[string]interface{}) ([]byte, error) +} + +const ( + // ErrEncoderNotFound is returned when there is no encoder registered for a format. + ErrEncoderNotFound = encodingError("encoder not found for this format") + + // ErrEncoderFormatAlreadyRegistered is returned when an encoder is already registered for a format. + ErrEncoderFormatAlreadyRegistered = encodingError("encoder already registered for this format") +) + +// EncoderRegistry can choose an appropriate Encoder based on the provided format. +type EncoderRegistry struct { + encoders map[string]Encoder + + mu sync.RWMutex +} + +// NewEncoderRegistry returns a new, initialized EncoderRegistry. +func NewEncoderRegistry() *EncoderRegistry { + return &EncoderRegistry{ + encoders: make(map[string]Encoder), + } +} + +// RegisterEncoder registers an Encoder for a format. +// Registering a Encoder for an already existing format is not supported. +func (e *EncoderRegistry) RegisterEncoder(format string, enc Encoder) error { + e.mu.Lock() + defer e.mu.Unlock() + + if _, ok := e.encoders[format]; ok { + return ErrEncoderFormatAlreadyRegistered + } + + e.encoders[format] = enc + + return nil +} + +func (e *EncoderRegistry) Encode(format string, v map[string]interface{}) ([]byte, error) { + e.mu.RLock() + encoder, ok := e.encoders[format] + e.mu.RUnlock() + + if !ok { + return nil, ErrEncoderNotFound + } + + b, err := encoder.Encode(v) + if err != nil { + return nil, fmt.Errorf("encoding map to %s format: %w", format, err) + } + + return b, nil +} diff --git a/internal/encoding/error.go b/internal/encoding/error.go new file mode 100644 index 00000000..e4cde02d --- /dev/null +++ b/internal/encoding/error.go @@ -0,0 +1,7 @@ +package encoding + +type encodingError string + +func (e encodingError) Error() string { + return string(e) +} diff --git a/internal/encoding/json/codec.go b/internal/encoding/json/codec.go new file mode 100644 index 00000000..84dec1c2 --- /dev/null +++ b/internal/encoding/json/codec.go @@ -0,0 +1,24 @@ +package json + +import ( + "encoding/json" + "fmt" +) + +// Codec implements the encoding.Encoder interface for JSON encoding. +type Codec struct { + // prefix for JSON marshal. + Prefix string + + // indentation for JSON marshal. + Indent string +} + +func (c *Codec) Encode(v map[string]interface{}) ([]byte, error) { + b, err := json.MarshalIndent(v, c.Prefix, c.Indent) + if err != nil { + return nil, fmt.Errorf("json marshalling value: %w", err) + } + + return b, nil +} diff --git a/internal/encoding/json/codec_test.go b/internal/encoding/json/codec_test.go new file mode 100644 index 00000000..85908b7c --- /dev/null +++ b/internal/encoding/json/codec_test.go @@ -0,0 +1,61 @@ +package json + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +const encoded = `{ + "key": "value", + "list": [ + "item1", + "item2", + "item3" + ], + "map": { + "key": "value" + }, + "nested_map": { + "map": { + "key": "value", + "list": [ + "item1", + "item2", + "item3" + ] + } + } +}` + +var data = map[string]interface{}{ + "key": "value", + "list": []interface{}{ + "item1", + "item2", + "item3", + }, + "map": map[string]interface{}{ + "key": "value", + }, + "nested_map": map[string]interface{}{ + "map": map[string]interface{}{ + "key": "value", + "list": []interface{}{ + "item1", + "item2", + "item3", + }, + }, + }, +} + +func TestEncode(t *testing.T) { + codec := Codec{ + Indent: " ", + } + + b, err := codec.Encode(data) + require.Empty(t, err) + require.Equal(t, encoded, string(b)) +} diff --git a/internal/encoding/toml/codec.go b/internal/encoding/toml/codec.go new file mode 100644 index 00000000..140445b6 --- /dev/null +++ b/internal/encoding/toml/codec.go @@ -0,0 +1,19 @@ +package toml + +import ( + "fmt" + + "github.com/pelletier/go-toml/v2" +) + +// Codec implements the encoding.Encoder interface for TOML encoding. +type Codec struct{} + +func (Codec) Encode(v map[string]interface{}) ([]byte, error) { + b, err := toml.Marshal(v) + if err != nil { + return nil, fmt.Errorf("toml marshalling value: %w", err) + } + + return b, nil +} diff --git a/internal/encoding/toml/codec_test.go b/internal/encoding/toml/codec_test.go new file mode 100644 index 00000000..17070889 --- /dev/null +++ b/internal/encoding/toml/codec_test.go @@ -0,0 +1,49 @@ +package toml + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +const encoded = `key = 'value' +list = ['item1', 'item2', 'item3'] + +[map] +key = 'value' + +[nested_map] +[nested_map.map] +key = 'value' +list = ['item1', 'item2', 'item3'] +` + +var data = map[string]interface{}{ + "key": "value", + "list": []interface{}{ + "item1", + "item2", + "item3", + }, + "map": map[string]interface{}{ + "key": "value", + }, + "nested_map": map[string]interface{}{ + "map": map[string]interface{}{ + "key": "value", + "list": []interface{}{ + "item1", + "item2", + "item3", + }, + }, + }, +} + +func TestEncode(t *testing.T) { + codec := Codec{} + + b, err := codec.Encode(data) + require.Empty(t, err) + require.Equal(t, encoded, string(b)) +} diff --git a/internal/encoding/yaml/codec.go b/internal/encoding/yaml/codec.go new file mode 100644 index 00000000..fa5391f5 --- /dev/null +++ b/internal/encoding/yaml/codec.go @@ -0,0 +1,19 @@ +package yaml + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// Codec implements the encoding.Encoder interfaces for YAML encoding. +type Codec struct{} + +func (Codec) Encode(v map[string]interface{}) ([]byte, error) { + b, err := yaml.Marshal(v) + if err != nil { + return nil, fmt.Errorf("yaml marshalling value: %w", err) + } + + return b, nil +} diff --git a/internal/encoding/yaml/codec_test.go b/internal/encoding/yaml/codec_test.go new file mode 100644 index 00000000..a73c1917 --- /dev/null +++ b/internal/encoding/yaml/codec_test.go @@ -0,0 +1,53 @@ +package yaml + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +const encoded = `key: value +list: + - item1 + - item2 + - item3 +map: + key: value +nested_map: + map: + key: value + list: + - item1 + - item2 + - item3 +` + +var data = map[string]interface{}{ + "key": "value", + "list": []interface{}{ + "item1", + "item2", + "item3", + }, + "map": map[string]interface{}{ + "key": "value", + }, + "nested_map": map[string]interface{}{ + "map": map[string]interface{}{ + "key": "value", + "list": []interface{}{ + "item1", + "item2", + "item3", + }, + }, + }, +} + +func TestEncode(t *testing.T) { + codec := Codec{} + + b, err := codec.Encode(data) + require.Empty(t, err) + require.Equal(t, encoded, string(b)) +}