From 74b0ff781607e758d01ec062462dd8fe2a95de7b Mon Sep 17 00:00:00 2001 From: Aaron Lindsay Date: Sat, 11 Mar 2017 12:59:47 -0500 Subject: [PATCH] Add parsing of profile messages, fix date parsing Profile messages are still missing validation --- ofx.go | 32 +++++++++++-- profile.go | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++ types.go | 36 +++++++-------- 3 files changed, 174 insertions(+), 23 deletions(-) diff --git a/ofx.go b/ofx.go index bfcc5fd..22e1f31 100644 --- a/ofx.go +++ b/ofx.go @@ -4,7 +4,6 @@ import ( "bufio" "bytes" "errors" - "fmt" "github.com/golang/go/src/encoding/xml" "io" "net/http" @@ -368,11 +367,36 @@ func (or *Response) Unmarshal(reader io.Reader, xmlVersion bool) error { return nil // found closing XML element, so we're done } else if start, ok := tok.(xml.StartElement); ok { // TODO decode other types - fmt.Println("Found starting element for: " + start.Name.Local) + switch start.Name.Local { + // case "SIGNUPMSGSRSV1": + // msgs, err := DecodeSignupMessageSet(decoder, start) + // if err != nil { + // return err + // } + // or.Signup = msgs + //case "BANKMSGSRSV1": + //case "CREDITCARDMSGSRSV1": + //case "LOANMSGSRSV1": + //case "INVSTMTMSGSRSV1": + //case "INTERXFERMSGSRSV1": + //case "WIREXFERMSGSRSV1": + //case "BILLPAYMSGSRSV1": + //case "EMAILMSGSRSV1": + //case "SECLISTMSGSRSV1": + //case "PRESDIRMSGSRSV1": + //case "PRESDLVMSGSRSV1": + case "PROFMSGSRSV1": + msgs, err := DecodeProfileMessageSet(decoder, start) + if err != nil { + return err + } + or.Profile = msgs + //case "IMAGEMSGSRSV1": + default: + return errors.New("Unsupported message set: " + start.Name.Local) + } } else { return errors.New("Found unexpected token") } - - decoder.Skip() } } diff --git a/profile.go b/profile.go index 4a05534..15118fd 100644 --- a/profile.go +++ b/profile.go @@ -1,6 +1,7 @@ package ofxgo import ( + "errors" "github.com/golang/go/src/encoding/xml" ) @@ -22,3 +23,131 @@ func (r *ProfileRequest) Valid() (bool, error) { r.ClientRouting = "NONE" return true, nil } + +type SignonInfo struct { + XMLName xml.Name `xml:"SIGNONINFO"` + SignonRealm String `xml:"SIGNONREALM"` + Min Int `xml:"MIN"` // Minimum number of password characters + Max Int `xml:"MAX"` // Maximum number of password characters + Chartype String `xml:"CHARTYPE"` // ALPHAONLY, NUMERICONLY, ALPHAORNUMERIC, ALPHAANDNUMERIC + CaseSen Boolean `xml:"CASESEN"` // Password is case-sensitive? + Special Boolean `xml:"SPECIAL"` // Special characters allowed? + Spaces Boolean `xml:"SPACES"` // Spaces allowed? + Pinch Boolean `xml:"PINCH"` // Pin change requests allowed + ChgPinFirst Boolean `xml:"CHGPINFIRST"` // Server requires user to change password at first signon + UserCred1Label String `xml:"USERCRED1LABEL,omitempty"` // Prompt for USERCRED1 (if this field is present, USERCRED1 is required) + UserCred2Label String `xml:"USERCRED2LABEL,omitempty"` // Prompt for USERCRED2 (if this field is present, USERCRED2 is required) + ClientUIDReq Boolean `xml:"CLIENTUIDREQ,omitempty"` // CLIENTUID required? + AuthTokenFirst Boolean `xml:"AUTHTOKENFIRST,omitempty"` // Server requires AUTHTOKEN as part of first signon + AuthTokenLabel String `xml:"AUTHTOKENLABEL,omitempty"` + AuthTokenInfoURL String `xml:"AUTHTOKENINFOURL,omitempty"` + MFAChallengeSupt Boolean `xml:"MFACHALLENGESUPT,omitempty"` // Server supports MFACHALLENGE + MFAChallengeFIRST Boolean `xml:"MFACHALLENGEFIRST,omitempty"` // Server requires MFACHALLENGE to be sent with first signon + AccessTokenReq Boolean `xml:"ACCESSTOKENREQ,omitempty"` // Server requires ACCESSTOKEN to be sent with all requests except profile +} + +type MessageSet struct { + XMLName xml.Name // + Ver String `xml:"MSGSETCORE>VER"` + Url String `xml:"MSGSETCORE>URL"` + OfxSec String `xml:"MSGSETCORE>OFXSEC"` + TranspSec Boolean `xml:"MSGSETCORE>TRANSPSEC"` + SignonRealm String `xml:"MSGSETCORE>SIGNONREALM"` // Used to identify which SignonInfo to use for to this MessageSet + Language []String `xml:"MSGSETCORE>LANGUAGE"` + SyncMode String `xml:"MSGSETCORE>SYNCMODE"` + // TODO MessageSet-specific stuff? +} + +type MessageSetList []MessageSet + +func (msl *MessageSetList) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + for { + var msgset MessageSet + tok, err := d.Token() + 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 + } else if _, ok := tok.(xml.StartElement); ok { + // Found starting tag for . Get the next one (xxxMSGSETVn) and decode that struct + tok, err := d.Token() + if err != nil { + return err + } else if versionStart, ok := tok.(xml.StartElement); ok { + if err := d.DecodeElement(&msgset, &versionStart); err != nil { + return err + } + } else { + return errors.New("Invalid MSGSETLIST formatting") + } + + // Eat ending tags for + tok, err = d.Token() + if err != nil { + return err + } else if _, ok := tok.(xml.EndElement); !ok { + return errors.New("Invalid MSGSETLIST formatting") + } + } else { + return errors.New("MSGSETLIST didn't find an opening xxxMSGSETVn element") + } + *msl = MessageSetList(append(*(*[]MessageSet)(msl), msgset)) + } +} + +type ProfileResponse struct { + XMLName xml.Name `xml:"PROFTRNRS"` + TrnUID UID `xml:"TRNUID"` + MessageSetList MessageSetList `xml:"PROFRS>MSGSETLIST"` + SignonInfoList []SignonInfo `xml:"PROFRS>SIGNONINFOLIST>SIGNONINFO"` + DtProfup Date `xml:"PROFRS>DTPROFUP"` + Finame String `xml:"PROFRS>FINAME"` + Addr1 String `xml:"PROFRS>ADDR1"` + Addr2 String `xml:"PROFRS>ADDR2,omitempty"` + Addr3 String `xml:"PROFRS>ADDR3,omitempty"` + City String `xml:"PROFRS>CITY"` + State String `xml:"PROFRS>STATE"` + PostalCode String `xml:"PROFRS>POSTALCODE"` + Country String `xml:"PROFRS>COUNTRY"` + CsPhone String `xml:"PROFRS>CSPHONE,omitempty"` + TsPhone String `xml:"PROFRS>TSPHONE,omitempty"` + FaxPhone String `xml:"PROFRS>FAXPHONE,omitempty"` + URL String `xml:"PROFRS>URL,omitempty"` + Email String `xml:"PROFRS>EMAIL,omitempty"` +} + +func (pr ProfileResponse) Name() string { + return "PROFTRNRS" +} + +func (pr ProfileResponse) Valid() (bool, error) { + //TODO implement + return true, nil +} + +func DecodeProfileMessageSet(d *xml.Decoder, start xml.StartElement) ([]Message, error) { + var msgs []Message + for { + tok, err := d.Token() + if err != nil { + return nil, 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 msgs, nil + } else if startElement, ok := tok.(xml.StartElement); ok { + switch startElement.Name.Local { + case "PROFTRNRS": + var prof ProfileResponse + if err := d.DecodeElement(&prof, &startElement); err != nil { + return nil, err + } + msgs = append(msgs, Message(prof)) + default: + return nil, errors.New("Unsupported profile response tag: " + startElement.Name.Local) + } + } else { + return nil, errors.New("Didn't find an opening element") + } + } +} diff --git a/types.go b/types.go index 4314798..ce1f79e 100644 --- a/types.go +++ b/types.go @@ -27,6 +27,8 @@ func (oi *Int) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { return nil } +type Date time.Time + var ofxDateFormats = []string{ "20060102150405.000", "20060102150405", @@ -34,23 +36,25 @@ var ofxDateFormats = []string{ "2006010215", "20060102", } -var ofxDateZoneFormat = "20060102150405.000 -0700" -var ofxDateZoneRegex = regexp.MustCompile(`^\[([+-]?[0-9]+)(\.([0-9]{2}))?(:([A-Z]+))?\]$`) - -type Date time.Time +var ofxDateZoneRegex = regexp.MustCompile(`^([+-]?[0-9]+)(\.([0-9]{2}))?(:([A-Z]+))?$`) func (od *Date) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { - var value string + var value, zone, zoneFormat string err := d.DecodeElement(&value, &start) if err != nil { return err } - value = strings.TrimSpace(value) - if len(value) > len(ofxDateFormats[0]) { - matches := ofxDateZoneRegex.FindStringSubmatch(value[len(ofxDateFormats[0]):]) + // Split the time zone off, if any + split := strings.SplitN(value, "[", 2) + if len(split) == 2 { + value = split[0] + zoneFormat = " -0700" + zone = strings.TrimRight(split[1], "]") + + matches := ofxDateZoneRegex.FindStringSubmatch(zone) if matches == nil { - return errors.New("Invalid OFX Date Format") + return errors.New("Invalid OFX Date timezone format: " + zone) } var err error var zonehours, zoneminutes int @@ -65,17 +69,11 @@ func (od *Date) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { } zoneminutes = zoneminutes * 60 / 100 } - value = value[:len(ofxDateFormats[0])] + " " + fmt.Sprintf("%+d%02d", zonehours, zoneminutes) - t, err := time.Parse(ofxDateZoneFormat, value) - if err == nil { - tmpod := Date(t) - *od = tmpod - return nil - } + zone = fmt.Sprintf(" %+03d%02d", zonehours, zoneminutes) } for _, format := range ofxDateFormats { - t, err := time.Parse(format, value) + t, err := time.Parse(format+zoneFormat, value+zone) if err == nil { tmpod := Date(t) *od = tmpod @@ -85,8 +83,8 @@ func (od *Date) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { return errors.New("OFX: Couldn't parse date:" + value) } -func (od *Date) String() string { - t := time.Time(*od) +func (od Date) String() string { + t := time.Time(od) format := t.Format(ofxDateFormats[0]) zonename, zoneoffset := t.Zone() format += "[" + fmt.Sprintf("%+d", zoneoffset/3600)