1
0
Fork 0
mirror of https://github.com/Luzifer/go_helpers.git synced 2024-12-24 21:11:21 +00:00

Add validation to fieldcollection

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2024-04-03 19:03:45 +02:00
parent e407587b52
commit 6efbc2dd11
Signed by: luzifer
SSH key fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
21 changed files with 1000 additions and 330 deletions

View file

@ -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
}
}

View file

@ -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")
}

View file

@ -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
}

View file

@ -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()))
}

View file

@ -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
}

View file

@ -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()))
}

176
fieldcollection/schema.go Normal file
View file

@ -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
}

View file

@ -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")
}

View file

@ -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
}

View file

@ -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)))
}

View file

@ -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
}

View file

@ -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)))
}

View file

@ -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
}

View file

@ -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)))
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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"})))
}

View file

@ -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")))
}

3
go.mod
View file

@ -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
)

2
go.sum
View file

@ -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=

View file

@ -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