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