From d8491bed1df360d764276e941caa50df2001a5e1 Mon Sep 17 00:00:00 2001 From: Aaron Lindsay Date: Wed, 3 Oct 2018 09:59:04 -0400 Subject: [PATCH] Make Client an interface instead of a struct This makes it easier to maintain per-institution hacks that start interacting with each other if you try to do them all in the same client code. This commit also breaks out the existing Vanguard hack into its own Client implementation. --- basic_client.go | 80 ++++++++++++++++ client.go | 192 +++++++++++-------------------------- cmd/ofx/detect_settings.go | 13 +-- cmd/ofx/util.go | 15 +-- vanguard_client.go | 79 +++++++++++++++ 5 files changed, 230 insertions(+), 149 deletions(-) create mode 100644 basic_client.go create mode 100644 vanguard_client.go diff --git a/basic_client.go b/basic_client.go new file mode 100644 index 0000000..435a6a0 --- /dev/null +++ b/basic_client.go @@ -0,0 +1,80 @@ +package ofxgo + +import ( + "errors" + "io" + "net/http" + "strings" +) + +// BasicClient provides a standard Client implementation suitable for most +// financial institutions. BasicClient uses default, non-zero settings, even if +// its fields are not initialized. +type BasicClient struct { + // Request fields to overwrite with the client's values. If nonempty, + // defaults are used + SpecVersion ofxVersion // VERSION in header + AppID string // SONRQ>APPID + AppVer string // SONRQ>APPVER + + // Don't insert newlines or indentation when marshalling to SGML/XML + NoIndent bool +} + +// OfxVersion returns the OFX specification version this BasicClient will marshal +// Requests as. Defaults to "203" if the client's SpecVersion field is empty. +func (c *BasicClient) OfxVersion() ofxVersion { + if c.SpecVersion.Valid() { + return c.SpecVersion + } + return OfxVersion203 +} + +// ID returns this BasicClient's OFX AppID field, defaulting to "OFXGO" if +// unspecified. +func (c *BasicClient) ID() String { + if len(c.AppID) > 0 { + return String(c.AppID) + } + return String("OFXGO") +} + +// Version returns this BasicClient's version number as a string, defaulting to +// "0001" if unspecified. +func (c *BasicClient) Version() String { + if len(c.AppVer) > 0 { + return String(c.AppVer) + } + return String("0001") +} + +// IndentRequests returns true if the marshaled XML should be indented (and +// contain newlines, since the two are linked in the current implementation) +func (c *BasicClient) IndentRequests() bool { + return !c.NoIndent +} + +func (c *BasicClient) RawRequest(URL string, r io.Reader) (*http.Response, error) { + if !strings.HasPrefix(URL, "https://") { + return nil, errors.New("Refusing to send OFX request with possible plain-text password over non-https protocol") + } + + response, err := http.Post(URL, "application/x-ofx", r) + if err != nil { + return nil, err + } + + if response.StatusCode != 200 { + return nil, errors.New("OFXQuery request status: " + response.Status) + } + + return response, nil +} + +func (c *BasicClient) RequestNoParse(r *Request) (*http.Response, error) { + return clientRequestNoParse(c, r) +} + +func (c *BasicClient) Request(r *Request) (*Response, error) { + return clientRequest(c, r) +} diff --git a/client.go b/client.go index d77f435..b06642e 100644 --- a/client.go +++ b/client.go @@ -1,7 +1,6 @@ package ofxgo import ( - "errors" "io" "net/http" "strings" @@ -18,122 +17,65 @@ type Client interface { Version() String IndentRequests() bool - // Used to initiate requests to servers + // Request marshals a Request object into XML, makes an HTTP request + // against it's URL, and then unmarshals the response into a Response + // object. + // + // Before being marshaled, some of the the Request object's values are + // overwritten, namely those dictated by the BasicClient's configuration + // (Version, AppID, AppVer fields), and the client's current time + // (DtClient). These are updated in place in the supplied Request object so + // they may later be inspected by the caller. Request(r *Request) (*Response, error) + + // RequestNoParse marshals a Request object into XML, makes an HTTP + // request, and returns the raw HTTP response. Unlike RawRequest(), it + // takes client settings into account. Unlike Request(), it doesn't parse + // the response into an ofxgo.Request object. + // + // Caveat: The caller is responsible for closing the http Response.Body + // (see the http module's documentation for more information) RequestNoParse(r *Request) (*http.Response, error) + + // RawRequest is little more than a thin wrapper around http.Post + // + // In most cases, you should probably be using Request() instead, but + // RawRequest can be useful if you need to read the raw unparsed http + // response yourself (perhaps for downloading an OFX file for use by an + // external program, or debugging server behavior), or have a handcrafted + // request you'd like to try. + // + // Caveats: RawRequest does *not* take client settings into account as + // Client.Request() does, so your particular server may or may not like + // whatever we read from 'r'. The caller is responsible for closing the + // http Response.Body (see the http module's documentation for more + // information) + RawRequest(URL string, r io.Reader) (*http.Response, error) } -// BasicClient provides a standard Client implementation suitable for most -// financial institutions. BasicClient uses default, non-zero settings, even if -// its fields are not initialized. -type BasicClient struct { - // Request fields to overwrite with the client's values. If nonempty, - // defaults are used - SpecVersion ofxVersion // VERSION in header - AppID string // SONRQ>APPID - AppVer string // SONRQ>APPVER +type clientCreationFunc func(*BasicClient) Client - // Don't insert newlines or indentation when marshalling to SGML/XML - NoIndent bool +// GetClient returns a new Client for a given URL. It attempts to find a +// specialized client for this URL, but simply returns the passed-in +// BasicClient if no such match is found. +func GetClient(URL string, bc *BasicClient) Client { + clients := []struct { + URL string + Func clientCreationFunc + }{ + {"https://vesnc.vanguard.com/us/OfxDirectConnectServlet", NewVanguardClient}, + } + for _, client := range clients { + if client.URL == strings.Trim(URL, "/") { + return client.Func(bc) + } + } + return bc } -// OfxVersion returns the OFX specification version this BasicClient will marshal -// Requests as. Defaults to "203" if the client's SpecVersion field is empty. -func (c *BasicClient) OfxVersion() ofxVersion { - if c.SpecVersion.Valid() { - return c.SpecVersion - } - return OfxVersion203 -} - -// ID returns this BasicClient's OFX AppID field, defaulting to "OFXGO" if -// unspecified. -func (c *BasicClient) ID() String { - if len(c.AppID) > 0 { - return String(c.AppID) - } - return String("OFXGO") -} - -// Version returns this BasicClient's version number as a string, defaulting to -// "0001" if unspecified. -func (c *BasicClient) Version() String { - if len(c.AppVer) > 0 { - return String(c.AppVer) - } - return String("0001") -} - -// IndentRequests returns true if the marshaled XML should be indented (and -// contain newlines, since the two are linked in the current implementation) -func (c *BasicClient) IndentRequests() bool { - return !c.NoIndent -} - -// RawRequest is little more than a thin wrapper around http.Post -// -// In most cases, you should probably be using Request() instead, but -// RawRequest can be useful if you need to read the raw unparsed http response -// yourself (perhaps for downloading an OFX file for use by an external -// program, or debugging server behavior), or have a handcrafted request you'd -// like to try. -// -// Caveats: RawRequest does *not* take client settings into account as -// Client.Request() does, so your particular server may or may not like -// whatever we read from 'r'. The caller is responsible for closing the http -// Response.Body (see the http module's documentation for more information) -func RawRequest(URL string, r io.Reader) (*http.Response, error) { - if !strings.HasPrefix(URL, "https://") { - return nil, errors.New("Refusing to send OFX request with possible plain-text password over non-https protocol") - } - - response, err := http.Post(URL, "application/x-ofx", r) - if err != nil { - return nil, err - } - - if response.StatusCode != 200 { - return nil, errors.New("OFXQuery request status: " + response.Status) - } - - return response, nil -} - -// rawRequestCookies is RawRequest with the added feature of sending cookies -func rawRequestCookies(URL string, r io.Reader, cookies []*http.Cookie) (*http.Response, error) { - if !strings.HasPrefix(URL, "https://") { - return nil, errors.New("Refusing to send OFX request with possible plain-text password over non-https protocol") - } - - request, err := http.NewRequest("POST", URL, r) - if err != nil { - return nil, err - } - request.Header.Set("Content-Type", "application/x-ofx") - for _, cookie := range cookies { - request.AddCookie(cookie) - } - - response, err := http.DefaultClient.Do(request) - if err != nil { - return nil, err - } - - if response.StatusCode != 200 { - return nil, errors.New("OFXQuery request status: " + response.Status) - } - - return response, nil -} - -// RequestNoParse marshals a Request object into XML, makes an HTTP request, -// and returns the raw HTTP response. Unlike RawRequest(), it takes client -// settings into account. Unlike Request(), it doesn't parse the response into -// an ofxgo.Request object. -// -// Caveat: The caller is responsible for closing the http Response.Body (see -// the http module's documentation for more information) -func (c *BasicClient) RequestNoParse(r *Request) (*http.Response, error) { +// clientRequestNoParse can be used for building clients' RequestNoParse +// methods if they require fairly standard behavior +func clientRequestNoParse(c Client, r *Request) (*http.Response, error) { r.SetClientFields(c) b, err := r.Marshal() @@ -141,34 +83,12 @@ func (c *BasicClient) RequestNoParse(r *Request) (*http.Response, error) { return nil, err } - response, err := RawRequest(r.URL, b) - - // Some financial institutions (cough, Vanguard, cough), require a cookie - // to be set on the http request, or they return empty responses. - // Fortunately, the initial response contains the cookie we need, so if we - // detect an empty response with cookies set that didn't have any errors, - // re-try the request while sending their cookies back to them. - if err == nil && response.ContentLength <= 0 && len(response.Cookies()) > 0 { - b, err = r.Marshal() - if err != nil { - return nil, err - } - - return rawRequestCookies(r.URL, b, response.Cookies()) - } - - return response, err + return c.RawRequest(r.URL, b) } -// Request marshals a Request object into XML, makes an HTTP request against -// it's URL, and then unmarshals the response into a Response object. -// -// Before being marshaled, some of the the Request object's values are -// overwritten, namely those dictated by the BasicClient's configuration (Version, -// AppID, AppVer fields), and the client's current time (DtClient). These are -// updated in place in the supplied Request object so they may later be -// inspected by the caller. -func (c *BasicClient) Request(r *Request) (*Response, error) { +// clientRequest can be used for building clients' Request methods if they +// require fairly standard behavior +func clientRequest(c Client, r *Request) (*Response, error) { response, err := c.RequestNoParse(r) if err != nil { return nil, err diff --git a/cmd/ofx/detect_settings.go b/cmd/ofx/detect_settings.go index b0c98a6..7daffc3 100644 --- a/cmd/ofx/detect_settings.go +++ b/cmd/ofx/detect_settings.go @@ -126,12 +126,13 @@ func tryProfile(appID, appVer, version string, noindent bool) bool { fmt.Println("Error creating new OfxVersion enum:", err) os.Exit(1) } - var client = ofxgo.BasicClient{ - AppID: appID, - AppVer: appVer, - SpecVersion: ver, - NoIndent: noindent, - } + var client = ofxgo.GetClient(serverURL, + &ofxgo.BasicClient{ + AppID: appID, + AppVer: appVer, + SpecVersion: ver, + NoIndent: noindent, + }) var query ofxgo.Request query.URL = serverURL diff --git a/cmd/ofx/util.go b/cmd/ofx/util.go index e96b554..71be7f2 100644 --- a/cmd/ofx/util.go +++ b/cmd/ofx/util.go @@ -12,12 +12,13 @@ func newRequest() (ofxgo.Client, *ofxgo.Request) { fmt.Println("Error creating new OfxVersion enum:", err) os.Exit(1) } - var client = ofxgo.BasicClient{ - AppID: appID, - AppVer: appVer, - SpecVersion: ver, - NoIndent: noIndentRequests, - } + var client = ofxgo.GetClient(serverURL, + &ofxgo.BasicClient{ + AppID: appID, + AppVer: appVer, + SpecVersion: ver, + NoIndent: noIndentRequests, + }) var query ofxgo.Request query.URL = serverURL @@ -27,5 +28,5 @@ func newRequest() (ofxgo.Client, *ofxgo.Request) { query.Signon.Org = ofxgo.String(org) query.Signon.Fid = ofxgo.String(fid) - return &client, &query + return client, &query } diff --git a/vanguard_client.go b/vanguard_client.go new file mode 100644 index 0000000..171bc40 --- /dev/null +++ b/vanguard_client.go @@ -0,0 +1,79 @@ +package ofxgo + +import ( + "errors" + "io" + "net/http" + "strings" +) + +// VanguardClient provides a Client implementation which handles Vanguard's +// cookie-passing requirements. VanguardClient uses default, non-zero settings, +// if its fields are not initialized. +type VanguardClient struct { + *BasicClient +} + +// NewVanguardClient returns a Client interface configured to handle Vanguard's +// brand of idiosyncracy +func NewVanguardClient(bc *BasicClient) Client { + return &VanguardClient{bc} +} + +// rawRequestCookies is RawRequest with the added feature of sending cookies +func rawRequestCookies(URL string, r io.Reader, cookies []*http.Cookie) (*http.Response, error) { + if !strings.HasPrefix(URL, "https://") { + return nil, errors.New("Refusing to send OFX request with possible plain-text password over non-https protocol") + } + + request, err := http.NewRequest("POST", URL, r) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/x-ofx") + for _, cookie := range cookies { + request.AddCookie(cookie) + } + + response, err := http.DefaultClient.Do(request) + if err != nil { + return nil, err + } + + if response.StatusCode != 200 { + return nil, errors.New("OFXQuery request status: " + response.Status) + } + + return response, nil +} + +func (c *VanguardClient) RequestNoParse(r *Request) (*http.Response, error) { + r.SetClientFields(c) + + b, err := r.Marshal() + if err != nil { + return nil, err + } + + response, err := c.RawRequest(r.URL, b) + + // Some financial institutions (cough, Vanguard, cough), require a cookie + // to be set on the http request, or they return empty responses. + // Fortunately, the initial response contains the cookie we need, so if we + // detect an empty response with cookies set that didn't have any errors, + // re-try the request while sending their cookies back to them. + if err == nil && response.ContentLength <= 0 && len(response.Cookies()) > 0 { + b, err = r.Marshal() + if err != nil { + return nil, err + } + + return rawRequestCookies(r.URL, b, response.Cookies()) + } + + return response, err +} + +func (c *VanguardClient) Request(r *Request) (*Response, error) { + return clientRequest(c, r) +}