diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d86f78..01d0e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,14 @@ Games: - Added a valve protocol query example. - Made all of Just Cause 2: Multiplayer Response and Player fields public. - [Mindustry](https://mindustrygame.github.io/) support. +- Eco support (by @CosminPerRam). + +Crate: +- Changed the serde feature to only enable serde derive for some types: serde and serde_json is now a dependecy by default. Protocols: - Added the unreal2 protocol and its associated games: Darkest Hour, Devastation, Killing Floor, Red Orchestra, Unreal Tournament 2003, Unreal Tournament 2004 (by @Douile). +- Added HTTPClient to allow use of HTTP(S) (and JSON) APIs (by @CosminPerRam & @Douile). Crate: - Added a `packet_capture` feature to capture the raw packets sent and received by the socket (by @Douile). diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index 999b486..a557eab 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -18,21 +18,36 @@ categories = ["parser-implementations", "parsing", "network-programming", "encod [features] default = ["games", "services", "game_defs"] + +# Enable query functions for specific games games = [] -services = [] +# Enable game definitions for use with the generic query functions game_defs = ["dep:phf", "games"] -serde = ["dep:serde", "serde/derive"] + +# Enable service querying +services = [] + +# Enable serde derivations for our types +serde = [] + +# Enable clap derivations for our types clap = ["dep:clap"] packet_capture = ["dep:pcap-file", "dep:pnet_packet", "dep:lazy_static"] +# Enable TLS for HTTP Client +tls = ["ureq/tls"] + [dependencies] byteorder = "1.5" bzip2-rs = "0.1" crc32fast = "1.3" -serde_json = "1.0" -encoding_rs = "0.8" -serde = { version = "1.0", optional = true } +encoding_rs = "0.8" +ureq = { version = "2.8", default-features = false, features = ["gzip", "json"] } +url = "2" + +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } phf = { version = "0.11", optional = true, features = ["macros"] } @@ -58,6 +73,10 @@ required-features = ["games"] name = "valve_master_server_query" required-features = ["services"] +[[example]] +name = "test_eco" +required-features = ["games"] + [[example]] name = "generic" required-features = ["games", "game_defs"] diff --git a/crates/lib/examples/test_eco.rs b/crates/lib/examples/test_eco.rs new file mode 100644 index 0000000..735fe9c --- /dev/null +++ b/crates/lib/examples/test_eco.rs @@ -0,0 +1,10 @@ +use gamedig::games::eco; +use std::net::IpAddr; +use std::str::FromStr; + +fn main() { + let ip = IpAddr::from_str("142.132.154.69").unwrap(); + let port = 31111; + let r = eco::query(&ip, Some(port)); + println!("{:#?}", r); +} diff --git a/crates/lib/src/errors/kind.rs b/crates/lib/src/errors/kind.rs index 8cbf1ef..e7eb3d7 100644 --- a/crates/lib/src/errors/kind.rs +++ b/crates/lib/src/errors/kind.rs @@ -36,6 +36,8 @@ pub enum GDErrorKind { JsonParse, /// Couldn't parse a value. TypeParse, + /// Couldn't find the host specified. + HostLookup, } impl GDErrorKind { diff --git a/crates/lib/src/games/definitions.rs b/crates/lib/src/games/definitions.rs index 35c8c85..7fa0dfa 100644 --- a/crates/lib/src/games/definitions.rs +++ b/crates/lib/src/games/definitions.rs @@ -131,6 +131,7 @@ pub static GAMES: Map<&'static str, Game> = phf_map! { "redorchestra" => game!("Red Orchestra", 7759, Protocol::Unreal2), "unrealtournament2003" => game!("Unreal Tournament 2003", 7758, Protocol::Unreal2), "unrealtournament2004" => game!("Unreal Tournament 2004", 7778, Protocol::Unreal2), + "eco" => game!("Eco", 3000, Protocol::PROPRIETARY(ProprietaryProtocol::Eco)), "zps" => game!("Zombie Panic: Source", 27015, Protocol::Valve(Engine::new(17_500))), "mindustry" => game!("Mindustry", crate::games::mindustry::DEFAULT_PORT, Protocol::PROPRIETARY(ProprietaryProtocol::Mindustry)), }; diff --git a/crates/lib/src/games/eco/mod.rs b/crates/lib/src/games/eco/mod.rs new file mode 100644 index 0000000..e22cf53 --- /dev/null +++ b/crates/lib/src/games/eco/mod.rs @@ -0,0 +1,8 @@ +/// The implementation. +/// Reference: [Node-GameGig](https://github.com/gamedig/node-gamedig/blob/master/protocols/ffow.js) +pub mod protocol; +/// All types used by the implementation. +pub mod types; + +pub use protocol::*; +pub use types::*; diff --git a/crates/lib/src/games/eco/protocol.rs b/crates/lib/src/games/eco/protocol.rs new file mode 100644 index 0000000..c5a2b7a --- /dev/null +++ b/crates/lib/src/games/eco/protocol.rs @@ -0,0 +1,37 @@ +use crate::eco::{EcoRequestSettings, Response, Root}; +use crate::http::HttpClient; +use crate::{GDResult, TimeoutSettings}; +use std::net::{IpAddr, SocketAddr}; + +/// Query a eco server. +#[inline] +pub fn query(address: &IpAddr, port: Option) -> GDResult { query_with_timeout(address, port, &None) } + +/// Query a eco server. +#[inline] +pub fn query_with_timeout( + address: &IpAddr, + port: Option, + timeout_settings: &Option, +) -> GDResult { + query_with_timeout_and_extra_settings(address, port, timeout_settings, None) +} + +/// Query a eco server. +pub fn query_with_timeout_and_extra_settings( + address: &IpAddr, + port: Option, + timeout_settings: &Option, + extra_settings: Option, +) -> GDResult { + let address = &SocketAddr::new(*address, port.unwrap_or(3001)); + let mut client = HttpClient::new( + address, + timeout_settings, + extra_settings.unwrap_or_default().into(), + )?; + + let response = client.get_json::("/frontpage")?; + + Ok(response.into()) +} diff --git a/crates/lib/src/games/eco/types.rs b/crates/lib/src/games/eco/types.rs new file mode 100644 index 0000000..4a962ec --- /dev/null +++ b/crates/lib/src/games/eco/types.rs @@ -0,0 +1,241 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::http::{HttpProtocol, HttpSettings}; +use crate::protocols::types::{CommonPlayer, CommonResponse}; +use crate::ExtraRequestSettings; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Root { + #[serde(rename = "Info")] + pub info: Info, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Info { + #[serde(rename = "External")] + pub external: bool, + #[serde(rename = "GamePort")] + pub game_port: u32, + #[serde(rename = "WebPort")] + pub web_port: u32, + #[serde(rename = "IsLAN")] + pub is_lan: bool, + #[serde(rename = "Description")] + pub description: String, + #[serde(rename = "DetailedDescription")] + pub detailed_description: String, + #[serde(rename = "Category")] + pub category: String, + #[serde(rename = "OnlinePlayers")] + pub online_players: u32, + #[serde(rename = "TotalPlayers")] + pub total_players: u32, + #[serde(rename = "OnlinePlayersNames")] + pub online_players_names: Vec, + #[serde(rename = "AdminOnline")] + pub admin_online: bool, + #[serde(rename = "TimeSinceStart")] + pub time_since_start: f64, + #[serde(rename = "TimeLeft")] + pub time_left: f64, + #[serde(rename = "Animals")] + pub animals: u32, + #[serde(rename = "Plants")] + pub plants: u32, + #[serde(rename = "Laws")] + pub laws: u32, + #[serde(rename = "WorldSize")] + pub world_size: String, + #[serde(rename = "Version")] + pub version: String, + #[serde(rename = "EconomyDesc")] + pub economy_desc: String, + #[serde(rename = "SkillSpecializationSetting")] + pub skill_specialization_setting: String, + #[serde(rename = "Language")] + pub language: String, + #[serde(rename = "HasPassword")] + pub has_password: bool, + #[serde(rename = "HasMeteor")] + pub has_meteor: bool, + #[serde(rename = "DistributionStationItems")] + pub distribution_station_items: String, + #[serde(rename = "Playtimes")] + pub playtimes: String, + #[serde(rename = "DiscordAddress")] + pub discord_address: String, + #[serde(rename = "IsPaused")] + pub is_paused: bool, + #[serde(rename = "ActiveAndOnlinePlayers")] + pub active_and_online_players: u32, + #[serde(rename = "PeakActivePlayers")] + pub peak_active_players: u32, + #[serde(rename = "MaxActivePlayers")] + pub max_active_players: u32, + #[serde(rename = "ShelfLifeMultiplier")] + pub shelf_life_multiplier: f64, + #[serde(rename = "ExhaustionAfterHours")] + pub exhaustion_after_hours: f64, + #[serde(rename = "IsLimitingHours")] + pub is_limiting_hours: bool, + #[serde(rename = "ServerAchievementsDict")] + pub server_achievements_dict: HashMap, + #[serde(rename = "RelayAddress")] + pub relay_address: String, + #[serde(rename = "Access")] + pub access: String, + #[serde(rename = "JoinUrl")] + pub join_url: String, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Player { + pub name: String, +} + +impl CommonPlayer for Player { + fn as_original(&self) -> crate::protocols::types::GenericPlayer { + crate::protocols::types::GenericPlayer::Eco(self) + } + + fn name(&self) -> &str { &self.name } +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub struct Response { + pub external: bool, + pub port: u32, + pub query_port: u32, + pub is_lan: bool, + pub description: String, // this and other fields require some text filtering + pub description_detailed: String, + pub description_economy: String, + pub category: String, + pub players_online: u32, + pub players_maximum: u32, + pub players: Vec, + pub admin_online: bool, + pub time_since_start: f64, + pub time_left: f64, + pub animals: u32, + pub plants: u32, + pub laws: u32, + pub world_size: String, + pub game_version: String, + pub skill_specialization_setting: String, + pub language: String, + pub has_password: bool, + pub has_meteor: bool, + pub distribution_station_items: String, + pub playtimes: String, + pub discord_address: String, + pub is_paused: bool, + pub active_and_online_players: u32, + pub peak_active_players: u32, + pub max_active_players: u32, + pub shelf_life_multiplier: f64, + pub exhaustion_after_hours: f64, + pub is_limiting_hours: bool, + pub server_achievements_dict: HashMap, + pub relay_address: String, + pub access: String, + pub connect: String, +} + +impl From for Response { + fn from(root: Root) -> Self { + let value = root.info; + Self { + external: value.external, + port: value.game_port, + query_port: value.web_port, + is_lan: value.is_lan, + description: value.description, + description_detailed: value.detailed_description, + description_economy: value.economy_desc, + category: value.category, + players_online: value.online_players, + players_maximum: value.total_players, + players: value + .online_players_names + .iter() + .map(|player| { + Player { + name: player.clone(), + } + }) + .collect(), + admin_online: value.admin_online, + time_since_start: value.time_since_start, + time_left: value.time_left, + animals: value.animals, + plants: value.plants, + laws: value.laws, + world_size: value.world_size, + game_version: value.version, + skill_specialization_setting: value.skill_specialization_setting, + language: value.language, + has_password: value.has_password, + has_meteor: value.has_meteor, + distribution_station_items: value.distribution_station_items, + playtimes: value.playtimes, + discord_address: value.discord_address, + is_paused: value.is_paused, + active_and_online_players: value.active_and_online_players, + peak_active_players: value.peak_active_players, + max_active_players: value.max_active_players, + shelf_life_multiplier: value.shelf_life_multiplier, + exhaustion_after_hours: value.exhaustion_after_hours, + is_limiting_hours: value.is_limiting_hours, + server_achievements_dict: value.server_achievements_dict, + relay_address: value.relay_address, + access: value.access, + connect: value.join_url, + } + } +} + +impl CommonResponse for Response { + fn as_original(&self) -> crate::protocols::GenericResponse { crate::protocols::GenericResponse::Eco(self) } + + fn players_online(&self) -> u32 { self.players_online } + + fn players_maximum(&self) -> u32 { self.players_maximum } + + fn description(&self) -> Option<&str> { Some(&self.description) } + + fn game_version(&self) -> Option<&str> { Some(&self.game_version) } + + fn has_password(&self) -> Option { Some(self.has_password) } + + fn players(&self) -> Option> { Some(self.players.iter().map(|p| p as _).collect()) } +} + +/// Extra request settings for eco queries. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct EcoRequestSettings { + hostname: Option, +} + +impl From for EcoRequestSettings { + fn from(value: ExtraRequestSettings) -> Self { + EcoRequestSettings { + hostname: value.hostname, + } + } +} + +impl From for HttpSettings { + fn from(value: EcoRequestSettings) -> Self { + HttpSettings { + protocol: HttpProtocol::Http, + hostname: value.hostname, + headers: Vec::with_capacity(0), + } + } +} diff --git a/crates/lib/src/games/mod.rs b/crates/lib/src/games/mod.rs index 5088ffd..7c53935 100644 --- a/crates/lib/src/games/mod.rs +++ b/crates/lib/src/games/mod.rs @@ -12,6 +12,8 @@ pub use valve::*; /// Battalion 1944 pub mod battalion1944; +/// Eco +pub mod eco; /// Frontlines: Fuel of War pub mod ffow; /// Just Cause 2: Multiplayer diff --git a/crates/lib/src/games/query.rs b/crates/lib/src/games/query.rs index 2d2bf9c..402ed76 100644 --- a/crates/lib/src/games/query.rs +++ b/crates/lib/src/games/query.rs @@ -3,7 +3,7 @@ use std::net::{IpAddr, SocketAddr}; use crate::games::types::Game; -use crate::games::{ffow, jc2m, mindustry, minecraft, savage2, theship}; +use crate::games::{eco, ffow, jc2m, mindustry, minecraft, savage2, theship}; use crate::protocols; use crate::protocols::gamespy::GameSpyVersion; use crate::protocols::quake::QuakeVersion; @@ -112,6 +112,15 @@ pub fn query_with_timeout_and_extra_settings( } } } + ProprietaryProtocol::Eco => { + eco::query_with_timeout_and_extra_settings( + address, + port, + &timeout_settings, + extra_settings.map(ExtraRequestSettings::into), + ) + .map(Box::new)? + } } } }) diff --git a/crates/lib/src/http.rs b/crates/lib/src/http.rs new file mode 100644 index 0000000..ae9ffd0 --- /dev/null +++ b/crates/lib/src/http.rs @@ -0,0 +1,447 @@ +//! Client for making HTTP requests. +//! +//! This is the first draft implementation: feel free to change things to suit +//! your needs. + +// Because this is first draft some functionality is not used yet. +// TODO: When this is used in more places remove this and refine the interface. +#![allow(dead_code)] + +use crate::GDErrorKind::{HostLookup, InvalidInput, PacketReceive, PacketSend, ProtocolFormat}; +use crate::{GDResult, TimeoutSettings}; + +use std::io::Read; +use std::net::{SocketAddr, SocketAddrV4, SocketAddrV6, ToSocketAddrs}; + +use ureq::{Agent, AgentBuilder}; +use url::{Host, Url}; + +use serde::{de::DeserializeOwned, Serialize}; + +/// Max length of HTTP responses in bytes: 1GB +const MAX_RESPONSE_LENGTH: usize = 1024 * 1024 * 1024; + +/// HTTP request client. Define parameters host parameters on new, then re-use +/// for each request. +/// +/// When making requests directly to the server use [HttpClient::new] as this +/// allows directly specifying the IP to connect to. +/// +/// When requests must go through an intermediatary (that we don't know the IP +/// of) use [HttpClient::from_url] which will perform a DNS lookup internally. +/// +/// For example usage see [tests]. +pub struct HttpClient { + client: Agent, + address: Url, + headers: Vec<(String, String)>, +} + +/// HTTP Protocols. +/// +/// Note: if the `tls` feature is disabled this will only contain Http. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub enum HttpProtocol { + #[default] + Http, + #[cfg(feature = "tls")] + Https, +} + +impl HttpProtocol { + /// Convert [Protocol] to a static str for use in a [Url]. + /// e.g. "http:" + pub const fn as_str(&self) -> &'static str { + use HttpProtocol::*; + match self { + Http => "http:", + #[cfg(feature = "tls")] + Https => "https:", + } + } +} + +/// Additional settings for HTTPClients. +/// +/// # Can be created using builder functions: +/// ```ignore, We cannot test private functionality +/// use gamedig::http::{HttpSettings, Protocol}; +/// +/// let _ = HttpSettings::default() +/// .protocol(Protocol::Http) +/// .hostname(String::from("test.com")) +/// .header(String::from("Authorization"), String::from("Bearer Token")); +/// ``` +#[derive(Debug, Default, Clone, PartialEq)] +pub struct HttpSettings> { + /// Choose whether to use HTTP or HTTPS. + pub protocol: HttpProtocol, + /// Choose a hostname override (used to set the [Host](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host) header) and for TLS. + pub hostname: Option, + /// Choose HTTP headers to send with requests. + pub headers: Vec<(S, S)>, +} + +impl> HttpSettings { + /// Set the HTTP protocol (defaults to HTTP). + pub const fn protocol(mut self, protocol: HttpProtocol) -> HttpSettings { + self.protocol = protocol; + self + } + + /// Set the desired HTTP host name: used for the HTTP Host header and for + /// TLS negotiation. + pub fn hostname(mut self, hostname: S) -> HttpSettings { + self.hostname = Some(hostname); + self + } + + /// Overwrite all the current HTTP headers with new headers. + pub fn headers(mut self, headers: Vec<(S, S)>) -> HttpSettings { + self.headers = headers; + self + } + + /// Set one HTTP header value. + pub fn header(mut self, name: S, value: S) -> HttpSettings { + self.headers.push((name, value)); + self + } +} + +impl HttpClient { + /// Creates a new HTTPClient that can be used to send requests. + /// + /// # Parameters + /// - [address](SocketAddr): The IP and port the HTTP request will connect + /// to. + /// - [timeout_settings](TimeoutSettings): Used to set the connect and + /// socket timeouts for the requests. + /// - [http_settings](HttpSettings): Additional settings for the HTTPClient. + pub fn new>( + address: &SocketAddr, + timeout_settings: &Option, + http_settings: HttpSettings, + ) -> GDResult + where + Self: Sized, + { + let mut client_builder = AgentBuilder::new(); + + // Set timeout settings + let (read_timeout, write_timeout) = TimeoutSettings::get_read_and_write_or_defaults(timeout_settings); + + if let Some(read_timeout) = read_timeout { + client_builder = client_builder.timeout_read(read_timeout); + } + + if let Some(write_timeout) = write_timeout { + client_builder = client_builder.timeout_write(write_timeout); + } + + if let Some(connect_timeout) = TimeoutSettings::get_connect_or_default(timeout_settings) { + client_builder = client_builder.timeout_connect(connect_timeout); + } + + // Every request sent from this client will connect to the address set + { + let address = *address; + client_builder = client_builder.resolver(move |_: &str| Ok(vec![address])); + } + + // Set a friendly user-agent string + client_builder = client_builder.user_agent(concat!( + env!("CARGO_PKG_NAME"), + "/", + env!("CARGO_PKG_VERSION") + )); + + let client = client_builder.build(); + + let host = http_settings + .hostname + .map(S::into) + .unwrap_or(address.ip().to_string()); + + Ok(Self { + client, + // TODO: Use Url from_parts if it gets added + address: Url::parse(&format!( + "{}//{}:{}", + http_settings.protocol.as_str(), + host, + address.port() + )) + .map_err(|e| InvalidInput.context(e))?, + headers: http_settings + .headers + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(), + }) + } + + /// Create a new HTTP client from a pre-existing URL, performing a DNS + /// lookup on the host when necessary. + /// + /// This is aimed to be used when we know the domain of the server but not + /// the IP i.e. when the server is not the service being directly queried + /// for server info. + pub fn from_url>( + url: U, + timeout_settings: &Option, + headers: Option>, + ) -> GDResult + where + U::Error: std::error::Error + Send + Sync + 'static, + { + let url: Url = url.try_into().map_err(|e| InvalidInput.context(e))?; + + let host = url + .host() + .ok_or_else(|| InvalidInput.context("URL used to create a HTTPClient must have a host"))?; + let port = url + .port_or_known_default() + .ok_or_else(|| InvalidInput.context("URL used to create HttpClient must have a port"))?; + + let address = match host { + Host::Ipv4(ip) => SocketAddr::V4(SocketAddrV4::new(ip, port)), + Host::Ipv6(ip) => SocketAddr::V6(SocketAddrV6::new(ip, port, 0, 0)), + Host::Domain(domain) => { + format!("{}:{}", domain, port) + .to_socket_addrs() + .map_err(|e| HostLookup.context(e))? + .next() + .ok_or_else(|| HostLookup.context("No socket addresses found for host"))? + } + }; + + let http_settings = HttpSettings { + hostname: url.host_str(), + protocol: match url.scheme() { + #[cfg(feature = "tls")] + "https" => HttpProtocol::Https, + _ => HttpProtocol::Http, + }, + headers: headers.unwrap_or_default(), + }; + + HttpClient::new(&address, timeout_settings, http_settings) + } + + /// Send a HTTP GET request and return the response data as a buffer. + pub fn get(&mut self, path: &str) -> GDResult> { self.request("GET", path) } + + /// Send a HTTP GET request and parse the JSON resonse. + pub fn get_json(&mut self, path: &str) -> GDResult { self.request_json("GET", path) } + + /// Send a HTTP Post request with JSON data and parse a JSON response. + pub fn post_json(&mut self, path: &str, data: S) -> GDResult { + self.request_with_json_data("POST", path, data) + } + + // NOTE: More methods can be added here as required using the request_json or + // request_with_json methods + + #[inline] + fn request(&mut self, method: &str, path: &str) -> GDResult> { + // Append the path to the pre-parsed URL and create a request object. + self.address.set_path(path); + let mut request = self.client.request_url(method, &self.address); + + // Set the request headers. + for (key, value) in self.headers.iter() { + request = request.set(key, value); + } + + // Send the request. + let http_response = request.call().map_err(|e| PacketSend.context(e))?; + + let length = if let Some(length) = http_response.header("Content-Length") { + length + .parse::() + .map_err(|e| ProtocolFormat.context(e))? + .min(MAX_RESPONSE_LENGTH) + } else { + 5012 // Sensible default allocation + }; + + let mut buffer: Vec = Vec::with_capacity(length); + + let _ = http_response + .into_reader() + .take(MAX_RESPONSE_LENGTH as u64) + .read_to_end(&mut buffer) + .map_err(|e| PacketReceive.context(e))?; + + Ok(buffer) + } + + /// Send a HTTP request without any data and parse the JSON response. + #[inline] + fn request_json(&mut self, method: &str, path: &str) -> GDResult { + // Append the path to the pre-parsed URL and create a request object. + self.address.set_path(path); + let mut request = self.client.request_url(method, &self.address); + + // Set the request headers. + for (key, value) in self.headers.iter() { + request = request.set(key, value); + } + + // Send the request and parse the response as JSON. + request + .call() + .map_err(|e| PacketSend.context(e))? + .into_json::() + .map_err(|e| ProtocolFormat.context(e)) + } + + /// Send a HTTP request with JSON data and parse the JSON response. + #[inline] + fn request_with_json_data( + &mut self, + method: &str, + path: &str, + data: S, + ) -> GDResult { + self.address.set_path(path); + let mut request = self.client.request_url(method, &self.address); + + for (key, value) in self.headers.iter() { + request = request.set(key, value); + } + + request + .send_json(data) + .map_err(|e| PacketSend.context(e))? + .into_json::() + .map_err(|e| ProtocolFormat.context(e)) + } +} + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, SocketAddrV4, ToSocketAddrs}; + + use super::*; + + #[test] + fn http_settings_builder() { + const HOSTNAME: &str = "example.org"; + + #[cfg(feature = "tls")] + const PROTOCOL: HttpProtocol = HttpProtocol::Https; + #[cfg(not(feature = "tls"))] + const PROTOCOL: HttpProtocol = HttpProtocol::Http; + + let settings = HttpSettings::default() + .hostname(HOSTNAME) + .protocol(PROTOCOL) + .header("Gamedig", "Is Awesome") + .headers(vec![("Foo", "bar")]) + .header("Baz", "Buzz"); + + assert_eq!(settings.hostname, Some(HOSTNAME)); + assert_eq!(settings.protocol, PROTOCOL); + assert_eq!(settings.headers, vec![("Foo", "bar"), ("Baz", "Buzz"),]); + } + + #[test] + fn http_client_new() { + const PROTOCOL: HttpProtocol = HttpProtocol::Http; + + const ADDRESS: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 8000)); + + let settings = HttpSettings { + protocol: PROTOCOL, + hostname: Some("github.com"), + headers: vec![("Authorization", "UUDDLRLRBA")], + }; + + let client = HttpClient::new(&ADDRESS, &None, settings).unwrap(); + + assert_eq!(client.address.as_str(), "http://github.com:8000/"); + assert_eq!( + client.headers, + vec![(String::from("Authorization"), String::from("UUDDLRLRBA")),] + ); + } + + #[cfg(feature = "tls")] + #[test] + #[ignore = "HTTP requests won't work without internet"] + fn https_json_get_request() { + let address = "api.github.com:443" + .to_socket_addrs() + .unwrap() + .next() + .unwrap(); + + let settings = HttpSettings::default() + .protocol(HttpProtocol::Https) + .hostname("api.github.com"); + + let mut client = HttpClient::new(&address, &None, settings).unwrap(); + + let response: serde_json::Value = client.get_json("/events").unwrap(); + + println!("{:?}", response); + } + + #[test] + #[ignore = "HTTP requests won't work without internet"] + fn http_json_get_request() { + let address = "postman-echo.com:80" + .to_socket_addrs() + .unwrap() + .next() + .unwrap(); + + let settings = HttpSettings::default().hostname("postman-echo.com"); + + let mut client = HttpClient::new(&address, &None, settings).unwrap(); + + let response: serde_json::Value = client.get_json("/get").unwrap(); + + println!("{:?}", response); + } + + #[test] + #[ignore = "HTTP requests won't work without internet"] + fn http_get_request() { + let address = "ifconfig.me:80".to_socket_addrs().unwrap().next().unwrap(); + + let settings = HttpSettings::default() + .hostname("ifconfig.me") + .header("User-Agent", "Curl/8.6.0"); + + let mut client = HttpClient::new(&address, &None, settings).unwrap(); + + let response = client.get("/").unwrap(); + + println!("{:?}", std::str::from_utf8(&response)); + } + + #[test] + #[ignore = "HTTP requests won't work without internet"] + fn http_get_from_url() { + let mut client = HttpClient::from_url("http://postman-echo.com/path-is-ignored", &None, None).unwrap(); + + let response: serde_json::Value = client.get_json("/get").unwrap(); + + println!("{:?}", response); + } + + #[test] + #[ignore = "HTTP requests won't work without internet"] + fn http_get_from_url_parsed() { + let url = Url::parse("http://postman-echo.com:443/path-is-ignored").unwrap(); + + let mut client = HttpClient::from_url(url, &None, None).unwrap(); + + let response: serde_json::Value = client.get_json("/get").unwrap(); + + println!("{:?}", response); + } +} diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index e0214f3..ea27255 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -28,11 +28,14 @@ //! # Crate features: //! Enabled by default: `games`, `game_defs`, `services` //! -//! `serde` - enables json serialization/deserialization for all response types. -//!
`games` - include games support.
+//! `serde` - enables serde serialization/deserialization for many gamedig types +//! using serde derive.
+//! `games` - include games support.
//! `services` - include services support.
-//! `game_defs` - Include game definitions for programmatic access (enabled by -//! default). +//! `game_defs` - include game definitions for programmatic access (enabled by +//! default).
+//! `clap` - enable clap derivations for gamedig settings types.
+//! `tls` - enable TLS support for the HTTP client. pub mod errors; #[cfg(feature = "games")] @@ -42,6 +45,7 @@ pub mod protocols; pub mod services; mod buffer; +mod http; mod socket; mod utils; diff --git a/crates/lib/src/protocols/types.rs b/crates/lib/src/protocols/types.rs index 189cb19..0470a60 100644 --- a/crates/lib/src/protocols/types.rs +++ b/crates/lib/src/protocols/types.rs @@ -19,6 +19,7 @@ pub enum ProprietaryProtocol { FFOW, JC2M, Savage2, + Eco, Mindustry, } @@ -54,6 +55,8 @@ pub enum GenericResponse<'a> { JC2M(&'a crate::games::jc2m::Response), #[cfg(feature = "games")] Savage2(&'a crate::games::savage2::Response), + #[cfg(feature = "games")] + Eco(&'a crate::games::eco::Response), } /// All player types @@ -71,6 +74,8 @@ pub enum GenericPlayer<'a> { TheShip(&'a crate::games::theship::TheShipPlayer), #[cfg(feature = "games")] JCMP2(&'a crate::games::jc2m::Player), + #[cfg(feature = "games")] + Eco(&'a crate::games::eco::Player), } pub trait CommonResponse {