summary history branches tags files
commit:c3bba3dcfc0badfddc2bba349c8cea45e5377b62
author:Trevor Bentley
committer:Trevor Bentley
date:Sat May 13 01:08:27 2017 +0200
parents:2664e015a9a21b42aa4fc15a0fb1d10c5f811aea
Add some webapi unit tests and some error handling
diff --git a/Cargo.toml b/Cargo.toml
line changes: +6/-1
index 730aa1a..85552bc
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -56,4 +56,9 @@ objc_id = "0.1"
 
 [target."cfg(target_os = \"macos\")".dependencies.objc]
 version = "0.2.2"
-features = ["exception"] 
\ No newline at end of file
+features = ["exception"]
+
+[dev-dependencies]
+hyper = {git = "https://github.com/hyperium/hyper"}
+futures = "0.1.11"
+lazy_static = "0.2"

diff --git a/src/http/mod.rs b/src/http/mod.rs
line changes: +4/-4
index 91af8cb..f3df4b6
--- a/src/http/mod.rs
+++ b/src/http/mod.rs
@@ -15,7 +15,6 @@ extern crate url;
 use self::url::percent_encoding;
 
 use super::settings;
-use super::spotify_api;
 
 #[derive(PartialEq)]
 pub enum HttpMethod {
@@ -128,6 +127,8 @@ pub fn http(url: &str, query: &str, body: &str,
             match transfer.perform() {
                 Err(x) => {
                     let result: Result<String,String> = Err(x.description().to_string());
+                    #[cfg(feature = "verbose_http")]
+                    println!("HTTP response: err: {}", x.description().to_string());
                     return HttpResponse {code: response, data: result }
                 }
                 _ => {}
@@ -147,11 +148,10 @@ pub fn http(url: &str, query: &str, body: &str,
     HttpResponse {code: response, data: result }
 }
 
-pub fn authenticate(settings: &settings::Settings) -> String {
-    let scopes = spotify_api::SCOPES.join(" ");
+pub fn authenticate(scopes: &str, url: &str, settings: &settings::Settings) -> String {
     let host = format!("http://127.0.0.1:{}", settings.port);
     let url = format!("{}?client_id={}&response_type=code&scope={}&redirect_uri={}",
-                      spotify_api::AUTHORIZE,settings.client_id, scopes, host);
+                      url,settings.client_id, scopes, host);
     let query = percent_encoding::utf8_percent_encode(&url, percent_encoding::QUERY_ENCODE_SET).collect::<String>();
     let response = "HTTP/1.1 200 OK\r\n\r\n<html><body>
 Authenticated with Spotify.<br/><br/>

diff --git a/src/lib.rs b/src/lib.rs
line changes: +37/-17
index 6bc77f7..c5dac12
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -5,6 +5,10 @@ pub mod webapi;
 // Re-export webapi interface to connectr root
 pub use webapi::*;
 
+#[cfg(test)]
+#[macro_use]
+extern crate lazy_static;
+
 #[macro_use]
 extern crate log;
 
@@ -20,25 +24,41 @@ extern crate objc;
 
 extern crate rustc_serialize;
 
-pub mod spotify_api {
-    pub const SCOPES: &'static [&'static str] = &[
-        "user-read-private", "streaming", "user-read-playback-state"
-    ];
-    pub const AUTHORIZE: &'static str = "https://accounts.spotify.com/en/authorize";
-    pub const TOKEN: &'static str = "https://accounts.spotify.com/api/token";
-    pub const DEVICES: &'static str = "https://api.spotify.com/v1/me/player/devices";
-    pub const PLAYER_STATE: &'static str = "https://api.spotify.com/v1/me/player";
-    pub const PLAY: &'static str = "https://api.spotify.com/v1/me/player/play";
-    pub const PAUSE: &'static str = "https://api.spotify.com/v1/me/player/pause";
-    pub const NEXT: &'static str = "https://api.spotify.com/v1/me/player/next";
-    pub const PREVIOUS: &'static str = "https://api.spotify.com/v1/me/player/previous";
-    pub const SEEK: &'static str = "https://api.spotify.com/v1/me/player/seek";
-    pub const VOLUME: &'static str = "https://api.spotify.com/v1/me/player/volume";
-    pub const SHUFFLE: &'static str = "https://api.spotify.com/v1/me/player/shuffle";
-    pub const REPEAT: &'static str = "https://api.spotify.com/v1/me/player/repeat";
-    pub const PLAYER: &'static str = "https://api.spotify.com/v1/me/player";
+#[derive(Clone, Copy)]
+pub struct SpotifyEndpoints<'a> {
+    scopes: &'a str,
+    authorize: &'a str,
+    token: &'a str,
+    devices: &'a str,
+    player_state: &'a str,
+    play: &'a str,
+    pause: &'a str,
+    next: &'a str,
+    previous: &'a str,
+    seek: &'a str,
+    volume: &'a str,
+    shuffle: &'a str,
+    repeat: &'a str,
+    player: &'a str,
 }
 
+pub const SPOTIFY_API: SpotifyEndpoints = SpotifyEndpoints {
+    scopes: "user-read-private streaming user-read-playback-state",
+    authorize: "https://accounts.spotify.com/en/authorize",
+    token: "https://accounts.spotify.com/api/token",
+    devices: "https://api.spotify.com/v1/me/player/devices",
+    player_state: "https://api.spotify.com/v1/me/player",
+    play: "https://api.spotify.com/v1/me/player/play",
+    pause: "https://api.spotify.com/v1/me/player/pause",
+    next: "https://api.spotify.com/v1/me/player/next",
+    previous: "https://api.spotify.com/v1/me/player/previous",
+    seek: "https://api.spotify.com/v1/me/player/seek",
+    volume: "https://api.spotify.com/v1/me/player/volume",
+    shuffle: "https://api.spotify.com/v1/me/player/shuffle",
+    repeat: "https://api.spotify.com/v1/me/player/repeat",
+    player: "https://api.spotify.com/v1/me/player",
+};
+
 #[cfg(target_os = "unix")]
 pub type Object = u64;
 #[cfg(target_os = "windows")]

diff --git a/src/main.rs b/src/main.rs
line changes: +9/-5
index 50022ed..4317832
--- a/src/main.rs
+++ b/src/main.rs
@@ -66,17 +66,21 @@ fn play_action_label(is_playing: bool) -> &'static str {
     }
 }
 
-fn fill_menu<T: TStatusBar>(app: &mut ConnectrApp, spotify: &mut connectr::SpotifyConnectr, status: &mut T) {
+fn update_state(app: &mut ConnectrApp, spotify: &mut connectr::SpotifyConnectr) -> bool {
     let dev_list = spotify.request_device_list();
     let player_state = spotify.request_player_state();
     match dev_list {
         Some(_) => { app.device_list = dev_list },
-        None => { return },
+        None => { return false },
     }
     match player_state {
         Some(_) => { app.player_state = player_state },
-        None => { return },
+        None => { return false },
     }
+    true
+}
+
+fn fill_menu<T: TStatusBar>(app: &mut ConnectrApp, spotify: &mut connectr::SpotifyConnectr, status: &mut T) {
     let ref device_list = app.device_list.as_ref().unwrap();
     let ref player_state = app.player_state.as_ref().unwrap();
 
@@ -392,7 +396,7 @@ fn main() {
 
     while running.load(Ordering::SeqCst) {
         let now = time::now_utc().to_timespec().sec as i64;
-        if now > refresh_time_utc && status.can_redraw() {
+        if now > refresh_time_utc && status.can_redraw() && update_state(&mut app, &mut spotify) {
             // Redraw the whole menu once every 60 seconds, or sooner if a
             // command is processed later.
             clear_menu(&mut app, &mut spotify, &mut status);
@@ -409,7 +413,7 @@ fn main() {
             refresh_time_utc = now + 1;
         }
         status.run(false);
-        sleep(Duration::from_millis(10));
+        sleep(Duration::from_millis(100));
     }
     info!("Exiting.\n");
     if let Some(mut tiny_proc) = tiny {

diff --git a/src/osx/mod.rs b/src/osx/mod.rs
line changes: +41/-23
index 9d16390..1ada38c
--- a/src/osx/mod.rs
+++ b/src/osx/mod.rs
@@ -28,6 +28,7 @@ use self::rustnsobject::{NSObj, NSObjTrait, NSObjCallbackTrait};
 use std::sync::mpsc::Sender;
 
 use std::ptr;
+use std::cell::Cell;
 use std::ffi::CStr;
 use std::thread::sleep;
 use std::time::Duration;
@@ -42,6 +43,14 @@ pub struct OSXStatusBar {
     app: *mut objc::runtime::Object,
     status_bar_item: *mut objc::runtime::Object,
     menu_bar: *mut objc::runtime::Object,
+
+    // Run loop state
+    // Keeping these in persistent state instead of recalculating saves quite a
+    // bit of CPU during idle.
+    pool: Cell<*mut objc::runtime::Object>,
+    run_count: Cell<u64>,
+    run_mode: *mut objc::runtime::Object,
+    run_date: *mut objc::runtime::Object,
 }
 
 impl TStatusBar for OSXStatusBar {
@@ -51,11 +60,16 @@ impl TStatusBar for OSXStatusBar {
         unsafe {
             let app = NSApp();
             let status_bar = NSStatusBar::systemStatusBar(nil);
+            let date_cls = Class::get("NSDate").unwrap();
             bar = OSXStatusBar {
                 app: app,
                 status_bar_item: status_bar.statusItemWithLength_(NSVariableStatusItemLength),
                 menu_bar: NSMenu::new(nil),
                 object: NSObj::alloc(tx).setup(),
+                pool: Cell::new(nil),
+                run_count: Cell::new(0),
+                run_mode: NSString::alloc(nil).init_str("kCFRunLoopDefaultMode"),
+                run_date: msg_send![date_cls, distantPast],
             };
             bar.app.setActivationPolicy_(NSApplicationActivationPolicyAccessory);
             msg_send![bar.status_bar_item, setHighlightMode:YES];
@@ -80,7 +94,6 @@ impl TStatusBar for OSXStatusBar {
                     cb(sender, &s.tx);
                 }
             ));
-            //unsafe { let _: () = msg_send![self.app, finishLaunching]; }
             let _: () = msg_send![app, finishLaunching];
         }
         bar
@@ -177,16 +190,21 @@ impl TStatusBar for OSXStatusBar {
     fn run(&mut self, block: bool) {
         loop {
             unsafe {
-                let pool = NSAutoreleasePool::new(nil);
-                let cls = Class::get("NSDate").unwrap();
-                let date: Id<Object> = msg_send![cls, distantPast];
-                let mode = NSString::alloc(nil).init_str("kCFRunLoopDefaultMode");
+                let run_count = self.run_count.get();
+                // Create a new release pool every once in a while, draining the old one
+                if run_count % 100 == 0 {
+                    let old_pool = self.pool.get();
+                    if run_count != 0 {
+                        let _ = msg_send![old_pool, drain];
+                    }
+                    self.pool.set(NSAutoreleasePool::new(nil));
+                }
+                let mode = self.run_mode;
                 let event: Id<Object> = msg_send![self.app, nextEventMatchingMask: -1
-                                                  untilDate: date inMode:mode dequeue: YES];
+                                                  untilDate: self.run_date inMode:mode dequeue: YES];
                 let _ = msg_send![self.app, sendEvent: event];
                 let _ = msg_send![self.app, updateWindows];
-                let _ = msg_send![mode, release];
-                let _ = msg_send![pool, drain];
+                self.run_count.set(run_count + 1);
             }
             if !block { break; }
             sleep(Duration::from_millis(50));
@@ -194,21 +212,21 @@ impl TStatusBar for OSXStatusBar {
     }
 }
 
-pub fn osx_alert(text: &str) {
-    unsafe {
-        let ns_text = NSString::alloc(nil).init_str(text);
-        let button = NSString::alloc(nil).init_str("ok");
-        let cls = Class::get("NSAlert").unwrap();
-        let alert: *mut Object = msg_send![cls, alloc];
-        let _ = msg_send![alert, init];
-        let _ = msg_send![alert, setMessageText: ns_text];
-        let _ = msg_send![alert, addButtonWithTitle: button];
-        let _ = msg_send![alert, runModal];
-        let _ = msg_send![ns_text, release];
-        let _ = msg_send![button, release];
-        let _ = msg_send![alert, release];
-    }
-}
+//pub fn osx_alert(text: &str) {
+//    unsafe {
+//        let ns_text = NSString::alloc(nil).init_str(text);
+//        let button = NSString::alloc(nil).init_str("ok");
+//        let cls = Class::get("NSAlert").unwrap();
+//        let alert: *mut Object = msg_send![cls, alloc];
+//        let _ = msg_send![alert, init];
+//        let _ = msg_send![alert, setMessageText: ns_text];
+//        let _ = msg_send![alert, addButtonWithTitle: button];
+//        let _ = msg_send![alert, runModal];
+//        let _ = msg_send![ns_text, release];
+//        let _ = msg_send![button, release];
+//        let _ = msg_send![alert, release];
+//    }
+//}
 
 pub fn resource_dir() -> Option<String> {
     unsafe {

diff --git a/src/webapi/mod.rs b/src/webapi/mod.rs
line changes: +95/-60
index 9f70e59..2fdcaa3
--- a/src/webapi/mod.rs
+++ b/src/webapi/mod.rs
@@ -1,3 +1,6 @@
+#[cfg(test)]
+mod test;
+
 extern crate time;
 extern crate timer;
 extern crate chrono;
@@ -5,6 +8,7 @@ extern crate chrono;
 use std::fmt;
 use std::iter;
 use std::process;
+use std::cell::Cell;
 use std::collections::BTreeMap;
 use std::sync::mpsc::{channel, Receiver};
 
@@ -14,7 +18,8 @@ use self::rustc_serialize::json::Json;
 
 use super::http;
 use super::settings;
-use super::spotify_api;
+use super::SpotifyEndpoints;
+use super::SPOTIFY_API;
 use super::http::HttpResponse;
 
 pub type DeviceId = String;
@@ -165,20 +170,6 @@ impl fmt::Display for PlayerState {
     }
 }
 
-pub fn request_oauth_tokens(auth_code: &str, settings: &settings::Settings) -> (String, String, u64) {
-    let query = QueryString::new()
-        .add("grant_type", "authorization_code")
-        .add("code", auth_code)
-        .add("redirect_uri", format!("http://127.0.0.1:{}", settings.port))
-        .add("client_id", settings.client_id.clone())
-        .add("client_secret", settings.secret.clone())
-        .build();
-
-    let json_response = http::http(spotify_api::TOKEN, &query, "", http::HttpMethod::POST,
-                                   http::AccessToken::None).unwrap();
-    parse_spotify_token(&json_response)
-}
-
 #[derive(RustcDecodable, RustcEncodable)]
 pub struct PlayContextOffset {
     pub position: Option<u32>,
@@ -304,7 +295,8 @@ struct DeviceIdList {
     play: bool,
 }
 
-pub struct SpotifyConnectr {
+pub struct SpotifyConnectr<'a> {
+    api: Cell<SpotifyEndpoints<'a>>,
     settings: settings::Settings,
     auth_code: String,
     access_token: Option<String>,
@@ -317,8 +309,8 @@ pub struct SpotifyConnectr {
     refresh_timer_channel: Option<Receiver<()>>,
 }
 
-impl SpotifyConnectr {
-    pub fn new() -> SpotifyConnectr {
+impl<'a> SpotifyConnectr<'a> {
+    pub fn new() -> SpotifyConnectr<'a> {
         let settings = match settings::read_settings() {
             Some(s) => s,
             None => process::exit(0),
@@ -326,7 +318,8 @@ impl SpotifyConnectr {
         let expire = settings.expire_utc;
         let access = settings.access_token.clone();
         let refresh = settings.refresh_token.clone();
-        SpotifyConnectr {settings: settings,
+        SpotifyConnectr {api:Cell::new(SPOTIFY_API),
+                         settings: settings,
                          auth_code: String::new(),
                          access_token: access,
                          refresh_token: refresh,
@@ -336,11 +329,16 @@ impl SpotifyConnectr {
                          refresh_timer_guard: None,
                          refresh_timer_channel: None}
     }
-    fn is_token_expired(&self) -> bool {
-        let now = time::now_utc().to_timespec().sec as u64;
-        let expire_utc = self.expire_utc.unwrap_or(0);
-        expire_utc <= (now - 60)
+    #[cfg(test)]
+    fn with_api(self, api: SpotifyEndpoints<'a>) -> SpotifyConnectr<'a> {
+        self.api.set(api);
+        self
     }
+    //fn is_token_expired(&self) -> bool {
+    //    let now = time::now_utc().to_timespec().sec as u64;
+    //    let expire_utc = self.expire_utc.unwrap_or(0);
+    //    expire_utc <= (now - 60)
+    //}
     fn expire_offset_to_utc(&self, expires_in: u64) -> u64 {
         let now = time::now_utc().to_timespec().sec as u64;
         now + expires_in
@@ -359,7 +357,13 @@ impl SpotifyConnectr {
                 let (tx, rx) = channel::<()>();
                 self.refresh_timer_channel = Some(rx);
                 let expire_offset = self.expire_utc_to_offset(expire_utc) as i64;
+                // Refresh a bit before it expires
+                let expire_offset = match expire_offset {
+                    x if x > 60 => x - 60,
+                    _ => expire_offset,
+                };
                 let expire_offset = chrono::Duration::seconds(expire_offset);
+                info!("Refreshing Spotify credentials in {} sec", expire_offset.num_seconds());
                 let closure = move || { tx.send(()).unwrap(); };
                 self.refresh_timer_guard = Some(self.refresh_timer.schedule_with_delay(expire_offset, closure));
                 Ok(())
@@ -367,6 +371,29 @@ impl SpotifyConnectr {
             _ => Err(())
         }
     }
+    pub fn refresh_access_token(&mut self) {
+        info!("Refreshing Spotify credentials now.");
+        self.refresh_timer_channel = None;
+        match self.refresh_oauth_tokens() {
+            Some((access_token, expires_in)) => {
+                self.access_token = Some(access_token.clone());
+                self.expire_utc = Some(self.expire_offset_to_utc(expires_in));
+            },
+            None => {
+                self.authenticate();
+            }
+        }
+        //let (access_token, expires_in) = ;
+
+        info!("Refreshed credentials.");
+        let _ = self.schedule_token_refresh();
+
+        let access_token = self.access_token.clone().unwrap();
+        let refresh_token = self.refresh_token.clone().unwrap();
+        let _ = settings::save_tokens(&access_token,
+                                      &refresh_token,
+                                      self.expire_utc.unwrap());
+    }
     pub fn await_once(&mut self, blocking: bool) {
         // Choose between blocking or non-blocking receive.
         let recv_fn: Box<Fn(&Receiver<()>) -> bool> = match blocking {
@@ -380,23 +407,12 @@ impl SpotifyConnectr {
         if !need_refresh {
             return ()
         }
-        self.refresh_timer_channel = None;
-        let (access_token, expires_in) = self.refresh_oauth_tokens();
-        self.access_token = Some(access_token.clone());
-        self.expire_utc = Some(self.expire_offset_to_utc(expires_in));
-        println!("Refreshed credentials.");
-        let _ = self.schedule_token_refresh();
-
-        let access_token = self.access_token.clone().unwrap();
-        let refresh_token = self.refresh_token.clone().unwrap();
-        let _ = settings::save_tokens(&access_token,
-                                      &refresh_token,
-                                      self.expire_utc.unwrap());
+        self.refresh_access_token();
     }
     pub fn authenticate(&mut self) {
-        println!("Requesting fresh credentials.");
-        self.auth_code = http::authenticate(&self.settings);
-        let (access_token, refresh_token, expires_in) = request_oauth_tokens(&self.auth_code, &self.settings);
+        info!("Requesting fresh credentials.");
+        self.auth_code = http::authenticate(self.api.get().scopes, self.api.get().authorize, &self.settings);
+        let (access_token, refresh_token, expires_in) = self.request_oauth_tokens(&self.auth_code, &self.settings);
         let expire_utc = self.expire_offset_to_utc(expires_in);
         let _ = settings::save_tokens(&access_token, &refresh_token, expire_utc);
         self.access_token = Some(access_token);
@@ -404,10 +420,22 @@ impl SpotifyConnectr {
         self.expire_utc = Some(expire_utc);
         let _ = self.schedule_token_refresh();
     }
+    pub fn request_oauth_tokens(&self, auth_code: &str, settings: &settings::Settings) -> (String, String, u64) {
+    let query = QueryString::new()
+        .add("grant_type", "authorization_code")
+        .add("code", auth_code)
+        .add("redirect_uri", format!("http://127.0.0.1:{}", settings.port))
+        .add("client_id", settings.client_id.clone())
+        .add("client_secret", settings.secret.clone())
+        .build();
+        let json_response = http::http(self.api.get().token, &query, "", http::HttpMethod::POST,
+                                       http::AccessToken::None).unwrap();
+        parse_spotify_token(&json_response)
+    }
     pub fn connect(&mut self) {
-        if self.access_token.is_some() && !self.is_token_expired() {
-            println!("Reusing saved credentials.");
-            let _ = self.schedule_token_refresh();
+        if self.access_token.is_some() {
+            info!("Reusing saved credentials.");
+            self.refresh_access_token();
             return ()
         }
         self.authenticate()
@@ -424,37 +452,44 @@ impl SpotifyConnectr {
             None => http::AccessToken::None,
         }
     }
-    pub fn refresh_oauth_tokens(&self) -> (String, u64) {
+    pub fn refresh_oauth_tokens(&self) -> Option<(String, u64)> {
         let query = QueryString::new()
             .add("grant_type", "refresh_token")
             .add("refresh_token", self.refresh_token.as_ref().unwrap())
             .add("client_id", self.settings.client_id.clone())
             .add("client_secret", self.settings.secret.clone())
             .build();
-        let json_response = http::http(spotify_api::TOKEN, &query, "",
-                                       http::HttpMethod::POST, http::AccessToken::None).unwrap();
-        let (access_token, _, expires_in) = parse_spotify_token(&json_response);
-        (access_token, expires_in)
+        let json_response = http::http(self.api.get().token, &query, "",
+                                       http::HttpMethod::POST, http::AccessToken::None);
+        match json_response.code {
+            Some(200) => {
+                let (access_token, _, expires_in) = parse_spotify_token(&json_response.data.unwrap());
+                Some((access_token, expires_in))
+            },
+            _ => { None }
+        }
     }
     pub fn request_device_list(&mut self) -> Option<ConnectDeviceList> {
-        let json_response = http::http(spotify_api::DEVICES, "", "",
+        let json_response = http::http(self.api.get().devices, "", "",
                                        http::HttpMethod::GET, self.bearer_token());
         match json_response.code {
             Some(200) => Some(json::decode(&json_response.data.unwrap()).unwrap()),
             Some(401) => {
-                self.authenticate();
+                warn!("Access token invalid.  Attempting to reauthenticate.");
+                self.refresh_access_token();
                 None
             }
             _ => None
         }
     }
     pub fn request_player_state(&mut self) -> Option<PlayerState> {
-        let json_response = http::http(spotify_api::PLAYER_STATE, "", "",
+        let json_response = http::http(self.api.get().player_state, "", "",
                                        http::HttpMethod::GET, self.bearer_token());
         match json_response.code {
             Some(200) => Some(json::decode(&json_response.data.unwrap()).unwrap()),
             Some(401) => {
-                self.authenticate();
+                warn!("Access token invalid.  Attempting to reauthenticate.");
+                self.refresh_access_token();
                 None
             }
             _ => None
@@ -469,58 +504,58 @@ impl SpotifyConnectr {
             Some(x) => json::encode(x).unwrap(),
             None => String::new(),
         };
-        http::http(spotify_api::PLAY, &query, &body, http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.get().play, &query, &body, http::HttpMethod::PUT, self.bearer_token())
     }
     pub fn pause(&self) -> SpotifyResponse {
         let query = QueryString::new().add_opt("device_id", self.device.clone()).build();
-        http::http(spotify_api::PAUSE, &query, "", http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.get().pause, &query, "", http::HttpMethod::PUT, self.bearer_token())
     }
     pub fn next(&self) -> SpotifyResponse {
         let query = QueryString::new().add_opt("device_id", self.device.clone()).build();
-        http::http(spotify_api::NEXT, &query, "", http::HttpMethod::POST, self.bearer_token())
+        http::http(self.api.get().next, &query, "", http::HttpMethod::POST, self.bearer_token())
     }
     pub fn previous(&self) -> SpotifyResponse {
         let query = QueryString::new().add_opt("device_id", self.device.clone()).build();
-        http::http(spotify_api::PREVIOUS, &query, "", http::HttpMethod::POST, self.bearer_token())
+        http::http(self.api.get().previous, &query, "", http::HttpMethod::POST, self.bearer_token())
     }
     pub fn seek(&self, position: u32) -> SpotifyResponse {
         let query = QueryString::new()
             .add_opt("device_id", self.device.clone())
             .add("position_ms", position)
             .build();
-        http::http(spotify_api::SEEK, &query, "", http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.get().seek, &query, "", http::HttpMethod::PUT, self.bearer_token())
     }
     pub fn volume(&self, volume: u32) -> SpotifyResponse {
         let query = QueryString::new()
             .add_opt("device_id", self.device.clone())
             .add("volume_percent", volume)
             .build();
-        http::http(spotify_api::VOLUME, &query, "", http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.get().volume, &query, "", http::HttpMethod::PUT, self.bearer_token())
     }
     pub fn shuffle(&self, shuffle: bool) -> SpotifyResponse {
         let query = QueryString::new()
             .add_opt("device_id", self.device.clone())
             .add("state", shuffle)
             .build();
-        http::http(spotify_api::SHUFFLE, &query, "", http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.get().shuffle, &query, "", http::HttpMethod::PUT, self.bearer_token())
     }
     pub fn repeat(&self, repeat: SpotifyRepeat) -> SpotifyResponse {
         let query = QueryString::new()
             .add_opt("device_id", self.device.clone())
             .add("state", repeat)
             .build();
-        http::http(spotify_api::REPEAT, &query, "", http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.get().repeat, &query, "", http::HttpMethod::PUT, self.bearer_token())
     }
     pub fn transfer_multi(&mut self, devices: Vec<String>, play: bool) -> SpotifyResponse {
         let device = devices[0].clone();
         let body = json::encode(&DeviceIdList {device_ids: devices, play: play}).unwrap();
         self.set_target_device(Some(device));
-        http::http(spotify_api::PLAYER, "", &body, http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.get().player, "", &body, http::HttpMethod::PUT, self.bearer_token())
     }
     pub fn transfer(&mut self, device: String, play: bool) -> SpotifyResponse {
         let body = json::encode(&DeviceIdList {device_ids: vec![device.clone()], play: play}).unwrap();
         self.set_target_device(Some(device));
-        http::http(spotify_api::PLAYER, "", &body, http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.get().player, "", &body, http::HttpMethod::PUT, self.bearer_token())
     }
     pub fn get_presets(&mut self) -> &Vec<(String,String)> {
         &self.settings.presets

diff --git a/src/webapi/test.rs b/src/webapi/test.rs
line changes: +158/-0
index 0000000..98ee19c
--- /dev/null
+++ b/src/webapi/test.rs
@@ -0,0 +1,158 @@
+#[cfg(test)]
+mod tests {
+    extern crate futures;
+    extern crate hyper;
+
+    use super::super::*;
+    use super::super::super::SpotifyEndpoints;
+
+    use std;
+    use std::thread;
+    use std::thread::sleep;
+    use std::sync::atomic::AtomicBool;
+    use std::sync::atomic::Ordering;
+    use std::time::Duration;
+    use std::sync::{Once, ONCE_INIT};
+
+    use self::hyper::{Post, StatusCode};
+    use self::hyper::server::{Service, Request, Response};
+    use self::hyper::server::Http;
+    use self::futures::Stream;
+    use self::futures::Future;
+
+    static START: Once = ONCE_INIT;
+
+    lazy_static! {
+        static ref WEBSERVER_STARTED: AtomicBool = AtomicBool::new(false);
+    }
+
+    pub const TEST_API: SpotifyEndpoints = SpotifyEndpoints {
+        scopes: "user-read-private streaming user-read-playback-state",
+        authorize: "http://127.0.0.1:9799/en/authorize",
+        token: "http://127.0.0.1:9799/api/token",
+        devices: "http://127.0.0.1:9799/v1/me/player/devices",
+        player_state: "http://127.0.0.1:9799/v1/me/player",
+        play: "http://127.0.0.1:9799/v1/me/player/play",
+        pause: "http://127.0.0.1:9799/v1/me/player/pause",
+        next: "http://127.0.0.1:9799/v1/me/player/next",
+        previous: "http://127.0.0.1:9799/v1/me/player/previous",
+        seek: "http://127.0.0.1:9799/v1/me/player/seek",
+        volume: "http://127.0.0.1:9799/v1/me/player/volume",
+        shuffle: "http://127.0.0.1:9799/v1/me/player/shuffle",
+        repeat: "http://127.0.0.1:9799/v1/me/player/repeat",
+        player: "http://127.0.0.1:9799/v1/me/player",
+    };
+
+    /// Macro to parse the body of a POST request and send a response.
+    ///
+    /// There's probably a "body.to_string()" function somewhere.  I didn't find it.
+    /// So instead there's this unreadable, overly complicated bullshit.
+    ///
+    /// $body_in: a POST body (hyper::Body) from a received POST request
+    /// $pairs_out: the name of the key/value pair variable provided to the $block_in
+    /// $block_in: a block of code to be executed, with $pairs_out in scope, that evaluates
+    ///            to tuple (status_code: StatusCode, body: &str) to send as a response.
+    macro_rules! post {
+    ($body_in:ident, $pairs_out:ident, $block_in:block) => {
+        {
+            // Read chunks from user provided body var $body_in
+            $body_in.fold(vec![], |mut acc, chunk| {
+                acc.extend(chunk);
+                Ok::<_, hyper::Error>(acc)
+            }).and_then(move |bytes| {
+                // [u8] -> String
+                let post_data: String = std::str::from_utf8(&bytes).unwrap().to_string();;
+                // Split on & to get ["key=value"...]
+                let pairs = post_data.split("&");
+                // Split on = to get [[key,value]...], put in user provided var name $pairs_out
+                let $pairs_out = pairs.map(|pair| pair.split("=").collect::<Vec<&str>>()).collect::<Vec<Vec<&str>>>();
+                // User provided block takes $pairs_out and returns response string
+                let (code, response) = $block_in;
+                let res = Response::new();
+                Ok(res.with_status(code).with_body(response))
+            }).boxed()
+        }
+    };
+    }
+
+    fn token_response(pairs: &Vec<Vec<&str>>) -> (StatusCode, String) {
+        let mut resp = String::new();
+        let mut code = StatusCode::Ok;
+        resp.push_str("{");
+        resp.push_str(r#""access_token": "valid_access_code","#);
+        resp.push_str(r#""token_type": "Bearer","#);
+        resp.push_str(r#""scope": "user-read-private user-read-email","#);
+        resp.push_str(r#""expires_in": 3600"#);
+        resp.push_str("}");
+        for pair in pairs {
+            let (key,value) = (pair[0], pair[1]);
+            if key == "refresh_token" && value == "error" {
+                code = StatusCode::Forbidden;
+            }
+        }
+        (code, resp)
+    }
+
+    fn init() {
+        while !WEBSERVER_STARTED.load(Ordering::Relaxed) {
+            sleep(Duration::from_millis(100));
+        }
+        START.call_once(|| {
+            #[derive(Clone, Copy)]
+            struct Webapi;
+            impl Service for Webapi {
+                type Request = Request;
+                type Response = Response;
+                type Error = hyper::Error;
+                type Future = futures::BoxFuture<Response, hyper::Error>;
+                fn call(&self, req: Request) -> Self::Future {
+                    let (method, uri, _, _headers, body) = req.deconstruct();
+                    match(method, uri.path()) {
+                        (Post, "/api/token") => post!(body, pairs, { token_response(&pairs) }),
+                        _ => futures::future::ok(Response::new().with_status(StatusCode::NotFound)).boxed(),
+                    }
+                }
+            }
+            thread::spawn(move || {
+                let addr = "127.0.0.1:9799".parse().unwrap();
+                let server = Http::new().bind(&addr, || Ok(Webapi)).unwrap();
+                server.run().unwrap();
+                WEBSERVER_STARTED.store(true, Ordering::Relaxed);
+            });
+        });
+    }
+
+    #[test]
+    fn test_refresh_oauth_tokens_no_connection() {
+        let spotify = SpotifyConnectr::new().with_api(TEST_API);
+        let res = spotify.refresh_oauth_tokens();
+        // Unlock webserver init so all other tests can run
+        WEBSERVER_STARTED.store(true, Ordering::Relaxed);
+        assert!(res.is_none());
+    }
+
+    #[test]
+    fn test_refresh_oauth_tokens_pass() {
+        init();
+        let spotify = SpotifyConnectr::new().with_api(TEST_API);
+        match spotify.refresh_oauth_tokens() {
+            Some((access,expires)) => {
+                assert_eq!(access, "valid_access_code");
+                assert_eq!(expires, 3600);
+            },
+            None => { assert!(false) },
+        }
+    }
+
+    #[test]
+    fn test_refresh_oauth_tokens_error_status() {
+        init();
+        let mut spotify = SpotifyConnectr::new().with_api(TEST_API);
+        spotify.refresh_token = Some("error".to_string());
+        match spotify.refresh_oauth_tokens() {
+            Some(_) => { assert!(false) },
+            None => { },
+        }
+    }
+
+}