From 677a09295a26521f4ad424f54a624153cd4fb9cf Mon Sep 17 00:00:00 2001 From: John Starich Date: Wed, 25 Mar 2020 00:40:03 -0500 Subject: [PATCH] Continue parsing after hitting validation errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supports mixed-case severity for Ally Bank's Quicken downloads 🙄 --- response.go | 49 +++++++++++++++++--- response_test.go | 118 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 159 insertions(+), 8 deletions(-) diff --git a/response.go b/response.go index 00728e5..75907c0 100644 --- a/response.go +++ b/response.go @@ -4,10 +4,12 @@ import ( "bufio" "bytes" "errors" - "github.com/aclindsa/xml" + "fmt" "io" "reflect" "strings" + + "github.com/aclindsa/xml" ) // Response is the top-level object returned from a parsed OFX response file. @@ -234,13 +236,14 @@ func decodeMessageSet(d *xml.Decoder, start xml.StartElement, msgs *[]Message, v if !ok { return errors.New("Invalid message set: " + start.Name.Local) } + var errs ErrInvalid for { tok, err := nextNonWhitespaceToken(d) if err != nil { return err } else if end, ok := tok.(xml.EndElement); ok && end.Name.Local == start.Name.Local { // If we found the end of our starting element, we're done parsing - return nil + return errs.ErrOrNil() } else if startElement, ok := tok.(xml.StartElement); ok { responseType, ok := setTypes[startElement.Name.Local] if !ok { @@ -256,7 +259,7 @@ func decodeMessageSet(d *xml.Decoder, start xml.StartElement, msgs *[]Message, v return err } if ok, err := responseMessage.Valid(version); !ok { - return err + errs.AddErr(err) } *msgs = append(*msgs, responseMessage) } else { @@ -323,6 +326,8 @@ func ParseResponse(reader io.Reader) (*Response, error) { return nil, errors.New("Missing opening SIGNONMSGSRSV1 xml element") } + var errs ErrInvalid + tok, err = nextNonWhitespaceToken(decoder) if err != nil { return nil, err @@ -330,7 +335,7 @@ func ParseResponse(reader io.Reader) (*Response, error) { return nil, errors.New("Missing closing SIGNONMSGSRSV1 xml element") } if ok, err := or.Signon.Valid(or.Version); !ok { - return nil, err + errs.AddErr(err) } var messageSlices = map[string]*[]Message{ @@ -355,14 +360,17 @@ func ParseResponse(reader io.Reader) (*Response, error) { if err != nil { return nil, err } else if ofxEnd, ok := tok.(xml.EndElement); ok && ofxEnd.Name.Local == "OFX" { - return &or, nil // found closing XML element, so we're done + return &or, errs.ErrOrNil() // found closing XML element, so we're done } else if start, ok := tok.(xml.StartElement); ok { slice, ok := messageSlices[start.Name.Local] if !ok { return nil, errors.New("Invalid message set: " + start.Name.Local) } if err := decodeMessageSet(decoder, start, slice, or.Version); err != nil { - return nil, err + if _, ok := err.(ErrInvalid); !ok { + return nil, err + } + errs.AddErr(err) } } else { return nil, errors.New("Found unexpected token") @@ -436,3 +444,32 @@ func (or *Response) Marshal() (*bytes.Buffer, error) { } return &b, nil } + +// ErrInvalid represents validation failures while parsing an OFX response +// If an institution returns slightly malformed data, ParseResponse will return a best-effort parsed response and a validation error. +type ErrInvalid []error + +func (e ErrInvalid) Error() string { + var errStrings []string + for _, err := range e { + errStrings = append(errStrings, err.Error()) + } + return fmt.Sprintf("Validation failed: %s", strings.Join(errStrings, "; ")) +} + +func (e *ErrInvalid) AddErr(err error) { + if err != nil { + if errs, ok := err.(ErrInvalid); ok { + *e = append(*e, errs...) + } else { + *e = append(*e, err) + } + } +} + +func (e ErrInvalid) ErrOrNil() error { + if len(e) > 0 { + return e + } + return nil +} diff --git a/response_test.go b/response_test.go index c1faa3b..ee24498 100644 --- a/response_test.go +++ b/response_test.go @@ -1,13 +1,16 @@ package ofxgo_test import ( + "bytes" + "errors" "fmt" - "github.com/aclindsa/ofxgo" - "github.com/aclindsa/xml" "os" "path/filepath" "reflect" "testing" + + "github.com/aclindsa/ofxgo" + "github.com/aclindsa/xml" ) // Attempt to find a method on the provided Value called 'Equal' which is a @@ -171,3 +174,114 @@ func TestValidSamples(t *testing.T) { filepath.Walk("samples/valid_responses", fn) filepath.Walk("samples/busted_responses", fn) } + +func TestInvalidResponse(t *testing.T) { + // in this example, the severity is invalid due to mixed upper and lower case letters + resp, err := ofxgo.ParseResponse(bytes.NewReader([]byte(` +OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + Info + + ENG + + + + + 0 + + 0 + Info + + + + +`))) + expectedErr := "Validation failed: Invalid STATUS>SEVERITY; Invalid STATUS>SEVERITY" + if err == nil { + t.Fatalf("ParseResponse should fail with %q, found nil", expectedErr) + } + if _, ok := err.(ofxgo.ErrInvalid); !ok { + t.Errorf("ParseResponse should return an error with type ErrInvalid, found %T", err) + } + if err.Error() != expectedErr { + t.Errorf("ParseResponse should fail with %q, found %v", expectedErr, err) + } + if resp == nil { + t.Errorf("Response must not be nil if only validation errors are present") + } +} + +func TestErrInvalidError(t *testing.T) { + expectedErr := `Validation failed: A; B; C` + actualErr := ofxgo.ErrInvalid{ + errors.New("A"), + errors.New("B"), + errors.New("C"), + }.Error() + if expectedErr != actualErr { + t.Errorf("Unexpected invalid error message to be %q, but was: %s", expectedErr, actualErr) + } +} + +func TestErrInvalidAddErr(t *testing.T) { + t.Run("nil error should be a no-op", func(t *testing.T) { + var errs ofxgo.ErrInvalid + errs.AddErr(nil) + if len(errs) != 0 { + t.Errorf("Nil err should not be added") + } + }) + + t.Run("adds an error normally", func(t *testing.T) { + var errs ofxgo.ErrInvalid + errs.AddErr(errors.New("some error")) + + }) + + t.Run("adding the same type should flatten the errors", func(t *testing.T) { + var errs ofxgo.ErrInvalid + errs.AddErr(ofxgo.ErrInvalid{ + errors.New("A"), + errors.New("B"), + }) + errs.AddErr(ofxgo.ErrInvalid{ + errors.New("C"), + }) + if len(errs) != 3 { + t.Errorf("Errors should be flattened like [A, B, C], but found: %+v", errs) + } + }) +} + +func TestErrInvalidErrOrNil(t *testing.T) { + var errs ofxgo.ErrInvalid + if err := errs.ErrOrNil(); err != nil { + t.Errorf("No added errors should return nil, found: %v", err) + } + someError := errors.New("some error") + errs.AddErr(someError) + err := errs.ErrOrNil() + if err == nil { + t.Fatal("Expected an error, found nil.") + } + if _, ok := err.(ofxgo.ErrInvalid); !ok { + t.Fatalf("Expected err to be of type ErrInvalid, found: %T", err) + } + errInvalid := err.(ofxgo.ErrInvalid) + if len(errInvalid) != 1 || errInvalid[0] != someError { + t.Errorf("Expected ErrOrNil to return itself, found: %v", err) + } +}