diff --git a/src/lib.rs b/src/lib.rs index 4172488..bc12a86 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,3 +13,5 @@ pub mod robocraft_simple; pub mod techblox; #[cfg(feature = "convert")] pub mod convert; +#[cfg(feature = "robocraft")] +pub mod robocraft2; diff --git a/src/robocraft/account.rs b/src/robocraft/account.rs index a27b86b..2d888cd 100644 --- a/src/robocraft/account.rs +++ b/src/robocraft/account.rs @@ -85,7 +85,7 @@ pub(crate) struct AuthenticationUsernamePayload { pub password: String, } -#[derive(Deserialize, Serialize, Clone)] +#[derive(Deserialize, Serialize, Clone, Debug)] pub(crate) struct AuthenticationResponseInfo { #[serde(rename = "Token")] pub token: String, diff --git a/src/robocraft/mod.rs b/src/robocraft/mod.rs index 5d0d4b9..a91e9fa 100644 --- a/src/robocraft/mod.rs +++ b/src/robocraft/mod.rs @@ -16,7 +16,7 @@ pub use self::cubes::{Cube, Cubes}; mod auth; pub use self::auth::{ITokenProvider, DefaultTokenProvider}; -mod account; +pub(crate) mod account; pub use self::account::{AuthenticatedTokenProvider, AccountInfo}; /// Token defined in a javascript file from Freejam which never expires diff --git a/src/robocraft2/factory.rs b/src/robocraft2/factory.rs new file mode 100644 index 0000000..0447b2e --- /dev/null +++ b/src/robocraft2/factory.rs @@ -0,0 +1,52 @@ +use reqwest::{Client, Error}; +use url::{Url}; + +use crate::robocraft::{ITokenProvider, DefaultTokenProvider}; +use crate::robocraft2::{SearchPayload, SearchResponse}; + +/// Community Factory Robot 2 root URL +pub const FACTORY_DOMAIN: &str = "https://factory.production.robocraft2.com"; + +/// CRF API implementation +pub struct FactoryAPI { + client: Client, + token: Box, +} + +impl FactoryAPI { + /// Create a new instance, using `DefaultTokenProvider`. + pub fn new() -> FactoryAPI { + FactoryAPI { + client: Client::new(), + token: Box::new(DefaultTokenProvider{}), + } + } + + /// Create a new instance using the provided token provider. + pub fn with_auth(token_provider: Box) -> FactoryAPI { + FactoryAPI { + client: Client::new(), + token: token_provider, + } + } + + /// Retrieve CRF robots on the main page. + /// + /// For searching, use `list_builder()` instead. + pub async fn list(&self) -> Result { + let url = Url::parse(FACTORY_DOMAIN) + .unwrap() + .join("/v1/foundry/search") + .unwrap(); + let mut request_builder = self.client.get(url); + if let Ok(token) = self.token.token() { + request_builder = request_builder.header("Authorization", "Bearer ".to_owned() + &token); + } + let result = request_builder.send().await?; + result.json::().await + } + + async fn search(&self, params: SearchPayload) -> Result { + todo!() + } +} diff --git a/src/robocraft2/factory_json.rs b/src/robocraft2/factory_json.rs new file mode 100644 index 0000000..04518c9 --- /dev/null +++ b/src/robocraft2/factory_json.rs @@ -0,0 +1,132 @@ +use serde::{Deserialize, Serialize}; + +// search endpoint + +#[derive(Deserialize, Serialize, Clone)] +pub struct SearchPayload { + #[serde(rename = "text")] + pub text: Option, + #[serde(rename = "baseCpuMinimum")] + pub base_minimum_cpu: Option, + #[serde(rename = "baseCpuMaximum")] + pub base_maximum_cpu: Option, + #[serde(rename = "weaponCpuMinimum")] + pub weapon_minimum_cpu: Option, + #[serde(rename = "weaponCpuMaximum")] + pub weapon_maximum_cpu: Option, + #[serde(rename = "cosmeticCpuMinimum")] + pub cosmetic_minimum_cpu: Option, + #[serde(rename = "cosmeticCpuMaximum")] + pub cosmetic_maximum_cpu: Option, + #[serde(rename = "clusterMinimum")] + pub cluster_minimum: Option, + #[serde(rename = "clusterMaximum")] + pub cluster_maximum: Option, + #[serde(rename = "dateMinimum")] + pub date_minimum: Option, + #[serde(rename = "dateMaximum")] + pub date_maximum: Option, + #[serde(rename = "creatorId")] + pub creator_id: Option, // GUID + #[serde(rename = "page")] + pub page: Option, + #[serde(rename = "count")] + pub count: Option, + #[serde(rename = "sortBy")] + pub sort_by: String, + #[serde(rename = "orderBy")] + pub order_by: String, +} + +impl Default for SearchPayload { + fn default() -> Self { + Self { + text: None, + base_minimum_cpu: None, + base_maximum_cpu: None, + weapon_minimum_cpu: None, + weapon_maximum_cpu: None, + cosmetic_minimum_cpu: None, + cosmetic_maximum_cpu: None, + cluster_minimum: None, + cluster_maximum: None, + date_minimum: None, + date_maximum: None, + creator_id: None, + page: None, + count: None, + sort_by: "default".to_owned(), + order_by: "ascending".to_owned(), + } + } +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct SearchResponse { + #[serde(rename = "results")] + pub results: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct SearchResponseItem { + #[serde(rename = "robot")] + pub robot: RobotInfo, + #[serde(rename = "prices")] + pub prices: Vec, + #[serde(rename = "purchased")] + pub purchased: bool, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct RobotInfo { + #[serde(rename = "id")] + pub id: String, // GUID + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "creatorId")] + pub creator_id: String, // GUID + #[serde(rename = "creatorName")] + pub creator_name: String, + #[serde(rename = "created")] + pub created: String, // date + #[serde(rename = "image")] + pub image: String, // base64?? + #[serde(rename = "baseCpu")] + pub base_cpu: isize, + #[serde(rename = "weaponCpu")] + pub weapon_cpu: isize, + #[serde(rename = "cosmeticCpu")] + pub cosmetic_cpu: isize, + #[serde(rename = "clusterCount")] + pub cluster_count: isize, + #[serde(rename = "blockCounts")] + pub block_counts: std::collections::HashMap, + #[serde(rename = "materialsUsed")] + pub materials_used: std::collections::HashSet, + #[serde(rename = "minimumOffsetX")] + pub minimum_offset_x: f64, + #[serde(rename = "minimumOffsetY")] + pub minimum_offset_y: f64, + #[serde(rename = "minimumOffsetZ")] + pub minimum_offset_z: f64, + #[serde(rename = "maximumOffsetX")] + pub maximum_offset_x: f64, + #[serde(rename = "maximumOffsetY")] + pub maximum_offset_y: f64, + #[serde(rename = "maximumOffsetZ")] + pub maximum_offset_z: f64, +} + +impl std::string::ToString for RobotInfo { + fn to_string(&self) -> String { + format!("{} by {} ({})", &self.name, &self.creator_name, &self.id) + } +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct RobotPrice { + #[serde(rename = "currency")] + pub currency: isize, + #[serde(rename = "amount")] + pub amount: isize, +} diff --git a/src/robocraft2/mod.rs b/src/robocraft2/mod.rs new file mode 100644 index 0000000..c46b5a6 --- /dev/null +++ b/src/robocraft2/mod.rs @@ -0,0 +1,11 @@ +//! Robocraft2 APIs for the CRF. +//! Subject to change and breakages as RC2 is still in an early development stage. + +mod factory; +pub use factory::FactoryAPI; + +mod factory_json; +pub use factory_json::{SearchPayload, SearchResponse, SearchResponseItem, RobotInfo, RobotPrice}; + +mod portal; +pub use self::portal::PortalTokenProvider; diff --git a/src/robocraft2/portal.rs b/src/robocraft2/portal.rs new file mode 100644 index 0000000..84dd801 --- /dev/null +++ b/src/robocraft2/portal.rs @@ -0,0 +1,119 @@ +use std::sync::RwLock; + +use serde::{Deserialize, Serialize}; +use ureq::{Agent, Error}; +use serde_json::to_string; + +use crate::robocraft::{ITokenProvider, account::AuthenticationResponseInfo, AccountInfo}; + +/// Token provider for an existing Freejam account, authenticated through the web browser portal. +/// +/// Steam and Epic accounts are not supported. +pub struct PortalTokenProvider { + /// Login token + token: RwLock, + /// User info token + jwt: AuthenticationResponseInfo, + /// Ureq HTTP client + client: Agent, +} + +impl PortalTokenProvider { + pub fn portal() -> Result { + Self::target("Techblox".to_owned()) + } + + pub fn target(value: String) -> Result { + let client = Agent::new(); + let payload = PortalStartPayload { + target: value, + }; + let start_response = client.post("https://account.freejamgames.com/api/authenticate/portal/start") + .set("Content-Type", "application/json") + .send_string(&to_string(&payload).unwrap())?; + let start_res = start_response.into_json::()?; + + println!("GO TO https://account.freejamgames.com/login?theme=rc2&redirect_url=portal?theme=rc2%26portalToken={}", start_res.token); + + let payload = PortalCheckPayload { + token: start_res.token, + }; + let mut check_response = client.post("https://account.freejamgames.com/api/authenticate/portal/check") + .set("Content-Type", "application/json") + .send_json(&payload)?; + //.send_string(&to_string(&payload).unwrap())?; + let mut auth_complete = check_response.status() == 200; + while !auth_complete { + check_response = client.post("https://account.freejamgames.com/api/authenticate/portal/check") + .set("Content-Type", "application/json") + .send_json(&payload)?; + auth_complete = check_response.status() == 200; + } + let check_res = check_response.into_json::()?; + + // login with token we just got + let payload = ProgressionLoginPayload { + token: check_res.token.clone(), + }; + let progress_response = client.post("https://progression.production.robocraft2.com/login/fj") + .set("Content-Type", "application/json") + .send_json(&payload)?; + let progress_res = progress_response.into_json::()?; + Ok(Self { + token: RwLock::new(progress_res), + jwt: check_res, + client: client, + }) + } + + pub fn get_account_info(&self) -> Result { + Ok(self.jwt.decode_jwt_data()) + } +} + +impl ITokenProvider for PortalTokenProvider { + fn token(&self) -> Result { + // TODO re-authenticate when expired + if let Some(token) = self.token.read().map_err(|_| ())?.token.clone() { + Ok(token) + } else { + Err(()) + } + } +} + +#[derive(Deserialize, Serialize, Clone)] +pub(crate) struct PortalStartPayload { + #[serde(rename = "Target")] + pub target: String, +} + +#[derive(Deserialize, Serialize, Clone)] +pub(crate) struct PortalStartResponse { + #[serde(rename = "Token")] + pub token: String, +} + +#[derive(Deserialize, Serialize, Clone)] +pub(crate) struct PortalCheckPayload { + #[serde(rename = "Token")] + pub token: String, +} + +#[derive(Deserialize, Serialize, Clone)] +pub(crate) struct ProgressionLoginPayload { + #[serde(rename = "token")] + pub token: String, +} + +#[derive(Deserialize, Serialize, Clone)] +pub(crate) struct ProgressionLoginResponse { + #[serde(rename = "success")] + pub success: bool, + #[serde(rename = "error")] + pub error: Option, + #[serde(rename = "token")] + pub token: Option, + #[serde(rename = "serverToken")] + pub server_token: Option, +} diff --git a/tests/robocraft_auth.rs b/tests/robocraft_auth.rs index 22a7767..7b0fa91 100644 --- a/tests/robocraft_auth.rs +++ b/tests/robocraft_auth.rs @@ -1,6 +1,8 @@ #[cfg(feature = "robocraft")] use libfj::robocraft; #[cfg(feature = "robocraft")] +use libfj::robocraft2; +#[cfg(feature = "robocraft")] use libfj::robocraft::ITokenProvider; #[cfg(feature = "robocraft")] @@ -30,3 +32,19 @@ fn robocraft_account() -> Result<(), ()> { assert_eq!(account.created_date, "2019-01-18T14:48:09"); Ok(()) } + +// this requires human-interaction so it's disabled by default +#[cfg(feature = "robocraft")] +#[allow(dead_code)] +//#[test] +fn robocraft2_account() -> Result<(), ()> { + let token_maybe = robocraft2::PortalTokenProvider::portal(); + assert!(token_maybe.is_ok()); + let token_provider = token_maybe.unwrap(); + let account_maybe = token_provider.get_account_info(); + assert!(account_maybe.is_ok()); + let account = account_maybe.unwrap(); + assert_eq!(account.display_name, "NGniusness"); + assert_eq!(account.created_date, "2014-09-17T21:02:46"); + Ok(()) +} diff --git a/tests/robocraft_factory.rs b/tests/robocraft_factory.rs index 6fe739b..b34065d 100644 --- a/tests/robocraft_factory.rs +++ b/tests/robocraft_factory.rs @@ -1,6 +1,8 @@ #[cfg(feature = "robocraft")] use libfj::robocraft; #[cfg(feature = "robocraft")] +use libfj::robocraft2; +#[cfg(feature = "robocraft")] use std::convert::From; #[cfg(feature = "robocraft")] @@ -29,6 +31,24 @@ async fn robocraft_factory_default_query() -> Result<(), ()> { Ok(()) } +#[cfg(feature = "robocraft")] +#[tokio::test] +async fn robocraft2_factory_default_query() -> Result<(), ()> { + let api = robocraft2::FactoryAPI::with_auth(Box::new(robocraft2::PortalTokenProvider::portal().unwrap())); + let result = api.list().await; + //assert!(result.is_ok()); + let robo_info = result.unwrap(); + assert_ne!(robo_info.results.len(), 0); + for robot in &robo_info.results { + assert_ne!(robot.robot.name, ""); + assert_ne!(robot.robot.creator_id, ""); + assert_ne!(robot.robot.creator_id, ""); + assert_ne!(robot.robot.image, ""); + //println!("FactoryRobotListInfo.to_string() -> `{}`", robot.to_string()); + } + Ok(()) +} + #[cfg(feature = "robocraft")] fn builder() -> robocraft::FactorySearchBuilder { robocraft::FactoryAPI::new().list_builder()