From 6efbc2dd11d6d7014efdea1155e4846e05e36022 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Wed, 3 Apr 2024 19:03:45 +0200 Subject: [PATCH] Add validation to fieldcollection Signed-off-by: Knut Ahlers --- fieldcollection/fieldcollection.go | 312 ++++-------------------- fieldcollection/fieldcollection_test.go | 96 +++----- fieldcollection/marshalJSON.go | 38 +++ fieldcollection/marshalJSON_test.go | 23 ++ fieldcollection/marshalYAML.go | 21 ++ fieldcollection/marshalYAML_test.go | 23 ++ fieldcollection/schema.go | 176 +++++++++++++ fieldcollection/schema_test.go | 116 +++++++++ fieldcollection/typeBool.go | 50 ++++ fieldcollection/typeBool_test.go | 46 ++++ fieldcollection/typeDuration.go | 51 ++++ fieldcollection/typeDuration_test.go | 47 ++++ fieldcollection/typeInt64.go | 64 +++++ fieldcollection/typeInt64_test.go | 56 +++++ fieldcollection/typeString.go | 50 ++++ fieldcollection/typeStringSlice.go | 56 +++++ fieldcollection/typeStringSlice_test.go | 45 ++++ fieldcollection/typeString_test.go | 46 ++++ go.mod | 3 +- go.sum | 2 - yaml/tojson.go | 9 +- 21 files changed, 1000 insertions(+), 330 deletions(-) create mode 100644 fieldcollection/marshalJSON.go create mode 100644 fieldcollection/marshalJSON_test.go create mode 100644 fieldcollection/marshalYAML.go create mode 100644 fieldcollection/marshalYAML_test.go create mode 100644 fieldcollection/schema.go create mode 100644 fieldcollection/schema_test.go create mode 100644 fieldcollection/typeBool.go create mode 100644 fieldcollection/typeBool_test.go create mode 100644 fieldcollection/typeDuration.go create mode 100644 fieldcollection/typeDuration_test.go create mode 100644 fieldcollection/typeInt64.go create mode 100644 fieldcollection/typeInt64_test.go create mode 100644 fieldcollection/typeString.go create mode 100644 fieldcollection/typeStringSlice.go create mode 100644 fieldcollection/typeStringSlice_test.go create mode 100644 fieldcollection/typeString_test.go diff --git a/fieldcollection/fieldcollection.go b/fieldcollection/fieldcollection.go index 3df3c2c..5ff9913 100644 --- a/fieldcollection/fieldcollection.go +++ b/fieldcollection/fieldcollection.go @@ -1,62 +1,44 @@ +// Package fieldcollection contains a map[string]any with accessor +// methods to derive them into different formats package fieldcollection import ( - "encoding/json" - "fmt" - "strconv" "strings" "sync" - "time" "github.com/pkg/errors" ) var ( - ErrValueNotSet = errors.New("specified value not found") + // ErrValueNotSet signalizes the value does not exist in the map + ErrValueNotSet = errors.New("specified value not found") + // ErrValueMismatch signalizes the value has a different data type ErrValueMismatch = errors.New("specified value has different format") ) -type FieldCollection struct { - data map[string]interface{} - lock sync.RWMutex -} +type ( + // FieldCollection holds a map with integrated locking and can + // therefore used in multiple Go-routines concurrently + FieldCollection struct { + data map[string]any + lock sync.RWMutex + } +) // NewFieldCollection creates a new FieldCollection with empty data store func NewFieldCollection() *FieldCollection { - return &FieldCollection{data: make(map[string]interface{})} + return &FieldCollection{data: make(map[string]any)} } // FieldCollectionFromData is a wrapper around NewFieldCollection and SetFromData -func FieldCollectionFromData(data map[string]interface{}) *FieldCollection { +// +//revive:disable-next-line:exported +func FieldCollectionFromData(data map[string]any) *FieldCollection { o := NewFieldCollection() o.SetFromData(data) return o } -// CanBool tries to read key name as bool and checks whether error is nil -func (f *FieldCollection) CanBool(name string) bool { - _, err := f.Bool(name) - return err == nil -} - -// CanDuration tries to read key name as time.Duration and checks whether error is nil -func (f *FieldCollection) CanDuration(name string) bool { - _, err := f.Duration(name) - return err == nil -} - -// CanInt64 tries to read key name as int64 and checks whether error is nil -func (f *FieldCollection) CanInt64(name string) bool { - _, err := f.Int64(name) - return err == nil -} - -// CanString tries to read key name as string and checks whether error is nil -func (f *FieldCollection) CanString(name string) bool { - _, err := f.String(name) - return err == nil -} - // Clone is a wrapper around n.SetFromData(o.Data()) func (f *FieldCollection) Clone() *FieldCollection { out := new(FieldCollection) @@ -65,7 +47,7 @@ func (f *FieldCollection) Clone() *FieldCollection { } // Data creates a map-copy of the data stored inside the FieldCollection -func (f *FieldCollection) Data() map[string]interface{} { +func (f *FieldCollection) Data() map[string]any { if f == nil { return nil } @@ -73,7 +55,7 @@ func (f *FieldCollection) Data() map[string]interface{} { f.lock.RLock() defer f.lock.RUnlock() - out := make(map[string]interface{}) + out := make(map[string]any) for k := range f.data { out[k] = f.data[k] } @@ -114,186 +96,9 @@ func (f *FieldCollection) HasAll(keys ...string) bool { return f.Expect(keys...) == nil } -// MustBool is a wrapper around Bool and panics if an error was returned -func (f *FieldCollection) MustBool(name string, defVal *bool) bool { - v, err := f.Bool(name) - if err != nil { - if defVal != nil { - return *defVal - } - panic(err) - } - return v -} - -// MustDuration is a wrapper around Duration and panics if an error was returned -func (f *FieldCollection) MustDuration(name string, defVal *time.Duration) time.Duration { - v, err := f.Duration(name) - if err != nil { - if defVal != nil { - return *defVal - } - panic(err) - } - return v -} - -// MustInt64 is a wrapper around Int64 and panics if an error was returned -func (f *FieldCollection) MustInt64(name string, defVal *int64) int64 { - v, err := f.Int64(name) - if err != nil { - if defVal != nil { - return *defVal - } - panic(err) - } - return v -} - -// MustString is a wrapper around String and panics if an error was returned -func (f *FieldCollection) MustString(name string, defVal *string) string { - v, err := f.String(name) - if err != nil { - if defVal != nil { - return *defVal - } - panic(err) - } - return v -} - -// Bool tries to read key name as bool -func (f *FieldCollection) Bool(name string) (bool, error) { - if f == nil || f.data == nil { - return false, errors.New("uninitialized field collection") - } - - f.lock.RLock() - defer f.lock.RUnlock() - - v, ok := f.data[name] - if !ok { - return false, ErrValueNotSet - } - - switch v := v.(type) { - case bool: - return v, nil - case string: - bv, err := strconv.ParseBool(v) - return bv, errors.Wrap(err, "parsing string to bool") - } - - return false, ErrValueMismatch -} - -// Duration tries to read key name as time.Duration -func (f *FieldCollection) Duration(name string) (time.Duration, error) { - if f == nil || f.data == nil { - return 0, errors.New("uninitialized field collection") - } - - f.lock.RLock() - defer f.lock.RUnlock() - - v, err := f.String(name) - if err != nil { - return 0, errors.Wrap(err, "getting string value") - } - - d, err := time.ParseDuration(v) - return d, errors.Wrap(err, "parsing value") -} - -// Int64 tries to read key name as int64 -func (f *FieldCollection) Int64(name string) (int64, error) { - if f == nil || f.data == nil { - return 0, errors.New("uninitialized field collection") - } - - f.lock.RLock() - defer f.lock.RUnlock() - - v, ok := f.data[name] - if !ok { - return 0, ErrValueNotSet - } - - switch v := v.(type) { - case int: - return int64(v), nil - case int16: - return int64(v), nil - case int32: - return int64(v), nil - case int64: - return v, nil - } - - return 0, ErrValueMismatch -} - -// Set sets a single key to specified value -func (f *FieldCollection) Set(key string, value interface{}) { - if f == nil { - f = NewFieldCollection() - } - - f.lock.Lock() - defer f.lock.Unlock() - - if f.data == nil { - f.data = make(map[string]interface{}) - } - - f.data[key] = value -} - -// SetFromData takes a map of data and copies all data into the FieldCollection -func (f *FieldCollection) SetFromData(data map[string]interface{}) { - if f == nil { - f = NewFieldCollection() - } - - f.lock.Lock() - defer f.lock.Unlock() - - if f.data == nil { - f.data = make(map[string]interface{}) - } - - for key, value := range data { - f.data[key] = value - } -} - -// String tries to read key name as string -func (f *FieldCollection) String(name string) (string, error) { - if f == nil || f.data == nil { - return "", errors.New("uninitialized field collection") - } - - f.lock.RLock() - defer f.lock.RUnlock() - - v, ok := f.data[name] - if !ok { - return "", ErrValueNotSet - } - - if sv, ok := v.(string); ok { - return sv, nil - } - - if iv, ok := v.(fmt.Stringer); ok { - return iv.String(), nil - } - - return "", ErrValueMismatch -} - -// StringSlice tries to read key name as []string -func (f *FieldCollection) StringSlice(name string) ([]string, error) { +// Get retrieves the value of a key as "any" type or returns an error +// in case the field is not set +func (f *FieldCollection) Get(name string) (any, error) { if f == nil || f.data == nil { return nil, errors.New("uninitialized field collection") } @@ -306,62 +111,43 @@ func (f *FieldCollection) StringSlice(name string) ([]string, error) { return nil, ErrValueNotSet } - switch v := v.(type) { - case []string: - return v, nil - - case []interface{}: - var out []string - - for _, iv := range v { - sv, ok := iv.(string) - if !ok { - return nil, errors.New("value in slice was not string") - } - out = append(out, sv) - } - - return out, nil - } - - return nil, ErrValueMismatch + return v, nil } -// Implement JSON marshalling to plain underlying map[string]interface{} - -func (f *FieldCollection) MarshalJSON() ([]byte, error) { - if f == nil || f.data == nil { - return []byte("{}"), nil - } - +// Keys returns a list of all known keys +func (f *FieldCollection) Keys() (keys []string) { f.lock.RLock() defer f.lock.RUnlock() - return json.Marshal(f.data) -} - -func (f *FieldCollection) UnmarshalJSON(raw []byte) error { - data := make(map[string]interface{}) - if err := json.Unmarshal(raw, &data); err != nil { - return errors.Wrap(err, "unmarshalling from JSON") + for k := range f.data { + keys = append(keys, k) } - f.SetFromData(data) - return nil + return keys } -// Implement YAML marshalling to plain underlying map[string]interface{} +// Set sets a single key to specified value +func (f *FieldCollection) Set(key string, value any) { + f.lock.Lock() + defer f.lock.Unlock() -func (f *FieldCollection) MarshalYAML() (interface{}, error) { - return f.Data(), nil -} - -func (f *FieldCollection) UnmarshalYAML(unmarshal func(interface{}) error) error { - data := make(map[string]interface{}) - if err := unmarshal(&data); err != nil { - return errors.Wrap(err, "unmarshalling from YAML") + if f.data == nil { + f.data = make(map[string]any) } - f.SetFromData(data) - return nil + f.data[key] = value +} + +// SetFromData takes a map of data and copies all data into the FieldCollection +func (f *FieldCollection) SetFromData(data map[string]any) { + f.lock.Lock() + defer f.lock.Unlock() + + if f.data == nil { + f.data = make(map[string]any) + } + + for key, value := range data { + f.data[key] = value + } } diff --git a/fieldcollection/fieldcollection_test.go b/fieldcollection/fieldcollection_test.go index c764707..00394b6 100644 --- a/fieldcollection/fieldcollection_test.go +++ b/fieldcollection/fieldcollection_test.go @@ -1,66 +1,19 @@ package fieldcollection import ( - "bytes" - "encoding/json" - "strings" "testing" - "gopkg.in/yaml.v2" + "github.com/stretchr/testify/assert" ) -func TestFieldCollectionJSONMarshal(t *testing.T) { - var ( - buf = new(bytes.Buffer) - raw = `{"key1":"test1","key2":"test2"}` - f = NewFieldCollection() - ) - - if err := json.NewDecoder(strings.NewReader(raw)).Decode(f); err != nil { - t.Fatalf("Unable to unmarshal: %s", err) - } - - if err := json.NewEncoder(buf).Encode(f); err != nil { - t.Fatalf("Unable to marshal: %s", err) - } - - if raw != strings.TrimSpace(buf.String()) { - t.Errorf("Marshalled JSON does not match expectation: res=%s exp=%s", buf.String(), raw) - } -} - -func TestFieldCollectionYAMLMarshal(t *testing.T) { - var ( - buf = new(bytes.Buffer) - raw = "key1: test1\nkey2: test2" - f = NewFieldCollection() - ) - - if err := yaml.NewDecoder(strings.NewReader(raw)).Decode(f); err != nil { - t.Fatalf("Unable to unmarshal: %s", err) - } - - if err := yaml.NewEncoder(buf).Encode(f); err != nil { - t.Fatalf("Unable to marshal: %s", err) - } - - if raw != strings.TrimSpace(buf.String()) { - t.Errorf("Marshalled YAML does not match expectation: res=%s exp=%s", buf.String(), raw) - } -} - -func TestFieldCollectionNilModify(t *testing.T) { +func TestExpect(t *testing.T) { var f *FieldCollection - - f.Set("foo", "bar") - - f = nil - f.SetFromData(map[string]interface{}{"foo": "bar"}) + assert.NoError(t, f.Expect()) + assert.Error(t, f.Expect("foo")) } -func TestFieldCollectionNilClone(t *testing.T) { +func TestFieldCollectionNilClone(*testing.T) { var f *FieldCollection - f.Clone() } @@ -68,13 +21,38 @@ func TestFieldCollectionNilDataGet(t *testing.T) { var f *FieldCollection for name, fn := range map[string]func(name string) bool{ - "bool": f.CanBool, - "duration": f.CanDuration, - "int64": f.CanInt64, - "string": f.CanString, + "bool": f.CanBool, + "duration": f.CanDuration, + "int64": f.CanInt64, + "string": f.CanString, + "stringSlice": f.CanStringSlice, } { - if fn("foo") { - t.Errorf("%s key is available", name) - } + assert.False(t, fn("foo"), "%s key is available", name) } } + +func TestGet(t *testing.T) { + f := &FieldCollection{} + _, err := f.Get("foo") + assert.Error(t, err) + + f.Set("foo", "bar") + _, err = f.Get("bar") + assert.ErrorIs(t, err, ErrValueNotSet) + + v, err := f.Get("foo") + assert.NoError(t, err) + assert.Equal(t, "bar", v) +} + +func TestKeys(t *testing.T) { + f := FieldCollectionFromData(map[string]any{ + "foo": "bar", + }) + assert.Equal(t, []string{"foo"}, f.Keys()) +} + +func TestSetOnNew(*testing.T) { + f := new(FieldCollection) + f.Set("foo", "bar") +} diff --git a/fieldcollection/marshalJSON.go b/fieldcollection/marshalJSON.go new file mode 100644 index 0000000..45f4ceb --- /dev/null +++ b/fieldcollection/marshalJSON.go @@ -0,0 +1,38 @@ +package fieldcollection + +import ( + "encoding/json" + "fmt" + + "github.com/pkg/errors" +) + +// Implement JSON marshalling to plain underlying map[string]any + +// MarshalJSON implements json.Marshaller interface +func (f *FieldCollection) MarshalJSON() ([]byte, error) { + if f == nil || f.data == nil { + return []byte("{}"), nil + } + + f.lock.RLock() + defer f.lock.RUnlock() + + data, err := json.Marshal(f.data) + if err != nil { + return nil, fmt.Errorf("marshalling to JSON: %w", err) + } + + return data, nil +} + +// UnmarshalJSON implements json.Unmarshaller interface +func (f *FieldCollection) UnmarshalJSON(raw []byte) error { + data := make(map[string]any) + if err := json.Unmarshal(raw, &data); err != nil { + return errors.Wrap(err, "unmarshalling from JSON") + } + + f.SetFromData(data) + return nil +} diff --git a/fieldcollection/marshalJSON_test.go b/fieldcollection/marshalJSON_test.go new file mode 100644 index 0000000..f078f22 --- /dev/null +++ b/fieldcollection/marshalJSON_test.go @@ -0,0 +1,23 @@ +package fieldcollection + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFieldCollectionJSONMarshal(t *testing.T) { + var ( + buf = new(bytes.Buffer) + raw = `{"key1":"test1","key2":"test2"}` + f = NewFieldCollection() + ) + + require.NoError(t, json.NewDecoder(strings.NewReader(raw)).Decode(f)) + require.NoError(t, json.NewEncoder(buf).Encode(f)) + assert.Equal(t, raw, strings.TrimSpace(buf.String())) +} diff --git a/fieldcollection/marshalYAML.go b/fieldcollection/marshalYAML.go new file mode 100644 index 0000000..3220a05 --- /dev/null +++ b/fieldcollection/marshalYAML.go @@ -0,0 +1,21 @@ +package fieldcollection + +import "github.com/pkg/errors" + +// Implement YAML marshalling to plain underlying map[string]any + +// MarshalYAML implements yaml.Marshaller interface +func (f *FieldCollection) MarshalYAML() (any, error) { + return f.Data(), nil +} + +// UnmarshalYAML implements yaml.Unmarshaller interface +func (f *FieldCollection) UnmarshalYAML(unmarshal func(any) error) error { + data := make(map[string]any) + if err := unmarshal(&data); err != nil { + return errors.Wrap(err, "unmarshalling from YAML") + } + + f.SetFromData(data) + return nil +} diff --git a/fieldcollection/marshalYAML_test.go b/fieldcollection/marshalYAML_test.go new file mode 100644 index 0000000..7756ea2 --- /dev/null +++ b/fieldcollection/marshalYAML_test.go @@ -0,0 +1,23 @@ +package fieldcollection + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestFieldCollectionYAMLMarshal(t *testing.T) { + var ( + buf = new(bytes.Buffer) + raw = "key1: test1\nkey2: test2" + f = NewFieldCollection() + ) + + require.NoError(t, yaml.NewDecoder(strings.NewReader(raw)).Decode(f)) + require.NoError(t, yaml.NewEncoder(buf).Encode(f)) + assert.Equal(t, raw, strings.TrimSpace(buf.String())) +} diff --git a/fieldcollection/schema.go b/fieldcollection/schema.go new file mode 100644 index 0000000..fec7bf6 --- /dev/null +++ b/fieldcollection/schema.go @@ -0,0 +1,176 @@ +package fieldcollection + +import ( + "fmt" + "sort" + "strings" + + "github.com/Luzifer/go_helpers/v2/str" +) + +const ( + knownFields = "knownFields" +) + +type ( + // SchemaField defines how a field is expected to be + SchemaField struct { + // Name of the field to validate + Name string + // If set to true the field must i.e. not be "" for a string field + NonEmpty bool + // The expected type of the field + Type SchemaFieldType + } + + // SchemaFieldType is a collection of known field types for which + // can be checked + SchemaFieldType uint64 + + // ValidateOpt is a validation function to be executed during the + // validation call + ValidateOpt func(f, validateStore *FieldCollection) error +) + +// Collection of known field types for which can be checked +const ( + SchemaFieldTypeAny SchemaFieldType = iota + SchemaFieldTypeBool + SchemaFieldTypeDuration + SchemaFieldTypeInt64 + SchemaFieldTypeString + SchemaFieldTypeStringSlice +) + +// CanHaveField validates the type of the field if it exists and puts +// the field to the allow-list for MustHaveNoUnknowFields +func CanHaveField(field SchemaField) ValidateOpt { + return func(f, validateStore *FieldCollection) error { + validateStore.Set(knownFields, append(validateStore.MustStringSlice(knownFields, nil), field.Name)) + + if !f.HasAll(field.Name) { + // It is allowed to not exist, and if it does not we don't need + // to type-check it + return nil + } + + return validateFieldType(f, field) + } +} + +// MustHaveField validates the type of the field and puts the field to +// the allow-list for MustHaveNoUnknowFields +func MustHaveField(field SchemaField) ValidateOpt { + return func(f, validateStore *FieldCollection) error { + validateStore.Set(knownFields, append(validateStore.MustStringSlice(knownFields, nil), field.Name)) + + if !f.HasAll(field.Name) { + // It must exist and does not + return fmt.Errorf("field %s does not exist", field.Name) + } + + return validateFieldType(f, field) + } +} + +// MustHaveNoUnknowFields validates no fields are present which are +// not previously allow-listed through CanHaveField or MustHaveField +// and therefore should be put as the last ValidateOpt +func MustHaveNoUnknowFields(f *FieldCollection, validateStore *FieldCollection) error { + var unexpected []string + + for _, k := range f.Keys() { + if !str.StringInSlice(k, validateStore.MustStringSlice(knownFields, nil)) { + unexpected = append(unexpected, k) + } + } + + sort.Strings(unexpected) + + if len(unexpected) > 0 { + return fmt.Errorf("found unexpected fields: %s", strings.Join(unexpected, ", ")) + } + + return nil +} + +// ValidateSchema can be used to validate the contents of the +// FieldCollection by passing in field definitions which may be there +// or must be there and to check whether there are no surplus fields +func (f *FieldCollection) ValidateSchema(opts ...ValidateOpt) error { + validateStore := NewFieldCollection() + validateStore.Set(knownFields, []string{}) + + for _, opt := range opts { + if err := opt(f, validateStore); err != nil { + return err + } + } + + return nil +} + +//nolint:gocyclo // These are quite simple checks +func validateFieldType(f *FieldCollection, field SchemaField) (err error) { + switch field.Type { + case SchemaFieldTypeAny: + v, err := f.Get(field.Name) + if err != nil { + return fmt.Errorf("getting field %s: %w", field.Name, err) + } + + if field.NonEmpty && v == nil { + return fmt.Errorf("field %s is empty", field.Name) + } + + case SchemaFieldTypeBool: + if !f.CanBool(field.Name) { + return fmt.Errorf("field %s is not of type bool", field.Name) + } + + case SchemaFieldTypeDuration: + v, err := f.Duration(field.Name) + if err != nil { + return fmt.Errorf("field %s is not of type time.Duration: %w", field.Name, err) + } + + if field.NonEmpty && v == 0 { + return fmt.Errorf("field %s is empty", field.Name) + } + + case SchemaFieldTypeInt64: + v, err := f.Int64(field.Name) + if err != nil { + return fmt.Errorf("field %s is not of type int64: %w", field.Name, err) + } + + if field.NonEmpty && v == 0 { + return fmt.Errorf("field %s is empty", field.Name) + } + + case SchemaFieldTypeString: + v, err := f.String(field.Name) + if err != nil { + return fmt.Errorf("field %s is not of type string: %w", field.Name, err) + } + + if field.NonEmpty && v == "" { + return fmt.Errorf("field %s is empty", field.Name) + } + + case SchemaFieldTypeStringSlice: + v, err := f.StringSlice(field.Name) + if err != nil { + return fmt.Errorf("field %s is not of type []string: %w", field.Name, err) + } + + if field.NonEmpty && len(v) == 0 { + return fmt.Errorf("field %s is empty", field.Name) + } + + default: + return fmt.Errorf("unknown field type specified") + } + + return nil +} diff --git a/fieldcollection/schema_test.go b/fieldcollection/schema_test.go new file mode 100644 index 0000000..24bf543 --- /dev/null +++ b/fieldcollection/schema_test.go @@ -0,0 +1,116 @@ +package fieldcollection + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +//nolint:funlen +func TestSchemaValidation(t *testing.T) { + fc := FieldCollectionFromData(map[string]any{ + "anyZero": nil, + "bool": true, + "duration": time.Second, + "durationZero": time.Duration(0), + "int64": int64(12), + "int64Zero": int64(0), + "string": "ohai", + "stringZero": "", + "stringSlice": []string{"ohai"}, + "stringSliceZero": []string{}, + "stringSliceNil": nil, + }) + + // No validations + assert.NoError(t, fc.ValidateSchema()) + + // Non-existing field + assert.ErrorContains(t, fc.ValidateSchema( + MustHaveField(SchemaField{Name: "foo"}), + ), "field foo does not exist") + + // Non-existing field but can + assert.NoError(t, fc.ValidateSchema( + CanHaveField(SchemaField{Name: "foo"}), + )) + + // No unexpected fields (none given) + assert.ErrorContains(t, fc.ValidateSchema( + MustHaveNoUnknowFields, + ), "found unexpected fields: anyZero, bool, duration, durationZero, int64, int64Zero, string, stringSlice, stringSliceNil, stringSliceZero, stringZero") + + // No unexpected fields (all given) + assert.NoError(t, fc.ValidateSchema( + CanHaveField(SchemaField{Name: "anyZero"}), + CanHaveField(SchemaField{Name: "bool"}), + CanHaveField(SchemaField{Name: "duration"}), + CanHaveField(SchemaField{Name: "durationZero"}), + CanHaveField(SchemaField{Name: "int64"}), + CanHaveField(SchemaField{Name: "int64Zero"}), + CanHaveField(SchemaField{Name: "string"}), + CanHaveField(SchemaField{Name: "stringSlice"}), + CanHaveField(SchemaField{Name: "stringSliceNil"}), + CanHaveField(SchemaField{Name: "stringSliceZero"}), + CanHaveField(SchemaField{Name: "stringZero"}), + MustHaveNoUnknowFields, + )) + + // Field must exist in any type and not be zero + assert.NoError(t, fc.ValidateSchema( + MustHaveField(SchemaField{Name: "string", NonEmpty: true}), + )) + + // Field must exist in any type and not be zero but is zero + assert.ErrorContains(t, fc.ValidateSchema( + MustHaveField(SchemaField{Name: "anyZero", NonEmpty: true}), + ), "field anyZero is empty") + + // Fields must exist and be of correct type + assert.NoError(t, fc.ValidateSchema( + MustHaveField(SchemaField{Name: "bool", Type: SchemaFieldTypeBool}), + MustHaveField(SchemaField{Name: "duration", Type: SchemaFieldTypeDuration}), + MustHaveField(SchemaField{Name: "int64", Type: SchemaFieldTypeInt64}), + MustHaveField(SchemaField{Name: "string", Type: SchemaFieldTypeString}), + MustHaveField(SchemaField{Name: "stringSlice", Type: SchemaFieldTypeStringSlice}), + )) + assert.Error(t, fc.ValidateSchema( + MustHaveField(SchemaField{Name: "bool", Type: SchemaFieldTypeDuration}), + )) + assert.Error(t, fc.ValidateSchema( + MustHaveField(SchemaField{Name: "duration", Type: SchemaFieldTypeBool}), + )) + assert.Error(t, fc.ValidateSchema( + MustHaveField(SchemaField{Name: "int64", Type: SchemaFieldTypeStringSlice}), + )) + assert.Error(t, fc.ValidateSchema( + MustHaveField(SchemaField{Name: "string", Type: SchemaFieldTypeInt64}), + )) + assert.Error(t, fc.ValidateSchema( + MustHaveField(SchemaField{Name: "stringSlice", Type: SchemaFieldTypeString}), + )) + + // Fields must not be zero + assert.ErrorContains(t, fc.ValidateSchema( + MustHaveField(SchemaField{Name: "duration", NonEmpty: true, Type: SchemaFieldTypeDuration}), + MustHaveField(SchemaField{Name: "durationZero", NonEmpty: true, Type: SchemaFieldTypeDuration}), + ), "field durationZero is empty") + assert.ErrorContains(t, fc.ValidateSchema( + MustHaveField(SchemaField{Name: "int64", NonEmpty: true, Type: SchemaFieldTypeInt64}), + MustHaveField(SchemaField{Name: "int64Zero", NonEmpty: true, Type: SchemaFieldTypeInt64}), + ), "field int64Zero is empty") + assert.ErrorContains(t, fc.ValidateSchema( + MustHaveField(SchemaField{Name: "string", NonEmpty: true, Type: SchemaFieldTypeString}), + MustHaveField(SchemaField{Name: "stringZero", NonEmpty: true, Type: SchemaFieldTypeString}), + ), "field stringZero is empty") + assert.ErrorContains(t, fc.ValidateSchema( + MustHaveField(SchemaField{Name: "stringSlice", NonEmpty: true, Type: SchemaFieldTypeStringSlice}), + MustHaveField(SchemaField{Name: "stringSliceZero", NonEmpty: true, Type: SchemaFieldTypeStringSlice}), + ), "field stringSliceZero is empty") + + // Invalid field type + assert.ErrorContains(t, fc.ValidateSchema( + MustHaveField(SchemaField{Name: "stringSlice", NonEmpty: true, Type: 99999}), + ), "unknown field type specified") +} diff --git a/fieldcollection/typeBool.go b/fieldcollection/typeBool.go new file mode 100644 index 0000000..f4efff4 --- /dev/null +++ b/fieldcollection/typeBool.go @@ -0,0 +1,50 @@ +package fieldcollection + +import ( + "strconv" + + "github.com/pkg/errors" +) + +// Bool tries to read key name as bool +func (f *FieldCollection) Bool(name string) (bool, error) { + if f == nil || f.data == nil { + return false, errors.New("uninitialized field collection") + } + + f.lock.RLock() + defer f.lock.RUnlock() + + v, ok := f.data[name] + if !ok { + return false, ErrValueNotSet + } + + switch v := v.(type) { + case bool: + return v, nil + case string: + bv, err := strconv.ParseBool(v) + return bv, errors.Wrap(err, "parsing string to bool") + } + + return false, ErrValueMismatch +} + +// CanBool tries to read key name as bool and checks whether error is nil +func (f *FieldCollection) CanBool(name string) bool { + _, err := f.Bool(name) + return err == nil +} + +// MustBool is a wrapper around Bool and panics if an error was returned +func (f *FieldCollection) MustBool(name string, defVal *bool) bool { + v, err := f.Bool(name) + if err != nil { + if defVal != nil { + return *defVal + } + panic(err) + } + return v +} diff --git a/fieldcollection/typeBool_test.go b/fieldcollection/typeBool_test.go new file mode 100644 index 0000000..1f3b23f --- /dev/null +++ b/fieldcollection/typeBool_test.go @@ -0,0 +1,46 @@ +package fieldcollection + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBool(t *testing.T) { + fc := FieldCollectionFromData(map[string]any{ + "int": 12, + "invalidBoolString": "I'm a string!", + "validBool": true, + "validBoolString": "true", + "validBoolStringFalse": "false", + }) + + _, err := fc.Bool("_") + assert.ErrorIs(t, err, ErrValueNotSet) + + _, err = fc.Bool("int") + assert.ErrorIs(t, err, ErrValueMismatch) + + _, err = fc.Bool("invalidBoolString") + assert.Error(t, err) + + v, err := fc.Bool("validBool") + assert.NoError(t, err) + assert.True(t, v) + + v, err = fc.Bool("validBoolString") + assert.NoError(t, err) + assert.True(t, v) + + v, err = fc.Bool("validBoolStringFalse") + assert.NoError(t, err) + assert.False(t, v) + + assert.True(t, fc.CanBool("validBool")) + assert.False(t, fc.CanBool("int")) + + assert.NotPanics(t, func() { fc.MustBool("validBool", nil) }) + assert.Panics(t, func() { fc.MustBool("int", nil) }) + + assert.True(t, fc.MustBool("_", func(v bool) *bool { return &v }(true))) +} diff --git a/fieldcollection/typeDuration.go b/fieldcollection/typeDuration.go new file mode 100644 index 0000000..63a21f0 --- /dev/null +++ b/fieldcollection/typeDuration.go @@ -0,0 +1,51 @@ +package fieldcollection + +import ( + "fmt" + "time" + + "github.com/pkg/errors" +) + +// CanDuration tries to read key name as time.Duration and checks whether error is nil +func (f *FieldCollection) CanDuration(name string) bool { + _, err := f.Duration(name) + return err == nil +} + +// Duration tries to read key name as time.Duration +func (f *FieldCollection) Duration(name string) (time.Duration, error) { + if f == nil || f.data == nil { + return 0, errors.New("uninitialized field collection") + } + + switch { + case !f.HasAll(name): + return 0, ErrValueNotSet + + case f.CanInt64(name): + return time.Duration(f.MustInt64(name, nil)), nil + + case f.CanString(name): + v, err := time.ParseDuration(f.MustString(name, nil)) + if err != nil { + return 0, fmt.Errorf("parsing value: %w", err) + } + return v, nil + + default: + return 0, ErrValueMismatch + } +} + +// MustDuration is a wrapper around Duration and panics if an error was returned +func (f *FieldCollection) MustDuration(name string, defVal *time.Duration) time.Duration { + v, err := f.Duration(name) + if err != nil { + if defVal != nil { + return *defVal + } + panic(err) + } + return v +} diff --git a/fieldcollection/typeDuration_test.go b/fieldcollection/typeDuration_test.go new file mode 100644 index 0000000..689c932 --- /dev/null +++ b/fieldcollection/typeDuration_test.go @@ -0,0 +1,47 @@ +package fieldcollection + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDuration(t *testing.T) { + fc := FieldCollectionFromData(map[string]any{ + "int": 12, + "bool": true, + "invalidString": "I'm a string!", + "valid": time.Second, + "validString": "12m", + }) + + _, err := fc.Duration("_") + assert.ErrorIs(t, err, ErrValueNotSet) + + _, err = fc.Duration("bool") + assert.ErrorIs(t, err, ErrValueMismatch) + + _, err = fc.Duration("invalidString") + assert.Error(t, err) + + v, err := fc.Duration("valid") + assert.NoError(t, err) + assert.Equal(t, time.Second, v) + + v, err = fc.Duration("validString") + assert.NoError(t, err) + assert.Equal(t, 12*time.Minute, v) + + v, err = fc.Duration("int") + assert.NoError(t, err) + assert.Equal(t, 12*time.Nanosecond, v) + + assert.True(t, fc.CanDuration("valid")) + assert.False(t, fc.CanDuration("bool")) + + assert.NotPanics(t, func() { fc.MustDuration("valid", nil) }) + assert.Panics(t, func() { fc.MustDuration("bool", nil) }) + + assert.Equal(t, time.Second, fc.MustDuration("_", func(v time.Duration) *time.Duration { return &v }(time.Second))) +} diff --git a/fieldcollection/typeInt64.go b/fieldcollection/typeInt64.go new file mode 100644 index 0000000..e9a8996 --- /dev/null +++ b/fieldcollection/typeInt64.go @@ -0,0 +1,64 @@ +package fieldcollection + +import ( + "fmt" + "strconv" + + "github.com/pkg/errors" +) + +// Int64 tries to read key name as int64 +func (f *FieldCollection) Int64(name string) (int64, error) { + if f == nil || f.data == nil { + return 0, errors.New("uninitialized field collection") + } + + f.lock.RLock() + defer f.lock.RUnlock() + + v, ok := f.data[name] + if !ok { + return 0, ErrValueNotSet + } + + switch v := v.(type) { + case int: + return int64(v), nil + + case int16: + return int64(v), nil + + case int32: + return int64(v), nil + + case int64: + return v, nil + + case string: + pv, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("parsing value: %w", err) + } + return pv, nil + } + + return 0, ErrValueMismatch +} + +// CanInt64 tries to read key name as int64 and checks whether error is nil +func (f *FieldCollection) CanInt64(name string) bool { + _, err := f.Int64(name) + return err == nil +} + +// MustInt64 is a wrapper around Int64 and panics if an error was returned +func (f *FieldCollection) MustInt64(name string, defVal *int64) int64 { + v, err := f.Int64(name) + if err != nil { + if defVal != nil { + return *defVal + } + panic(err) + } + return v +} diff --git a/fieldcollection/typeInt64_test.go b/fieldcollection/typeInt64_test.go new file mode 100644 index 0000000..67a1f0c --- /dev/null +++ b/fieldcollection/typeInt64_test.go @@ -0,0 +1,56 @@ +package fieldcollection + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInt64(t *testing.T) { + fc := FieldCollectionFromData(map[string]any{ + "int": int(12), + "int16": int16(12), + "int32": int32(12), + "int64": int64(12), + "bool": true, + "invalidString": "I'm a string!", + "validString": "12", + }) + + _, err := fc.Int64("_") + assert.ErrorIs(t, err, ErrValueNotSet) + + _, err = fc.Int64("bool") + assert.ErrorIs(t, err, ErrValueMismatch) + + _, err = fc.Int64("invalidString") + assert.Error(t, err) + + v, err := fc.Int64("int") + assert.NoError(t, err) + assert.Equal(t, int64(12), v) + + v, err = fc.Int64("int16") + assert.NoError(t, err) + assert.Equal(t, int64(12), v) + + v, err = fc.Int64("int32") + assert.NoError(t, err) + assert.Equal(t, int64(12), v) + + v, err = fc.Int64("int64") + assert.NoError(t, err) + assert.Equal(t, int64(12), v) + + v, err = fc.Int64("validString") + assert.NoError(t, err) + assert.Equal(t, int64(12), v) + + assert.True(t, fc.CanInt64("int")) + assert.False(t, fc.CanInt64("bool")) + + assert.NotPanics(t, func() { fc.MustInt64("int32", nil) }) + assert.Panics(t, func() { fc.MustInt64("bool", nil) }) + + assert.Equal(t, int64(5), fc.MustInt64("_", func(v int64) *int64 { return &v }(5))) +} diff --git a/fieldcollection/typeString.go b/fieldcollection/typeString.go new file mode 100644 index 0000000..aa73d23 --- /dev/null +++ b/fieldcollection/typeString.go @@ -0,0 +1,50 @@ +package fieldcollection + +import ( + "fmt" + + "github.com/pkg/errors" +) + +// CanString tries to read key name as string and checks whether error is nil +func (f *FieldCollection) CanString(name string) bool { + _, err := f.String(name) + return err == nil +} + +// MustString is a wrapper around String and panics if an error was returned +func (f *FieldCollection) MustString(name string, defVal *string) string { + v, err := f.String(name) + if err != nil { + if defVal != nil { + return *defVal + } + panic(err) + } + return v +} + +// String tries to read key name as string +func (f *FieldCollection) String(name string) (string, error) { + if f == nil || f.data == nil { + return "", errors.New("uninitialized field collection") + } + + f.lock.RLock() + defer f.lock.RUnlock() + + v, ok := f.data[name] + if !ok { + return "", ErrValueNotSet + } + + if sv, ok := v.(string); ok { + return sv, nil + } + + if iv, ok := v.(fmt.Stringer); ok { + return iv.String(), nil + } + + return "", ErrValueMismatch +} diff --git a/fieldcollection/typeStringSlice.go b/fieldcollection/typeStringSlice.go new file mode 100644 index 0000000..e30f30c --- /dev/null +++ b/fieldcollection/typeStringSlice.go @@ -0,0 +1,56 @@ +package fieldcollection + +import "github.com/pkg/errors" + +// CanStringSlice tries to read key name as []string and checks whether error is nil +func (f *FieldCollection) CanStringSlice(name string) bool { + _, err := f.StringSlice(name) + return err == nil +} + +// MustStringSlice is a wrapper around StringSlice and panics if an error was returned +func (f *FieldCollection) MustStringSlice(name string, defVal *[]string) []string { + v, err := f.StringSlice(name) + if err != nil { + if defVal != nil { + return *defVal + } + panic(err) + } + return v +} + +// StringSlice tries to read key name as []string +func (f *FieldCollection) StringSlice(name string) ([]string, error) { + if f == nil || f.data == nil { + return nil, errors.New("uninitialized field collection") + } + + f.lock.RLock() + defer f.lock.RUnlock() + + v, ok := f.data[name] + if !ok { + return nil, ErrValueNotSet + } + + switch v := v.(type) { + case []string: + return v, nil + + case []any: + var out []string + + for _, iv := range v { + sv, ok := iv.(string) + if !ok { + return nil, errors.New("value in slice was not string") + } + out = append(out, sv) + } + + return out, nil + } + + return nil, ErrValueMismatch +} diff --git a/fieldcollection/typeStringSlice_test.go b/fieldcollection/typeStringSlice_test.go new file mode 100644 index 0000000..df62c2d --- /dev/null +++ b/fieldcollection/typeStringSlice_test.go @@ -0,0 +1,45 @@ +package fieldcollection + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStringSlice(t *testing.T) { + fc := FieldCollectionFromData(map[string]any{ + "int": 12, + "valid": []string{"ohai"}, + "invalidSlice": []int{12}, + "mixed": []any{"ohai", 12}, + "validAny": []any{"ohai"}, + }) + + _, err := fc.StringSlice("_") + assert.ErrorIs(t, err, ErrValueNotSet) + + _, err = fc.StringSlice("int") + assert.ErrorIs(t, err, ErrValueMismatch) + + _, err = fc.StringSlice("invalidSlice") + assert.Error(t, err) + + _, err = fc.StringSlice("mixed") + assert.Error(t, err) + + v, err := fc.StringSlice("valid") + assert.NoError(t, err) + assert.Equal(t, []string{"ohai"}, v) + + v, err = fc.StringSlice("validAny") + assert.NoError(t, err) + assert.Equal(t, []string{"ohai"}, v) + + assert.True(t, fc.CanStringSlice("valid")) + assert.False(t, fc.CanStringSlice("bool")) + + assert.NotPanics(t, func() { fc.MustStringSlice("valid", nil) }) + assert.Panics(t, func() { fc.MustStringSlice("bool", nil) }) + + assert.Equal(t, []string{"a"}, fc.MustStringSlice("_", func(v []string) *[]string { return &v }([]string{"a"}))) +} diff --git a/fieldcollection/typeString_test.go b/fieldcollection/typeString_test.go new file mode 100644 index 0000000..420f33c --- /dev/null +++ b/fieldcollection/typeString_test.go @@ -0,0 +1,46 @@ +package fieldcollection + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type ( + testStringer struct{} +) + +func (testStringer) String() string { return "ohai" } + +func TestString(t *testing.T) { + fc := FieldCollectionFromData(map[string]any{ + "int": 12, + "validString": "Ello!", + "stringer": testStringer{}, + }) + + _, err := fc.String("_") + assert.ErrorIs(t, err, ErrValueNotSet) + + _, err = fc.String("int") + assert.ErrorIs(t, err, ErrValueMismatch) + + _, err = fc.String("invalidString") + assert.Error(t, err) + + v, err := fc.String("validString") + assert.NoError(t, err) + assert.Equal(t, "Ello!", v) + + v, err = fc.String("stringer") + assert.NoError(t, err) + assert.Equal(t, "ohai", v) + + assert.True(t, fc.CanString("validString")) + assert.False(t, fc.CanString("bool")) + + assert.NotPanics(t, func() { fc.MustString("validString", nil) }) + assert.Panics(t, func() { fc.MustString("bool", nil) }) + + assert.Equal(t, "a", fc.MustString("_", func(v string) *string { return &v }("a"))) +} diff --git a/go.mod b/go.mod index 03f61d0..2802f65 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.8.4 - gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -17,5 +17,4 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/sys v0.18.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 530b786..2ec886c 100644 --- a/go.sum +++ b/go.sum @@ -28,8 +28,6 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/yaml/tojson.go b/yaml/tojson.go index dc9756c..3957b2c 100644 --- a/yaml/tojson.go +++ b/yaml/tojson.go @@ -1,3 +1,4 @@ +// Package yaml contains a method to convert a YAML into a JSON object package yaml import ( @@ -6,7 +7,7 @@ import ( "fmt" "io" - yaml "gopkg.in/yaml.v2" + yaml "gopkg.in/yaml.v3" ) // ToJSON takes an io.Reader containing YAML source and converts it into @@ -15,14 +16,14 @@ func ToJSON(in io.Reader) (io.Reader, error) { var body interface{} if err := yaml.NewDecoder(in).Decode(&body); err != nil { - return nil, fmt.Errorf("Unable to unmarshal YAML: %s", err) + return nil, fmt.Errorf("unmarshaling YAML: %s", err) } body = convert(body) - var buf = new(bytes.Buffer) + buf := new(bytes.Buffer) if err := json.NewEncoder(buf).Encode(body); err != nil { - return nil, fmt.Errorf("Unable to marshal JSON: %s", err) + return nil, fmt.Errorf("marshaling JSON: %s", err) } return buf, nil