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.
This commit is contained in:
Aaron Lindsay 2018-10-03 09:59:04 -04:00
parent eb35a26986
commit d8491bed1d
5 changed files with 230 additions and 149 deletions

80
basic_client.go Normal file
View File

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

192
client.go
View File

@ -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

View File

@ -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

View File

@ -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
}

79
vanguard_client.go Normal file
View File

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