diff --git a/Cargo.toml b/Cargo.toml index f11b327d..0459d768 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ clap = { version = "4.0.17", features = ["derive"] } envtestkit = "1.1.2" log = { version = "0.4.17", features = ["release_max_level_info", "max_level_trace"] } reqwest = { version = "0.11.12", features = ["json", "blocking"] } +serde = { version = "1.0.147", features = ["derive"] } +serde_json = "1.0.87" simple_logger = "3.0.0" tempdir = "0.3.7" thiserror = "1.0.37" diff --git a/src/authentication.rs b/src/authentication.rs index 59e6c45e..c61c60a5 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -1,29 +1,35 @@ use reqwest::header; use std::{env, fs}; + +use reqwest::header::{HeaderMap, InvalidHeaderValue}; use thiserror::Error; const API_BASE_URL: &str = "https://api.screenlyapp.com/api"; pub struct Config { - url: String, + pub url: String, } #[derive(Error, Debug)] pub enum AuthenticationError { #[error("wrong credentials error")] WrongCredentialsError, + #[error("no credentials error")] + NoCredentialsError, #[error("request error")] RequestError(#[from] reqwest::Error), #[error("i/o error")] IoError(#[from] std::io::Error), #[error("env error")] EnvError(#[from] env::VarError), + #[error("invalid header error")] + InvalidHeaderError(#[from] InvalidHeaderValue), #[error("unknown error")] Unknown, } pub struct Authentication { - config: Config, + pub config: Config, } impl Config { @@ -48,6 +54,15 @@ impl Authentication { } } + fn read_token() -> Result { + match env::var("HOME") { + Ok(path) => { + std::fs::read_to_string(path + "/.screenly").map_err(AuthenticationError::IoError) + } + Err(_) => Err(AuthenticationError::NoCredentialsError), + } + } + #[cfg(test)] pub fn new_with_config(config: Config) -> Self { Self { config } @@ -68,7 +83,7 @@ impl Authentication { fn verify_token(&self, token: &str) -> anyhow::Result<(), AuthenticationError> { // Using uuid of non existing playlist. If we get 404 it means we authenticated successfully. let url = self.config.url.clone() + "/v3/groups/11CF9Z3GZR0005XXKH00F8V20R/"; - let secret = "Token ".to_owned() + token; + let secret = format!("Token {}", token); let client = reqwest::blocking::Client::builder().build()?; let res = client @@ -76,21 +91,36 @@ impl Authentication { .header(header::AUTHORIZATION, &secret) .send()?; - return match res.status().as_u16() { + match res.status().as_u16() { 401 => Err(AuthenticationError::WrongCredentialsError), 404 => Ok(()), _ => Err(AuthenticationError::Unknown), - }; + } + } + + pub fn build_client(&self) -> Result { + let token = Authentication::read_token()?; + let secret = format!("Token {}", token.as_str()); + let mut default_headers = HeaderMap::new(); + default_headers.insert(header::AUTHORIZATION, secret.parse()?); + default_headers.insert( + header::USER_AGENT, + "screenly-cli 0.1.0".to_string().parse()?, + ); + reqwest::blocking::Client::builder() + .default_headers(default_headers) + .build() + .map_err(AuthenticationError::RequestError) } } #[cfg(test)] mod tests { - use std::ffi::OsString; use crate::authentication::Config; use crate::Authentication; use httpmock::{Method::GET, MockServer}; use simple_logger::SimpleLogger; + use std::ffi::OsString; use std::fs; use tempdir::TempDir; @@ -110,7 +140,8 @@ mod tests { let mock_server = MockServer::start(); mock_server.mock(|when, then| { when.method(GET) - .path("/v3/groups/11CF9Z3GZR0005XXKH00F8V20R/"); + .path("/v3/groups/11CF9Z3GZR0005XXKH00F8V20R/") + .header("Authorization", "Token token"); then.status(404); }); diff --git a/src/commands.rs b/src/commands.rs index 384daf04..30cdda8d 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,15 +1,199 @@ -use crate::Authentication; +use crate::{Authentication, AuthenticationError}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use serde_json::Value; + +#[derive(Error, Debug)] +pub enum CommandError { + #[error("auth error")] + AuthenticationError(#[from] AuthenticationError), + #[error("request error")] + RequestError(#[from] reqwest::Error), + #[error("parse error")] + ParseError(#[from] serde_json::Error), + #[error("unknown error #[0]")] + WrongResponseStatus(u16), +} +// TODO: Use in the future no need to deserialize for current features +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct ScreenConfig {} +// TODO: Use in the future no need to deserialize for current features +#[derive(Deserialize, Serialize, Debug, Default)] +pub struct Screen { + #[serde(default)] + pub id: String, + #[serde(default)] + pub team_id: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub is_enabled: bool, + #[serde(default)] + pub last_ping: Option, + #[serde(default)] + pub last_ip: Option, + #[serde(default)] + pub local_ip: Option, + #[serde(default)] + pub mac: Option, + #[serde(default)] + pub last_screenshot_time: Option, + #[serde(default)] + pub uptime: Option, + #[serde(default)] + pub load_avg: Option, + #[serde(default)] + pub signal_strength: Option, + #[serde(default)] + pub interface: Option, + #[serde(default)] + pub debug: bool, + #[serde(default)] + pub location: Option, + #[serde(default)] + pub team: Option, + #[serde(default)] + pub timezone: Option, + #[serde(default)] + pub hostname: Option, + #[serde(default)] + pub ws_open: bool, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub last_screenshot: Option, + #[serde(default)] + pub in_sync: bool, + #[serde(default)] + pub software_version: Option, + #[serde(default)] + pub hardware_version: Option, + #[serde(default)] + pub config: ScreenConfig, +} pub struct ScreenCommand { authentication: Authentication, } - impl ScreenCommand { - fn new(authentication: Authentication) -> Self { + pub fn new(authentication: Authentication) -> Self { Self { authentication } } + pub fn list(&self) -> anyhow::Result { + let url = self.authentication.config.url.clone() + "/v4/screens"; + let response = self.authentication.build_client()?.get(url).send()?; + if response.status().as_u16() != 200 { + return Err(CommandError::WrongResponseStatus( + response.status().as_u16(), + )); + } + serde_json::from_str(&response.text().unwrap_or_else(|_| "{}".to_owned())) + .map_err(CommandError::ParseError) + } + pub fn get(&self, id: &str) -> anyhow::Result { + let url = self.authentication.config.url.clone() + "/v4/screens?id=eq." + id; + let response = self.authentication.build_client()?.get(url).send()?; + if response.status().as_u16() != 200 { + return Err(CommandError::WrongResponseStatus( + response.status().as_u16(), + )); + } + + serde_json::from_str(&response.text().unwrap_or_else(|_| "{}".to_owned())) + .map_err(CommandError::ParseError) + } +} + +#[cfg(test)] +mod tests { + use crate::authentication::Config; + use crate::Authentication; + use httpmock::{Method::GET, MockServer}; + + use envtestkit::lock::lock_test; + use envtestkit::set_env; + use std::ffi::OsString; + use std::fs; + + use crate::commands::ScreenCommand; + use serde_json::Value; + use tempdir::TempDir; + + #[test] + fn test_list_screens_should_return_correct_screen_list() { + let tmp_dir = TempDir::new("test").unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + fs::write(tmp_dir.path().join(".screenly").to_str().unwrap(), "token").unwrap(); + let mock_server = MockServer::start(); + mock_server.mock(|when, then| { + when.method(GET) + .path("/v4/screens") + .header("Authorization", "Token token") + .header("user-agent", "screenly-cli 0.1.0"); + then + .status(200) + .body(b"[{\"id\":\"017a5104-524b-33d8-8026-9087b59e7eb5\",\"team_id\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"created_at\":\"2021-06-28T05:07:55+00:00\",\"name\":\"Renat's integrated wired NM\",\"is_enabled\":true,\"coords\":[55.22931, 48.90429],\"last_ping\":\"2021-08-25T06:17:20.728+00:00\",\"last_ip\":null,\"local_ip\":\"192.168.1.146\",\"mac\":\"b8:27:eb:d6:83:6f\",\"last_screenshot_time\":\"2021-08-25T06:09:04.399+00:00\",\"uptime\":\"230728.38\",\"load_avg\":\"0.14\",\"signal_strength\":null,\"interface\":\"eth0\",\"debug\":false,\"location\":\"Kamsko-Ust'inskiy rayon, Russia\",\"team\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"timezone\":\"Europe/Moscow\",\"type\":\"hardware\",\"hostname\":\"srly-4shnfrdc5cd2p0p\",\"ws_open\":false,\"status\":\"Offline\",\"last_screenshot\":\"https://us-assets.screenlyapp.com/01CD1W50NR000A28F31W83B1TY/screenshots/01F98G8MJB6FC809MGGYTSWZNN/5267668e6db35498e61b83d4c702dbe8\",\"in_sync\":false,\"software_version\":\"Screenly 2 Player\",\"hardware_version\":\"Raspberry Pi 3B\",\"config\":{\"hdmi_mode\": 34, \"hdmi_boost\": 2, \"hdmi_drive\": 0, \"hdmi_group\": 0, \"verify_ssl\": true, \"audio_output\": \"hdmi\", \"hdmi_timings\": \"\", \"overscan_top\": 0, \"overscan_left\": 0, \"use_composite\": false, \"display_rotate\": 0, \"overscan_right\": 0, \"overscan_scale\": 0, \"overscan_bottom\": 0, \"disable_overscan\": 0, \"shuffle_playlist\": false, \"framebuffer_width\": 0, \"use_composite_pal\": false, \"framebuffer_height\": 0, \"hdmi_force_hotplug\": true, \"use_composite_ntsc\": false, \"hdmi_pixel_encoding\": 0, \"play_history_enabled\": false}}]"); + }); + + let config = Config::new(mock_server.base_url()); + let authentication = Authentication::new_with_config(config); + let screen_command = ScreenCommand::new(authentication); + let expected = serde_json::from_str::("[{\"id\":\"017a5104-524b-33d8-8026-9087b59e7eb5\",\"team_id\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"created_at\":\"2021-06-28T05:07:55+00:00\",\"name\":\"Renat's integrated wired NM\",\"is_enabled\":true,\"coords\":[55.22931, 48.90429],\"last_ping\":\"2021-08-25T06:17:20.728+00:00\",\"last_ip\":null,\"local_ip\":\"192.168.1.146\",\"mac\":\"b8:27:eb:d6:83:6f\",\"last_screenshot_time\":\"2021-08-25T06:09:04.399+00:00\",\"uptime\":\"230728.38\",\"load_avg\":\"0.14\",\"signal_strength\":null,\"interface\":\"eth0\",\"debug\":false,\"location\":\"Kamsko-Ust'inskiy rayon, Russia\",\"team\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"timezone\":\"Europe/Moscow\",\"type\":\"hardware\",\"hostname\":\"srly-4shnfrdc5cd2p0p\",\"ws_open\":false,\"status\":\"Offline\",\"last_screenshot\":\"https://us-assets.screenlyapp.com/01CD1W50NR000A28F31W83B1TY/screenshots/01F98G8MJB6FC809MGGYTSWZNN/5267668e6db35498e61b83d4c702dbe8\",\"in_sync\":false,\"software_version\":\"Screenly 2 Player\",\"hardware_version\":\"Raspberry Pi 3B\",\"config\":{\"hdmi_mode\": 34, \"hdmi_boost\": 2, \"hdmi_drive\": 0, \"hdmi_group\": 0, \"verify_ssl\": true, \"audio_output\": \"hdmi\", \"hdmi_timings\": \"\", \"overscan_top\": 0, \"overscan_left\": 0, \"use_composite\": false, \"display_rotate\": 0, \"overscan_right\": 0, \"overscan_scale\": 0, \"overscan_bottom\": 0, \"disable_overscan\": 0, \"shuffle_playlist\": false, \"framebuffer_width\": 0, \"use_composite_pal\": false, \"framebuffer_height\": 0, \"hdmi_force_hotplug\": true, \"use_composite_ntsc\": false, \"hdmi_pixel_encoding\": 0, \"play_history_enabled\": false}}]").unwrap(); + let v = screen_command.list().unwrap(); + assert_eq!(v, expected); + } - fn list() {} + #[test] + fn test_list_screens_when_not_authenticated_should_return_error() { + let tmp_dir = TempDir::new("test").unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let mock_server = MockServer::start(); + mock_server.mock(|when, then| { + when.method(GET) + .path("/v4/screens") + .header("Authorization", "Token token") + .header("user-agent", "screenly-cli 0.1.0"); + then + .status(200) + .body(b"[{\"id\":\"017a5104-524b-33d8-8026-9087b59e7eb5\",\"team_id\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"created_at\":\"2021-06-28T05:07:55+00:00\",\"name\":\"Renat's integrated wired NM\",\"is_enabled\":true,\"coords\":[55.22931, 48.90429],\"last_ping\":\"2021-08-25T06:17:20.728+00:00\",\"last_ip\":null,\"local_ip\":\"192.168.1.146\",\"mac\":\"b8:27:eb:d6:83:6f\",\"last_screenshot_time\":\"2021-08-25T06:09:04.399+00:00\",\"uptime\":\"230728.38\",\"load_avg\":\"0.14\",\"signal_strength\":null,\"interface\":\"eth0\",\"debug\":false,\"location\":\"Kamsko-Ust'inskiy rayon, Russia\",\"team\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"timezone\":\"Europe/Moscow\",\"type\":\"hardware\",\"hostname\":\"srly-4shnfrdc5cd2p0p\",\"ws_open\":false,\"status\":\"Offline\",\"last_screenshot\":\"https://us-assets.screenlyapp.com/01CD1W50NR000A28F31W83B1TY/screenshots/01F98G8MJB6FC809MGGYTSWZNN/5267668e6db35498e61b83d4c702dbe8\",\"in_sync\":false,\"software_version\":\"Screenly 2 Player\",\"hardware_version\":\"Raspberry Pi 3B\",\"config\":{\"hdmi_mode\": 34, \"hdmi_boost\": 2, \"hdmi_drive\": 0, \"hdmi_group\": 0, \"verify_ssl\": true, \"audio_output\": \"hdmi\", \"hdmi_timings\": \"\", \"overscan_top\": 0, \"overscan_left\": 0, \"use_composite\": false, \"display_rotate\": 0, \"overscan_right\": 0, \"overscan_scale\": 0, \"overscan_bottom\": 0, \"disable_overscan\": 0, \"shuffle_playlist\": false, \"framebuffer_width\": 0, \"use_composite_pal\": false, \"framebuffer_height\": 0, \"hdmi_force_hotplug\": true, \"use_composite_ntsc\": false, \"hdmi_pixel_encoding\": 0, \"play_history_enabled\": false}}]"); + }); - fn get(_id: &str) {} + let config = Config::new(mock_server.base_url()); + let authentication = Authentication::new_with_config(config); + let screen_command = ScreenCommand::new(authentication); + let v = screen_command.list(); + assert!(v.is_err()); + } + + #[test] + fn test_get_screen_should_return_screen() { + let tmp_dir = TempDir::new("test").unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + fs::write(tmp_dir.path().join(".screenly").to_str().unwrap(), "token").unwrap(); + let mock_server = MockServer::start(); + mock_server.mock(|when, then| { + when.method(GET) + .path("/v4/screens") + .query_param("id", "eq.017a5104-524b-33d8-8026-9087b59e7eb5") + .header("user-agent", "screenly-cli 0.1.0") + .header("Authorization", "Token token"); + then + .status(200) + .body(b"[{\"id\":\"017a5104-524b-33d8-8026-9087b59e7eb5\",\"team_id\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"created_at\":\"2021-06-28T05:07:55+00:00\",\"name\":\"Renat's integrated wired NM\",\"is_enabled\":true,\"coords\":[55.22931, 48.90429],\"last_ping\":\"2021-08-25T06:17:20.728+00:00\",\"last_ip\":null,\"local_ip\":\"192.168.1.146\",\"mac\":\"b8:27:eb:d6:83:6f\",\"last_screenshot_time\":\"2021-08-25T06:09:04.399+00:00\",\"uptime\":\"230728.38\",\"load_avg\":\"0.14\",\"signal_strength\":null,\"interface\":\"eth0\",\"debug\":false,\"location\":\"Kamsko-Ust'inskiy rayon, Russia\",\"team\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"timezone\":\"Europe/Moscow\",\"type\":\"hardware\",\"hostname\":\"srly-4shnfrdc5cd2p0p\",\"ws_open\":false,\"status\":\"Offline\",\"last_screenshot\":\"https://us-assets.screenlyapp.com/01CD1W50NR000A28F31W83B1TY/screenshots/01F98G8MJB6FC809MGGYTSWZNN/5267668e6db35498e61b83d4c702dbe8\",\"in_sync\":false,\"software_version\":\"Screenly 2 Player\",\"hardware_version\":\"Raspberry Pi 3B\",\"config\":{\"hdmi_mode\": 34, \"hdmi_boost\": 2, \"hdmi_drive\": 0, \"hdmi_group\": 0, \"verify_ssl\": true, \"audio_output\": \"hdmi\", \"hdmi_timings\": \"\", \"overscan_top\": 0, \"overscan_left\": 0, \"use_composite\": false, \"display_rotate\": 0, \"overscan_right\": 0, \"overscan_scale\": 0, \"overscan_bottom\": 0, \"disable_overscan\": 0, \"shuffle_playlist\": false, \"framebuffer_width\": 0, \"use_composite_pal\": false, \"framebuffer_height\": 0, \"hdmi_force_hotplug\": true, \"use_composite_ntsc\": false, \"hdmi_pixel_encoding\": 0, \"play_history_enabled\": false}}]"); + }); + + let config = Config::new(mock_server.base_url()); + let authentication = Authentication::new_with_config(config); + let screen_command = ScreenCommand::new(authentication); + let expected = serde_json::from_str::("[{\"id\":\"017a5104-524b-33d8-8026-9087b59e7eb5\",\"team_id\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"created_at\":\"2021-06-28T05:07:55+00:00\",\"name\":\"Renat's integrated wired NM\",\"is_enabled\":true,\"coords\":[55.22931, 48.90429],\"last_ping\":\"2021-08-25T06:17:20.728+00:00\",\"last_ip\":null,\"local_ip\":\"192.168.1.146\",\"mac\":\"b8:27:eb:d6:83:6f\",\"last_screenshot_time\":\"2021-08-25T06:09:04.399+00:00\",\"uptime\":\"230728.38\",\"load_avg\":\"0.14\",\"signal_strength\":null,\"interface\":\"eth0\",\"debug\":false,\"location\":\"Kamsko-Ust'inskiy rayon, Russia\",\"team\":\"016343c2-82b8-0000-a121-e30f1035875e\",\"timezone\":\"Europe/Moscow\",\"type\":\"hardware\",\"hostname\":\"srly-4shnfrdc5cd2p0p\",\"ws_open\":false,\"status\":\"Offline\",\"last_screenshot\":\"https://us-assets.screenlyapp.com/01CD1W50NR000A28F31W83B1TY/screenshots/01F98G8MJB6FC809MGGYTSWZNN/5267668e6db35498e61b83d4c702dbe8\",\"in_sync\":false,\"software_version\":\"Screenly 2 Player\",\"hardware_version\":\"Raspberry Pi 3B\",\"config\":{\"hdmi_mode\": 34, \"hdmi_boost\": 2, \"hdmi_drive\": 0, \"hdmi_group\": 0, \"verify_ssl\": true, \"audio_output\": \"hdmi\", \"hdmi_timings\": \"\", \"overscan_top\": 0, \"overscan_left\": 0, \"use_composite\": false, \"display_rotate\": 0, \"overscan_right\": 0, \"overscan_scale\": 0, \"overscan_bottom\": 0, \"disable_overscan\": 0, \"shuffle_playlist\": false, \"framebuffer_width\": 0, \"use_composite_pal\": false, \"framebuffer_height\": 0, \"hdmi_force_hotplug\": true, \"use_composite_ntsc\": false, \"hdmi_pixel_encoding\": 0, \"play_history_enabled\": false}}]").unwrap(); + let v = screen_command + .get("017a5104-524b-33d8-8026-9087b59e7eb5") + .unwrap(); + assert_eq!(v, expected); + } } diff --git a/src/main.rs b/src/main.rs index 9b84d2a0..009e7794 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ mod commands; use crate::authentication::{Authentication, AuthenticationError}; use clap::{command, Parser, Subcommand}; -use log::warn; + use simple_logger::SimpleLogger; #[derive(Parser)] @@ -47,21 +47,46 @@ fn main() { Err(e) => match e { AuthenticationError::WrongCredentialsError => { - println!("Token verification failed."); + eprintln!("Token verification failed."); std::process::exit(1); } _ => { - println!("Unknown error"); - std::process::exit(2); + eprintln!("Error occurred: {:?}", e); + std::process::exit(1); } }, }, Commands::Screen(command) => match command { ScreenCommands::List => { - warn!("List: to be implemented"); + let screen_command = commands::ScreenCommand::new(authentication); + match screen_command.list() { + Ok(v) => { + println!( + "{}", + serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string()) + ); + } + Err(e) => { + eprintln!("Error occurred: {:?}", e); + std::process::exit(1); + } + } } - ScreenCommands::Get { id: _ } => { - warn!("Get: to be implemented"); + ScreenCommands::Get { id } => { + let screen_command = commands::ScreenCommand::new(authentication); + match screen_command.get(id) { + Ok(v) => { + println!( + "{}", + serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string()) + ); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error occurred: {:?}", e); + std::process::exit(1); + } + } } }, }