Skip to content

Commit

Permalink
feat: aggregate validation errors (#233)
Browse files Browse the repository at this point in the history
* Error aggregation

* added aggregate error to be able to print it nicely

Co-authored-by: Alexander Kutuev <[email protected]>
  • Loading branch information
akutuev and Alexander Kutuev authored Aug 22, 2022
1 parent 4c93d81 commit 69c7b5a
Show file tree
Hide file tree
Showing 2 changed files with 87 additions and 49 deletions.
102 changes: 63 additions & 39 deletions env.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,46 +200,55 @@ func ParseWithFuncs(v interface{}, funcMap map[reflect.Type]ParserFunc, opts ...
func doParse(ref reflect.Value, funcMap map[reflect.Type]ParserFunc, opts []Options) error {
refType := ref.Type()

var agrErr aggregateError

for i := 0; i < refType.NumField(); i++ {
refField := ref.Field(i)
if !refField.CanSet() {
continue
}
if reflect.Ptr == refField.Kind() && !refField.IsNil() {
if refField.Elem().Kind() == reflect.Struct {
if err := ParseWithFuncs(refField.Interface(), funcMap, optsWithPrefix(refType.Field(i), opts)...); err != nil {
return err
}
continue
}
if err := ParseWithFuncs(refField.Interface(), funcMap, opts...); err != nil {
return err
}
continue
}
if reflect.Struct == refField.Kind() && refField.CanAddr() && refField.Type().Name() == "" {
if err := ParseWithFuncs(refField.Addr().Interface(), funcMap, optsWithPrefix(refType.Field(i), opts)...); err != nil {
return err
}
continue
}
refTypeField := refType.Field(i)
value, err := get(refTypeField, opts)
if err != nil {
return err
}
if value == "" {
if reflect.Struct == refField.Kind() {
if err := doParse(refField, funcMap, optsWithPrefix(refType.Field(i), opts)); err != nil {
return err
}

if err := doParseField(refField, refTypeField, funcMap, opts); err != nil {
if val, ok := err.(aggregateError); ok {
agrErr.errors = append(agrErr.errors, val.errors...)
} else {
agrErr.errors = append(agrErr.errors, err)
}
continue
}
if err := set(refField, refTypeField, value, funcMap); err != nil {
return err
}

if len(agrErr.errors) == 0 {
return nil
}

return agrErr
}

func doParseField(refField reflect.Value, refTypeField reflect.StructField, funcMap map[reflect.Type]ParserFunc, opts []Options) error {
if !refField.CanSet() {
return nil
}
if reflect.Ptr == refField.Kind() && !refField.IsNil() {
if refField.Elem().Kind() == reflect.Struct {
return ParseWithFuncs(refField.Interface(), funcMap, optsWithPrefix(refTypeField, opts)...)
}

return ParseWithFuncs(refField.Interface(), funcMap, opts...)
}
if reflect.Struct == refField.Kind() && refField.CanAddr() && refField.Type().Name() == "" {
return ParseWithFuncs(refField.Addr().Interface(), funcMap, optsWithPrefix(refTypeField, opts)...)
}
value, err := get(refTypeField, opts)
if err != nil {
return err
}

if value != "" {
return set(refField, refTypeField, value, funcMap)
}

if reflect.Struct == refField.Kind() {
return doParse(refField, funcMap, optsWithPrefix(refTypeField, opts))
}

return nil
}

Expand Down Expand Up @@ -267,7 +276,7 @@ func get(field reflect.StructField, opts []Options) (val string, err error) {
case "notEmpty":
notEmpty = true
default:
return "", fmt.Errorf("env: tag option %q not supported", tag)
return "", fmt.Errorf("tag option %q not supported", tag)
}
}
expand := strings.EqualFold(field.Tag.Get("envExpand"), "true")
Expand All @@ -283,18 +292,18 @@ func get(field reflect.StructField, opts []Options) (val string, err error) {
}

if required && !exists && len(ownKey) > 0 {
return "", fmt.Errorf(`env: required environment variable %q is not set`, key)
return "", fmt.Errorf(`required environment variable %q is not set`, key)
}

if notEmpty && val == "" {
return "", fmt.Errorf("env: environment variable %q should not be empty", key)
return "", fmt.Errorf("environment variable %q should not be empty", key)
}

if loadFile && val != "" {
filename := val
val, err = getFromFile(filename)
if err != nil {
return "", fmt.Errorf(`env: could not load content of file "%s" from variable %s: %v`, filename, key, err)
return "", fmt.Errorf(`could not load content of file "%s" from variable %s: %v`, filename, key, err)
}
}

Expand Down Expand Up @@ -467,11 +476,11 @@ type parseError struct {
}

func (e parseError) Error() string {
return fmt.Sprintf(`env: parse error on field "%s" of type "%s": %v`, e.sf.Name, e.sf.Type, e.err)
return fmt.Sprintf(`parse error on field "%s" of type "%s": %v`, e.sf.Name, e.sf.Type, e.err)
}

func newNoParserError(sf reflect.StructField) error {
return fmt.Errorf(`env: no parser found for field "%s" of type "%s"`, sf.Name, sf.Type)
return fmt.Errorf(`no parser found for field "%s" of type "%s"`, sf.Name, sf.Type)
}

func optsWithPrefix(field reflect.StructField, opts []Options) []Options {
Expand All @@ -482,3 +491,18 @@ func optsWithPrefix(field reflect.StructField, opts []Options) []Options {
}
return subOpts
}

type aggregateError struct {
errors []error
}

func (e aggregateError) Error() string {
var sb strings.Builder
sb.WriteString("env:")

for _, err := range e.errors {
sb.WriteString(fmt.Sprintf(" %v;", err.Error()))
}

return strings.TrimRight(sb.String(), ";")
}
34 changes: 24 additions & 10 deletions env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,20 @@ func TestParsesEnvInnerFails(t *testing.T) {
isErrorWithMessage(t, Parse(&config{}), `env: parse error on field "Number" of type "int": strconv.ParseInt: parsing "not-a-number": invalid syntax`)
}

func TestParsesEnvInnerFailsMultipleErrors(t *testing.T) {
type config struct {
Foo struct {
Name string `env:"NAME,required"`
Number int `env:"NUMBER"`
Bar struct {
Age int `env:"AGE,required"`
}
}
}
setEnv(t, "NUMBER", "not-a-number")
isErrorWithMessage(t, Parse(&config{}), `env: required environment variable "NAME" is not set; parse error on field "Number" of type "int": strconv.ParseInt: parsing "not-a-number": invalid syntax; required environment variable "AGE" is not set`)
}

func TestParsesEnvInnerNil(t *testing.T) {
setEnv(t, "innervar", "someinnervalue")
cfg := ParentStruct{}
Expand Down Expand Up @@ -492,37 +506,37 @@ func TestPassReference(t *testing.T) {

func TestInvalidBool(t *testing.T) {
setEnv(t, "BOOL", "should-be-a-bool")
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Bool" of type "bool": strconv.ParseBool: parsing "should-be-a-bool": invalid syntax`)
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Bool" of type "bool": strconv.ParseBool: parsing "should-be-a-bool": invalid syntax; parse error on field "BoolPtr" of type "*bool": strconv.ParseBool: parsing "should-be-a-bool": invalid syntax`)
}

func TestInvalidInt(t *testing.T) {
setEnv(t, "INT", "should-be-an-int")
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Int" of type "int": strconv.ParseInt: parsing "should-be-an-int": invalid syntax`)
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Int" of type "int": strconv.ParseInt: parsing "should-be-an-int": invalid syntax; parse error on field "IntPtr" of type "*int": strconv.ParseInt: parsing "should-be-an-int": invalid syntax`)
}

func TestInvalidUint(t *testing.T) {
setEnv(t, "UINT", "-44")
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Uint" of type "uint": strconv.ParseUint: parsing "-44": invalid syntax`)
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Uint" of type "uint": strconv.ParseUint: parsing "-44": invalid syntax; parse error on field "UintPtr" of type "*uint": strconv.ParseUint: parsing "-44": invalid syntax`)
}

func TestInvalidFloat32(t *testing.T) {
setEnv(t, "FLOAT32", "AAA")
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Float32" of type "float32": strconv.ParseFloat: parsing "AAA": invalid syntax`)
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Float32" of type "float32": strconv.ParseFloat: parsing "AAA": invalid syntax; parse error on field "Float32Ptr" of type "*float32": strconv.ParseFloat: parsing "AAA": invalid syntax`)
}

func TestInvalidFloat64(t *testing.T) {
setEnv(t, "FLOAT64", "AAA")
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Float64" of type "float64": strconv.ParseFloat: parsing "AAA": invalid syntax`)
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Float64" of type "float64": strconv.ParseFloat: parsing "AAA": invalid syntax; parse error on field "Float64Ptr" of type "*float64": strconv.ParseFloat: parsing "AAA": invalid syntax`)
}

func TestInvalidUint64(t *testing.T) {
setEnv(t, "UINT64", "AAA")
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Uint64" of type "uint64": strconv.ParseUint: parsing "AAA": invalid syntax`)
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Uint64" of type "uint64": strconv.ParseUint: parsing "AAA": invalid syntax; parse error on field "Uint64Ptr" of type "*uint64": strconv.ParseUint: parsing "AAA": invalid syntax`)
}

func TestInvalidInt64(t *testing.T) {
setEnv(t, "INT64", "AAA")
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Int64" of type "int64": strconv.ParseInt: parsing "AAA": invalid syntax`)
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Int64" of type "int64": strconv.ParseInt: parsing "AAA": invalid syntax; parse error on field "Int64Ptr" of type "*int64": strconv.ParseInt: parsing "AAA": invalid syntax`)
}

func TestInvalidInt64Slice(t *testing.T) {
Expand Down Expand Up @@ -567,12 +581,12 @@ func TestInvalidBoolsSlice(t *testing.T) {

func TestInvalidDuration(t *testing.T) {
setEnv(t, "DURATION", "should-be-a-valid-duration")
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Duration" of type "time.Duration": unable to parse duration: time: invalid duration "should-be-a-valid-duration"`)
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Duration" of type "time.Duration": unable to parse duration: time: invalid duration "should-be-a-valid-duration"; parse error on field "DurationPtr" of type "*time.Duration": unable to parse duration: time: invalid duration "should-be-a-valid-duration"`)
}

func TestInvalidDurations(t *testing.T) {
setEnv(t, "DURATIONS", "1s,contains-an-invalid-duration,3s")
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Durations" of type "[]time.Duration": unable to parse duration: time: invalid duration "contains-an-invalid-duration"`)
isErrorWithMessage(t, Parse(&Config{}), `env: parse error on field "Durations" of type "[]time.Duration": unable to parse duration: time: invalid duration "contains-an-invalid-duration"; parse error on field "DurationPtrs" of type "[]*time.Duration": unable to parse duration: time: invalid duration "contains-an-invalid-duration"`)
}

func TestParseStructWithoutEnvTag(t *testing.T) {
Expand Down Expand Up @@ -1330,7 +1344,7 @@ func TestRequiredIfNoDefOption(t *testing.T) {
var cfg config

t.Run("missing", func(t *testing.T) {
isErrorWithMessage(t, Parse(&cfg, Options{RequiredIfNoDef: true}), `env: required environment variable "NAME" is not set`)
isErrorWithMessage(t, Parse(&cfg, Options{RequiredIfNoDef: true}), `env: required environment variable "NAME" is not set; required environment variable "FRUIT" is not set`)
setEnv(t, "NAME", "John")
isErrorWithMessage(t, Parse(&cfg, Options{RequiredIfNoDef: true}), `env: required environment variable "FRUIT" is not set`)
})
Expand Down

0 comments on commit 69c7b5a

Please sign in to comment.