about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--monzo_ynab/main.go149
-rw-r--r--monzo_ynab/requests.txt22
-rw-r--r--monzo_ynab/tokens.go183
-rw-r--r--monzo_ynab/utils.go40
4 files changed, 342 insertions, 52 deletions
diff --git a/monzo_ynab/main.go b/monzo_ynab/main.go
index 07f3af9a1c81..1709ffda786e 100644
--- a/monzo_ynab/main.go
+++ b/monzo_ynab/main.go
@@ -10,11 +10,15 @@
 package main
 
 import (
+	"bytes"
 	"encoding/json"
 	"fmt"
+	"io/ioutil"
 	"log"
 	"net/http"
+	"net/http/httputil"
 	"net/url"
+	"strings"
 	"os"
 	"os/exec"
 )
@@ -24,6 +28,7 @@ import (
 ////////////////////////////////////////////////////////////////////////////////
 
 var (
+	accountId    = os.Getenv("monzo_account_id")
 	clientId     = os.Getenv("monzo_client_id")
 	clientSecret = os.Getenv("monzo_client_secret")
 )
@@ -45,13 +50,27 @@ const (
 type accessTokenResponse struct {
 	AccessToken  string `json:"access_token"`
 	RefreshToken string `json:"refresh_token"`
+	ExpiresIn    int    `json:"expires_in"`
+}
+
+type setTokensRequest struct {
+	AccessToken  string `json:"access_token"`
+	RefreshToken string `json:"refresh_token"`
+	ExpiresIn    int    `json:"expires_in"`
+}
+
+type Tokens struct {
+	AccessToken  string `json:"access_token"`
+	RefreshToken string `json:"refresh_token"`
+	ExpiresIn    int    `json:"expires_in"`
 }
 
 // TODO(wpcarro): Replace http.PostForm and other similar calls with
 // client.postForm. The default http.Get and other methods doesn't timeout, so
 // it's better to create a configured client with a value for the timeout.
 
-func getAccessToken(code string) {
+// Returns the access token and refresh tokens for the Monzo API.
+func getTokens(code string) *Tokens {
 	res, err := http.PostForm("https://api.monzo.com/oauth2/token", url.Values{
 		"grant_type":    {"authorization_code"},
 		"client_id":     {clientId},
@@ -59,67 +78,93 @@ func getAccessToken(code string) {
 		"redirect_uri":  {redirectURI},
 		"code":          {code},
 	})
-	failOn(err)
+	if err != nil {
+		log.Fatal(err)
+	}
 	defer res.Body.Close()
+	payload := &accessTokenResponse{}
+	json.NewDecoder(res.Body).Decode(payload)
 
-	payload := accessTokenResponse{}
-	json.NewDecoder(res.Body).Decode(&payload)
-
-	log.Printf("Access token: %s\n", payload.AccessToken)
-	log.Printf("Refresh token: %s\n", payload.AccessToken)
+	return &Tokens{payload.AccessToken, payload.RefreshToken, payload.ExpiresIn}
 }
 
-func listenHttp(sigint chan os.Signal) {
-	// Use a go-routine to listen for interrupt signals to shutdown our HTTP
-	// server.
-	go func() {
-		<-sigint
-		// TODO(wpcarro): Do we need context here? I took this example from the
-		// example on golang.org.
-		log.Println("Warning: I should be shutting down and closing the connection here, but I'm not.")
-		close(sigint)
-	}()
-
-	log.Fatal(http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-		// 1. Get authorization code from Monzo.
-		if req.URL.Path == "/authorization-code" {
-			params := req.URL.Query()
-			reqState := params["state"][0]
-			code := params["code"][0]
+// TODO(wpcarro): Prefer using an environment variable for the web browser
+// instead of assuming it will be google-chrome.
+// Open a web browser to allow the user to authorize this application. Return
+// the authorization code sent from Monzo.
+func getAuthCode() string {
+	url := fmt.Sprintf("https://auth.monzo.com/?client_id=%s&redirect_uri=%s&response_type=code&state=%s", clientId, redirectURI, state)
+	exec.Command("google-chrome", url).Start()
 
-			if reqState != state {
-				log.Fatalf("Value for state returned by Monzo does not equal our state. %s != %s", reqState, state)
+	authCode := make(chan string)
+	go func() {
+		log.Fatal(http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			// 1. Get authorization code from Monzo.
+			if req.URL.Path == "/authorization-code" {
+				params := req.URL.Query()
+				reqState := params["state"][0]
+				code := params["code"][0]
+
+				if reqState != state {
+					log.Fatalf("Value for state returned by Monzo does not equal our state. %s != %s", reqState, state)
+				}
+				authCode <- code
+
+				fmt.Fprintf(w, "Authorized!")
+			} else {
+				log.Printf("Unhandled request: %v\n", *req)
 			}
+		})))
+	}()
+	result := <-authCode
+	return result
+}
 
-			// TODO(wpcarro): Add a more interesting authorization confirmation
-			// screen -- or even nothing at all.
-			fmt.Fprintf(w, "Authorized!")
-
-			// Exchange the authorization code for an access token.
-			getAccessToken(code)
-			return
-		}
-
-		log.Printf("Unhandled request: %v\n", *req)
-	})))
+// TODO(wpcarro): Move this logic out of here and into the tokens server.
+func authorize() {
+	authCode := getAuthCode()
+	tokens := getTokens(authCode)
+	client := &http.Client{}
+
+	payload, _ := json.Marshal(setTokensRequest{
+		tokens.AccessToken,
+		tokens.RefreshToken,
+		tokens.ExpiresIn})
+	log.Printf("Access token: %s\n", tokens.AccessToken)
+	log.Printf("Refresh token: %s\n", tokens.RefreshToken)
+	log.Printf("Expires: %s\n", tokens.ExpiresIn)
+	req, _ := http.NewRequest("POST", "http://localhost:4242/set-tokens", bytes.NewBuffer(payload))
+	req.Header.Set("Content-Type", "application/json")
+	_, err := client.Do(req)
+	if err != nil {
+		log.Fatal(err)
+	}
 }
 
-// Open a web browser to allow the user to authorize this application.
-// TODO(wpcarro): Prefer using an environment variable for the web browser
-// instead of assuming it will be google-chrome.
-func authorizeClient() {
-	url := fmt.Sprintf("https://auth.monzo.com/?client_id=%s&redirect_uri=%s&response_type=code&state=%s", clientId, redirectURI, state)
-	exec.Command("google-chrome", url).Start()
+// Retrieves the access token from the tokens server.
+func getAccessToken() string {
+	return simpleGet("http://localhost:4242/token")
 }
 
 func main() {
-	sigint := make(chan os.Signal, 1)
-	// TODO(wpcarro): Remove state here. I'm using as a hack to prevent my
-	// program from halting before I'd like it to. Once I'm more comfortable
-	// using channels, this should be a trivial change.
-	state := make(chan bool)
-
-	authorizeClient()
-	listenHttp(sigint)
-	<-state
+	accessToken := getAccessToken()
+	// authHeaders := map[string]string{
+	// 	"Authorization": fmt.Sprintf("Bearer %s", accessToken),
+	// }
+
+	client := &http.Client{}
+	form := url.Values{"account_id": {accountId}}
+	req, _ := http.NewRequest("GET", "https://api.monzo.com/transactions", strings.NewReader(form.Encode()))
+	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken))
+	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+	bytes, _ := httputil.DumpRequest(req, true)
+	fmt.Println(string(bytes))
+	res, _ := client.Do(req)
+	bytes, _ = httputil.DumpResponse(res, true)
+	fmt.Println(string(bytes))
+
+	// res := simpleGet("https://api.monzo.com/accounts", authHeaders, true)
+	// fmt.Println(res)
+
+	os.Exit(0)
 }
diff --git a/monzo_ynab/requests.txt b/monzo_ynab/requests.txt
index 4e35e6b6c8dd..5698dfee3763 100644
--- a/monzo_ynab/requests.txt
+++ b/monzo_ynab/requests.txt
@@ -34,3 +34,25 @@ Content-Type: application/x-www-form-urlencoded
 Authorization: Bearer :monzo-access-token
 grant_type=refresh_token&client_id=:monzo-client-id&client_secret=:monzo-client-secret&refresh_token=:monzo-refresh-token
 
+################################################################################
+# Tokens server
+################################################################################
+:tokens = http://localhost:4242
+
+# Get tokens
+GET :tokens/tokens
+
+# Get application state for debugging purposes
+GET :tokens/state
+
+# Force refresh tokens
+POST :tokens/refresh-tokens
+
+# Set tokens
+POST :tokens/set-tokens
+Content-Type: application/json
+{
+  "access_token": "access-token",
+  "refresh_token": "refresh-token",
+  "expires_in": 120
+}
diff --git a/monzo_ynab/tokens.go b/monzo_ynab/tokens.go
new file mode 100644
index 000000000000..47e991e56132
--- /dev/null
+++ b/monzo_ynab/tokens.go
@@ -0,0 +1,183 @@
+// Creating a Tokens server to manage my access and refresh tokens. Keeping this
+// as a separate server allows me to develop and use the access tokens without
+// going through client authorization.
+package main
+
+////////////////////////////////////////////////////////////////////////////////
+// Dependencies
+////////////////////////////////////////////////////////////////////////////////
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"log"
+	"net/http"
+	"net/url"
+	"os"
+	"time"
+)
+
+////////////////////////////////////////////////////////////////////////////////
+// Types
+////////////////////////////////////////////////////////////////////////////////
+
+// This is the response from Monzo's API after we request an access token
+// refresh.
+type refreshTokenResponse struct {
+	AccessToken  string `json:"access_token"`
+	RefreshToken string `json:"refresh_token"`
+	ClientId     string `json:"client_id"`
+	ExpiresIn    int    `json:"expires_in"`
+}
+
+// This is the shape of the request from clients wishing to set state of the
+// server.
+type setTokensRequest struct {
+	AccessToken  string `json:"access_token"`
+	RefreshToken string `json:"refresh_token"`
+	ExpiresIn    int    `json:"expires_in"`
+}
+
+// This is our application state.
+type state struct {
+	accessToken  string `json:"access_token"`
+	refreshToken string `json:"refresh_token"`
+}
+
+type readMsg struct {
+	sender chan state
+}
+
+type writeMsg struct {
+	state state
+}
+
+type channels struct {
+	reads  chan readMsg
+	writes chan writeMsg
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Top-level Definitions
+////////////////////////////////////////////////////////////////////////////////
+
+var chans = &channels{
+	reads:  make(chan readMsg),
+	writes: make(chan writeMsg),
+}
+
+var (
+	monzoClientId      = os.Getenv("monzo_client_id")
+	monzoClientSecret  = os.Getenv("monzo_client_secret")
+	cachedAccessToken  = os.Getenv("monzo_cached_access_token")
+	cachedRefreshToken = os.Getenv("monzo_cached_access_token")
+)
+
+////////////////////////////////////////////////////////////////////////////////
+// Utils
+////////////////////////////////////////////////////////////////////////////////
+
+// Schedule a token refresh for `expiresIn` seconds using the provided
+// `refreshToken`. This will update the application state with the access token
+// and schedule an additional token refresh for the newly acquired tokens.
+func scheduleTokenRefresh(expiresIn int, refreshToken string) {
+	duration := time.Second * time.Duration(expiresIn)
+	timestamp := time.Now().Local().Add(duration)
+	log.Printf("Scheduling token refresh for %v\n", timestamp)
+	time.Sleep(duration)
+	log.Println("Refreshing tokens now...")
+	access, refresh := refreshTokens(refreshToken)
+	log.Println("Successfully refreshed tokens.")
+	chans.writes <- writeMsg{state{access, refresh}}
+}
+
+// Exchange existing credentials for a new access token and `refreshToken`. Also
+// schedule the next refresh. This function returns the newly acquired access
+// token and refresh token.
+func refreshTokens(refreshToken string) (string, string) {
+	// TODO(wpcarro): Support retries with exponential backoff.
+	res, err := http.PostForm("https://api.monzo.com/oauth2/token", url.Values{
+		"grant_type":    {"refresh_token"},
+		"client_id":     {monzoClientId},
+		"client_secret": {monzoClientSecret},
+		"refresh_token": {refreshToken},
+	})
+	if err != nil {
+		log.Println(res)
+		log.Fatal("The request to Monzo to refresh our access token failed.", err)
+	}
+	defer res.Body.Close()
+	payload := &refreshTokenResponse{}
+	err = json.NewDecoder(res.Body).Decode(payload)
+	if err != nil {
+		log.Println(res)
+		log.Fatal("Could not decode the JSON response from Monzo.", err)
+	}
+	go scheduleTokenRefresh(payload.ExpiresIn, payload.RefreshToken)
+
+	return payload.AccessToken, payload.RefreshToken
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Main
+////////////////////////////////////////////////////////////////////////////////
+
+func main() {
+	// Manage application state.
+	go func() {
+		state := &state{cachedAccessToken, cachedRefreshToken}
+		for {
+			select {
+			case msg := <-chans.reads:
+				log.Printf("Reading from state.")
+				log.Printf("Access Token: %s\n", state.accessToken)
+				log.Printf("Refresh Token: %s\n", state.refreshToken)
+				msg.sender <- *state
+			case msg := <-chans.writes:
+				fmt.Printf("Writing new state: %v\n", msg.state)
+				*state = msg.state
+			}
+		}
+	}()
+
+	// Listen to inbound requests.
+	fmt.Println("Listening on http://localhost:4242 ...")
+	log.Fatal(http.ListenAndServe(":4242", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+		if req.URL.Path == "/refresh-tokens" && req.Method == "POST" {
+			msg := readMsg{make(chan state)}
+			chans.reads <- msg
+			state := <-msg.sender
+			go scheduleTokenRefresh(0, state.refreshToken)
+			fmt.Fprintf(w, "Done.")
+
+		} else if req.URL.Path == "/set-tokens" && req.Method == "POST" {
+			// Parse
+			payload := &setTokensRequest{}
+			err := json.NewDecoder(req.Body).Decode(payload)
+			if err != nil {
+				log.Fatal("Could not decode the user's JSON request.", err)
+			}
+
+			// Update application state
+			msg := writeMsg{state{payload.AccessToken, payload.RefreshToken}}
+			chans.writes <- msg
+
+			// Refresh tokens
+			go scheduleTokenRefresh(payload.ExpiresIn, payload.RefreshToken)
+
+			// Ack
+			fmt.Fprintf(w, "Done.")
+		} else if req.URL.Path == "/state" && req.Method == "GET" {
+			// TODO(wpcarro): Ensure that this returns serialized state.
+			w.Header().Set("Content-type", "application/json")
+			msg := readMsg{make(chan state)}
+			chans.reads <- msg
+			state := <-msg.sender
+			payload, _ := json.Marshal(state)
+			fmt.Fprintf(w, "Application state: %s\n", bytes.NewBuffer(payload))
+		} else {
+			log.Printf("Unhandled request: %v\n", *req)
+		}
+	})))
+}
diff --git a/monzo_ynab/utils.go b/monzo_ynab/utils.go
index 8b7decb90a3f..9b8843f24a77 100644
--- a/monzo_ynab/utils.go
+++ b/monzo_ynab/utils.go
@@ -7,3 +7,43 @@ func failOn(err error) {
 		log.Fatal(err)
 	}
 }
+
+// Make a simple GET request to `url`. Fail if anything returns an error. I'd
+// like to accumulate a library of these, so that I can write scrappy Go
+// quickly. For now, this function just returns the body of the response back as
+// a string.
+func simpleGet(url string, headers map[string]string, debug bool) string {
+	client := &http.Client{}
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		log.Fatal(err)
+	}
+	for k, v := range headers {
+		req.Header.Add(k, v)
+	}
+
+	res, err := client.Do(req)
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer res.Body.Close()
+
+	if debug {
+		bytes, _ := httputil.DumpRequest(req, true)
+		log.Println(string(bytes))
+		bytes, _ = httputil.DumpResponse(res, true)
+		log.Println(string(bytes))
+	}
+
+	if res.StatusCode == http.StatusOK {
+		bytes, err := ioutil.ReadAll(res.Body)
+		if err != nil {
+			log.Fatal(err)
+		}
+		return string(bytes)
+	} else {
+		log.Println(res)
+		log.Fatalf("HTTP status code of response not OK: %v\n", res.StatusCode)
+		return ""
+	}
+}